4 Commits
Author SHA1 Message Date
OneWay 586852073e Release 1.6.0
- The app starts faster
- Smooth screen transition animations
- Articles render DSL line breaks, indentation and named colors correctly
- Tabs and extra spaces inside articles no longer break the layout
- The article title can be copied, tapping the article clears the selection
- Source URLs accept host:port addresses
- Dictionary downloads retry automatically when the network switches
- Download progress notifications are more reliable
- Search results clear instantly when the query is erased
- Leftover files from cancelled downloads are now removed automatically
- Improved app stability
- Faster dictionary indexing
- Updated build tooling and documentation
- Long link-heavy articles no longer freeze while scrolling
- Large search result lists render faster
- Dark article colors are readable in the dark theme
2026-09-23 22:21:15 +08:00
OneWay 10a7ff06c1 fix(ui): clear article title selection on any tap
The title SelectionContainer covered only the text, so a tap on the empty bar area or the article body never cleared the highlight. A tap anywhere on the article screen now recreates the container and drops the selection.
2026-09-23 22:10:16 +08:00
OneWay ec28d63656 fix(ui): remap dark article colors in dark theme
Colors set by dictionaries such as navy, dark red and brown are nearly invisible on the dark background. Swap the dark named colors for lighter tones when rendering in the dark theme; the light theme keeps the original colors.
2026-09-23 18:27:00 +08:00
OneWay c9b4a958bf fix(ui): merge article blocks to fix scroll jank
Link-heavy articles split every line into its own list item, so a
fast fling composed a dozen items in one frame and froze. Merge
small blocks into multi-paragraph items: indent via em space and
paragraph spacing via baseline shift.
2026-09-23 09:30:00 +08:00
7 changed files with 201 additions and 57 deletions

No files matched your search

+10 -25
View File
@@ -5,7 +5,6 @@
<p align="center">
<a href="#screenshots">Screenshots</a> &bull;
<a href="#features">Features</a> &bull;
<a href="#architecture">Architecture</a> &bull;
<a href="#dictionary-pipeline">Pipeline</a> &bull;
<a href="#build">Build</a>
</p>
@@ -23,31 +22,17 @@ The app stores dictionaries as DictZip files and builds compact binary indexes f
- Fast headword search across multiple DSL dictionaries.
- Import local `.dsl`, `.dsl.gz`, and `.dsl.dz` dictionary files.
- Download dictionaries from configured URLs.
- Download dictionaries from configured URLs, with an optional `{DATE}` placeholder for dated releases.
- Automatic extraction, DictZip compression, and indexing.
- Faithful article rendering with DSL indentation, margins, and named colors.
- Offline search after dictionaries are indexed.
- Random-access article loading from DictZip dictionaries.
- Background processing with progress shown in Settings and notifications.
- Dictionary management with enable, disable, update, and delete actions.
- Background processing with progress shown in Settings and notifications; downloads are cancelled with a button on the progress notification.
- Dictionary management with enable, disable, and update actions; deleting a dictionary is done with a swipe.
- Material 3 interface with dynamic color, light, dark, and system themes.
- English and Russian localization.
- About screen with open source license information.
## Architecture
ReSearch is an Android app written in Kotlin and Jetpack Compose.
```text
com.example.research/
├── common/ # shared progress, UI, file, archive, notification utilities
├── core/ # domain models, source validation, operation results
├── data/ # DictZip, DSL parsing, indexing, paging, repositories
├── feature/ # search, dictionary download, local import workflows
└── ui/ # Compose screens, article view, settings, navigation
```
State is exposed through Kotlin Flow and collected by Compose screens. Long-running work uses coroutines and foreground services where Android requires visible progress.
## Dictionary Pipeline
ReSearch supports both downloaded and locally imported dictionaries.
@@ -94,16 +79,16 @@ The main search screen remains usable as long as at least one indexed dictionary
## Tech Stack
- Kotlin 2.4.10
- Jetpack Compose (BOM 2026.08.00)
- Kotlin 2.4.20
- Jetpack Compose (BOM 2026.09.00)
- Material 3
- Coroutines and Flow 1.11.0
- DataStore Preferences 1.2.1
- Paging 3.5.1
- OkHttp 5.4.0
- OkHttp 5.5.0
- kotlinx.serialization 1.11.0
- AboutLibraries 15.0.4 metadata generation
- Android Gradle Plugin 9.3.1
- AboutLibraries 15.2.0 metadata generation
- Android Gradle Plugin 9.4.1
## Build
@@ -111,7 +96,7 @@ The main search screen remains usable as long as at least one indexed dictionary
./gradlew :app:assembleRelease
```
The release APK targets `arm64-v8a`. English and Russian resources are packaged together.
The release APK targets `arm64-v8a`.
## License
+2 -2
View File
@@ -92,8 +92,8 @@ android {
applicationId = "com.example.research"
minSdk = project.property("minSdk").toString().toInt()
targetSdk = project.property("targetSdk").toString().toInt()
versionCode = 8
versionName = "1.5.0"
versionCode = 9
versionName = "1.6.0"
vectorDrawables {
useSupportLibrary = true
@@ -1,6 +1,7 @@
package com.example.research.ui.article
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.lazy.LazyColumn
@@ -11,14 +12,22 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.unit.dp
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.isUnspecified
import androidx.compose.ui.unit.IntSize
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.research.R
@@ -48,6 +57,72 @@ fun ArticleRoute(
)
}
private const val MERGED_BLOCK_MAX_CHARS = 1500
private const val MERGED_BLOCK_MAX_ITEMS = 12
private fun mergeDslBlocks(
blocks: List<DslBlock>,
density: Density,
baseStyle: TextStyle,
): List<DslBlock> {
if (blocks.size <= MERGED_BLOCK_MAX_ITEMS) return blocks
val gapShift = with(density) {
val fontPx =
if (baseStyle.fontSize.isUnspecified) 0f else baseStyle.fontSize.toPx()
if (fontPx > 0f) 8.dp.toPx() / (0.534f * fontPx) else 0.94f
}
val merged = ArrayList<DslBlock>(blocks.size / 4)
var i = 0
while (i < blocks.size) {
val builder = AnnotatedString.Builder()
val refs = ArrayList<DslAnnotatedParser.LazyRef>()
var j = i
while (j < blocks.size) {
val block = blocks[j]
repeat(block.indent) { builder.append(' ') }
val paraStart = builder.length
builder.append(block.text)
block.lazyRefs.forEach { ref ->
refs.add(
ref.copy(
startIndex = ref.startIndex + paraStart,
endIndex = ref.endIndex + paraStart
)
)
}
val more = j + 1 < blocks.size &&
(j + 1 - i) < MERGED_BLOCK_MAX_ITEMS &&
builder.length < MERGED_BLOCK_MAX_CHARS
if (more && !blocks[j + 1].continuesPrevious) {
val shiftPos = builder.length
builder.append('​')
builder.addStyle(
SpanStyle(baselineShift = BaselineShift(-gapShift)),
shiftPos,
shiftPos + 1
)
}
if (more) {
builder.append('\n')
}
j++
if (!more) {
break
}
}
merged.add(
DslBlock(
text = builder.toAnnotatedString(),
indent = 0,
lazyRefs = refs,
continuesPrevious = blocks[i].continuesPrevious
)
)
i = j
}
return merged
}
@Composable
fun ArticleContent(
articleState: ArticleState?,
@@ -123,6 +198,24 @@ fun ArticleContent(
val isHeavyArticle =
articleState.dslContent.size > HEAVY_ARTICLE_BLOCK_COUNT
val density = LocalDensity.current
val darkTheme = MaterialTheme.colorScheme.background.luminance() < 0.5f
val displayBlocks = remember(
articleState.dslContent,
density,
baseTextStyle,
darkTheme
) {
val merged =
mergeDslBlocks(articleState.dslContent, density, baseTextStyle)
if (darkTheme) {
merged.map {
it.copy(text = DslAnnotatedParser.remapForDarkTheme(it.text))
}
} else {
merged
}
}
val articleList: @Composable (Modifier) -> Unit = { listModifier ->
LazyColumn(
state = listState,
@@ -134,15 +227,14 @@ fun ArticleContent(
}
) {
itemsIndexed(
items = articleState.dslContent,
items = displayBlocks,
key = { index, _ ->
// Use stable key combining article save key and index
"$articleSaveKey:$index"
},
contentType = { _, _ -> "article_block" }
) { index, block ->
val followedByContinuation =
articleState.dslContent.getOrNull(index + 1)?.continuesPrevious == true
displayBlocks.getOrNull(index + 1)?.continuesPrevious == true
val textContent = @Composable {
Box(
modifier = Modifier
@@ -213,6 +305,7 @@ fun ArticleTitleBar(
onNavigateBack: () -> Unit,
modifier: Modifier = Modifier,
compact: Boolean = false,
selectionReset: Int = 0,
) {
val title = when (articleState) {
is ArticleState.Loading -> articleState.title
@@ -258,18 +351,20 @@ fun ArticleTitleBar(
}
}
SelectionContainer {
Text(
text = title,
style = MaterialTheme.typography.dictionaryTitleLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.padding(start = if (showBackButton) Spacing.searchTextStart - Spacing.iconStart - 24.dp else 16.dp)
.testTag("article_title")
.semantics { testTagsAsResourceId = true }
)
key(selectionReset) {
SelectionContainer {
Text(
text = title,
style = MaterialTheme.typography.dictionaryTitleLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.padding(start = if (showBackButton) Spacing.searchTextStart - Spacing.iconStart - 24.dp else 16.dp)
.testTag("article_title")
.semantics { testTagsAsResourceId = true }
)
}
}
}
}
@@ -283,22 +378,31 @@ internal fun ArticleScreen(
onRefClick: ((String) -> Unit)? = null,
onScrollPositionChanged: ((Int, Int) -> Unit)? = null,
) {
Scaffold(
topBar = {
ArticleTitleBar(
val titleSelectionReset = remember { mutableIntStateOf(0) }
Box(
modifier = Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { titleSelectionReset.intValue++ }
) {
Scaffold(
topBar = {
ArticleTitleBar(
articleState = articleState,
showBackButton = true,
onNavigateBack = onNavigateBack,
selectionReset = titleSelectionReset.intValue
)
},
contentWindowInsets = AppWindowInsets.contentWithNavigation
) { padding ->
ArticleContent(
articleState = articleState,
showBackButton = true,
onNavigateBack = onNavigateBack
onNavigateBack = onNavigateBack,
onRefClick = onRefClick,
onScrollPositionChanged = onScrollPositionChanged,
modifier = Modifier.padding(padding)
)
},
contentWindowInsets = AppWindowInsets.contentWithNavigation
) { padding ->
ArticleContent(
articleState = articleState,
onNavigateBack = onNavigateBack,
onRefClick = onRefClick,
onScrollPositionChanged = onScrollPositionChanged,
modifier = Modifier.padding(padding)
)
}
}
}
@@ -411,6 +411,35 @@ object DslAnnotatedParser {
linkText = Color(0xFF1E88E5),
)
private val DARK_THEME_COLORS: Map<Color, Color> = mapOf(
Color(0xFF000000) to Color(0xFFE8E6E3),
Color(0xFF000080) to Color(0xFF8FA8E0),
Color(0xFF00008B) to Color(0xFF8FA8E0),
Color(0xFF0000FF) to Color(0xFF7C9CE8),
Color(0xFF006400) to Color(0xFF66BB6A),
Color(0xFF008000) to Color(0xFF66BB6A),
Color(0xFF8B0000) to Color(0xFFE57373),
Color(0xFF800000) to Color(0xFFE57373),
Color(0xFF800080) to Color(0xFFBA68C8),
Color(0xFFA52A2A) to Color(0xFFBCAAA4),
Color(0xFF008080) to Color(0xFF4DB6AC),
Color(0xFF808000) to Color(0xFFC0CA33),
)
fun remapForDarkTheme(text: AnnotatedString): AnnotatedString {
if (text.spanStyles.none { it.item.color in DARK_THEME_COLORS }) return text
val builder = AnnotatedString.Builder(text.text)
text.spanStyles.forEach { range ->
val style = DARK_THEME_COLORS[range.item.color]
?.let { range.item.copy(color = it) } ?: range.item
builder.addStyle(style, range.start, range.end)
}
text.paragraphStyles.forEach { range ->
builder.addStyle(range.item, range.start, range.end)
}
return builder.toAnnotatedString()
}
// Allow external cache clearing (e.g., when dictionaries change)
fun clearRefCache() {
refCache.evictAll()
@@ -1,5 +1,7 @@
package com.example.research.ui.main
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
@@ -90,9 +92,14 @@ fun AdaptiveMainScreen(
)
},
detailPane = {
val titleSelectionReset = remember { mutableIntStateOf(0) }
Column(
modifier = Modifier
.fillMaxSize()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { titleSelectionReset.intValue++ }
.padding(bottom = 16.dp)
) {
ArticleTitleBar(
@@ -100,6 +107,7 @@ fun AdaptiveMainScreen(
showBackButton = isShowingDetail && articleState != null,
onNavigateBack = onNavigateBackFromArticle,
compact = true,
selectionReset = titleSelectionReset.intValue,
modifier = Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
)
@@ -1,7 +1,16 @@
- The app starts faster
- Smooth screen transition animations
- Articles render DSL line breaks, indentation and named colors correctly
- Tabs and extra spaces inside articles no longer break the layout
- The article title can be copied, tapping the article clears the selection
- Source URLs accept host:port addresses
- Dictionary downloads retry automatically when the network switches
- Download progress notifications are more reliable
- Search results clear instantly when the query is erased
- Leftover files from cancelled downloads are now removed automatically
- Improved app stability
- Faster dictionary indexing
- Updated build tooling and documentation
- Long link-heavy articles no longer freeze while scrolling
- Large search result lists render faster
- Dark article colors are readable in the dark theme
@@ -1,7 +1,16 @@
- Приложение запускается быстрее
- Плавные анимации переходов между экранами
- Статьи корректно отображают переносы строк, отступы и цвета из DSL-разметки
- Табуляции и лишние пробелы в статьях больше не ломают вёрстку
- Заголовок статьи можно скопировать, тап по статье сбрасывает выделение
- Адреса источников принимают формат host:port
- Загрузка словарей автоматически повторяется при переключении сети
- Уведомления о прогрессе загрузки стали надёжнее
- Результаты поиска очищаются мгновенно при стирании запроса
- Остаточные файлы после отменённых загрузок удаляются автоматически
- Повышена стабильность приложения
- Ускорена индексация словарей
- Обновлены тулинг сборки и документация
- Длинные статьи со ссылками больше не подвисают при прокрутке
- Большие списки результатов поиска отображаются быстрее
- Тёмные цвета статей читаемы в тёмной теме