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