8 Commits
Author SHA1 Message Date
OneWay c7c080939c 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-24 13:00:00 +08:00
OneWay 2c8668dab3 build: update core to 1.19.1 2026-09-24 12:00:00 +08:00
OneWay e4e50db08e 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-24 10:00:00 +08:00
OneWay 98ec38f272 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-24 09:00:00 +08:00
OneWay 6040ca8db5 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 a uniform
line pitch via LineHeightStyle(Proportional, Trim.Both).
2026-09-23 09:30:00 +08:00
OneWay 71fea4472b fix(ui): read paging snapshot directly
Replace the per-index peek loop with the item snapshot list;
the loop walked the whole result set on every recomposition.
2026-09-23 09:00:00 +08:00
OneWay b5fc7e7246 fix(ui): keep previous search results while reloading
Keep showing the last results while a new search generation loads,
and guard list access: the paging snapshot can shrink mid-layout,
which threw and crashed on an empty result set.
2026-09-22 00:37:48 +08:00
OneWay 447d086cdf fix(dsl): close margin blocks at the closing tag
Blocks stayed open until the next character arrived, so following text
merged into them and trailing escapes dropped. Close each block right
at its tag and append escaped characters unconditionally.
2026-09-21 22:08:45 +08:00
10 changed files with 234 additions and 99 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,6 +12,7 @@ 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.testTag
import androidx.compose.ui.res.painterResource
@@ -18,6 +20,9 @@ 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.TextStyle
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -48,6 +53,54 @@ fun ArticleRoute(
)
}
private const val MERGED_BLOCK_MAX_CHARS = 1500
private const val MERGED_BLOCK_MAX_ITEMS = 12
private fun mergeDslBlocks(blocks: List<DslBlock>): List<DslBlock> {
if (blocks.size <= MERGED_BLOCK_MAX_ITEMS) return blocks
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) {
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?,
@@ -63,7 +116,11 @@ fun ArticleContent(
is ArticleState.Success -> {
val articleSaveKey = "${articleState.dictionaryPath}:${articleState.title}"
val baseTextStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface
color = MaterialTheme.colorScheme.onSurface,
lineHeightStyle = LineHeightStyle(
alignment = LineHeightStyle.Alignment.Proportional,
trim = LineHeightStyle.Trim.Both,
),
)
val indentStep = 16.dp
@@ -123,6 +180,22 @@ fun ArticleContent(
val isHeavyArticle =
articleState.dslContent.size > HEAVY_ARTICLE_BLOCK_COUNT
val darkTheme = MaterialTheme.colorScheme.background.luminance() < 0.5f
val displayBlocks = remember(
articleState.dslContent,
baseTextStyle,
darkTheme
) {
val merged =
mergeDslBlocks(articleState.dslContent)
if (darkTheme) {
merged.map {
it.copy(text = DslAnnotatedParser.remapForDarkTheme(it.text))
}
} else {
merged
}
}
val articleList: @Composable (Modifier) -> Unit = { listModifier ->
LazyColumn(
state = listState,
@@ -134,39 +207,30 @@ 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
val textContent = @Composable {
Box(
Box(
modifier = Modifier
.padding(start = Spacing.contentStart + indentStep * block.indent)
) {
ArticleLinkText(
block = block,
linksEnabled = true,
preferDirectHitTesting = isHeavyArticle,
style = baseTextStyle,
onRefClick = refClickHandler,
onTextLayout = {},
modifier = Modifier
.padding(
start = Spacing.contentStart + indentStep * block.indent
)
.padding(bottom = if (followedByContinuation) 0.dp else 8.dp)
) {
ArticleLinkText(
block = block,
linksEnabled = true,
preferDirectHitTesting = isHeavyArticle,
style = baseTextStyle,
onRefClick = refClickHandler,
onTextLayout = {},
modifier = Modifier
.testTag("article_block_$index")
.semantics {
testTagsAsResourceId = true
}
)
}
.testTag("article_block_$index")
.semantics {
testTagsAsResourceId = true
}
)
}
textContent()
}
}
}
@@ -213,6 +277,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 +323,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 +350,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)
)
}
}
}
@@ -85,7 +85,6 @@ object DslAnnotatedParser {
val lazyRefs = mutableListOf<LazyRef>()
var accentNext = false
var currentIndent = 0
var mClosedPendingBreak = false
var lastAppended: Char? = null
val exampleStyle = SpanStyle(color = colorScheme.secondaryText)
val labelStyle = SpanStyle(color = colorScheme.labelText)
@@ -116,7 +115,6 @@ object DslAnnotatedParser {
stack.forEach { builder.pushStyle(it) }
lazyRefs.clear()
lastAppended = null
mClosedPendingBreak = false
}
fun appendChar(char: Char, hard: Boolean = false) {
@@ -124,12 +122,6 @@ object DslAnnotatedParser {
if (lastAppended == null || lastAppended == ' ') return
}
lastAppended = char
if (mClosedPendingBreak) {
mClosedPendingBreak = false
if (refStack.isEmpty() && !char.isWhitespace()) {
flushBlock()
}
}
builder.append(char)
if (refStack.isNotEmpty()) {
refStack.last().second.append(char)
@@ -158,7 +150,7 @@ object DslAnnotatedParser {
when (val char = dsl[i]) {
'\\' -> {
if (i + 1 < length) {
appendChar(dsl[i + 1])
appendChar(dsl[i + 1], hard = true)
i += 2
continue
}
@@ -198,10 +190,11 @@ object DslAnnotatedParser {
} else if (tagName == "'") {
accentNext = false
}
if (tagName.startsWith("m")) {
mClosedPendingBreak = true
}
pop()
if (tagName.startsWith("m")) {
flushBlock()
currentIndent = 0
}
} else {
when (tagName) {
"'" -> {
@@ -418,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)
)
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -50,6 +51,7 @@ internal fun SearchResultsPane(
var restoredScrollPosition by remember { mutableStateOf(false) }
var pendingScrollToTop by remember { mutableStateOf(false) }
var sawRefreshForPendingScroll by remember { mutableStateOf(false) }
var staleEntries by remember { mutableStateOf(emptyList<IndexEntry>()) }
val imeBottomPadding = with(LocalDensity.current) {
WindowInsets.ime.getBottom(this).toDp()
}
@@ -72,9 +74,22 @@ internal fun SearchResultsPane(
restoredScrollPosition = true
pendingScrollToTop = query.isNotBlank()
sawRefreshForPendingScroll = false
if (query.isBlank()) {
staleEntries = emptyList()
}
}
}
val loaded = results.itemSnapshotList.items.filterNotNull()
SideEffect {
if (results.loadState.refresh is LoadState.NotLoading && loaded.isNotEmpty()) {
staleEntries = loaded
}
}
val pendingReload = results.itemCount == 0 &&
results.loadState.refresh is LoadState.Loading
val shownStale = if (pendingReload && searchState.text.isNotBlank()) staleEntries else emptyList()
LaunchedEffect(results.loadState.refresh, pendingScrollToTop) {
if (pendingScrollToTop && results.loadState.refresh is LoadState.Loading) {
sawRefreshForPendingScroll = true
@@ -125,6 +140,7 @@ internal fun SearchResultsPane(
listState = listState,
showDictionaryName = showDictionaryName,
contentPadding = PaddingValues(bottom = imeBottomPadding),
staleEntries = shownStale,
)
}
}
@@ -30,8 +30,11 @@ fun SearchResultsList(
modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
showDictionaryName: Boolean = true,
contentPadding: PaddingValues = PaddingValues()
contentPadding: PaddingValues = PaddingValues(),
staleEntries: List<IndexEntry> = emptyList(),
) {
val showStale = staleEntries.isNotEmpty() && results.itemCount == 0
val rowCount = if (showStale) staleEntries.size else results.itemCount
LazyColumn(
modifier = modifier
.testTag("search_results")
@@ -41,11 +44,18 @@ fun SearchResultsList(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(
count = results.itemCount,
key = results.itemKey { "${it.dictionaryPath}:${it.offset.value}:${it.word}" },
contentType = results.itemContentType { "search_result" }
count = rowCount,
key = { index ->
val entry = if (showStale) staleEntries.getOrNull(index)
else if (index in 0 until results.itemCount) results.peek(index) else null
entry?.let { "${it.dictionaryPath}:${it.offset.value}:${it.word}" }
?: "search_result_$index"
},
contentType = { "search_result" }
) { index ->
results[index]?.let { entry ->
val entry = if (showStale) staleEntries.getOrNull(index)
else if (index in 0 until results.itemCount) results.peek(index) else null
if (entry != null) {
Box(
modifier = Modifier.animateItem(
fadeInSpec = if (index < 8) tween(150) else null,
@@ -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
- Загрузка словарей автоматически повторяется при переключении сети
- Уведомления о прогрессе загрузки стали надёжнее
- Результаты поиска очищаются мгновенно при стирании запроса
- Остаточные файлы после отменённых загрузок удаляются автоматически
- Повышена стабильность приложения
- Ускорена индексация словарей
- Обновлены тулинг сборки и документация
- Длинные статьи со ссылками больше не подвисают при прокрутке
- Большие списки результатов поиска отображаются быстрее
- Тёмные цвета статей читаемы в тёмной теме
+1 -1
View File
@@ -3,7 +3,7 @@ aboutlibraries = "15.2.0"
activity_compose = "1.13.0"
agp = "9.4.1"
compose_bom = "2026.09.00"
core = "1.19.0"
core = "1.19.1"
datastore_preferences = "1.2.1"
documentfile = "1.1.0"
kotlin = "2.4.20"