From 540b80d36ab693942ea567613e5032c17ddf9830 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Wed, 28 Oct 2026 02:24:21 +0200 Subject: [PATCH] feat(reader): the places that refer to a line, and the sources, as search results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new "מראי מקומות" pane lists the places that refer to the selected line: its REFERENCE and מסורת הש״ס links in both directions and its עין משפט (Rambam, Tur, Shulchan Aruch...), until now hidden. The "מקור" pane is drawn by the same view. Both read as the search's results: a book at a time, its first passage cut around its quote of the line, its other places behind "עוד N מקומות", filter tabs by kind and category. A click opens the book there. - One view (LinePlacesView) and one loader for both panes, on SeforimLibrary's getReferencesForLines / getSourcesForLines; the legacy sources paging, the per-line "selected sources" state and the SOURCE branches of LineTargumView are removed. - The result row, title, snippet, filter tabs and links are shared with the search page (ResultRow, ResultTitle, SnippetText, FilterTabs). - The results follow the text zoom in the panes and the search list (LocalResultTextScale); tabs and status lines keep their size. - A daf or perek heading lists the places of its whole section, as the commentaries do; the rail buttons are enabled by the same lines. - The quote of each passage is found only for the passages on screen, off the UI thread: Bereshit chapter 1 loads in about 20 ms. - Panes docked beside the text sit side by side, in the rail's order. - showReferences is appended at the end of the persisted tab state (ProtoBuf field numbers); the now unused sources selections stay for the same reason. - SeforimLibrary bumped to the master with #138. Signed-off-by: Elie Gambache --- .../composeResources/values/strings.xml | 10 + .../core/presentation/window/WindowBody.kt | 2 + .../features/bookcontent/BookContentEvent.kt | 8 +- .../bookcontent/BookContentViewModel.kt | 36 +- .../bookcontent/references/LineReferences.kt | 264 +++++++++ .../bookcontent/state/BookContentState.kt | 8 +- .../state/BookContentStateManager.kt | 9 +- .../state/LineConnectionsSnapshot.kt | 1 - .../features/bookcontent/state/StateKeys.kt | 2 - .../bookcontent/ui/components/VerticalBars.kt | 43 +- .../ui/panels/bookcontent/BookContentPanel.kt | 23 +- .../bookcontent/views/LinePlacesView.kt | 322 +++++++++++ .../bookcontent/views/LineTargumView.kt | 132 +---- .../bookcontent/ui/panes/ReaderPanes.kt | 48 +- .../usecases/CommentariesUseCase.kt | 159 +----- .../features/search/SearchResultScreen.kt | 530 ++++++++++-------- .../framework/session/SessionModels.kt | 3 + .../bookcontent/PostSelectLineTest.kt | 8 - .../features/bookcontent/ReaderBench.kt | 1 - .../references/LineReferencesTest.kt | 130 +++++ .../BookContentStateManagerPersistenceTest.kt | 2 - .../state/LineConnectionsSnapshotTest.kt | 27 +- .../CommentariesUseCaseReapplyTest.kt | 41 +- SeforimLibrary | 2 +- 24 files changed, 1160 insertions(+), 651 deletions(-) create mode 100644 SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/references/LineReferences.kt create mode 100644 SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/LinePlacesView.kt create mode 100644 SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/references/LineReferencesTest.kt diff --git a/SeforimApp/src/commonMain/composeResources/values/strings.xml b/SeforimApp/src/commonMain/composeResources/values/strings.xml index 0428d11c1..d7ba2af43 100644 --- a/SeforimApp/src/commonMain/composeResources/values/strings.xml +++ b/SeforimApp/src/commonMain/composeResources/values/strings.xml @@ -276,6 +276,16 @@ אנא בחר לפחות מפרש אחד מהרשימה בחר שורה כדי לצפות במקורות אין מקורות זמינים לשורה זו + מראי מקומות + מראי מקומות + הצג את הספרים שמזכירים שורה זו + בחר שורה כדי לראות אילו ספרים מזכירים אותה + אין מראי מקומות לשורה זו + %1$d ספרים · %2$d מקומות + עוד %1$d מקומות מ%2$s + עוד מקום אחד מ%1$s + עין משפט + מסורת הש״ס אין מפרשים זמינים diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/window/WindowBody.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/window/WindowBody.kt index ee6514411..8ee6beb7c 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/window/WindowBody.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/window/WindowBody.kt @@ -36,6 +36,7 @@ import io.github.kdroidfilter.seforimapp.features.bookcontent.ui.panels.bookcont import io.github.kdroidfilter.seforimapp.features.bookcontent.ui.panels.bookcontent.LocalBookTextReady import io.github.kdroidfilter.seforimapp.features.bookcontent.ui.panels.bookcontent.isBookTextShown import io.github.kdroidfilter.seforimapp.features.bookcontent.ui.panes.DockedPaneCard +import io.github.kdroidfilter.seforimapp.features.bookcontent.ui.panes.LineLayeredSides import io.github.kdroidfilter.seforimapp.features.bookcontent.ui.panes.LineSideOrder import io.github.kdroidfilter.seforimapp.features.bookcontent.ui.panes.NavigationLayeredSides import io.github.kdroidfilter.seforimapp.features.bookcontent.ui.panes.PaneCard @@ -107,6 +108,7 @@ fun WindowBody(openWindow: OpenWindow) { workspace = lineWorkspace, modifier = Modifier.weight(1f).fillMaxWidth().onSizeChanged { innerSize = it }, sideOrder = LineSideOrder, + layeredSides = LineLayeredSides, splitter = { ReaderSplitter() }, panel = { panel -> DockedPaneCard(bottomOpen) { panel() } }, ) { diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentEvent.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentEvent.kt index 1ed35bca8..22aa19944 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentEvent.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentEvent.kt @@ -154,6 +154,9 @@ sealed interface BookContentEvent { data object ToggleSources : BookContentEvent + /** Shows or hides the pane of the places that refer to the selected line (its REFERENCE links). */ + data object ToggleReferences : BookContentEvent + /** Shows or hides the book-details pane (in every tab: one setting). */ data object ToggleBookDetails : BookContentEvent @@ -230,11 +233,6 @@ sealed interface BookContentEvent { val selectedIds: Set, ) : BookContentEvent - data class SelectedSourcesChanged( - val lineId: Long, - val selectedIds: Set, - ) : BookContentEvent - // Scroll events data class ParagraphScrolled( val position: Int, diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentViewModel.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentViewModel.kt index a5e4eb620..eea147a5a 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentViewModel.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentViewModel.kt @@ -147,14 +147,6 @@ class BookContentViewModel( } else -> state.content.selectedLinkSourcesByBook[bookId].orEmpty() } - val selectedSources: Set = - when { - lineId != null -> { - val perLine = state.content.selectedSourcesByLine[lineId].orEmpty() - perLine.ifEmpty { state.content.selectedSourcesByBook[bookId].orEmpty() } - } - else -> state.content.selectedSourcesByBook[bookId].orEmpty() - } state.copy( providers = Providers( @@ -166,15 +158,11 @@ class BookContentViewModel( prefetchCommentaries = commentariesUseCase::prefetchCommentaries, buildLinksPagerFor = commentariesUseCase::buildLinksPager, getAvailableLinksForLine = commentariesUseCase::getAvailableLinks, - buildSourcesPagerFor = commentariesUseCase::buildSourcesPager, - getAvailableSourcesForLine = commentariesUseCase::getAvailableSources, // Multi-line providers buildCommentariesPagerForLines = commentariesUseCase::buildCommentariesPagerForLines, getCommentatorGroupsForLines = commentariesUseCase::getCommentatorGroupsForLines, buildLinksPagerForLines = commentariesUseCase::buildLinksPagerForLines, getAvailableLinksForLines = commentariesUseCase::getAvailableLinksForLines, - buildSourcesPagerForLines = commentariesUseCase::buildSourcesPagerForLines, - getAvailableSourcesForLines = commentariesUseCase::getAvailableSourcesForLines, getCommentaryCharCountsForLine = commentariesUseCase::getCommentaryCharCountsForLine, getCommentaryCharCountsForLines = commentariesUseCase::getCommentaryCharCountsForLines, getLinkCharCountsForLine = commentariesUseCase::getLinkCharCountsForLine, @@ -186,7 +174,6 @@ class BookContentViewModel( state.content.copy( selectedCommentatorIds = selectedCommentators, selectedTargumSourceIds = selectedLinks, - selectedSourceIds = selectedSources, ), ) }.stateIn( @@ -212,14 +199,6 @@ class BookContentViewModel( } else -> s.content.selectedLinkSourcesByBook[bookId].orEmpty() } - val selectedSources: Set = - when { - lineId != null -> { - val perLine = s.content.selectedSourcesByLine[lineId].orEmpty() - perLine.ifEmpty { s.content.selectedSourcesByBook[bookId].orEmpty() } - } - else -> s.content.selectedSourcesByBook[bookId].orEmpty() - } s.copy( providers = Providers( @@ -231,15 +210,11 @@ class BookContentViewModel( prefetchCommentaries = commentariesUseCase::prefetchCommentaries, buildLinksPagerFor = commentariesUseCase::buildLinksPager, getAvailableLinksForLine = commentariesUseCase::getAvailableLinks, - buildSourcesPagerFor = commentariesUseCase::buildSourcesPager, - getAvailableSourcesForLine = commentariesUseCase::getAvailableSources, // Multi-line providers buildCommentariesPagerForLines = commentariesUseCase::buildCommentariesPagerForLines, getCommentatorGroupsForLines = commentariesUseCase::getCommentatorGroupsForLines, buildLinksPagerForLines = commentariesUseCase::buildLinksPagerForLines, getAvailableLinksForLines = commentariesUseCase::getAvailableLinksForLines, - buildSourcesPagerForLines = commentariesUseCase::buildSourcesPagerForLines, - getAvailableSourcesForLines = commentariesUseCase::getAvailableSourcesForLines, getCommentaryCharCountsForLine = commentariesUseCase::getCommentaryCharCountsForLine, getCommentaryCharCountsForLines = commentariesUseCase::getCommentaryCharCountsForLines, getLinkCharCountsForLine = commentariesUseCase::getLinkCharCountsForLine, @@ -251,7 +226,6 @@ class BookContentViewModel( s.content.copy( selectedCommentatorIds = selectedCommentators, selectedTargumSourceIds = selectedLinks, - selectedSourceIds = selectedSources, ), ) }, @@ -644,6 +618,9 @@ class BookContentViewModel( BookContentEvent.ToggleSources -> contentUseCase.toggleSources() + BookContentEvent.ToggleReferences -> + stateManager.updateContent { copy(showReferences = !showReferences) } + BookContentEvent.ToggleBookDetails -> appSettings.setBookDetailsPaneVisible(!appSettings.isBookDetailsPaneVisible()) @@ -720,9 +697,6 @@ class BookContentViewModel( is BookContentEvent.SelectedTargumSourcesChanged -> commentariesUseCase.updateSelectedLinkSources(event.lineId, event.selectedIds) - is BookContentEvent.SelectedSourcesChanged -> - commentariesUseCase.updateSelectedSources(event.lineId, event.selectedIds) - // State BookContentEvent.SaveState -> stateManager.saveAllStates() @@ -798,7 +772,7 @@ class BookContentViewModel( val persisted = persistedBeforeLoad ?: persistedStore.get(tabId)?.bookContent val shouldEnsureSelectionForPanes = persisted?.let { - it.showCommentaries || it.showTargum || it.showSources + it.showCommentaries || it.showTargum || it.showSources || it.showReferences } == true // Restaurer la sélection multi-ligne ou simple val selectedLineIds = persisted?.selectedLineIds?.takeIf { it.isNotEmpty() } @@ -1042,12 +1016,10 @@ class BookContentViewModel( val primaryLineId = currentState.content.primarySelectedLineId ?: lineIds.firstOrNull() ?: line.id commentariesUseCase.reapplySelectedCommentatorsForLines(lineIds, primaryLineId, bookId) commentariesUseCase.reapplySelectedLinkSourcesForLines(lineIds, primaryLineId, bookId) - commentariesUseCase.reapplySelectedSourcesForLines(lineIds, primaryLineId, bookId) } else { val primaryLine = selectedLines.firstOrNull() ?: line commentariesUseCase.reapplySelectedCommentators(primaryLine) commentariesUseCase.reapplySelectedLinkSources(primaryLine) - commentariesUseCase.reapplySelectedSources(primaryLine) } if (syncAltToc) { diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/references/LineReferences.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/references/LineReferences.kt new file mode 100644 index 000000000..7bc477703 --- /dev/null +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/references/LineReferences.kt @@ -0,0 +1,264 @@ +package io.github.kdroidfilter.seforimapp.features.bookcontent.references + +import androidx.compose.runtime.Immutable +import io.github.kdroidfilter.seforim.htmlparser.plainTextFromHtml +import io.github.kdroidfilter.seforimlibrary.core.models.Category +import io.github.kdroidfilter.seforimlibrary.core.models.ConnectionType +import io.github.kdroidfilter.seforimlibrary.core.models.Line +import io.github.kdroidfilter.seforimlibrary.dao.repository.LineReference +import io.github.kdroidfilter.seforimlibrary.dao.repository.SeforimRepository +import io.github.kdroidfilter.seforimlibrary.search.SearchEngine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** A place that refers to the selected line: its line, its place in its book, and its text and the line it quotes. */ +@Immutable +data class ReferencePlace( + val line: Line, + val place: String?, + /** The passage's plain text. */ + val text: String, + /** The plain text of the selected line it is linked to, which it quotes. */ + val quoted: String?, +) { + /** + * HTML: the passage from a little before its quote of [quoted], the quote in `` — the longest run of the line's + * words the search finds in it ([SearchEngine.highlightRanges], the search's own matching, prefixes and inflections + * included). Worked out for the places shown only: on a whole perek the passages run to hundreds of thousands of + * letters, and the search loads its dictionary on its first call. + */ + fun snippet(searchEngine: SearchEngine): String = snippetAround(text, quoted?.let { quoteIn(text, it, searchEngine) }) + + /** The passage's start, before its quote is known. */ + val start: String get() = snippetAround(text, null) +} + +/** + * The kinds of places that refer to a line, in their order in the pane: the codes that rule on it (עין משפט), the + * same passage elsewhere (מסורת הש״ס), then the other references. Each of the first two is a filter tab of its own. + */ +enum class ReferenceKind( + val tabId: Long, +) { + EinMishpat(-1), + MesoratHashas(-2), + Reference(0), + ; + + companion object { + fun of(type: ConnectionType): ReferenceKind = + when (type) { + ConnectionType.EIN_MISHPAT -> EinMishpat + ConnectionType.MESORAT_HASHAS -> MesoratHashas + else -> Reference + } + } +} + +/** The places of one book, of one kind, that refer to the selected line. */ +@Immutable +data class ReferenceBook( + val bookId: Long, + val title: String, + val kind: ReferenceKind, + val places: List, + /** The filter tab the book falls under, null when the places need no tabs. */ + val tabId: Long?, +) + +/** The places that refer to the selected lines (their cross-reference links), a book at a time. */ +@Immutable +data class LineReferences( + val books: List, + /** The kinds with a tab of their own ([ReferenceKind.tabId]), before [categoryTabs]. */ + val kindTabs: List, + /** The other references' tabs by category, id to title, the most books first. */ + val categoryTabs: List>, +) { + val placeCount: Int get() = books.sumOf { it.places.size } + + companion object { + val Empty = LineReferences(emptyList(), emptyList(), emptyList()) + } +} + +/** The places a line pane lists: the places that refer to the line, or the ones it comments on. */ +enum class LinePlaces { + /** Its cross-references: the books that refer the most first. */ + References, + + /** The SOURCE view: in its own order, declared bases first. */ + Sources, +} + +/** The [kind] places of [lines], each with the line it is linked to (see [ReferencePlace.snippet]). */ +suspend fun loadLinePlaces( + kind: LinePlaces, + repository: SeforimRepository, + lines: Collection, +): LineReferences { + val ids = lines.map { it.id } + val references = + when (kind) { + LinePlaces.References -> repository.getReferencesForLines(ids) + LinePlaces.Sources -> repository.getSourcesForLines(ids) + } + if (references.isEmpty()) return LineReferences.Empty + val paths = references.map { it.categoryId }.distinct().associateWith { categoryPath(repository, it) } + return withContext(Dispatchers.Default) { + val quoted = lines.associate { it.id to plainTextFromHtml(it.content) } + groupReferences(references, paths, byCount = kind == LinePlaces.References) { quoted[it.fromLineId] } + } +} + +/** + * Groups [references] by kind and book: the kinds in their order, then [byCount] the books that refer the most first + * (then in catalog order), else in the order of [references]; each book's places in its order. [paths] gives each + * category's path from its root, for the other references' filter tabs; [quotedOf] the text of the line a reference + * starts from. + */ +internal fun groupReferences( + references: List, + paths: Map>, + byCount: Boolean = true, + quotedOf: (LineReference) -> String?, +): LineReferences { + val grouped = references.groupBy { ReferenceKind.of(it.connectionType) to it.line.bookId }.entries + val byBook = + if (byCount) { + grouped.sortedWith( + compareBy, List>> { it.key.first } + .thenByDescending { it.value.size } + .thenBy { it.value.first().bookOrderIndex } + .thenBy { it.value.first().bookTitle }, + ) + } else { + // groupBy keeps the order of the first place of each book; the sort is stable + grouped.sortedBy { it.key.first } + } + val others = byBook.filter { it.key.first == ReferenceKind.Reference } + val categoryOf = others.map { it.key }.zip(filterTabs(others.map { paths[it.value.first().categoryId].orEmpty() })).toMap() + val categoryTabs = + categoryOf.values + .filterNotNull() + .groupingBy { it.id to it.title } + .eachCount() + .entries + .sortedByDescending { it.value } + .map { it.key } + val kindTabs = byBook.map { it.key.first }.distinct().filter { it != ReferenceKind.Reference } + val withTabs = kindTabs.size + categoryTabs.size > 1 + val books = + byBook.map { (key, refs) -> + val first = refs.first() + ReferenceBook( + bookId = first.line.bookId, + title = first.bookTitle, + kind = key.first, + places = + refs.sortedBy { it.line.lineIndex }.map { ref -> + ReferencePlace(ref.line, placeOf(ref.line, ref.bookTitle), plainTextFromHtml(ref.line.content), quotedOf(ref)) + }, + tabId = + when { + !withTabs -> null + key.first != ReferenceKind.Reference -> key.first.tabId + else -> categoryOf[key]?.id + }, + ) + } + return if (withTabs) LineReferences(books, kindTabs, categoryTabs) else LineReferences(books, emptyList(), emptyList()) +} + +/** + * The filter tab of each book, from its category path: the books' top categories, and a category holding more than + * two thirds of them split into its children, as long as it splits (the commentators on the Torah all sit under + * תנ״ך: their ראשונים and אחרונים tell more). A book right in a split category keeps it. + */ +internal fun filterTabs(paths: List>): List { + val depth = IntArray(paths.size) + val tabs = paths.map { it.firstOrNull() }.toMutableList() + while (true) { + val dominant = + tabs.indices + .filter { tabs[it] != null } + .groupBy { tabs[it]!!.id } + .values + .maxByOrNull { it.size } ?: break + if (dominant.size * 3 <= paths.size * 2) break + val deeper = dominant.filter { paths[it].size > depth[it] + 1 } + if (deeper.isEmpty()) break + deeper.forEach { + depth[it]++ + tabs[it] = paths[it][depth[it]] + } + } + return tabs +} + +/** The place of [line] in its book, as its reference reads without the book's title ("בראשית, יב, ב"). */ +internal fun placeOf( + line: Line, + bookTitle: String, +): String? = + line.heRef + ?.removePrefix(bookTitle) + ?.trimStart(',', ' ') + ?.replace(SPACES, " ") + ?.trim() + ?.ifBlank { null } + +/** [text] from a little before [quote] (cut at a word), the quote in ``; the text's start when there is none. */ +internal fun snippetAround( + text: String, + quote: IntRange?, +): String { + if (quote == null) return text.take(SNIPPET_CHARS).escapeHtml() + var start = (quote.first - CONTEXT_CHARS).coerceAtLeast(0) + if (start > 0) start = text.indexOf(' ', start).takeIf { it in 0 until quote.first }?.plus(1) ?: quote.first + val end = (quote.last + 1 + SNIPPET_CHARS).coerceAtMost(text.length) + return buildString { + if (start > 0) append("…") + append(text.substring(start, quote.first).escapeHtml()) + append("").append(text.substring(quote.first, quote.last + 1).escapeHtml()).append("") + append(text.substring(quote.last + 1, end).escapeHtml()) + } +} + +/** + * The quote of [quoted] in [text]: the longest run of its words the search finds there. A lone short word (את, על) + * is no quote. + */ +private fun quoteIn( + text: String, + quoted: String, + searchEngine: SearchEngine, +): IntRange? = + searchEngine + .highlightRanges(text, quoted) + .filter { range -> ' ' in text.substring(range) || range.last - range.first + 1 >= MIN_QUOTE_CHARS } + .maxByOrNull { it.last - it.first } + +private suspend fun categoryPath( + repository: SeforimRepository, + categoryId: Long, +): List { + val path = ArrayList() + var id: Long? = categoryId + while (id != null && path.size < MAX_DEPTH) { + val category = repository.getCategory(id) ?: break + path += category + id = category.parentId + } + return path.asReversed() +} + +private fun String.escapeHtml(): String = replace("&", "&").replace("<", "<").replace(">", ">") + +private val SPACES = Regex("\\s+") + +// The passage kept before the quote, and after it (the pane shows two to six lines of it) +private const val CONTEXT_CHARS = 40 +private const val SNIPPET_CHARS = 1200 +private const val MIN_QUOTE_CHARS = 4 +private const val MAX_DEPTH = 32 diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookContentState.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookContentState.kt index a587e2712..434443d54 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookContentState.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookContentState.kt @@ -24,15 +24,11 @@ data class Providers( val prefetchCommentaries: suspend (Long, Set) -> Unit, val buildLinksPagerFor: (Long, Long?) -> Flow>, val getAvailableLinksForLine: suspend (Long) -> Map, - val buildSourcesPagerFor: (Long, Long?) -> Flow>, - val getAvailableSourcesForLine: suspend (Long) -> Map, // Multi-line pagers (for multi-selection) val buildCommentariesPagerForLines: (List, Long?) -> Flow>, val getCommentatorGroupsForLines: suspend (List) -> List, val buildLinksPagerForLines: (List, Long?) -> Flow>, val getAvailableLinksForLines: suspend (List) -> Map, - val buildSourcesPagerForLines: (List, Long?) -> Flow>, - val getAvailableSourcesForLines: suspend (List) -> Map, // Char-count vectors used by the commentary scrollbar to derive visual-line metrics. // Mirror the pager ordering above; failures fold to an empty list. val getCommentaryCharCountsForLine: suspend (Long, Long) -> List, @@ -207,6 +203,7 @@ data class ContentState( val showCommentaries: Boolean = false, val showTargum: Boolean = false, val showSources: Boolean = false, + val showReferences: Boolean = false, // Each verse twice, then its targum val shnayimMikra: Boolean = false, // Scroll positions @@ -234,14 +231,11 @@ data class ContentState( // Filters selected in UI (for current line) val selectedCommentatorIds: Set = emptySet(), val selectedTargumSourceIds: Set = emptySet(), - val selectedSourceIds: Set = emptySet(), // Business selections by line/book (kept for use cases) val selectedCommentatorsByLine: Map> = emptyMap(), val selectedCommentatorsByBook: Map> = emptyMap(), val selectedLinkSourcesByLine: Map> = emptyMap(), val selectedLinkSourcesByBook: Map> = emptyMap(), - val selectedSourcesByLine: Map> = emptyMap(), - val selectedSourcesByBook: Map> = emptyMap(), // Bumped to hand the keyboard focus back to the text, e.g. when a breadcrumb popup closes (not persisted) val focusTextRequest: Int = 0, // Scrolling behavior control diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookContentStateManager.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookContentStateManager.kt index 376968cfb..4be031f36 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookContentStateManager.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookContentStateManager.kt @@ -73,6 +73,7 @@ class BookContentStateManager( showCommentaries = persisted.showCommentaries, showTargum = persisted.showTargum, showSources = persisted.showSources, + showReferences = persisted.showReferences, shnayimMikra = persisted.shnayimMikra, scrollIndex = persisted.contentScrollIndex, scrollOffset = persisted.contentScrollOffset, @@ -100,8 +101,6 @@ class BookContentStateManager( selectedCommentatorsByBook = persisted.selectedCommentatorsByBook, selectedLinkSourcesByLine = persisted.selectedTargumSourcesByLine, selectedLinkSourcesByBook = persisted.selectedTargumSourcesByBook, - selectedSourcesByLine = persisted.selectedSourcesByLine, - selectedSourcesByBook = persisted.selectedSourcesByBook, ), layout = LayoutState( @@ -215,15 +214,14 @@ class BookContentStateManager( copy( selectedCommentatorsByLine = emptyMap(), selectedLinkSourcesByLine = emptyMap(), - selectedSourcesByLine = emptyMap(), selectedTargumSourceIds = emptySet(), - selectedSourceIds = emptySet(), selectedLines = emptySet(), primarySelectedLineId = null, anchorId = -1L, scrollIndex = 0, scrollOffset = 0, showSources = false, + showReferences = false, topAnchorLineId = -1L, topAnchorRequestTimestamp = 0L, ) @@ -277,6 +275,7 @@ class BookContentStateManager( showCommentaries = currentState.content.showCommentaries, showTargum = currentState.content.showTargum, showSources = currentState.content.showSources, + showReferences = currentState.content.showReferences, shnayimMikra = currentState.content.shnayimMikra, paragraphScrollPosition = currentState.content.paragraphScrollPosition, chapterScrollPosition = currentState.content.chapterScrollPosition, @@ -298,8 +297,6 @@ class BookContentStateManager( selectedCommentatorsByBook = currentState.content.selectedCommentatorsByBook, selectedTargumSourcesByLine = currentState.content.selectedLinkSourcesByLine, selectedTargumSourcesByBook = currentState.content.selectedLinkSourcesByBook, - selectedSourcesByLine = currentState.content.selectedSourcesByLine, - selectedSourcesByBook = currentState.content.selectedSourcesByBook, mainSplitPosition = currentState.layout.mainSplitState.positionPercentage, tocSplitPosition = currentState.layout.tocSplitState.positionPercentage, notesSplitPosition = currentState.layout.notesSplitState.positionPercentage, diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/LineConnectionsSnapshot.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/LineConnectionsSnapshot.kt index 8130932a3..5443ab59e 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/LineConnectionsSnapshot.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/LineConnectionsSnapshot.kt @@ -6,5 +6,4 @@ import androidx.compose.runtime.Immutable data class LineConnectionsSnapshot( val commentatorGroups: List = emptyList(), val targumSources: Map = emptyMap(), - val sources: Map = emptyMap(), ) diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/StateKeys.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/StateKeys.kt index 4d36d093d..d472e75dd 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/StateKeys.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/StateKeys.kt @@ -65,8 +65,6 @@ object StateKeys { const val SELECTED_COMMENTATORS_BY_BOOK = "selectedCommentatorsByBook" const val SELECTED_TARGUM_SOURCES_BY_LINE = "selectedTargumSourcesByLine" const val SELECTED_TARGUM_SOURCES_BY_BOOK = "selectedTargumSourcesByBook" - const val SELECTED_SOURCES_BY_LINE = "selectedSourcesByLine" - const val SELECTED_SOURCES_BY_BOOK = "selectedSourcesByBook" // Layout - Split Panes const val SPLIT_PANE_POSITION = "splitPanePosition" diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/components/VerticalBars.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/components/VerticalBars.kt index 9cfcc4526..968577c94 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/components/VerticalBars.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/components/VerticalBars.kt @@ -22,6 +22,7 @@ import io.github.kdroidfilter.seforimapp.core.settings.AppSettings import io.github.kdroidfilter.seforimapp.features.bookcontent.BookContentEvent import io.github.kdroidfilter.seforimapp.features.bookcontent.state.BookContentState import io.github.kdroidfilter.seforimapp.features.bookcontent.ui.panels.bookcontent.views.isChumash +import io.github.kdroidfilter.seforimapp.features.bookcontent.ui.panels.bookcontent.views.placesLines import io.github.kdroidfilter.seforimapp.framework.di.LocalAppGraph import io.github.kdroidfilter.seforimapp.framework.platform.PlatformInfo import io.github.kdroidfilter.seforimapp.icons.* @@ -87,11 +88,15 @@ fun EndVerticalBar( val noBookSelected = selectedBook == null val selectedLine = uiState.content.primaryLine val providers = uiState.providers + val repository = LocalAppGraph.current.repository + // The places panes' lines: a heading's whole section, as their panes list it + val placesLineIds = uiState.content.placesLines().map { it.id } val lineAvailability by produceState( initialValue = LineResourceAvailability(), - key1 = selectedLine?.id, - key2 = providers, + selectedLine?.id, + providers, + placesLineIds, ) { if (selectedLine == null || providers == null) { value = LineResourceAvailability() @@ -106,9 +111,14 @@ fun EndVerticalBar( runSuspendCatching { providers.getAvailableCommentatorsForLine(selectedLine.id) }.getOrNull()?.isNotEmpty() + // The line panes' own queries, so a button is enabled exactly when its pane has something to show val sourcesAvailable = runSuspendCatching { - providers.getAvailableSourcesForLine(selectedLine.id) + repository.getSourcesForLines(placesLineIds) + }.getOrNull()?.isNotEmpty() + val referencesAvailable = + runSuspendCatching { + repository.getReferencesForLines(placesLineIds) }.getOrNull()?.isNotEmpty() value = @@ -116,6 +126,7 @@ fun EndVerticalBar( targumAvailable = targumAvailable, commentariesAvailable = commentariesAvailable, sourcesAvailable = sourcesAvailable, + referencesAvailable = referencesAvailable, ) } @@ -175,6 +186,8 @@ fun EndVerticalBar( val targumEnabled = selectedBook?.hasTargumConnection == true val commentaryEnabled = selectedBook?.hasCommentaryConnection == true val sourcesEnabled = selectedBook?.hasSourceConnection == true + // Ein mishpat and masoret hashas have no flag of their own: OTHER covers the books that have them + val referencesEnabled = selectedBook?.hasReferenceConnection == true || selectedBook?.hasOtherConnection == true val linksEnabled = (selectedBook?.hasReferenceConnection == true) || (selectedBook?.hasOtherConnection == true) // Hide both buttons on Home (no book selected) @@ -192,6 +205,11 @@ fun EndVerticalBar( lineAvailability.sourcesAvailable == false && !uiState.content.showSources + val referencesDisabledForLine = + selectedLine != null && + lineAvailability.referencesAvailable == false && + !uiState.content.showReferences + val targumTooltip = when { targumDisabledForLine -> stringResource(Res.string.no_links_for_line) @@ -225,6 +243,24 @@ fun EndVerticalBar( ) } + if (referencesEnabled) { + val referencesLabel = stringResource(Res.string.show_references) + SelectableIconButtonWithToolip( + toolTipText = + when { + referencesDisabledForLine -> stringResource(Res.string.no_references_for_line) + selectedLine == null -> stringResource(Res.string.select_line_for_references) + else -> stringResource(Res.string.show_references_tooltip) + }, + onClick = { onEvent(BookContentEvent.ToggleReferences) }, + isSelected = uiState.content.showReferences, + icon = Library_books, + iconDescription = referencesLabel, + label = referencesLabel, + enabled = !referencesDisabledForLine, + ) + } + if (sourcesEnabled) { SelectableIconButtonWithToolip( toolTipText = sourcesTooltip, @@ -274,6 +310,7 @@ private data class LineResourceAvailability( val targumAvailable: Boolean? = null, val commentariesAvailable: Boolean? = null, val sourcesAvailable: Boolean? = null, + val referencesAvailable: Boolean? = null, ) /** The text's zoom in and out, shared by the reading screens' end bars. */ diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/BookContentPanel.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/BookContentPanel.kt index 524a2eb7f..32ff1b432 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/BookContentPanel.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/BookContentPanel.kt @@ -21,6 +21,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.paging.compose.collectAsLazyPagingItems import io.github.kdroidfilter.seforimapp.core.presentation.text.DiacriticsMode import io.github.kdroidfilter.seforimapp.features.bookcontent.BookContentEvent +import io.github.kdroidfilter.seforimapp.features.bookcontent.references.LinePlaces import io.github.kdroidfilter.seforimapp.features.bookcontent.state.BookContentState import io.github.kdroidfilter.seforimapp.features.bookcontent.state.BookTabUi import io.github.kdroidfilter.seforimapp.features.bookcontent.state.LineConnectionsSnapshot @@ -28,7 +29,6 @@ import io.github.kdroidfilter.seforimapp.features.bookcontent.ui.panels.bookcont import io.github.kdroidfilter.seforimapp.features.bookcontent.ui.panels.bookcontent.views.HomeSearchCallbacks import io.github.kdroidfilter.seforimapp.features.bookcontent.ui.panels.notes.NoteDraftAnchor import io.github.kdroidfilter.seforimapp.features.search.SearchHomeUiState -import io.github.kdroidfilter.seforimlibrary.core.models.ConnectionType import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce @@ -299,18 +299,23 @@ fun CommentsPane( fun SourcesPane( uiState: BookContentState, onEvent: (BookContentEvent) -> Unit, - lineConnections: Map, diacritics: DiacriticsMode, modifier: Modifier = Modifier, ) { Box(modifier = modifier) { - LineTargumView( - uiState = uiState, - onEvent = onEvent, - lineConnections = lineConnections, - availabilityType = ConnectionType.SOURCE, - diacritics = diacritics, - ) + LinePlacesView(kind = LinePlaces.Sources, uiState = uiState, onEvent = onEvent, diacritics = diacritics) + } +} + +@Composable +fun ReferencesPane( + uiState: BookContentState, + onEvent: (BookContentEvent) -> Unit, + diacritics: DiacriticsMode, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier) { + LinePlacesView(kind = LinePlaces.References, uiState = uiState, onEvent = onEvent, diacritics = diacritics) } } diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/LinePlacesView.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/LinePlacesView.kt new file mode 100644 index 000000000..26f432f40 --- /dev/null +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/LinePlacesView.kt @@ -0,0 +1,322 @@ +package io.github.kdroidfilter.seforimapp.features.bookcontent.ui.panels.bookcontent.views + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.github.kdroidfilter.seforimapp.core.coroutines.runSuspendCatching +import io.github.kdroidfilter.seforimapp.core.presentation.tabs.LocalTabSelected +import io.github.kdroidfilter.seforimapp.core.presentation.text.DiacriticsMode +import io.github.kdroidfilter.seforimapp.core.settings.AppSettings +import io.github.kdroidfilter.seforimapp.features.bookcontent.BookContentEvent +import io.github.kdroidfilter.seforimapp.features.bookcontent.references.LinePlaces +import io.github.kdroidfilter.seforimapp.features.bookcontent.references.LineReferences +import io.github.kdroidfilter.seforimapp.features.bookcontent.references.ReferenceBook +import io.github.kdroidfilter.seforimapp.features.bookcontent.references.ReferenceKind +import io.github.kdroidfilter.seforimapp.features.bookcontent.references.ReferencePlace +import io.github.kdroidfilter.seforimapp.features.bookcontent.references.loadLinePlaces +import io.github.kdroidfilter.seforimapp.features.bookcontent.state.BookContentState +import io.github.kdroidfilter.seforimapp.features.bookcontent.state.ContentState +import io.github.kdroidfilter.seforimapp.features.bookcontent.ui.components.PaneHeader +import io.github.kdroidfilter.seforimapp.features.search.FilterTabs +import io.github.kdroidfilter.seforimapp.features.search.LocalResultTextScale +import io.github.kdroidfilter.seforimapp.features.search.ResultLink +import io.github.kdroidfilter.seforimapp.features.search.ResultRow +import io.github.kdroidfilter.seforimapp.features.search.ResultTitle +import io.github.kdroidfilter.seforimapp.features.search.SnippetText +import io.github.kdroidfilter.seforimapp.features.search.readingSecondary +import io.github.kdroidfilter.seforimapp.features.search.resultSp +import io.github.kdroidfilter.seforimapp.framework.di.LocalAppGraph +import io.github.kdroidfilter.seforimlibrary.core.models.Line +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.CircularProgressIndicator +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.component.VerticallyScrollableContainer +import seforimapp.seforimapp.generated.resources.Res +import seforimapp.seforimapp.generated.resources.no_references_for_line +import seforimapp.seforimapp.generated.resources.no_sources_for_line +import seforimapp.seforimapp.generated.resources.references +import seforimapp.seforimapp.generated.resources.references_ein_mishpat +import seforimapp.seforimapp.generated.resources.references_mesorat_hashas +import seforimapp.seforimapp.generated.resources.references_more_from_book +import seforimapp.seforimapp.generated.resources.references_one_more_from_book +import seforimapp.seforimapp.generated.resources.references_summary +import seforimapp.seforimapp.generated.resources.search_collapse +import seforimapp.seforimapp.generated.resources.select_line_for_references +import seforimapp.seforimapp.generated.resources.select_line_for_sources +import seforimapp.seforimapp.generated.resources.sources + +/** + * The [kind] places of the selected line (the places that refer to it, or its sources), as the search's results: a + * book at a time, its first passage cut around the quote, its other places behind "עוד N מקומות", tabs by category + * when the books are many. A click opens the book there. + * + * The pane docks anywhere: a column beside the text, a row of columns under it — as many as its width holds. + */ +@Composable +fun LinePlacesView( + kind: LinePlaces, + uiState: BookContentState, + onEvent: (BookContentEvent) -> Unit, + diacritics: DiacriticsMode, +) { + val graph = LocalAppGraph.current + val content = uiState.content + val lines = content.placesLines() + val lineIds = lines.map { it.id } + + val references by produceState(null, kind, lineIds) { + value = null + value = + if (lines.isEmpty()) { + LineReferences.Empty + } else { + runSuspendCatching { loadLinePlaces(kind, graph.repository, lines) } + .getOrDefault(LineReferences.Empty) + } + } + + // The text follows the zoom as the other line panes' do, animated in the selected tab + val textSize by graph.appSettings.textSizeFlow + .collectAsState() + val animateZoom = LocalTabSelected.current && !LocalBookContentZoomInProgress.current + val scale by animateFloatAsState( + targetValue = textSize / AppSettings.DEFAULT_TEXT_SIZE, + animationSpec = if (animateZoom) tween(durationMillis = ZOOM_ANIMATION_MS) else snap(), + label = "linePlacesScale", + ) + + val paneInteractionSource = remember { MutableInteractionSource() } + Column(modifier = Modifier.fillMaxSize().hoverable(paneInteractionSource)) { + PaneHeader( + label = stringResource(kind.title), + interactionSource = paneInteractionSource, + onHide = { onEvent(kind.toggle) }, + ) + val shown = references + when { + lines.isEmpty() -> CenteredText(stringResource(kind.selectLine)) + shown == null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + shown.books.isEmpty() -> CenteredText(stringResource(kind.empty)) + else -> + CompositionLocalProvider(LocalResultTextScale provides scale) { + ReferencesList( + references = shown, + tabsMinBooks = kind.tabsMinBooks, + diacritics = diacritics, + onOpen = { place -> onEvent(BookContentEvent.OpenCommentaryTarget(place.line.bookId, place.line.id)) }, + ) + } + } + } +} + +@Composable +private fun ReferencesList( + references: LineReferences, + tabsMinBooks: Int, + diacritics: DiacriticsMode, + onOpen: (ReferencePlace) -> Unit, +) { + var tab by remember(references) { mutableStateOf(null) } + var expanded by remember(references) { mutableStateOf(emptySet()) } + val books = remember(references, tab) { references.books.filter { tab == null || it.tabId == tab } } + val listState = rememberLazyListState() + LaunchedEffect(tab) { listState.scrollToItem(0) } + + BoxWithConstraints(Modifier.fillMaxSize().padding(horizontal = 8.dp)) { + // Docked under the text the pane is wide: its books side by side, each column as wide as in a side pane + // Zoomed in, a column needs more room: fewer columns rather than cramped ones + val columns = (maxWidth / (COLUMN_MIN_WIDTH * LocalResultTextScale.current)).toInt().coerceIn(1, MAX_COLUMNS) + val rows = remember(books, columns) { books.chunked(columns) } + // A few books, all side by side: the room is theirs, their passages read longer + val passageLines = if (references.books.size <= columns) FEW_BOOKS_LINES else SNIPPET_LINES + VerticallyScrollableContainer(scrollState = listState as ScrollableState, modifier = Modifier.fillMaxSize()) { + // Room for the scrollbar, so that it never covers the passages + LazyColumn(state = listState, modifier = Modifier.fillMaxSize().padding(end = 11.dp)) { + item(key = "header") { + ReferencesHeader(references, tabsMinBooks, tab) { tab = it } + } + items(rows, key = { row -> row.first().key }) { row -> + Row(horizontalArrangement = Arrangement.spacedBy(COLUMN_GAP), verticalAlignment = Alignment.Top) { + row.forEach { book -> + ReferenceBookView( + book = book, + expanded = book.key in expanded, + diacritics = diacritics, + passageLines = passageLines, + onExpand = { expanded = if (it) expanded + book.key else expanded - book.key }, + onOpen = onOpen, + modifier = Modifier.weight(1f), + ) + } + // A last row short of books keeps the columns' width + repeat(columns - row.size) { Spacer(Modifier.weight(1f)) } + } + } + } + } + } +} + +/** How many books and places refer to the line, and the tabs that filter them by category. */ +@Composable +private fun ReferencesHeader( + references: LineReferences, + tabsMinBooks: Int, + tab: Long?, + onTab: (Long?) -> Unit, +) { + Column(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + stringResource(Res.string.references_summary, references.books.size, references.placeCount), + fontSize = 12.sp, + color = readingSecondary(), + ) + if (references.books.size >= tabsMinBooks) { + val kindTabs = references.kindTabs.map { it.tabId to stringResource(it.label!!) } + val tabs = remember(kindTabs, references.categoryTabs) { kindTabs + references.categoryTabs } + if (tabs.isNotEmpty()) FilterTabs(tabs, tab, onTab) + } + } +} + +/** A book's places: its first one, the others once [expanded]; each a result row that opens the book there. */ +@Composable +private fun ReferenceBookView( + book: ReferenceBook, + expanded: Boolean, + diacritics: DiacriticsMode, + passageLines: Int, + onExpand: (Boolean) -> Unit, + onOpen: (ReferencePlace) -> Unit, + modifier: Modifier = Modifier, +) { + val accent = JewelTheme.globalColors.outlines.focused + val graph = LocalAppGraph.current + val searchEngine = graph.searchEngine + val bookFontCode by graph.appSettings.bookFontCodeFlow + .collectAsState() + val places = if (expanded) book.places else book.places.take(1) + // The kind of a ruling or a parallel passage beside its place: it is no plain reference + val kind = book.kind.label?.let { stringResource(it) } + Column(modifier) { + places.forEachIndexed { index, place -> + ResultRow(selected = false, onClick = { onOpen(place) }) { + if (index == 0) { + ResultTitle(book.title, listOfNotNull(kind, place.place).joinToString(" · ").ifEmpty { null }) + } else if (place.place != null) { + Text(place.place, fontSize = resultSp(12f), color = readingSecondary(), maxLines = 1) + } + // The passage's start until its quote is found, off the UI thread, for the places on screen only + val snippet by produceState(diacritics.apply(place.start), place, diacritics) { + value = withContext(Dispatchers.Default) { diacritics.apply(place.snippet(searchEngine)) } + } + SnippetText(snippet, null, bookFontCode, maxLines = passageLines) + val more = book.places.size - 1 + when { + index == 0 && !expanded && more > 0 -> { + val label = + if (more == 1) { + stringResource(Res.string.references_one_more_from_book, book.title) + } else { + stringResource(Res.string.references_more_from_book, more, book.title) + } + ResultLink(label, accent, resultSp(11f)) { onExpand(true) } + } + expanded && index == places.lastIndex && more > 0 -> + ResultLink(stringResource(Res.string.search_collapse), accent, resultSp(11f)) { onExpand(false) } + } + } + } + } +} + +private val LinePlaces.title: StringResource + get() = if (this == LinePlaces.Sources) Res.string.sources else Res.string.references + +private val LinePlaces.selectLine: StringResource + get() = if (this == LinePlaces.Sources) Res.string.select_line_for_sources else Res.string.select_line_for_references + +private val LinePlaces.empty: StringResource + get() = if (this == LinePlaces.Sources) Res.string.no_sources_for_line else Res.string.no_references_for_line + +private val LinePlaces.toggle: BookContentEvent + get() = if (this == LinePlaces.Sources) BookContentEvent.ToggleSources else BookContentEvent.ToggleReferences + +/** + * The lines a places pane lists the places of: every selected line — a manual multi-selection (Ctrl+click), or a + * heading's whole section (a daf, a perek), as the commentaries show it — else the selected line. + */ +fun ContentState.placesLines(): List = + if (selectedLines.size > 1) selectedLines.sortedBy { it.lineIndex } else listOfNotNull(primaryLine) + +/** A book's key in the list: one book may refer to the line as a ruling and as a plain reference. */ +private val ReferenceBook.key: String get() = "${kind.name}-$bookId" + +private val ReferenceKind.label: StringResource? + get() = + when (this) { + ReferenceKind.EinMishpat -> Res.string.references_ein_mishpat + ReferenceKind.MesoratHashas -> Res.string.references_mesorat_hashas + ReferenceKind.Reference -> null + } + +@Composable +private fun CenteredText(text: String) { + Box(Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) { + Text(text, textAlign = TextAlign.Center) + } +} + +// A column of places as wide as the pane beside the text holds it; never more than four side by side +private val COLUMN_MIN_WIDTH = 300.dp +private val COLUMN_GAP = 12.dp +private const val MAX_COLUMNS = 4 + +// The passage's lines: two as the search's, more when a few books have the pane to themselves +private const val SNIPPET_LINES = 2 +private const val FEW_BOOKS_LINES = 6 + +/** + * Under this many books the filter tabs are hidden: a handful of references need no filter; the sources keep them, + * which tell the kind of each (חומש, תלמוד, הלכה) at a glance. + */ +private val LinePlaces.tabsMinBooks: Int + get() = if (this == LinePlaces.Sources) 0 else REFERENCES_TABS_MIN_BOOKS + +private const val REFERENCES_TABS_MIN_BOOKS = 6 + +private const val ZOOM_ANIMATION_MS = 300 diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/LineTargumView.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/LineTargumView.kt index 9574c2321..ceef2841e 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/LineTargumView.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/LineTargumView.kt @@ -52,9 +52,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged -import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi import org.jetbrains.jewel.foundation.theme.JewelTheme @@ -63,10 +61,7 @@ import org.jetbrains.jewel.ui.component.Text import seforimapp.seforimapp.generated.resources.Res import seforimapp.seforimapp.generated.resources.links import seforimapp.seforimapp.generated.resources.no_links_for_line -import seforimapp.seforimapp.generated.resources.no_sources_for_line import seforimapp.seforimapp.generated.resources.select_line_for_links -import seforimapp.seforimapp.generated.resources.select_line_for_sources -import seforimapp.seforimapp.generated.resources.sources // Per-side vertical padding applied by `LinkItem`'s Column. Exposed so the scrollbar // can derive the exact per-item padding contribution as `2 × LinkItemVerticalPaddingPerSide`. @@ -90,11 +85,6 @@ private fun SingleLineTargumView( onHide: () -> Unit = {}, highlightQuery: String = "", lineConnections: Map = emptyMap(), - availabilityType: ConnectionType = ConnectionType.TARGUM, - fontCodeFlow: StateFlow = LocalAppGraph.current.appSettings.targumFontCodeFlow, - titleRes: StringResource = Res.string.links, - selectLineRes: StringResource = Res.string.select_line_for_links, - emptyRes: StringResource = Res.string.no_links_for_line, ) { val appSettings = LocalAppGraph.current.appSettings val rawTextSize by appSettings.textSizeFlow.collectAsState() @@ -118,7 +108,7 @@ private fun SingleLineTargumView( val currentOnScroll by rememberUpdatedState(onScroll) // Selected font for targumim - val targumFontCode by fontCodeFlow.collectAsState() + val targumFontCode by appSettings.targumFontCodeFlow.collectAsState() val targumFontFamily = FontCatalog.familyFor(targumFontCode) val boldScaleForPlatform = FontCatalog.boldScaleFor(targumFontCode) @@ -131,7 +121,7 @@ private fun SingleLineTargumView( .hoverable(paneInteractionSource), ) { PaneHeader( - label = stringResource(titleRes), + label = stringResource(Res.string.links), interactionSource = paneInteractionSource, onHide = onHide, ) @@ -140,19 +130,14 @@ private fun SingleLineTargumView( when (selectedLine) { null -> { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text(text = stringResource(selectLineRes)) + Text(text = stringResource(Res.string.select_line_for_links)) } } else -> { val cachedSources = - remember(selectedLine.id, lineConnections, availabilityType) { - lineConnections[selectedLine.id]?.let { snapshot -> - when (availabilityType) { - ConnectionType.SOURCE -> snapshot.sources - else -> snapshot.targumSources - } - } + remember(selectedLine.id, lineConnections) { + lineConnections[selectedLine.id]?.targumSources } var titleToIdMap by remember(selectedLine.id, cachedSources) { @@ -160,13 +145,7 @@ private fun SingleLineTargumView( } LaunchedEffect(selectedLine.id, lineConnections) { - val cached = - lineConnections[selectedLine.id]?.let { snapshot -> - when (availabilityType) { - ConnectionType.SOURCE -> snapshot.sources - else -> snapshot.targumSources - } - } + val cached = lineConnections[selectedLine.id]?.targumSources if (cached != null) { titleToIdMap = cached return@LaunchedEffect @@ -179,7 +158,7 @@ private fun SingleLineTargumView( if (titleToIdMap.isEmpty()) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text(text = stringResource(emptyRes)) + Text(text = stringResource(Res.string.no_links_for_line)) } } else { // Preserve insertion order from the provider — the SQL @@ -219,7 +198,7 @@ private fun SingleLineTargumView( ) } val annotationCache = - remember(selectedLine.id, availabilityType) { + remember(selectedLine.id) { StableAnnotatedCache() } @@ -252,7 +231,6 @@ private fun SingleLineTargumView( initialValue = emptyList(), selectedLine.id, sectionBookIds, - availabilityType, ) { value = runSuspendCatching { @@ -263,7 +241,7 @@ private fun SingleLineTargumView( getLinkCharCountsRef( selectedLine.id, bookId, - availabilityType, + ConnectionType.TARGUM, ) } }.awaitAll() @@ -392,39 +370,24 @@ fun LineTargumView( onEvent: (BookContentEvent) -> Unit, diacritics: DiacriticsMode, lineConnections: Map = emptyMap(), - availabilityType: ConnectionType = ConnectionType.TARGUM, ) { val appSettings = LocalAppGraph.current.appSettings val providers = uiState.providers ?: return val contentState = uiState.content val selectedLineIds = contentState.selectedLineIds.toList() - // Multi-sélection manuelle (Ctrl+click) = afficher targum/sources de toutes les lignes - // TOC entry selection = afficher targum/sources seulement de la ligne primaire + // Manual multi-selection (Ctrl+click) shows the targum of every selected line; + // a TOC entry selection shows only the primary line's targum val isManualMultiSelection = selectedLineIds.size > 1 && !contentState.isTocEntrySelection val windowInfo = LocalWindowInfo.current val findQuery by appSettings.findQueryFlow(uiState.tabId).collectAsState("") val showFind by appSettings.findBarOpenFlow(uiState.tabId).collectAsState() val activeQuery = if (showFind) findQuery else "" - // Sélectionner les bons providers et callbacks selon le type - val isSourceType = availabilityType == ConnectionType.SOURCE - - val buildPagerFor = - if (isSourceType) providers.buildSourcesPagerFor else providers.buildLinksPagerFor - val getAvailableForLine = - if (isSourceType) providers.getAvailableSourcesForLine else providers.getAvailableLinksForLine - val initiallySelectedIds = - if (isSourceType) contentState.selectedSourceIds else contentState.selectedTargumSourceIds - val onSelectedSourcesChange = - remember(contentState.primaryLine, isSourceType) { + remember(contentState.primaryLine) { { ids: Set -> contentState.primaryLine?.let { line -> - if (isSourceType) { - onEvent(BookContentEvent.SelectedSourcesChanged(line.id, ids)) - } else { - onEvent(BookContentEvent.SelectedTargumSourcesChanged(line.id, ids)) - } + onEvent(BookContentEvent.SelectedTargumSourcesChanged(line.id, ids)) } Unit } @@ -452,23 +415,7 @@ fun LineTargumView( } } - val onHide = - remember(isSourceType) { - { - if (isSourceType) { - onEvent(BookContentEvent.ToggleSources) - } else { - onEvent(BookContentEvent.ToggleTargum) - } - } - } - - // Titres et messages selon le type - val titleRes = if (isSourceType) Res.string.sources else Res.string.links - val selectLineRes = - if (isSourceType) Res.string.select_line_for_sources else Res.string.select_line_for_links - val emptyRes = if (isSourceType) Res.string.no_sources_for_line else Res.string.no_links_for_line - val fontCodeFlow = if (isSourceType) appSettings.sourceFontCodeFlow else appSettings.targumFontCodeFlow + val onHide = remember { { onEvent(BookContentEvent.ToggleTargum) } } if (isManualMultiSelection) { MultiLineTargumView( @@ -476,19 +423,18 @@ fun LineTargumView( uiState = uiState, onEvent = onEvent, diacritics = diacritics, - availabilityType = availabilityType, highlightQuery = activeQuery, onHide = onHide, ) } else { SingleLineTargumView( selectedLine = contentState.primaryLine, - buildLinksPagerFor = buildPagerFor, - getAvailableLinksForLine = getAvailableForLine, + buildLinksPagerFor = providers.buildLinksPagerFor, + getAvailableLinksForLine = providers.getAvailableLinksForLine, getLinkCharCountsForLine = providers.getLinkCharCountsForLine, commentariesScrollIndex = contentState.commentariesScrollIndex, commentariesScrollOffset = contentState.commentariesScrollOffset, - initiallySelectedSourceIds = initiallySelectedIds, + initiallySelectedSourceIds = contentState.selectedTargumSourceIds, onSelectedSourcesChange = onSelectedSourcesChange, onLinkClick = onLinkClick, onOpenBook = onEvent, @@ -496,11 +442,6 @@ fun LineTargumView( onHide = onHide, highlightQuery = activeQuery, lineConnections = lineConnections, - availabilityType = availabilityType, - fontCodeFlow = fontCodeFlow, - titleRes = titleRes, - selectLineRes = selectLineRes, - emptyRes = emptyRes, diacritics = diacritics, ) } @@ -516,7 +457,6 @@ private fun MultiLineTargumView( uiState: BookContentState, onEvent: (BookContentEvent) -> Unit, diacritics: DiacriticsMode, - availabilityType: ConnectionType, highlightQuery: String, onHide: () -> Unit, ) { @@ -550,25 +490,10 @@ private fun MultiLineTargumView( val paneInteractionSource = remember { MutableInteractionSource() } // Use multi-line provider to get aggregated available links - val titleToIdMap by produceState>(emptyMap(), selectedLineIds, availabilityType) { - value = - when (availabilityType) { - ConnectionType.SOURCE -> providers.getAvailableSourcesForLines(selectedLineIds) - else -> providers.getAvailableLinksForLines(selectedLineIds) - } + val titleToIdMap by produceState>(emptyMap(), selectedLineIds) { + value = providers.getAvailableLinksForLines(selectedLineIds) } - val titleRes = - when (availabilityType) { - ConnectionType.SOURCE -> Res.string.sources - else -> Res.string.links - } - val emptyRes = - when (availabilityType) { - ConnectionType.SOURCE -> Res.string.no_sources_for_line - else -> Res.string.no_links_for_line - } - Column( modifier = Modifier @@ -576,7 +501,7 @@ private fun MultiLineTargumView( .hoverable(paneInteractionSource), ) { PaneHeader( - label = stringResource(titleRes), + label = stringResource(Res.string.links), interactionSource = paneInteractionSource, onHide = onHide, ) @@ -584,7 +509,7 @@ private fun MultiLineTargumView( Column(modifier = Modifier.padding(horizontal = 8.dp)) { if (titleToIdMap.isEmpty()) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text(text = stringResource(emptyRes)) + Text(text = stringResource(Res.string.no_links_for_line)) } } else { // Preserve insertion order from the provider — the underlying @@ -601,14 +526,8 @@ private fun MultiLineTargumView( val sourceSections = availableSources.mapNotNull { meta -> val pagerFlow = - remember(selectedLineIds, meta.bookId, availabilityType) { - when (availabilityType) { - ConnectionType.SOURCE -> - providers.buildSourcesPagerForLines(selectedLineIds, meta.bookId) - - else -> - providers.buildLinksPagerForLines(selectedLineIds, meta.bookId) - }.distinctUntilChanged() + remember(selectedLineIds, meta.bookId) { + providers.buildLinksPagerForLines(selectedLineIds, meta.bookId).distinctUntilChanged() } val lazyPagingItems = pagerFlow.collectAsLazyPagingItems() SourceSection( @@ -618,7 +537,7 @@ private fun MultiLineTargumView( ) } val annotationCache = - remember(selectedLineIds, availabilityType) { + remember(selectedLineIds) { StableAnnotatedCache() } @@ -648,7 +567,6 @@ private fun MultiLineTargumView( initialValue = emptyList(), selectedLineIds, sectionBookIds, - availabilityType, ) { value = runSuspendCatching { @@ -659,7 +577,7 @@ private fun MultiLineTargumView( getLinkCharCountsRef( selectedLineIds, bookId, - availabilityType, + ConnectionType.TARGUM, ) } }.awaitAll() diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panes/ReaderPanes.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panes/ReaderPanes.kt index 79e1fba1f..9400c559a 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panes/ReaderPanes.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panes/ReaderPanes.kt @@ -47,6 +47,7 @@ import io.github.kdroidfilter.seforimapp.features.bookcontent.state.LayoutState import io.github.kdroidfilter.seforimapp.features.bookcontent.state.SplitDefaults import io.github.kdroidfilter.seforimapp.features.bookcontent.ui.components.LocalPaneSatellite import io.github.kdroidfilter.seforimapp.features.bookcontent.ui.panels.bookcontent.CommentsPane +import io.github.kdroidfilter.seforimapp.features.bookcontent.ui.panels.bookcontent.ReferencesPane import io.github.kdroidfilter.seforimapp.features.bookcontent.ui.panels.bookcontent.SourcesPane import io.github.kdroidfilter.seforimapp.features.bookcontent.ui.panels.bookcontent.TargumPane import io.github.kdroidfilter.seforimapp.features.bookcontent.ui.panels.bookcontent.isBookTextShown @@ -74,6 +75,7 @@ import seforimapp.seforimapp.generated.resources.book_list import seforimapp.seforimapp.generated.resources.commentaries import seforimapp.seforimapp.generated.resources.links import seforimapp.seforimapp.generated.resources.notes_pane +import seforimapp.seforimapp.generated.resources.references import seforimapp.seforimapp.generated.resources.search_book_details import seforimapp.seforimapp.generated.resources.sources import seforimapp.seforimapp.generated.resources.table_of_contents @@ -87,7 +89,8 @@ import kotlin.math.roundToInt * right, in Hebrew); the line panes (links, commentaries, sources) belong to the inner dock around * the text, above its breadcrumb. [fixed] panes are the reader's furniture: they cannot float, move * side or be reordered, and no pane can be dropped in front of them — they can still be hidden and - * resized. + * resized. A side's panes come in the order of their buttons in the reader's bar: the first one at the side's edge + * (beside the bar), or at the start of the row under the text. */ enum class ReaderPane( val id: String, @@ -123,21 +126,21 @@ enum class ReaderPane( Targum( "targum", Res.string.links, - SatellitePlacement.Docked(DockSide.Left, extent = 220.dp), + SatellitePlacement.Docked(DockSide.Left, order = 1, extent = 220.dp), toggle = BookContentEvent.ToggleTargum, navigation = false, ), Comments( "comments", Res.string.commentaries, - SatellitePlacement.Docked(DockSide.Bottom, extent = 220.dp, weight = 2f), + SatellitePlacement.Docked(DockSide.Bottom, order = 1, extent = 220.dp, weight = 2f), toggle = BookContentEvent.ToggleCommentaries, navigation = false, ), Sources( "sources", Res.string.sources, - SatellitePlacement.Docked(DockSide.Bottom, order = 1, extent = 120.dp), + SatellitePlacement.Docked(DockSide.Bottom, order = 0, extent = 120.dp), toggle = BookContentEvent.ToggleSources, navigation = false, ), @@ -146,10 +149,19 @@ enum class ReaderPane( BookDetails( "book-details", Res.string.search_book_details, - SatellitePlacement.Docked(DockSide.Left, order = 1, extent = 280.dp), + SatellitePlacement.Docked(DockSide.Left, order = 0, extent = 280.dp), toggle = BookContentEvent.ToggleBookDetails, navigation = false, ), + + // The places that refer to the line: a column by default, a row of columns once docked under the text + References( + "references", + Res.string.references, + SatellitePlacement.Docked(DockSide.Left, order = 2, extent = 400.dp), + toggle = BookContentEvent.ToggleReferences, + navigation = false, + ), ; /** This pane's satellite id in the dock of window [groupId]: every window has its own docks. */ @@ -170,6 +182,9 @@ private val LineDockSides = setOf(DockSide.Left, DockSide.Right, DockSide.Bottom /** The navigation panes are columns, not a stack. */ val NavigationLayeredSides = setOf(DockSide.Right) +/** Beside the text, the line panes are columns next to each other, not a stack; under it they share the row. */ +val LineLayeredSides = setOf(DockSide.Left, DockSide.Right) + /** The commentaries run under the text and the links, as the vertical split did. */ val LineSideOrder = listOf(DockSide.Bottom, DockSide.Left, DockSide.Right, DockSide.Top) @@ -261,10 +276,16 @@ private fun PaneSync( fun sizedByTab(pane: ReaderPane): Boolean = pane.navigation || (entry(pane)?.placement as? SatellitePlacement.Docked)?.side == pane.home.side + // The navigation column and the line panes beside the text are layered (each pane its own width); the + // line panes under the text split their row. + fun layered(pane: ReaderPane): Boolean { + val side = (entry(pane)?.placement as? SatellitePlacement.Docked)?.side ?: pane.home.side + return pane.navigation || side in LineLayeredSides + } + fun extentDpOf(pane: ReaderPane): Float? { val docked = entry(pane)?.takeIf { it.isOpen }?.placement as? SatellitePlacement.Docked ?: return null - // The navigation column is layered (each pane its own width); the inner sides are split. - return if (pane.navigation) docked.extent?.value else workspace(pane).dockExtent(docked.side).value + return if (layered(pane)) docked.extent?.value else workspace(pane).dockExtent(docked.side).value } fun setExtent( @@ -273,7 +294,7 @@ private fun PaneSync( density: Float, ) { val extent = (px / density).dp - if (pane.navigation) { + if (layered(pane)) { workspace(pane).setDockedExtent(pane.idIn(groupId), extent) } else { val side = (entry(pane)?.placement as? SatellitePlacement.Docked)?.side ?: pane.home.side @@ -507,8 +528,8 @@ private fun writeBack( ReaderPane.Notes -> layout.notesSplitState.positionPercentage = ratio ReaderPane.Targum -> layout.targumSplitState.positionPercentage = ratio ReaderPane.Comments, ReaderPane.Sources -> layout.contentSplitState.positionPercentage = ratio - // Never planned (see plannedExtents): its size is the dock's own - ReaderPane.BookDetails -> Unit + // Never planned (see plannedExtents): their size is the dock's own + ReaderPane.BookDetails, ReaderPane.References -> Unit } } @@ -520,7 +541,7 @@ private fun ReaderPane.position(layout: LayoutState): Float = ReaderPane.Notes -> layout.notesSplitState.positionPercentage ReaderPane.Targum -> layout.targumSplitState.positionPercentage ReaderPane.Comments, ReaderPane.Sources -> layout.contentSplitState.positionPercentage - ReaderPane.BookDetails -> 0f + ReaderPane.BookDetails, ReaderPane.References -> 0f } /** The first pane's minimum of this pane's split (the text, for the line panes), in dp. */ @@ -554,6 +575,7 @@ private fun desiredPanes( if (uiState.content.showTargum) add(ReaderPane.Targum) if (uiState.content.showCommentaries) add(ReaderPane.Comments) if (uiState.content.showSources) add(ReaderPane.Sources) + if (uiState.content.showReferences) add(ReaderPane.References) } } @@ -634,13 +656,15 @@ private fun BookPaneBody( modifier = modifier, ) } + ReaderPane.References -> + ReferencesPane(uiState = uiState, onEvent = onEvent, diacritics = diacritics, modifier = modifier) ReaderPane.Targum, ReaderPane.Comments, ReaderPane.Sources -> { val book = uiState.navigation.selectedBook ?: return@BookTextMenus val connections = tabUi.connections(book.id) when (pane) { ReaderPane.Targum -> TargumPane(uiState, onEvent, connections, diacritics, modifier) ReaderPane.Comments -> CommentsPane(uiState, onEvent, connections, diacritics, modifier) - else -> SourcesPane(uiState, onEvent, connections, diacritics, modifier) + else -> SourcesPane(uiState, onEvent, diacritics, modifier) } } } diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CommentariesUseCase.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CommentariesUseCase.kt index 37c27eb57..ebe190599 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CommentariesUseCase.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CommentariesUseCase.kt @@ -243,20 +243,6 @@ class CommentariesUseCase( ).flow.cachedIn(pagerScope) } - fun buildSourcesPager( - lineId: Long, - sourceBookId: Long? = null, - ): Flow> = - cachedPager("src:$lineId:${sourceBookId ?: -1L}") { pagerScope -> - val ids = sourceBookId?.let { setOf(it) } ?: emptySet() - Pager( - config = PagingDefaults.COMMENTS.config(placeholders = false), - pagingSourceFactory = { - LineTargumPagingSource(repository, lineId, ids, setOf(ConnectionType.SOURCE)) - }, - ).flow.cachedIn(pagerScope) - } - // ========== Multi-line pagers for multi-selection ========== /** @@ -293,23 +279,6 @@ class CommentariesUseCase( ).flow.cachedIn(pagerScope) } - /** - * Construit un Pager pour les sources de plusieurs lignes - */ - fun buildSourcesPagerForLines( - lineIds: List, - sourceBookId: Long? = null, - ): Flow> = - cachedPager("srcL:${lineIds.joinToString(",")}:${sourceBookId ?: -1L}") { pagerScope -> - val ids = sourceBookId?.let { setOf(it) } ?: emptySet() - Pager( - config = PagingDefaults.COMMENTS.config(placeholders = false), - pagingSourceFactory = { - MultiLineLinksPagingSource(repository, lineIds, ids, setOf(ConnectionType.SOURCE)) - }, - ).flow.cachedIn(pagerScope) - } - /** * Ordered char-count vector for the commentary pager built from a single base line * (or its TOC section). Mirrors [buildCommentariesPager]'s ordering, so the scrollbar @@ -343,7 +312,7 @@ class CommentariesUseCase( /** * Ordered char-count vector for a single source/targum section attached to a base - * line (or its TOC section). Mirrors [buildLinksPager] / [buildSourcesPager] row + * line (or its TOC section). Mirrors [buildLinksPager] row * ordering (`b.orderIndex, l.targetLineIndex`). Returns an empty list on failure. */ suspend fun getLinkCharCountsForLine( @@ -361,7 +330,7 @@ class CommentariesUseCase( /** * Ordered char-count vector for a single source/targum section across multiple - * selected lines. Mirrors [buildLinksPagerForLines] / [buildSourcesPagerForLines] + * selected lines. Mirrors [buildLinksPagerForLines] * ordering. Returns an empty list on failure. */ suspend fun getLinkCharCountsForLines( @@ -447,39 +416,6 @@ class CommentariesUseCase( }.getOrElse { emptyMap() } } - /** - * Récupère les sources disponibles pour plusieurs lignes (union). - * - * Issues a single inverse-direction repository query against the union of - * all base lines so the order returned by the underlying SQL - * (`l.isDeclaredBase DESC, b.orderIndex, sl.lineIndex`) is respected - * globally. Per-lineId iteration would interleave per-source-book entries - * by lineId rather than by catalog position. - */ - suspend fun getAvailableSourcesForLines(lineIds: List): Map { - if (lineIds.isEmpty()) return emptyMap() - return runSuspendCatching { - val selectedBook = - stateManager.state - .first() - .navigation.selectedBook - if (selectedBook?.hasSourceConnection != true) return@runSuspendCatching emptyMap() - - val allBaseIds = - lineIds - .flatMap { resolveBaseLineIds(it) } - .distinct() - if (allBaseIds.isEmpty()) return@runSuspendCatching emptyMap() - - val links = - repository - .getCommentarySummariesForLines(allBaseIds, includeSources = true) - .filter { it.link.connectionType == ConnectionType.SOURCE } - - buildSourceMap(links, selectedBook.title.trim()) - }.getOrElse { emptyMap() } - } - /** * Récupère les commentateurs disponibles pour une ligne */ @@ -992,7 +928,6 @@ class CommentariesUseCase( val commentaries = connections.filter { it.link.connectionType == ConnectionType.COMMENTARY } val targumLinks = connections.filter { it.link.connectionType == ConnectionType.TARGUM } - val sourceLinks = connections.filter { it.link.connectionType == ConnectionType.SOURCE } val commentatorGroups = if (commentaries.isNotEmpty()) { @@ -1005,7 +940,6 @@ class CommentariesUseCase( return LineConnectionsSnapshot( commentatorGroups = commentatorGroups, targumSources = buildSourceMap(targumLinks, currentBookTitle), - sources = buildSourceMap(sourceLinks, currentBookTitle), ) } @@ -1028,11 +962,6 @@ class CommentariesUseCase( */ suspend fun getAvailableLinks(lineId: Long): Map = lineConnections(lineId).targumSources - /** - * Récupère les sources disponibles pour une ligne - */ - suspend fun getAvailableSources(lineId: Long): Map = lineConnections(lineId).sources - // Off the main thread: callers launch it from the UI, and grouping sorts every line's commentators. suspend fun loadLineConnections(lineIds: List): Map = withContext(Dispatchers.Default) { computeLineConnections(lineIds) } @@ -1071,7 +1000,7 @@ class CommentariesUseCase( val currentState = stateManager.state.first() val selectedBook = currentState.navigation.selectedBook - val allConnections = repository.getCommentarySummariesForLines(allBaseIds, includeSources = true) + val allConnections = repository.getCommentarySummariesForLines(allBaseIds) if (allConnections.isEmpty()) return storeAndMerge(missing.associateWith { LineConnectionsSnapshot() }) val connectionsBySource = allConnections.groupBy { it.link.sourceLineId } @@ -1264,40 +1193,6 @@ class CommentariesUseCase( } } - suspend fun updateSelectedSources( - lineId: Long, - selectedIds: Set, - ) { - val currentContent = stateManager.state.first().content - val bookId = - stateManager.state - .first() - .navigation.selectedBook - ?.id ?: return - - val byLine = currentContent.selectedSourcesByLine.toMutableMap() - if (selectedIds.isEmpty()) { - byLine.remove(lineId) - } else { - byLine[lineId] = selectedIds - } - - val byBook = currentContent.selectedSourcesByBook.toMutableMap() - if (selectedIds.isEmpty()) { - byBook.remove(bookId) - } else { - byBook[bookId] = selectedIds - } - - stateManager.updateContent { - copy( - selectedSourcesByLine = byLine, - selectedSourcesByBook = byBook, - selectedSourceIds = selectedIds, - ) - } - } - /** * Réapplique les commentateurs sélectionnés pour une nouvelle ligne */ @@ -1361,27 +1256,6 @@ class CommentariesUseCase( } } - suspend fun reapplySelectedSources(line: Line) { - val currentState = stateManager.state.first() - val bookId = currentState.navigation.selectedBook?.id ?: line.bookId - val remembered = currentState.content.selectedSourcesByBook[bookId] ?: emptySet() - - if (remembered.isEmpty()) return - - try { - val available = getAvailableSources(line.id) - val availableIds = available.values.toSet() - val intersection = remembered.intersect(availableIds) - - if (intersection.isNotEmpty()) { - updateSelectedSources(line.id, intersection) - } - } catch (e: CancellationException) { - throw e - } catch (_: Exception) { - } - } - /** * Réapplique les commentateurs sélectionnés pour plusieurs lignes (multi-sélection). * Ne sélectionne des commentateurs que s'il y en a de mémorisés pour ce livre. @@ -1441,33 +1315,6 @@ class CommentariesUseCase( } } - /** - * Réapplique les sources sélectionnées pour plusieurs lignes (multi-sélection) - */ - suspend fun reapplySelectedSourcesForLines( - lineIds: List, - primaryLineId: Long, - bookId: Long, - ) { - val currentState = stateManager.state.first() - val remembered = currentState.content.selectedSourcesByBook[bookId] ?: emptySet() - - if (remembered.isEmpty()) return - - try { - val available = getAvailableSourcesForLines(lineIds) - val availableIds = available.values.toSet() - val intersection = remembered.intersect(availableIds) - - if (intersection.isNotEmpty()) { - updateSelectedSources(primaryLineId, intersection) - } - } catch (e: CancellationException) { - throw e - } catch (_: Exception) { - } - } - /** * Met à jour l'onglet sélectionné des commentaires */ diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/search/SearchResultScreen.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/search/SearchResultScreen.kt index 7617aaf35..b632f9225 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/search/SearchResultScreen.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/search/SearchResultScreen.kt @@ -63,6 +63,7 @@ import io.github.kdroidfilter.seforimapp.core.presentation.text.DiacriticsMode import io.github.kdroidfilter.seforimapp.core.presentation.text.highlightAnnotated import io.github.kdroidfilter.seforimapp.core.presentation.text.withBackground import io.github.kdroidfilter.seforimapp.core.presentation.typography.FontCatalog +import io.github.kdroidfilter.seforimapp.core.settings.AppSettings import io.github.kdroidfilter.seforimapp.features.author.AUTHOR_ERAS import io.github.kdroidfilter.seforimapp.features.author.authorYears import io.github.kdroidfilter.seforimapp.features.author.plainPersonLinks @@ -368,206 +369,209 @@ private fun SearchResultContentMvi( Row(Modifier.fillMaxSize()) { val listModifier = if (twoPanes) Modifier.width(listWidth) else Modifier.fillMaxWidth() // One card: the bar, the tabs and the status over the list, as a mail client's search - Column(listModifier.fillMaxHeight().card()) { - Column(Modifier.fillMaxWidth().padding(start = 14.dp, end = 14.dp, top = 12.dp, bottom = 6.dp)) { - // The home page's smart bar: references, books and authors open; a text search runs here - // The logo beside the bar, as Google's - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - LogoBranch(Modifier.height(LOGO_HEIGHT).aspectRatio(LOGO_RATIO)) - UnifiedSearchBar( - searchUi = homeSearchUi, - searchCallbacks = homeSearchCallbacks, - modifier = Modifier.weight(1f), - initialText = barText, - autoFocus = false, - onTextChange = actions.onQueryChange, - ) - } + // The list follows the reading text's zoom, as the passage beside it + CompositionLocalProvider(LocalResultTextScale provides zoom.textSize / AppSettings.DEFAULT_TEXT_SIZE) { + Column(listModifier.fillMaxHeight().card()) { + Column(Modifier.fillMaxWidth().padding(start = 14.dp, end = 14.dp, top = 12.dp, bottom = 6.dp)) { + // The home page's smart bar: references, books and authors open; a text search runs here + // The logo beside the bar, as Google's + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + LogoBranch(Modifier.height(LOGO_HEIGHT).aspectRatio(LOGO_RATIO)) + UnifiedSearchBar( + searchUi = homeSearchUi, + searchCallbacks = homeSearchCallbacks, + modifier = Modifier.weight(1f), + initialText = barText, + autoFocus = false, + onTextChange = actions.onQueryChange, + ) + } - // Always there, as Google's, even with one category: "הכל" also undoes a kept filter - if (categories.isNotEmpty()) { - Spacer(Modifier.height(10.dp)) - CategoryTabs(categories, selectedCategoryIds, actions.onShowOnlyCategory) - } - Spacer(Modifier.height(8.dp)) - // Inside one book: the way back to all the results - if (inBook) { - ResultLink( - stringResource(Res.string.search_back_to_all, visibleResults.firstOrNull()?.bookTitle.orEmpty()), - JewelTheme.globalColors.outlines.focused, - 13.sp, - ) { actions.onBackFromBook() } - Spacer(Modifier.height(4.dp)) - } - val loadedResults = maxOf(state.progressCurrent, visibleResults.size) - val totalResults = - maxOf( - loadedResults, - (state.progressTotal ?: loadedResults.toLong()).coerceAtLeast(loadedResults.toLong()).toInt(), + // Always there, as Google's, even with one category: "הכל" also undoes a kept filter + if (categories.isNotEmpty()) { + Spacer(Modifier.height(10.dp)) + CategoryTabs(categories, selectedCategoryIds, actions.onShowOnlyCategory) + } + Spacer(Modifier.height(8.dp)) + // Inside one book: the way back to all the results + if (inBook) { + ResultLink( + stringResource(Res.string.search_back_to_all, visibleResults.firstOrNull()?.bookTitle.orEmpty()), + JewelTheme.globalColors.outlines.focused, + 13.sp, + ) { actions.onBackFromBook() } + Spacer(Modifier.height(4.dp)) + } + val loadedResults = maxOf(state.progressCurrent, visibleResults.size) + val totalResults = + maxOf( + loadedResults, + (state.progressTotal ?: loadedResults.toLong()).coerceAtLeast(loadedResults.toLong()).toInt(), + ) + // Only show top progress bar during initial search, not lazy loading + // (lazy loading has its own spinner at the bottom of the list) + val showProgress = state.isLoading + val hasTotal = (state.progressTotal ?: 0L) > 0L + val progressFraction by animateFloatAsState( + targetValue = + if (showProgress && hasTotal) { + val total = (state.progressTotal ?: 1L).coerceAtLeast(1L) + (state.progressCurrent.toFloat() / total.toFloat()).coerceIn(0f, 1f) + } else { + 0f + }, + animationSpec = tween(durationMillis = 250, easing = LinearEasing), + label = "searchProgress", ) - // Only show top progress bar during initial search, not lazy loading - // (lazy loading has its own spinner at the bottom of the list) - val showProgress = state.isLoading - val hasTotal = (state.progressTotal ?: 0L) > 0L - val progressFraction by animateFloatAsState( - targetValue = - if (showProgress && hasTotal) { - val total = (state.progressTotal ?: 1L).coerceAtLeast(1L) - (state.progressCurrent.toFloat() / total.toFloat()).coerceIn(0f, 1f) - } else { - 0f - }, - animationSpec = tween(durationMillis = 250, easing = LinearEasing), - label = "searchProgress", - ) - // Header row: results count + inline loader + optional cancel (space reserved to avoid width jitter) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - SearchStatusLine( - // In one book, where it searched: no base/all books choice there - scope = if (oneBook) state.scopeBook?.title ?: visibleResults.firstOrNull()?.bookTitle else null, - total = totalResults, - isLoading = showProgress, - globalExtended = state.globalExtended, - baseBooksHadNoResults = state.baseBooksHadNoResults, - onGlobalExtendedChange = actions.onGlobalExtendedChange, - modifier = Modifier.padding(end = 12.dp), - ) + // Header row: results count + inline loader + optional cancel (space reserved to avoid width jitter) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + SearchStatusLine( + // In one book, where it searched: no base/all books choice there + scope = if (oneBook) state.scopeBook?.title ?: visibleResults.firstOrNull()?.bookTitle else null, + total = totalResults, + isLoading = showProgress, + globalExtended = state.globalExtended, + baseBooksHadNoResults = state.baseBooksHadNoResults, + onGlobalExtendedChange = actions.onGlobalExtendedChange, + modifier = Modifier.padding(end = 12.dp), + ) + + // The progress shows while searching only + if (showProgress) { + Box( + modifier = + Modifier + .height(4.dp) + .weight(1f) + .clip(RoundedCornerShape(percent = 50)) + .background( + JewelTheme.globalColors.borders.disabled + .copy(alpha = 0.6f), + ), + ) { + if (showProgress && progressFraction > 0f) { + Box( + modifier = + Modifier + .fillMaxHeight() + .fillMaxWidth(progressFraction) + .background(JewelTheme.globalColors.outlines.focused), + ) + } + } + } else { + Spacer(Modifier.weight(1f)) + } - // The progress shows while searching only - if (showProgress) { + // Reserve space for the cancel button to prevent width jumps Box( - modifier = - Modifier - .height(4.dp) - .weight(1f) - .clip(RoundedCornerShape(percent = 50)) - .background( - JewelTheme.globalColors.borders.disabled - .copy(alpha = 0.6f), - ), + modifier = Modifier.width(40.dp).height(28.dp), + contentAlignment = Alignment.Center, ) { - if (showProgress && progressFraction > 0f) { - Box( - modifier = - Modifier - .fillMaxHeight() - .fillMaxWidth(progressFraction) - .background(JewelTheme.globalColors.outlines.focused), + // Only show cancel during initial search, not lazy loading + if (state.isLoading) { + IconActionButton( + key = AllIconsKeys.Windows.Close, + onClick = actions.onCancelSearch, + contentDescription = stringResource(Res.string.search_stop), ) } } - } else { - Spacer(Modifier.weight(1f)) - } - - // Reserve space for the cancel button to prevent width jumps - Box( - modifier = Modifier.width(40.dp).height(28.dp), - contentAlignment = Alignment.Center, - ) { - // Only show cancel during initial search, not lazy loading - if (state.isLoading) { - IconActionButton( - key = AllIconsKeys.Windows.Close, - onClick = actions.onCancelSearch, - contentDescription = stringResource(Res.string.search_stop), - ) - } } } - } - Divider(Orientation.Horizontal, Modifier.fillMaxWidth()) - Box( - modifier = - Modifier - .weight(1f) - .fillMaxWidth() - .focusRequester(listFocus) - .onPreviewKeyEvent(::onListKey) - .focusable(), - ) { - if (visibleResults.isEmpty()) { - if (state.isLoading) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text(stringResource(Res.string.search_searching), fontSize = commentSize.sp) + Divider(Orientation.Horizontal, Modifier.fillMaxWidth()) + Box( + modifier = + Modifier + .weight(1f) + .fillMaxWidth() + .focusRequester(listFocus) + .onPreviewKeyEvent(::onListKey) + .focusable(), + ) { + if (visibleResults.isEmpty()) { + if (state.isLoading) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(stringResource(Res.string.search_searching), fontSize = commentSize.sp) + } + } else { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(text = stringResource(Res.string.search_no_results), fontSize = commentSize.sp) + } } } else { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text(text = stringResource(Res.string.search_no_results), fontSize = commentSize.sp) - } - } - } else { - Box(modifier = Modifier.fillMaxSize()) { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize().padding(start = 8.dp, end = 16.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - itemsIndexed(items = items, key = { _, item -> item.hit.lineId }) { _, item -> - ResultView( - item = item, - selected = item.hit.lineId == selectedLineId, - findQuery = activeFindQuery, - bookFontCode = bookFontCode, - breadcrumbs = breadcrumbs, - onRequestBreadcrumb = actions.onRequestBreadcrumb, - // Wide: a click shows the passage beside the list, a double click opens it; narrow: opens it - onClick = { result -> - if (twoPanes) { - actions.onSelectResult(result.lineId) - listFocus.requestFocus() - } else { - val mods = windowInfo.keyboardModifiers - openResult(result, mods.isCtrlPressed || mods.isMetaPressed) + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize().padding(start = 8.dp, end = 16.dp).then(zoom.modifier), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + itemsIndexed(items = items, key = { _, item -> item.hit.lineId }) { _, item -> + ResultView( + item = item, + selected = item.hit.lineId == selectedLineId, + findQuery = activeFindQuery, + bookFontCode = bookFontCode, + breadcrumbs = breadcrumbs, + onRequestBreadcrumb = actions.onRequestBreadcrumb, + // Wide: a click shows the passage beside the list, a double click opens it; narrow: opens it + onClick = { result -> + if (twoPanes) { + actions.onSelectResult(result.lineId) + listFocus.requestFocus() + } else { + val mods = windowInfo.keyboardModifiers + openResult(result, mods.isCtrlPressed || mods.isMetaPressed) + } + }, + onOpen = { result -> openResult(result, false) }, + onMoreInBook = { actions.onMoreFromBook(item.hit.bookId) }, + ) + } + // Loading indicator at the end of the list (only for lazy loading) + if (state.isLoadingMore) { + item { + Box( + Modifier.fillMaxWidth().padding(vertical = 16.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() } - }, - onOpen = { result -> openResult(result, false) }, - onMoreInBook = { actions.onMoreFromBook(item.hit.bookId) }, - ) - } - // Loading indicator at the end of the list (only for lazy loading) - if (state.isLoadingMore) { - item { - Box( - Modifier.fillMaxWidth().padding(vertical = 16.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() } } } + StableListScrollbar( + listState = listState, + loadedCount = items.size, + totalCount = items.size, + modifier = Modifier.align(Alignment.CenterEnd), + ) } - StableListScrollbar( - listState = listState, - loadedCount = items.size, - totalCount = items.size, - modifier = Modifier.align(Alignment.CenterEnd), - ) } - } - // Loader overlay while applying filters (category/book/TOC) with quick fade - androidx.compose.animation.AnimatedVisibility( - visible = isFiltering, - enter = fadeIn(tween(durationMillis = 120, easing = LinearEasing)), - exit = fadeOut(tween(durationMillis = 120, easing = LinearEasing)), - ) { - Box( - modifier = - Modifier - .fillMaxSize() - .background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.4f)) - .zIndex(1f), - contentAlignment = Alignment.Center, + // Loader overlay while applying filters (category/book/TOC) with quick fade + androidx.compose.animation.AnimatedVisibility( + visible = isFiltering, + enter = fadeIn(tween(durationMillis = 120, easing = LinearEasing)), + exit = fadeOut(tween(durationMillis = 120, easing = LinearEasing)), ) { - CircularProgressIndicator() + Box( + modifier = + Modifier + .fillMaxSize() + .background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.4f)) + .zIndex(1f), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } } } } @@ -727,21 +731,46 @@ private fun ResultView( onMoreInBook: () -> Unit, ) { val hit = item.hit - // Titles in the app's text color, bold, as every other text; the rest grey - val ink = JewelTheme.globalColors.text.normal - val grey = readingSecondary() val currentOnRequestBreadcrumb by rememberUpdatedState(onRequestBreadcrumb) val pieces = breadcrumbs[hit.lineId] LaunchedEffect(hit.lineId) { if (pieces == null) currentOnRequestBreadcrumb(hit) } val place = remember(pieces, hit.bookTitle) { categoriesAndPlace(pieces, hit.bookTitle).second } + val accent = JewelTheme.globalColors.outlines.focused + ResultRow(selected = selected, onClick = { onClick(hit) }, onDoubleClick = { onOpen(hit) }) { + ResultTitle(hit.bookTitle, place) + Snippet(hit, findQuery, bookFontCode) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + if (item.more > 0) { + ResultLink( + stringResource(Res.string.search_more_from_book, item.more, hit.bookTitle), + accent, + resultSp(11f), + onClick = onMoreInBook, + ) + } + } + } +} + +/** + * A row of a result list, as the history's: a hover tint, the selected row's bar on its start side, a + * divider under it. Shared by the search results and the places that refer to a line. + */ +@Composable +internal fun ResultRow( + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + onDoubleClick: (() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { val hover = remember { MutableInteractionSource() } val hovered by hover.collectIsHoveredAsState() val accent = JewelTheme.globalColors.outlines.focused - // A row as the history's: hover tint, a divider under it Column( modifier = - Modifier + modifier .fillMaxWidth() .clip(RoundedCornerShape(6.dp)) .background( @@ -751,7 +780,6 @@ private fun ResultView( else -> Color.Transparent }, ).drawBehind { - // The selected row's bar, on its start side if (selected) { val bar = 3.dp.toPx() val x = if (layoutDirection == LayoutDirection.Rtl) size.width - bar else 0f @@ -761,42 +789,11 @@ private fun ResultView( .combinedClickable( interactionSource = hover, indication = null, - onDoubleClick = { onOpen(hit) }, - onClick = { onClick(hit) }, + onDoubleClick = onDoubleClick, + onClick = onClick, ).pointerHoverIcon(PointerIcon.Hand), ) { - Row( - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp), - ) { - Column(Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - Text(hit.bookTitle, color = ink, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, maxLines = 1) - if (place != null) { - Text( - "· $place", - color = grey, - fontSize = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), - ) - } - } - Snippet(hit, findQuery, bookFontCode) - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { - if (item.more > 0) { - ResultLink( - stringResource(Res.string.search_more_from_book, item.more, hit.bookTitle), - accent, - 11.sp, - onClick = onMoreInBook, - ) - } - } - } - } + Column(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp), content = content) Box( Modifier .fillMaxWidth() @@ -810,6 +807,27 @@ private fun ResultView( } } +/** A result's book as its bold title, the passage's place beside it in grey. */ +@Composable +internal fun ResultTitle( + title: String, + place: String?, +) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text(title, color = JewelTheme.globalColors.text.normal, fontSize = resultSp(14f), fontWeight = FontWeight.SemiBold, maxLines = 1) + if (place != null) { + Text( + "· $place", + color = readingSecondary(), + fontSize = resultSp(12f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + } + } +} + /** * The selected result in its book, to read without opening it: its place as a title, then the * found line marked by a bar, between the lines around it, faded; in the books' font and size, over @@ -925,21 +943,30 @@ private fun PassagePreview( private val BOLD_SPAN = Regex("(.*?)", RegexOption.IGNORE_CASE) private const val FOUND_LINE_MARGIN = 40 -/** A passage: two lines of grey text, the matched words bold in the text's color. */ @Composable private fun Snippet( hit: SearchResult, findQuery: String?, bookFontCode: String, +) = SnippetText(hit.snippet, findQuery, bookFontCode) + +/** A passage ([snippet], HTML): [maxLines] lines of grey text, the matched words (``) bold in the text's color. */ +@Composable +internal fun SnippetText( + snippet: String, + findQuery: String?, + bookFontCode: String, + maxLines: Int = 2, ) { val ink = JewelTheme.globalColors.text.normal - val display = rememberSnippetDisplay(hit.snippet, SNIPPET_SIZE, findQuery, bookFontCode, boldColor = ink) + val size = SNIPPET_SIZE * LocalResultTextScale.current + val display = rememberSnippetDisplay(snippet, size, findQuery, bookFontCode, boldColor = ink) Text( text = display, color = readingSecondary(), - fontSize = SNIPPET_SIZE.sp, - lineHeight = (SNIPPET_SIZE * 1.65f).sp, - maxLines = 2, + fontSize = size.sp, + lineHeight = (size * 1.65f).sp, + maxLines = maxLines, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(vertical = 2.dp), ) @@ -963,6 +990,17 @@ private fun categoriesAndPlace( return pieces.take(bookIndex).joinToString(" › ") to place } +/** + * How much a result list's results are scaled: the reading text's size over its default, so that they follow the zoom + * as the panes' texts do. Only the results: the tabs and the status above them keep their size. 1 where nothing + * provides it. + */ +internal val LocalResultTextScale = compositionLocalOf { 1f } + +/** [size] sp, scaled with the result list it is in ([LocalResultTextScale]). */ +@Composable +internal fun resultSp(size: Float): TextUnit = (size * LocalResultTextScale.current).sp + /** A text link in [color], underlined on hover. */ @Composable internal fun ResultLink( @@ -996,28 +1034,46 @@ private fun CategoryTabs( selectedCategoryIds: Set, onShowOnly: (Long?) -> Unit, ) { - val sorted = remember(categories) { categories.sortedByDescending { it.count } } - val selected = sorted.firstOrNull { it.category.id in selectedCategoryIds }?.category?.id - var showAll by remember(categories) { mutableStateOf(false) } + val sorted = remember(categories) { categories.sortedByDescending { it.count }.map { it.category.id to it.category.title } } + val selected = sorted.firstOrNull { it.first in selectedCategoryIds }?.first + FilterTabs(sorted, selected, onShowOnly) +} + +/** + * Tabs that filter a result list, as Google's "All · Images · News": "הכל", then the first five of + * [tabs] (id to title, most frequent first), the rest behind an "עוד" link. One at a time. + */ +@Composable +internal fun FilterTabs( + tabs: List>, + selected: Long?, + onSelect: (Long?) -> Unit, + modifier: Modifier = Modifier, +) { + var showAll by remember(tabs) { mutableStateOf(false) } val shown = if (showAll) { - sorted + tabs } else { - sorted.take(TOP_TABS) + sorted.drop(TOP_TABS).filter { it.category.id == selected } + tabs.take(TOP_TABS) + tabs.drop(TOP_TABS).filter { it.first == selected } } FlowRow( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.fillMaxWidth(), + itemVerticalAlignment = Alignment.CenterVertically, + modifier = modifier.fillMaxWidth(), ) { - CategoryTab(stringResource(Res.string.search_tab_all), selected == null) { onShowOnly(null) } - shown.forEach { node -> - CategoryTab(node.category.title, selected == node.category.id) { onShowOnly(node.category.id) } + CategoryTab(stringResource(Res.string.search_tab_all), selected == null) { onSelect(null) } + shown.forEach { (id, title) -> + CategoryTab(title, selected == id) { onSelect(id) } } - if (sorted.size > TOP_TABS) { - CategoryTab(stringResource(if (showAll) Res.string.search_tabs_less else Res.string.search_tabs_more), false) { - showAll = !showAll - } + // Not a tab: a link, in the accent as the other links of the results + if (tabs.size > TOP_TABS) { + ResultLink( + stringResource(if (showAll) Res.string.search_tabs_less else Res.string.search_tabs_more), + JewelTheme.globalColors.outlines.focused, + 13.sp, + ) { showAll = !showAll } } } } diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionModels.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionModels.kt index 55090dac0..1502c1c70 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionModels.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionModels.kt @@ -97,6 +97,7 @@ data class BookContentPersistedState( val selectedCommentatorsByBook: Map> = emptyMap(), val selectedTargumSourcesByLine: Map> = emptyMap(), val selectedTargumSourcesByBook: Map> = emptyMap(), + // Unused (the sources pane no longer filters by book), kept for the ProtoBuf field numbers val selectedSourcesByLine: Map> = emptyMap(), val selectedSourcesByBook: Map> = emptyMap(), // Layout @@ -111,6 +112,8 @@ data class BookContentPersistedState( val previousContentSplitPosition: Float = SplitDefaults.CONTENT, val previousSourcesSplitPosition: Float = SplitDefaults.SOURCES, val previousTargumSplitPosition: Float = 0.8f, + // Appended last: the session is ProtoBuf, numbered by declaration order + val showReferences: Boolean = false, ) // -- Virtual desktops persistence models -- diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/PostSelectLineTest.kt b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/PostSelectLineTest.kt index 75df36fd8..39f821a28 100644 --- a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/PostSelectLineTest.kt +++ b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/PostSelectLineTest.kt @@ -186,7 +186,6 @@ class PostSelectLineTest { coVerify { commentariesUseCase.reapplySelectedCommentators(any()) } coVerify { commentariesUseCase.reapplySelectedLinkSources(any()) } - coVerify { commentariesUseCase.reapplySelectedSources(any()) } } @Test @@ -216,9 +215,6 @@ class PostSelectLineTest { coVerify { commentariesUseCase.reapplySelectedLinkSourcesForLines(any(), any(), any()) } - coVerify { - commentariesUseCase.reapplySelectedSourcesForLines(any(), any(), any()) - } } // --- LoadAndSelectLine --- @@ -246,7 +242,6 @@ class PostSelectLineTest { coVerify { commentariesUseCase.reapplySelectedCommentators(any()) } coVerify { commentariesUseCase.reapplySelectedLinkSources(any()) } - coVerify { commentariesUseCase.reapplySelectedSources(any()) } } // --- NavigateToPreviousLine --- @@ -274,7 +269,6 @@ class PostSelectLineTest { coVerify { commentariesUseCase.reapplySelectedCommentators(any()) } coVerify { commentariesUseCase.reapplySelectedLinkSources(any()) } - coVerify { commentariesUseCase.reapplySelectedSources(any()) } } @Test @@ -315,7 +309,6 @@ class PostSelectLineTest { coVerify { commentariesUseCase.reapplySelectedCommentators(any()) } coVerify { commentariesUseCase.reapplySelectedLinkSources(any()) } - coVerify { commentariesUseCase.reapplySelectedSources(any()) } } @Test @@ -363,6 +356,5 @@ class PostSelectLineTest { coVerify { commentariesUseCase.reapplySelectedCommentators(any()) } coVerify { commentariesUseCase.reapplySelectedLinkSources(any()) } - coVerify { commentariesUseCase.reapplySelectedSources(any()) } } } diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ReaderBench.kt b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ReaderBench.kt index 8e32cd02a..3986e4e33 100644 --- a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ReaderBench.kt +++ b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ReaderBench.kt @@ -196,7 +196,6 @@ private suspend fun postSelect( .firstOrNull() ?: line comm.reapplySelectedCommentators(primary) comm.reapplySelectedLinkSources(primary) - comm.reapplySelectedSources(primary) } private suspend fun refresh(src: LinesPagingSource): List = diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/references/LineReferencesTest.kt b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/references/LineReferencesTest.kt new file mode 100644 index 000000000..c4da01aeb --- /dev/null +++ b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/references/LineReferencesTest.kt @@ -0,0 +1,130 @@ +package io.github.kdroidfilter.seforimapp.features.bookcontent.references + +import io.github.kdroidfilter.seforimlibrary.core.models.Category +import io.github.kdroidfilter.seforimlibrary.core.models.ConnectionType +import io.github.kdroidfilter.seforimlibrary.core.models.Line +import io.github.kdroidfilter.seforimlibrary.dao.repository.LineReference +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class LineReferencesTest { + private val tanakh = Category(id = 1, title = "תנ״ך") + private val rishonim = Category(id = 2, parentId = 1, title = "ראשונים") + private val acharonim = Category(id = 3, parentId = 1, title = "אחרונים") + private val mishnah = Category(id = 4, title = "משנה") + + private fun ref( + lineId: Long, + bookId: Long, + categoryId: Long, + lineIndex: Int = lineId.toInt(), + type: ConnectionType = ConnectionType.REFERENCE, + ) = LineReference( + connectionType = type, + fromLineId = 0, + line = Line(id = lineId, bookId = bookId, lineIndex = lineIndex, content = "line $lineId", heRef = "book$bookId, $lineIndex"), + bookTitle = "book$bookId", + categoryId = categoryId, + bookOrderIndex = bookId.toInt(), + ) + + @Test + fun splitsACategoryHoldingMostBooks() { + val tabs = + filterTabs( + listOf( + listOf(tanakh, rishonim), + listOf(tanakh, rishonim), + listOf(tanakh, acharonim), + listOf(mishnah), + ), + ) + assertEquals(listOf(rishonim, rishonim, acharonim, mishnah), tabs) + } + + @Test + fun keepsTopCategoriesWhenNoneDominates() { + val tabs = filterTabs(listOf(listOf(tanakh, rishonim), listOf(mishnah), listOf(mishnah, Category(id = 9, title = "x")))) + assertEquals(listOf(tanakh, mishnah, mishnah), tabs) + } + + @Test + fun groupsByBookTheMostPlacesFirst() { + val paths = mapOf(2L to listOf(tanakh, rishonim), 3L to listOf(tanakh, acharonim), 4L to listOf(mishnah)) + val references = + groupReferences( + listOf(ref(1, bookId = 10, categoryId = 4), ref(5, bookId = 20, categoryId = 2), ref(3, bookId = 20, categoryId = 2)), + paths, + ) { null } + assertEquals(listOf(20L, 10L), references.books.map { it.bookId }) + // A book's places in its own order + assertEquals( + listOf(3L, 5L), + references.books + .first() + .places + .map { it.line.id }, + ) + assertEquals(3, references.placeCount) + assertEquals(listOf(1L to "תנ״ך", 4L to "משנה"), references.categoryTabs) + assertTrue(references.kindTabs.isEmpty()) + } + + @Test + fun noTabsForASingleCategory() { + val references = + groupReferences( + listOf(ref(1, bookId = 10, categoryId = 2), ref(2, bookId = 20, categoryId = 2)), + mapOf(2L to listOf(tanakh, rishonim)), + ) { null } + assertTrue(references.categoryTabs.isEmpty()) + assertTrue(references.books.all { it.tabId == null }) + } + + @Test + fun rulingsAndParallelsComeFirstWithTabsOfTheirOwn() { + val references = + groupReferences( + listOf( + ref(1, bookId = 10, categoryId = 2), + ref(2, bookId = 10, categoryId = 2), + ref(3, bookId = 30, categoryId = 4, type = ConnectionType.MESORAT_HASHAS), + ref(4, bookId = 20, categoryId = 2, type = ConnectionType.EIN_MISHPAT), + // The same book as a plain reference and as a ruling: two entries + ref(5, bookId = 10, categoryId = 2, type = ConnectionType.EIN_MISHPAT), + ), + mapOf(2L to listOf(tanakh, rishonim), 4L to listOf(mishnah)), + ) { null } + assertEquals( + listOf(ReferenceKind.EinMishpat, ReferenceKind.EinMishpat, ReferenceKind.MesoratHashas, ReferenceKind.Reference), + references.books.map { it.kind }, + ) + assertEquals(listOf(ReferenceKind.EinMishpat, ReferenceKind.MesoratHashas), references.kindTabs) + assertEquals(listOf(-1L, -1L, -2L, 2L), references.books.map { it.tabId }) + } + + @Test + fun placeDropsTheBookTitle() { + val line = Line(bookId = 1, lineIndex = 0, content = "", heRef = "רבנו בחיי, בראשית, יב, ב") + assertEquals("בראשית, יב, ב", placeOf(line, "רבנו בחיי")) + assertNull(placeOf(line.copy(heRef = "רבנו בחיי"), "רבנו בחיי")) + } + + @Test + fun snippetStartsAWordBeforeTheQuote() { + val text = "אחת שתים שלש ארבע חמש שש שבע שמונה תשע עשר אחת עשרה שתים עשרה שלש עשרה בראשית ברא אלהים ועוד" + val start = text.indexOf("בראשית") + val snippet = snippetAround(text, start until start + "בראשית ברא".length) + assertTrue(snippet.startsWith("…")) + assertTrue("בראשית ברא אלהים" in snippet) + // Cut at a word: no half word after the ellipsis + assertTrue(snippet.removePrefix("…").split(" ").first() in text.split(" ")) + } + + @Test + fun snippetWithoutQuoteIsTheStartEscaped() { + assertEquals("a <b> c", snippetAround("a c", null)) + } +} diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookContentStateManagerPersistenceTest.kt b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookContentStateManagerPersistenceTest.kt index 5821c2cfc..06f572e75 100644 --- a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookContentStateManagerPersistenceTest.kt +++ b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookContentStateManagerPersistenceTest.kt @@ -251,7 +251,6 @@ class BookContentStateManagerPersistenceTest { copy( selectedCommentatorsByLine = mapOf(10L to setOf(1L, 2L)), selectedLinkSourcesByLine = mapOf(10L to setOf(100L)), - selectedSourcesByLine = mapOf(10L to setOf(500L)), ) } @@ -262,7 +261,6 @@ class BookContentStateManagerPersistenceTest { val state = stateManager.state.value assertTrue(state.content.selectedCommentatorsByLine.isEmpty()) assertTrue(state.content.selectedLinkSourcesByLine.isEmpty()) - assertTrue(state.content.selectedSourcesByLine.isEmpty()) } @Test diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/LineConnectionsSnapshotTest.kt b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/LineConnectionsSnapshotTest.kt index ebdfefae0..cf85e8851 100644 --- a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/LineConnectionsSnapshotTest.kt +++ b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/LineConnectionsSnapshotTest.kt @@ -11,7 +11,6 @@ class LineConnectionsSnapshotTest { assertTrue(snapshot.commentatorGroups.isEmpty()) assertTrue(snapshot.targumSources.isEmpty()) - assertTrue(snapshot.sources.isEmpty()) } @Test @@ -41,36 +40,19 @@ class LineConnectionsSnapshotTest { assertEquals(101L, snapshot.targumSources["Yonatan"]) } - @Test - fun `snapshot can be created with sources`() { - val sources = - mapOf( - "Source1" to 200L, - "Source2" to 201L, - "Source3" to 202L, - ) - val snapshot = LineConnectionsSnapshot(sources = sources) - - assertEquals(3, snapshot.sources.size) - assertTrue(snapshot.sources.containsKey("Source1")) - } - @Test fun `snapshot can be created with all data`() { val groups = listOf(CommentatorGroup("G1", emptyList())) val targum = mapOf("T1" to 1L) - val sources = mapOf("S1" to 2L) val snapshot = LineConnectionsSnapshot( commentatorGroups = groups, targumSources = targum, - sources = sources, ) assertEquals(1, snapshot.commentatorGroups.size) assertEquals(1, snapshot.targumSources.size) - assertEquals(1, snapshot.sources.size) } @Test @@ -79,16 +61,17 @@ class LineConnectionsSnapshotTest { LineConnectionsSnapshot( targumSources = mapOf("Original" to 1L), ) - val modified = original.copy(sources = mapOf("New" to 2L)) + val groups = listOf(CommentatorGroup("New", emptyList())) + val modified = original.copy(commentatorGroups = groups) assertEquals(mapOf("Original" to 1L), modified.targumSources) - assertEquals(mapOf("New" to 2L), modified.sources) + assertEquals(groups, modified.commentatorGroups) } @Test fun `snapshot equals works correctly`() { - val snapshot1 = LineConnectionsSnapshot(sources = mapOf("A" to 1L)) - val snapshot2 = LineConnectionsSnapshot(sources = mapOf("A" to 1L)) + val snapshot1 = LineConnectionsSnapshot(targumSources = mapOf("A" to 1L)) + val snapshot2 = LineConnectionsSnapshot(targumSources = mapOf("A" to 1L)) assertEquals(snapshot1, snapshot2) } diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CommentariesUseCaseReapplyTest.kt b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CommentariesUseCaseReapplyTest.kt index dd015bbf8..56131fd2d 100644 --- a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CommentariesUseCaseReapplyTest.kt +++ b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CommentariesUseCaseReapplyTest.kt @@ -119,39 +119,6 @@ class CommentariesUseCaseReapplyTest { assertEquals(sources, state.content.selectedLinkSourcesByLine[lineId]) } - // ==================== Source Selection State Tests ==================== - - @Test - fun `selectedSourcesByBook persists remembered sources`() { - // Given: Remembered sources for a book - val rememberedIds = setOf(500L, 600L) - - // When: Setting remembered sources - stateManager.updateContent { - copy(selectedSourcesByBook = mapOf(testBookId to rememberedIds)) - } - - // Then: Sources are persisted - val state = stateManager.state.value - assertEquals(rememberedIds, state.content.selectedSourcesByBook[testBookId]) - } - - @Test - fun `selectedSourcesByLine tracks per-line sources`() { - // Given: Per-line source selections - val lineId = 10L - val sources = setOf(500L) - - // When: Setting per-line sources - stateManager.updateContent { - copy(selectedSourcesByLine = mapOf(lineId to sources)) - } - - // Then: Per-line sources are tracked - val state = stateManager.state.value - assertEquals(sources, state.content.selectedSourcesByLine[lineId]) - } - // ==================== Intersection Logic Tests ==================== @Test @@ -202,7 +169,6 @@ class CommentariesUseCaseReapplyTest { copy( selectedCommentatorsByLine = mapOf(10L to setOf(1L, 2L)), selectedLinkSourcesByLine = mapOf(10L to setOf(100L)), - selectedSourcesByLine = mapOf(10L to setOf(500L)), ) } @@ -213,17 +179,13 @@ class CommentariesUseCaseReapplyTest { val state = stateManager.state.value assertTrue(state.content.selectedCommentatorsByLine.isEmpty()) assertTrue(state.content.selectedLinkSourcesByLine.isEmpty()) - assertTrue(state.content.selectedSourcesByLine.isEmpty()) } @Test fun `resetForNewBook clears current selection IDs`() { // Given: State with current selection IDs stateManager.updateContent { - copy( - selectedTargumSourceIds = setOf(100L), - selectedSourceIds = setOf(500L), - ) + copy(selectedTargumSourceIds = setOf(100L)) } // When: Resetting for new book @@ -232,6 +194,5 @@ class CommentariesUseCaseReapplyTest { // Then: Current selection IDs are cleared val state = stateManager.state.value assertTrue(state.content.selectedTargumSourceIds.isEmpty()) - assertTrue(state.content.selectedSourceIds.isEmpty()) } } diff --git a/SeforimLibrary b/SeforimLibrary index 7ddf045b9..4f07145a0 160000 --- a/SeforimLibrary +++ b/SeforimLibrary @@ -1 +1 @@ -Subproject commit 7ddf045b9a2ea9fe4ad5c6a24e0513c2022da67d +Subproject commit 4f07145a0ca3adb36d5719002e0c67b5cd3a9880