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