diff --git a/app/src/main/java/com/markreader/ui/screens/EditorScreen.kt b/app/src/main/java/com/markreader/ui/screens/EditorScreen.kt index 769840d..6860735 100644 --- a/app/src/main/java/com/markreader/ui/screens/EditorScreen.kt +++ b/app/src/main/java/com/markreader/ui/screens/EditorScreen.kt @@ -295,14 +295,17 @@ fun EditorScreen( } else { val previewTextColor = MaterialTheme.colorScheme.onSurface.toArgb() val previewIsDark = isSystemInDarkTheme() + // The preview never restores a scroll position. + val previewScrollY = remember { mutableStateOf(0) } Box(modifier = Modifier.weight(1f).fillMaxWidth()) { RenderedTextView( text = previewText, textColor = previewTextColor, padding = PaddingValues(0.dp), - savedScrollY = 0, + savedScrollY = previewScrollY, scrollToOffset = null, onScrollChanged = { _, _ -> }, + onScrollExtentChanged = {}, onScrollConsumed = {}, headings = emptyList(), onActiveHeadingChanged = {}, diff --git a/app/src/main/java/com/markreader/ui/screens/ViewerScreen.kt b/app/src/main/java/com/markreader/ui/screens/ViewerScreen.kt index 647c121..74fb2ed 100644 --- a/app/src/main/java/com/markreader/ui/screens/ViewerScreen.kt +++ b/app/src/main/java/com/markreader/ui/screens/ViewerScreen.kt @@ -2,6 +2,7 @@ package com.markreader.ui.screens import android.app.Application import android.os.Build +import android.os.SystemClock import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -79,10 +80,12 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -115,6 +118,12 @@ import com.markreader.ui.components.segmentShape import com.markreader.ui.export.ExportManager import kotlin.math.roundToInt +/** + * How long the chrome's visibility decision is held after it changes, covering the + * show/hide animation and the relayout it causes. + */ +private const val CHROME_SETTLE_MS = 350L + @OptIn(ExperimentalMaterial3Api::class) @Composable fun ViewerScreen( @@ -130,8 +139,13 @@ fun ViewerScreen( ) val uiState by viewModel.uiState.collectAsStateWithLifecycle() val scrollToOffset by viewModel.scrollToOffset.collectAsStateWithLifecycle() - val savedScrollY by viewModel.scrollY.collectAsStateWithLifecycle() - val scrollProgress by viewModel.scrollProgress.collectAsStateWithLifecycle() + // Deliberately not read with `by` here. Both change on every scroll frame, so + // reading them in this scope would recompose the whole screen — including the + // viewer's AndroidView update block — 60+ times a second while scrolling. + // They are read inside the leaf composables that actually display them, and + // inside snapshotFlow below, so the reads stay out of this scope. + val savedScrollY = viewModel.scrollY.collectAsStateWithLifecycle() + val scrollProgress = viewModel.scrollProgress.collectAsStateWithLifecycle() val prefs = uiState.userPreferences val isSystemDark = isSystemInDarkTheme() @@ -176,16 +190,32 @@ fun ViewerScreen( // Immersive reading: hide the chrome on downward scrolls, bring it back on // upward scrolls or at the top. Large deltas are programmatic jumps (TOC, // search match) where the user just used the chrome — keep it visible. + // Showing or hiding the bar changes the content's top inset, which resizes the + // ScrollView and can clamp its position — emitting a scroll delta that points + // the opposite way and immediately flips the decision back. Hold the decision + // briefly after each change so the bar can finish animating instead of + // stuttering against its own layout effect. LaunchedEffect(Unit) { var lastY = 0 - snapshotFlow { savedScrollY }.collect { y -> + var settleUntil = 0L + snapshotFlow { savedScrollY.value }.collect { y -> val delta = y - lastY - when { - y <= 0 -> isChromeVisible = true - delta < -8 -> isChromeVisible = true - delta in 9..1200 -> isChromeVisible = false - } lastY = y + if (y <= 0) { + isChromeVisible = true + settleUntil = 0L + return@collect + } + if (SystemClock.uptimeMillis() < settleUntil) return@collect + val target = when { + delta < -8 -> true + delta in 9..1200 -> false + else -> return@collect + } + if (target != isChromeVisible) { + isChromeVisible = target + settleUntil = SystemClock.uptimeMillis() + CHROME_SETTLE_MS + } } } LaunchedEffect(uiState.isSearchActive) { @@ -407,23 +437,11 @@ fun ViewerScreen( ) } } - scrollProgress?.let { progress -> - Surface( - shape = RoundedCornerShape(50), - color = chromeColors.tonalContainer.copy(alpha = 0.6f), - contentColor = chromeColors.muted - ) { - Text( - text = "${(progress * 100).roundToInt()}%", - style = MaterialTheme.typography.labelSmall, - maxLines = 1, - modifier = Modifier.padding( - horizontal = 8.dp, - vertical = 2.dp - ) - ) - } - } + ReadingProgressChip( + progress = scrollProgress, + containerColor = chromeColors.tonalContainer, + contentColor = chromeColors.muted + ) } } }, @@ -614,26 +632,11 @@ fun ViewerScreen( } } } - val animatedReadProgress by animateFloatAsState( - targetValue = scrollProgress ?: 0f, - animationSpec = spring(stiffness = Spring.StiffnessLow), - label = "readingProgress" + ReadingProgressBar( + progress = scrollProgress, + trackColor = chromeColors.tonalContainer, + barColor = chromeColors.content ) - if (scrollProgress != null) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(3.dp) - .background(chromeColors.tonalContainer.copy(alpha = 0.5f)) - ) { - Box( - modifier = Modifier - .fillMaxWidth(animatedReadProgress.coerceIn(0f, 1f)) - .fillMaxHeight() - .background(chromeColors.content.copy(alpha = 0.7f)) - ) - } - } } } ) { paddingValues: PaddingValues -> @@ -712,6 +715,7 @@ fun ViewerScreen( savedScrollY = savedScrollY, scrollToOffset = scrollToOffset, onScrollChanged = viewModel::onScrollPositionChanged, + onScrollExtentChanged = viewModel::onScrollExtentChanged, onScrollConsumed = viewModel::onScrollConsumed, headings = uiState.headings, onActiveHeadingChanged = viewModel::onActiveHeadingChanged, @@ -891,6 +895,65 @@ fun ViewerScreen( } } +/** + * Reading-progress percentage chip. + * + * Takes progress as [State] and reads it here rather than in [ViewerScreen], so + * that a scroll frame recomposes only this chip instead of the entire screen. + */ +@Composable +private fun ReadingProgressChip( + progress: State, + containerColor: Color, + contentColor: Color +) { + val value = progress.value ?: return + Surface( + shape = RoundedCornerShape(50), + color = containerColor.copy(alpha = 0.6f), + contentColor = contentColor + ) { + Text( + text = "${(value * 100).roundToInt()}%", + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp) + ) + } +} + +/** + * Reading-progress bar under the chrome. Reads progress in its own scope for the + * same reason as [ReadingProgressChip]. + */ +@Composable +private fun ReadingProgressBar( + progress: State, + trackColor: Color, + barColor: Color +) { + val value = progress.value + val animatedReadProgress by animateFloatAsState( + targetValue = value ?: 0f, + animationSpec = spring(stiffness = Spring.StiffnessLow), + label = "readingProgress" + ) + if (value == null) return + Box( + modifier = Modifier + .fillMaxWidth() + .height(3.dp) + .background(trackColor.copy(alpha = 0.5f)) + ) { + Box( + modifier = Modifier + .fillMaxWidth(animatedReadProgress.coerceIn(0f, 1f)) + .fillMaxHeight() + .background(barColor.copy(alpha = 0.7f)) + ) + } +} + @Composable private fun TocHeadingRow( heading: HeadingItem, diff --git a/app/src/main/java/com/markreader/ui/screens/ViewerTextInterop.kt b/app/src/main/java/com/markreader/ui/screens/ViewerTextInterop.kt index deec71e..4c2959c 100644 --- a/app/src/main/java/com/markreader/ui/screens/ViewerTextInterop.kt +++ b/app/src/main/java/com/markreader/ui/screens/ViewerTextInterop.kt @@ -1,6 +1,7 @@ package com.markreader.ui.screens import android.content.Context +import android.graphics.Rect import android.graphics.Typeface import android.os.Build import android.graphics.text.LineBreaker @@ -21,11 +22,13 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -46,6 +49,54 @@ import io.noties.markwon.ext.tables.TableRowSpan private enum class SegmentType { Text, Code, Table } private data class Segment(val start: Int, val end: Int, val type: SegmentType) +/** + * A [ScrollView] that never scrolls itself to keep a focused descendant on screen. + * + * Code blocks are selectable TextViews, and `setTextIsSelectable(true)` makes them + * focusable. Android's ScrollView reacts to a focused descendant in two ways, and + * both fight the user here: + * + * - `requestChildFocus()` scrolls the focused child into view. + * - `onSizeChanged()` re-scrolls to keep it on screen whenever the viewport + * height changes — and the chrome show/hide animation changes that height on + * every frame. + * + * The second one is the damaging one: the automatic `doScrollY` lands in the + * middle of the user's drag, and the scroll deltas it emits feed straight back + * into the chrome's show/hide threshold, so the bar re-hides itself as it is + * appearing. + * + * Both paths route through [computeScrollDeltaToGetChildRectOnScreen], but so + * does `requestChildRectangleOnScreen` — which is what scrolls when a selection + * handle is dragged past the edge of the viewport, and is worth keeping. So the + * suppression is scoped to the two callers that misbehave rather than applied to + * the method outright. + */ +private class FocusStableScrollView(context: Context) : ScrollView(context) { + private var suppressFocusScroll = false + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + suppressFocusScroll = true + try { + super.onSizeChanged(w, h, oldw, oldh) + } finally { + suppressFocusScroll = false + } + } + + override fun requestChildFocus(child: android.view.View?, focused: android.view.View?) { + suppressFocusScroll = true + try { + super.requestChildFocus(child, focused) + } finally { + suppressFocusScroll = false + } + } + + override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect?): Int = + if (suppressFocusScroll) 0 else super.computeScrollDeltaToGetChildRectOnScreen(rect) +} + private data class ContentKey( val textHash: Int, val fontSizeSp: Float, @@ -56,14 +107,32 @@ private data class ContentKey( val textAlignment: TextAlignmentPreference ) +/** + * Identity of a scroll restore. [ContentKey] alone is not enough: toggling a + * wrap setting rebuilds the view tree without changing the content, so a + * restore for the new structure must not be confused with one already applied + * to the old structure. + */ +private data class RestoreKey( + val content: ContentKey, + val isWordWrapEnabled: Boolean, + val isCodeBlockWrapEnabled: Boolean +) + @Composable fun RenderedTextView( text: Any, textColor: Int, padding: PaddingValues, - savedScrollY: Int, + // Held as State, not Int: the live scroll position changes every frame while + // scrolling, and taking it as a value would recompose this composable — and + // re-run the AndroidView update block — on every one of those frames. It is + // only ever read to restore a position, so it is read without snapshot + // observation below. + savedScrollY: State, scrollToOffset: Int?, onScrollChanged: (scrollY: Int, maxScrollY: Int) -> Unit, + onScrollExtentChanged: (maxScrollY: Int) -> Unit, onScrollConsumed: () -> Unit, headings: List, onActiveHeadingChanged: (Int) -> Unit, @@ -89,7 +158,23 @@ fun RenderedTextView( textAlignment = textAlignment ) } - var lastRestoredKey by remember { mutableStateOf(null) } + // Span scans over the whole document — cached per text, never per frame. + val docHasCodeBlocks = remember(text) { text is Spanned && hasCodeBlocks(text) } + val docHasTables = remember(text) { text is Spanned && hasTables(text) } + // Split mode gives code blocks and tables their own horizontally scrollable + // views. Only worth the cost when the document actually contains one. + val isSplitMode = remember( + text, isWordWrapEnabled, isCodeBlockWrapEnabled, docHasCodeBlocks, docHasTables + ) { + text is Spanned && ( + (isWordWrapEnabled && ((!isCodeBlockWrapEnabled && docHasCodeBlocks) || docHasTables)) || + (!isWordWrapEnabled && docHasCodeBlocks) + ) + } + val restoreKey = remember(contentKey, isWordWrapEnabled, isCodeBlockWrapEnabled) { + RestoreKey(contentKey, isWordWrapEnabled, isCodeBlockWrapEnabled) + } + var lastRestoredKey by remember { mutableStateOf(null) } var lastWrapEnabled by remember { mutableStateOf(isWordWrapEnabled) } var lastCodeBlockWrapEnabled by remember { mutableStateOf(isCodeBlockWrapEnabled) } var pendingAnchorOffset by remember { mutableStateOf(null) } @@ -111,6 +196,7 @@ fun RenderedTextView( var lastTextColor by remember { mutableStateOf(textColor) } var lastWrapEnabledApplied by remember { mutableStateOf(isWordWrapEnabled) } var lastSelectionHighlightColor by remember { mutableStateOf(selectionHighlightColor) } + var lastCodeBlockBackgroundColor by remember { mutableStateOf(codeBlockBackgroundColor) } var splitBoundaries by remember { mutableStateOf>(emptyList()) } val currentHeadings by rememberUpdatedState(headings) val currentSplitBoundaries by rememberUpdatedState(splitBoundaries) @@ -129,13 +215,9 @@ fun RenderedTextView( factory = { context -> val density = context.resources.displayMetrics.density val paddingPx = (16 * density).toInt() - val isSplitMode = text is Spanned && ( - (isWordWrapEnabled && (!isCodeBlockWrapEnabled || hasTables(text))) || - (!isWordWrapEnabled && hasCodeBlocks(text)) - ) val useGlobalHorizontalScroll = !isWordWrapEnabled - val scrollView = ScrollView(context).apply { + val scrollView = FocusStableScrollView(context).apply { if (isSplitMode) { val container = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL @@ -191,9 +273,13 @@ fun RenderedTextView( } } // Report scroll extent whenever content lays out so reading - // progress is available before the first scroll event. + // progress is available before the first scroll event. This + // reports the extent only — never a position. It fires on every + // window-wide layout, including every frame of the chrome's + // show/hide animation, and reporting a position from here would + // feed those frames into the chrome's scroll-delta threshold. viewTreeObserver.addOnGlobalLayoutListener { - onScrollChanged(scrollY, computeMaxScrollY(this)) + onScrollExtentChanged(computeMaxScrollY(this)) } } val rootView = if (useGlobalHorizontalScroll) { @@ -228,10 +314,6 @@ fun RenderedTextView( else -> rootView as ScrollView } val child = scrollView.getChildAt(0) ?: return@AndroidView - val isSplitMode = text is Spanned && ( - (isWordWrapEnabled && (!isCodeBlockWrapEnabled || hasTables(text))) || - (!isWordWrapEnabled && hasCodeBlocks(text)) - ) val useGlobalHorizontalScroll = !isWordWrapEnabled val wrapChanged = lastWrapEnabled != isWordWrapEnabled || lastCodeBlockWrapEnabled != isCodeBlockWrapEnabled @@ -239,6 +321,7 @@ fun RenderedTextView( val needsRestructure = wrapChanged || (isSplitMode != currentIsSplit) val density = scrollView.context.resources.displayMetrics.density val paddingPx = (16 * density).toInt() + var activeSplitBoundaries = currentSplitBoundaries if (needsRestructure) { val hasGlobalHorizontalScroll = rootView is HorizontalScrollView @@ -329,6 +412,7 @@ fun RenderedTextView( lastTextColor = textColor lastWrapEnabledApplied = isWordWrapEnabled lastSelectionHighlightColor = selectionHighlightColor + lastCodeBlockBackgroundColor = codeBlockBackgroundColor } // Text / style updates @@ -351,6 +435,7 @@ fun RenderedTextView( val splitTables = isWordWrapEnabled val segments = splitByMarkers(spanned, splitCode, splitTables) splitBoundaries = segments + activeSplitBoundaries = segments container.removeAllViews() for ((start, end, type) in segments) { val isCode = type == SegmentType.Code @@ -457,10 +542,11 @@ fun RenderedTextView( lastStyleKey = contentKey lastTextColor = textColor lastSelectionHighlightColor = selectionHighlightColor + lastCodeBlockBackgroundColor = codeBlockBackgroundColor } else if (textRefChanged) { // Same underlying text, different spans (search highlights) — // update existing TextViews in-place without rebuilding views - val boundaries = currentSplitBoundaries + val boundaries = activeSplitBoundaries for (i in 0 until container.childCount.coerceAtMost(boundaries.size)) { val (start, end, type) = boundaries[i] val segText = spanned.subSequence(start, end) as Spanned @@ -495,36 +581,47 @@ fun RenderedTextView( } } lastTextRef = spanned - } else if (lastStyleKey != contentKey || lastTextColor != textColor || - lastSelectionHighlightColor != selectionHighlightColor - ) { - // Rebuild segments to refresh table colors and code spans - lastTextHash = 0 } if (lastStyleKey != contentKey || lastTextColor != textColor || - lastSelectionHighlightColor != selectionHighlightColor + lastSelectionHighlightColor != selectionHighlightColor || + lastCodeBlockBackgroundColor != codeBlockBackgroundColor ) { + // Restyle in place. This must never fall back to a segment + // rebuild: textColor comes from an animated Color, so a + // rebuild here would tear down and recreate the whole view + // tree on alternating frames of that animation — which also + // cancels any touch gesture in flight. for (i in 0 until container.childCount) { val seg = container.getChildAt(i) val target = when (seg) { - is FrameLayout -> seg.getChildAt(0) + is FrameLayout -> { + // Code block wrapper — carries the block tint. + seg.setBackgroundColor(codeBlockBackgroundColor) + seg.getChildAt(0) + } else -> seg } - val tv = if (target is HorizontalScrollView) { - target.getChildAt(0) as? TextView + val inner = if (target is HorizontalScrollView) { + target.getChildAt(0) } else { - target as? TextView + target } - tv?.let { - applyStyleToTextView( - it, fontSizeSp, lineHeight, readingFont, codeFont, isSourceCode, - textAlignment, textColor, selectionHighlightColor + when (inner) { + is android.widget.TableLayout -> restyleTableLayout( + inner, textColor, fontSizeSp, lineHeight, readingFont, + selectionHighlightColor, density + ) + is TextView -> applyStyleToTextView( + inner, fontSizeSp, lineHeight, readingFont, codeFont, + isSourceCode, textAlignment, textColor, + selectionHighlightColor ) } } lastStyleKey = contentKey lastTextColor = textColor lastSelectionHighlightColor = selectionHighlightColor + lastCodeBlockBackgroundColor = codeBlockBackgroundColor } } else { // Single-TV mode @@ -574,33 +671,45 @@ fun RenderedTextView( } } - // Scroll handling - if (pendingAnchorOffset != null) { - val targetOffset = pendingAnchorOffset + // Scroll handling. Every branch claims restoreKey synchronously, + // before posting: the state writes above cause another pass through + // this block before the posted runnable gets to run, and a claim made + // inside the runnable would let that pass queue a second, conflicting + // restore behind this one. + val targetOffset = pendingAnchorOffset + if (targetOffset != null) { pendingAnchorOffset = null + lastRestoredKey = restoreKey scrollView.post { val y = resolveScrollY( - scrollView, currentSplitBoundaries, targetOffset ?: return@post + scrollView, activeSplitBoundaries, targetOffset ) - lastRestoredKey = contentKey - scrollView.smoothScrollTo(0, y) + // Unresolvable anchor — hold position rather than jumping. + if (y != null) scrollView.smoothScrollTo(0, y) + } + } else if (scrollToOffset == null && lastRestoredKey != restoreKey) { + // Restore-only read: observing it here would resubscribe this + // block to a value that changes on every scroll frame. + val restoreY = Snapshot.withoutReadObservation { savedScrollY.value } + if (restoreY > 0) { + lastRestoredKey = restoreKey + scrollView.post { scrollView.scrollTo(0, restoreY) } } - } else if (scrollToOffset == null && savedScrollY > 0 && - lastRestoredKey != contentKey - ) { - lastRestoredKey = contentKey - scrollView.post { scrollView.scrollTo(0, savedScrollY) } } if (scrollToOffset != null) { + lastRestoredKey = restoreKey scrollView.post { val y = resolveScrollY( - scrollView, currentSplitBoundaries, scrollToOffset + scrollView, activeSplitBoundaries, scrollToOffset ) - val centeredY = (y - scrollView.height / 3).coerceAtLeast(0) - lastRestoredKey = contentKey - scrollView.smoothScrollTo(0, centeredY) - if (zoomLayout.currentScale > 1f) zoomLayout.resetPan() + if (y != null) { + val centeredY = (y - scrollView.height / 3).coerceAtLeast(0) + scrollView.smoothScrollTo(0, centeredY) + if (zoomLayout.currentScale > 1f) zoomLayout.resetPan() + } + // Consumed either way, so an unresolvable target does not + // leave the request pending forever. onScrollConsumed() } } @@ -611,7 +720,7 @@ fun RenderedTextView( if (curChild is LinearLayout) { onActiveHeadingChangedState( findActiveHeadingInSplit( - curChild, currentSplitBoundaries, + curChild, activeSplitBoundaries, currentHeadings, scrollView.scrollY ) ) @@ -717,12 +826,14 @@ private fun getAnchorFromView( is HorizontalScrollView -> { val tv = child.getChildAt(0) as? TextView val layout = tv?.layout ?: return null - val line = layout.getLineForVertical(sy) + // sy is in the ScrollView's space; the layout's line 0 starts below + // the TextView's top padding. + val line = layout.getLineForVertical((sy - tv.paddingTop).coerceAtLeast(0)) return layout.getLineStart(line) } is TextView -> { val layout = child.layout ?: return null - val line = layout.getLineForVertical(sy) + val line = layout.getLineForVertical((sy - child.paddingTop).coerceAtLeast(0)) return layout.getLineStart(line) } else -> return null @@ -735,12 +846,18 @@ private fun computeMaxScrollY(scrollView: ScrollView): Int { .coerceAtLeast(0) } +/** + * Scroll position that puts [offset] at the top of the viewport, or null if it + * cannot be resolved — the views may not be laid out yet. Null means "leave the + * scroll position alone"; returning 0 here would silently jump to the top of the + * document. + */ private fun resolveScrollY( scrollView: ScrollView, boundaries: List, offset: Int -): Int { - val child = scrollView.getChildAt(0) ?: return 0 +): Int? { + val child = scrollView.getChildAt(0) ?: return null if (child is LinearLayout && boundaries.isNotEmpty()) { return scrollToOffsetInSplit(child, boundaries, offset) } @@ -748,32 +865,44 @@ private fun resolveScrollY( is HorizontalScrollView -> child.getChildAt(0) as? TextView is TextView -> child else -> null - } ?: return 0 - val layout = tv.layout ?: return 0 + } ?: return null + val layout = tv.layout ?: return null val line = layout.getLineForOffset(offset) - return layout.getLineTop(line) + return layout.getLineTop(line) + tv.paddingTop } private fun scrollToOffsetInSplit( container: LinearLayout, boundaries: List, offset: Int -): Int { +): Int? { + // Child positions are meaningless until the container has been laid out. + if (!container.isLaidOut) return null for (i in boundaries.indices) { val (segStart, segEnd, _) = boundaries[i] if (offset < segStart || offset >= segEnd) continue if (i >= container.childCount) break val child = container.getChildAt(i) - val tv = extractTextView(child) ?: continue - val layout = tv.layout ?: continue + val tv = extractTextView(child) + // child.top is already measured from the container's padded origin, so the + // container's own padding must not be added again. + val layout = if (tv == null) { + // No TextView at all — a table. Still anchorable, just at segment + // granularity rather than line granularity. + return child.top + } else { + // A TextView whose layout has not been built yet is not the same thing: + // guessing here is what used to send the viewer to the top. + tv.layout ?: return null + } val localOffset = (offset - segStart).coerceIn( 0, layout.text.length.coerceAtLeast(1) - 1 ) val line = layout.getLineForOffset(localOffset) - return child.top + layout.getLineTop(line) + container.paddingTop + return child.top + layout.getLineTop(line) } - return 0 + return null } private fun extractTextView(view: android.view.View): TextView? { @@ -846,10 +975,7 @@ private fun buildTableLayout( } catch (_: Exception) { return null } val cellPaddingPx = (8 * density).toInt() - val borderWidthPx = maxOf(1, density.toInt()) - // Markwon defaults: border = textColor at 75/255 alpha, odd row bg = textColor at 22/255 alpha - val borderColor = (textColor and 0x00FFFFFF) or (75 shl 24) - val oddRowBg = (textColor and 0x00FFFFFF) or (22 shl 24) + val borderWidthPx = tableBorderWidthPx(density) val typeface = resolveTypeface(context, false, readingFont, CodeFontPreference.JetBrainsMono) val tableLayout = android.widget.TableLayout(context) @@ -866,10 +992,7 @@ private fun buildTableLayout( val tableRow = android.widget.TableRow(context) for ((colIndex, cell) in cells.withIndex()) { - val cellBg = GradientDrawable().apply { - setStroke(borderWidthPx, borderColor) - setColor(if (isOdd) oddRowBg else Color.TRANSPARENT) - } + val cellBg = tableCellBackground(textColor, isOdd, borderWidthPx) val cellTv = SearchHighlightTextView(context).apply { this.text = cell.text() textSize = fontSizeSp @@ -906,6 +1029,57 @@ private fun buildTableLayout( return tableLayout } +private fun tableBorderWidthPx(density: Float): Int = maxOf(1, density.toInt()) + +// Markwon defaults: border = textColor at 75/255 alpha, odd row bg = textColor at 22/255 alpha +private fun tableCellBackground( + textColor: Int, + isOdd: Boolean, + borderWidthPx: Int +): GradientDrawable = GradientDrawable().apply { + setStroke(borderWidthPx, (textColor and 0x00FFFFFF) or (75 shl 24)) + setColor(if (isOdd) (textColor and 0x00FFFFFF) or (22 shl 24) else Color.TRANSPARENT) +} + +/** + * Reapply colors and text styling to an existing table without rebuilding it. + * Cell backgrounds bake in [textColor], so a theme change has to touch them — + * but tearing the table down to do it would also tear down the view tree the + * user may be mid-gesture on. + */ +private fun restyleTableLayout( + table: android.widget.TableLayout, + textColor: Int, + fontSizeSp: Float, + lineHeight: Float, + readingFont: ReadingFontPreference, + selectionHighlightColor: Int, + density: Float +) { + val borderWidthPx = tableBorderWidthPx(density) + val typeface = resolveTypeface( + table.context, false, readingFont, CodeFontPreference.JetBrainsMono + ) + for (rowIndex in 0 until table.childCount) { + val row = table.getChildAt(rowIndex) as? android.widget.TableRow ?: continue + val isHeader = rowIndex == 0 + val isOdd = !isHeader && rowIndex % 2 == 1 + for (colIndex in 0 until row.childCount) { + val cell = row.getChildAt(colIndex) as? TextView ?: continue + cell.textSize = fontSizeSp + cell.setLineSpacing(0f, lineHeight) + cell.setTextColor(textColor) + cell.highlightColor = selectionHighlightColor + cell.typeface = if (isHeader) { + Typeface.create(typeface, Typeface.BOLD) + } else { + typeface + } + cell.background = tableCellBackground(textColor, isOdd, borderWidthPx) + } + } +} + private fun stripBackgroundSpans(text: Spanned): Spanned { val mutable = SpannableStringBuilder(text) val lineSpans = mutable.getSpans(0, mutable.length, LineBackgroundSpan::class.java) diff --git a/app/src/main/java/com/markreader/ui/screens/ViewerViewModel.kt b/app/src/main/java/com/markreader/ui/screens/ViewerViewModel.kt index 0c70db2..b07ac55 100644 --- a/app/src/main/java/com/markreader/ui/screens/ViewerViewModel.kt +++ b/app/src/main/java/com/markreader/ui/screens/ViewerViewModel.kt @@ -351,11 +351,22 @@ class ViewerViewModel( fun onScrollPositionChanged(y: Int, maxY: Int) { _scrollY.value = y - _scrollProgress.value = if (maxY > 0) { - (y.toFloat() / maxY).coerceIn(0f, 1f) - } else { - null - } + _scrollProgress.value = progressFor(y, maxY) + } + + /** + * The scrollable extent changed without the position moving — a relayout + * rather than a scroll. Updates progress only: publishing a position here + * would make layout passes indistinguishable from user scrolling. + */ + fun onScrollExtentChanged(maxY: Int) { + _scrollProgress.value = progressFor(_scrollY.value, maxY) + } + + private fun progressFor(y: Int, maxY: Int): Float? = if (maxY > 0) { + (y.toFloat() / maxY).coerceIn(0f, 1f) + } else { + null } fun onScrollConsumed() {