From e284872eaab45852d08a09bc4a7d1dfcc61a09a7 Mon Sep 17 00:00:00 2001 From: anas Date: Wed, 7 Oct 2026 16:10:21 +0200 Subject: [PATCH] Give the reader a wide-screen shape: measure, contents, dictionary beside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes, all behind one breakpoint at 840dp — Material's "expanded" width, which is a tablet in landscape or a foldable opened out. Below it nothing moves. The measure. A couplet's two hemistichs are set to the start and end of the line, which on a phone reads as one unit and on a tablet flings them to opposite edges with a hand's width of nothing between. The poem is now held to 680dp and centred, so the halves of a line stay within one glance of each other. On a phone the constraint never binds. Contents. PoemTree puts the poem's place in the poet's work beside it — poet » book » section » poem — with the path to the open poem already unfolded and scrolled to, because a book of 495 ghazals otherwise opens at ghazal 1. Branches load as they open. Tapping a category folds it rather than navigating: the section's own page lists the same poems, so opening it would only cost the reader their place. The dictionary. On a wide screen it is a column beside the poem rather than a sheet over it, so the line the word came from stays in view while its meaning is read, and it holds its word until closed. The sheet and the pane share one lookup and one footer. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/com/ganjoor/android/ui/PoemScreen.kt | 245 +++++++++++++----- .../java/com/ganjoor/android/ui/PoemTree.kt | 226 ++++++++++++++++ .../java/com/ganjoor/android/ui/WordSheet.kt | 45 ++-- app/src/main/res/values-fa/strings.xml | 4 + app/src/main/res/values-ur/strings.xml | 4 + app/src/main/res/values/strings.xml | 4 + 6 files changed, 444 insertions(+), 84 deletions(-) create mode 100644 app/src/main/java/com/ganjoor/android/ui/PoemTree.kt diff --git a/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt b/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt index 504eb97..c177aea 100644 --- a/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt +++ b/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt @@ -4,14 +4,19 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.selection.SelectionContainer @@ -20,10 +25,12 @@ import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.VerticalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor @@ -38,6 +45,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.pointerInput @@ -115,83 +123,188 @@ fun PoemScreen( ) } ) { insets -> - // Free-form selection for copying any span; tapping a word looks it up, and the - // per-couplet actions save a passage with the reference attached, which a raw copy - // would lose. - // - // The system selection menu gets its own entries from ProcessTextActivity, not from - // here: Compose 1.10 stopped routing SelectionContainer through LocalTextToolbar, so - // a custom TextToolbar is never asked to show. ACTION_PROCESS_TEXT goes round that, - // and reaches every other app's selection menu as a side effect. - SelectionContainer { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues( - start = 20.dp, - end = 20.dp, - top = insets.calculateTopPadding() + 8.dp, - bottom = insets.calculateBottomPadding() + 32.dp, - ), - ) { - item { - Column(Modifier.padding(bottom = 12.dp)) { - Breadcrumbs(poem.fullTitle, poem.fullUrl.ifBlank { fullUrl }, onCategory) - RecitationPlayer(poem.id) - poem.metre?.rhythm?.let { rhythm -> - Text( - text = rhythm, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + // Narrow and wide are the same reader in different containers. On a phone the poem + // has the window to itself and the dictionary arrives as a sheet over it; given the + // width of a tablet or an opened foldable, the poem's place in the poet's work sits + // on one side of it and the dictionary on the other, and nothing covers the verse. + BoxWithConstraints(Modifier.fillMaxSize()) { + val wide = maxWidth >= WideScreen + + val body: @Composable (Modifier) -> Unit = { modifier -> + // Free-form selection for copying any span; tapping a word looks it up, and + // the per-couplet actions save a passage with the reference attached, which a + // raw copy would lose. + // + // The system selection menu gets its own entries from ProcessTextActivity, + // not from here: Compose 1.10 stopped routing SelectionContainer through + // LocalTextToolbar, so a custom TextToolbar is never asked to show. + // ACTION_PROCESS_TEXT goes round that, and reaches every other app's + // selection menu as a side effect. + SelectionContainer(modifier) { + PoemBody( + poem = poem, + fullUrl = fullUrl, + couplets = couplets, + style = style, + showSummaries = prefs.showSummaries, + siblings = siblings, + here = here, + insets = insets, + onPoem = onPoem, + onCategory = onCategory, + onWord = { word, passage -> tapped = word to passage }, + ) + } + } + + if (wide) { + Row(Modifier.fillMaxSize()) { + // First child, so in this right-to-left layout it lands on the right. + PoemTree( + poemUrl = fullUrl, + onPoem = onPoem, + contentPadding = insets, + modifier = Modifier.width(300.dp).fillMaxHeight(), + ) + VerticalDivider() + body(Modifier.weight(1f)) + tapped?.let { (word, passage) -> + VerticalDivider() + DictionaryPane( + word = word, + passage = passage, + onClose = { tapped = null }, + insets = insets, + modifier = Modifier.width(360.dp).fillMaxHeight(), ) } } - } - - items(couplets) { couplet -> - Couplet( - couplet = couplet, - style = style, - showSummaries = prefs.showSummaries, - source = Bookmark(fullUrl, poem.title, poem.fullTitle), - onWord = { word, passage -> tapped = word to passage }, - ) - } - - if (prefs.showSummaries) { - poem.poemSummary?.takeIf { it.isNotBlank() }?.let { summary -> - item { PoemSummary(summary) } - } - } - - item { - SiblingNav( - previous = if (here > 0) siblings[here - 1] else null, - next = if (here >= 0) siblings.getOrNull(here + 1) else null, - onPoem = onPoem, - ) - } - - poem.sourceName?.takeIf { it.isNotBlank() }?.let { source -> - item { - Text( - text = stringResource(R.string.source, source), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 16.dp), + } else { + body(Modifier) + tapped?.let { (word, passage) -> + WordSheet( + word = word, + passage = passage, + onDismiss = { tapped = null }, ) } } } + } + } +} + +/** + * The poem itself, held to a reading measure. Left to fill a tablet, a couplet's two hemistichs + * are flung to opposite edges of the window with a hand's width of nothing between them, and the + * eye has to cross the whole screen between halves of one line. + */ +@Composable +private fun PoemBody( + poem: Poem, + fullUrl: String, + couplets: List>, + style: androidx.compose.ui.text.TextStyle, + showSummaries: Boolean, + siblings: List, + here: Int, + insets: PaddingValues, + onPoem: (String) -> Unit, + onCategory: (String) -> Unit, + onWord: (String, Bookmark) -> Unit, +) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + LazyColumn( + modifier = Modifier.widthIn(max = ReadingMeasure).fillMaxSize(), + contentPadding = PaddingValues( + start = 20.dp, + end = 20.dp, + top = insets.calculateTopPadding() + 8.dp, + bottom = insets.calculateBottomPadding() + 32.dp, + ), + ) { + item { + Column(Modifier.padding(bottom = 12.dp)) { + Breadcrumbs(poem.fullTitle, poem.fullUrl.ifBlank { fullUrl }, onCategory) + RecitationPlayer(poem.id) + poem.metre?.rhythm?.let { rhythm -> + Text( + text = rhythm, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + items(couplets) { couplet -> + Couplet( + couplet = couplet, + style = style, + showSummaries = showSummaries, + source = Bookmark(fullUrl, poem.title, poem.fullTitle), + onWord = onWord, + ) + } + + if (showSummaries) { + poem.poemSummary?.takeIf { it.isNotBlank() }?.let { summary -> + item { PoemSummary(summary) } + } + } + + item { + SiblingNav( + previous = if (here > 0) siblings[here - 1] else null, + next = if (here >= 0) siblings.getOrNull(here + 1) else null, + onPoem = onPoem, + ) + } + + poem.sourceName?.takeIf { it.isNotBlank() }?.let { source -> + item { + Text( + text = stringResource(R.string.source, source), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 16.dp), + ) + } } } } +} - tapped?.let { (word, passage) -> - WordSheet( - word = word, - passage = passage, - onDismiss = { tapped = null }, - ) +/** + * The dictionary as a column beside the poem rather than a sheet over it, so the line the word + * came from stays in view while its meaning is read. Closeable, because it holds its word until + * dismissed — unlike the sheet, which a tap outside takes away. + */ +@Composable +private fun DictionaryPane( + word: String, + passage: Bookmark, + onClose: () -> Unit, + insets: PaddingValues, + modifier: Modifier = Modifier, +) { + Column(modifier.padding(top = insets.calculateTopPadding())) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 20.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.dictionary), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onClose) { + Icon(Icons.Default.Close, stringResource(R.string.close)) + } + } + HorizontalDivider() + WordLookup(word) { PassageFooter(passage) } } } diff --git a/app/src/main/java/com/ganjoor/android/ui/PoemTree.kt b/app/src/main/java/com/ganjoor/android/ui/PoemTree.kt new file mode 100644 index 0000000..1457267 --- /dev/null +++ b/app/src/main/java/com/ganjoor/android/ui/PoemTree.kt @@ -0,0 +1,226 @@ +package com.ganjoor.android.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.ganjoor.android.R +import com.ganjoor.android.data.Category +import com.ganjoor.android.data.Ganjoor + +/** + * Below this, the window is a phone: one column, and the dictionary arrives as a sheet over the + * poem. At or above it there is room to put the poem's place in the poet's work beside it, and + * the dictionary beside that, without squeezing the measure the poetry is set to. + * + * 840dp is Material's "expanded" width — a tablet in landscape, or a foldable opened out. + */ +val WideScreen = 840.dp + +/** The reading measure. A line of verse set across a whole tablet is a line nobody can follow. */ +val ReadingMeasure = 680.dp + +/** + * One row of the contents tree. [expanded] is null for a poem, which has nothing to open; + * [url] is null for the placeholder shown while a branch is still loading. + */ +private data class TreeRow( + val label: String, + val url: String?, + val depth: Int, + val expanded: Boolean?, + val current: Boolean = false, +) + +/** Every category above [poemUrl]: `/hafez/ghazal/sh1` -> `/hafez`, `/hafez/ghazal`. */ +private fun ancestorUrls(poemUrl: String): List { + val segments = poemUrl.trim('/').split('/').filter { it.isNotEmpty() } + if (segments.size <= 1) return emptyList() + return (1 until segments.size).map { "/" + segments.take(it).joinToString("/") } +} + +/** + * Flattens the loaded part of the tree into rows. A branch that is open but not yet fetched + * contributes a spinner rather than nothing, so an expanding node never looks broken. + */ +private fun buildRows( + root: String, + loaded: Map, + expanded: Set, + current: String, +): List { + val rows = mutableListOf() + + fun walk(url: String, depth: Int) { + val category = loaded[url] + if (category == null) { + rows += TreeRow(label = "", url = null, depth = depth, expanded = null) + return + } + category.childCats.forEach { child -> + val open = child.fullUrl in expanded + rows += TreeRow(child.title, child.fullUrl, depth, open) + if (open) walk(child.fullUrl, depth + 1) + } + category.poems.forEach { poem -> + rows += TreeRow(poem.title, poem.fullUrl, depth, null, poem.fullUrl == current) + } + } + + walk(root, 0) + return rows +} + +/** + * Where this poem sits in its poet's work — poet » book » section » poem — as a tree beside the + * reader, with the path to the open poem already unfolded and scrolled to. + * + * Tapping a category folds it open or shut rather than navigating: a section's own page lists the + * same poems this tree already shows, so opening it would only cost the reader their place. The + * breadcrumbs above the poem are the way out to a category page. + * + * ponytail: branches are fetched as they open and kept only while the screen lives. The disk + * cache makes re-opening cheap, and a poet's whole tree is far too much to hold eagerly. + */ +@Composable +fun PoemTree( + poemUrl: String, + onPoem: (String) -> Unit, + contentPadding: PaddingValues, + modifier: Modifier = Modifier, +) { + val ancestors = remember(poemUrl) { ancestorUrls(poemUrl) } + if (ancestors.isEmpty()) return + val root = ancestors.first() + + val loaded = remember(poemUrl) { mutableStateMapOf() } + var expanded by remember(poemUrl) { mutableStateOf(ancestors.toSet()) } + var unreachable by remember(poemUrl) { mutableStateOf(emptySet()) } + + LaunchedEffect(poemUrl, expanded) { + expanded.filterNot { it in loaded || it in unreachable }.forEach { url -> + runCatching { Ganjoor.category(url) } + .onSuccess { loaded[url] = it } + // A branch that will not load stays shut rather than retrying on every + // recomposition; the poem itself is already on screen and still readable. + .onFailure { unreachable = unreachable + url } + } + } + + val rows = remember(loaded.size, expanded, poemUrl) { + buildRows(root, loaded, expanded, poemUrl) + } + + val listState = rememberLazyListState() + var settled by remember(poemUrl) { mutableStateOf(false) } + // Once, when the open poem's row first exists: a book of 495 ghazals opens far from the one + // being read, and a tree that starts at ghazal 1 is a tree nobody scrolls. + LaunchedEffect(rows) { + if (settled) return@LaunchedEffect + val index = rows.indexOfFirst { it.current } + if (index >= 0) { + listState.scrollToItem(index + 1) + settled = true + } + } + + LazyColumn(modifier = modifier, state = listState, contentPadding = contentPadding) { + item { + Text( + text = loaded[root]?.title ?: stringResource(R.string.contents), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 12.dp, bottom = 8.dp), + ) + } + items(rows) { row -> + TreeRowItem( + row = row, + onToggle = { + expanded = if (row.url in expanded) expanded - row.url!! else expanded + row.url!! + }, + onPoem = onPoem, + ) + } + } +} + +@Composable +private fun TreeRowItem(row: TreeRow, onToggle: () -> Unit, onPoem: (String) -> Unit) { + // Indent by depth, on top of the row's own padding, so nesting reads at a glance. + val indent = (row.depth * 14).dp + + if (row.url == null) { + Box( + modifier = Modifier.fillMaxWidth().padding(start = indent + 12.dp, top = 8.dp, bottom = 8.dp), + ) { + CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) + } + return + } + + val isCategory = row.expanded != null + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { if (isCategory) onToggle() else onPoem(row.url) } + .padding(start = indent + 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (isCategory) { + Icon( + // Down when open, and pointing along the text when shut — the chevron is + // auto-mirrored, so it turns the right way in a right-to-left layout. + imageVector = if (row.expanded == true) Icons.Default.KeyboardArrowDown + else Icons.AutoMirrored.Filled.KeyboardArrowLeft, + contentDescription = stringResource( + if (row.expanded == true) R.string.collapse else R.string.expand + ), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } + Column(Modifier.fillMaxWidth()) { + Text( + text = row.label, + style = if (isCategory) MaterialTheme.typography.bodyMedium + else MaterialTheme.typography.bodySmall, + fontWeight = if (row.current) FontWeight.Bold else null, + color = when { + row.current -> MaterialTheme.colorScheme.primary + isCategory -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } +} diff --git a/app/src/main/java/com/ganjoor/android/ui/WordSheet.kt b/app/src/main/java/com/ganjoor/android/ui/WordSheet.kt index 350499b..2395779 100644 --- a/app/src/main/java/com/ganjoor/android/ui/WordSheet.kt +++ b/app/src/main/java/com/ganjoor/android/ui/WordSheet.kt @@ -78,24 +78,33 @@ fun WordSheet(word: String, onDismiss: () -> Unit, passage: Bookmark? = null) { ModalBottomSheet(onDismissRequest = onDismiss) { // Inside the lookup's own scroll, not after it: the lookup scrolls, so anything placed // below it is pushed past the bottom of the sheet with no way to reach it. - WordLookup(word) { - if (passage == null) return@WordLookup - HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) - Text( - text = stringResource(R.string.this_couplet), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - ) - PassageActions(passage) - if (LocalAssistant.current.serverReady) { - AssistantInline( - prompt = "explain", - text = passage.excerpt.orEmpty(), - label = R.string.assistant_explain, - instruction = R.string.assistant_ask_prompt, - ) - } - } + WordLookup(word) { PassageFooter(passage) } + } +} + +/** + * The couplet the word was tapped in, with its own actions, under the definitions. + * + * Shared by the phone's sheet and the wide-screen dictionary pane, which show the same lookup in + * two different containers. + */ +@Composable +internal fun ColumnScope.PassageFooter(passage: Bookmark?) { + if (passage == null) return + HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) + Text( + text = stringResource(R.string.this_couplet), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + PassageActions(passage) + if (LocalAssistant.current.serverReady) { + AssistantInline( + prompt = "explain", + text = passage.excerpt.orEmpty(), + label = R.string.assistant_explain, + instruction = R.string.assistant_ask_prompt, + ) } } diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 163b848..ee61da0 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -115,4 +115,8 @@ نمایش کارتی نمایش فهرستی پیش‌تر دریافت شده + بستن + فهرست + گشودن + بستن شاخه diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index 32adcd6..a7a72c5 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -115,4 +115,8 @@ کارڈ کی صورت فہرست کی صورت پہلے سے محفوظ ہے + بند کریں + فہرست + کھولیں + بند کریں diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 93ca018..b460be7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -120,4 +120,8 @@ Show as cards Show as a list Already downloaded + Close + Contents + Expand + Collapse