From 8f36f9ee877b149e330c0e8195012dbd77171b51 Mon Sep 17 00:00:00 2001 From: Lanlan13-14 <174582049+Lanlan13-14@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:16:22 +0800 Subject: [PATCH] perf(reader): window large documents so scrolling stays responsive A document of a few hundred kilobytes was laid out and drawn in full by one text view, which stalled opening and scrolling. Split documents past 24k characters into paragraph-bounded chunks and mount only the chunks near the viewport, keeping the rest as spacers so the scroll range still covers the whole document. --- CHANGELOG.md | 5 + .../ui/reader/ReaderContentController.kt | 9 + .../markreader/ui/reader/ReaderContentView.kt | 93 +++- .../com/markreader/ui/reader/ReadingWindow.kt | 141 ++++++ .../markreader/ui/reader/WindowedReader.kt | 447 ++++++++++++++++++ .../markreader/ui/reader/ReadingWindowTest.kt | 108 +++++ 6 files changed, 787 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/com/markreader/ui/reader/ReadingWindow.kt create mode 100644 app/src/main/java/com/markreader/ui/reader/WindowedReader.kt create mode 100644 app/src/test/java/com/markreader/ui/reader/ReadingWindowTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index b5b5018..2f191be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ All notable changes to MarkReader are documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [Unreleased] + +### Improved +- Large Markdown documents now stay responsive while scrolling. A document of more than a few dozen kilobytes used to be laid out and drawn in full by one text view, so opening or scrolling a file of a few hundred kilobytes stalled the reader. It is now split into chunks and only the chunks near the viewport are laid out; the rest of the document keeps its place in the scroll range without being drawn. + ## [1.0.6] - 2026-10-12 ### Fixed diff --git a/app/src/main/java/com/markreader/ui/reader/ReaderContentController.kt b/app/src/main/java/com/markreader/ui/reader/ReaderContentController.kt index 03aed0e..bf7fe5f 100644 --- a/app/src/main/java/com/markreader/ui/reader/ReaderContentController.kt +++ b/app/src/main/java/com/markreader/ui/reader/ReaderContentController.kt @@ -79,6 +79,15 @@ internal class ReaderContentController( /** Segment ranges the split container's children were built from. */ var splitBoundaries: List = emptyList() + /** + * Set while the document is large enough to be read through a window. The scroll listener + * and the update pass both touch it, and both run on the main thread. + */ + var windowHost: WindowedReaderHost? = null + + /** Chrome inset in dp, read by the scroll listener which has no Compose state of its own. */ + var contentTopInsetDp: Float = 0f + /** * The global-layout listener reporting scroll extent, held so it can be * detached when the view tree is released rather than outliving it. diff --git a/app/src/main/java/com/markreader/ui/reader/ReaderContentView.kt b/app/src/main/java/com/markreader/ui/reader/ReaderContentView.kt index 3627c8d..42f2eff 100644 --- a/app/src/main/java/com/markreader/ui/reader/ReaderContentView.kt +++ b/app/src/main/java/com/markreader/ui/reader/ReaderContentView.kt @@ -11,6 +11,9 @@ import android.widget.LinearLayout import android.widget.ScrollView import android.widget.TableLayout import android.widget.TextView +import com.markreader.data.CodeFontPreference +import com.markreader.data.ReadingFontPreference +import com.markreader.data.TextAlignmentPreference import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -27,9 +30,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView -import com.markreader.data.CodeFontPreference -import com.markreader.data.ReadingFontPreference -import com.markreader.data.TextAlignmentPreference import com.markreader.format.HeadingItem /** @@ -114,6 +114,10 @@ fun RenderedTextView( textAlignment: TextAlignmentPreference, codeBlockBackgroundColor: Int ) { + // A long rendered document is read through a window: only the chunks near the viewport are + // real views, and the rest are spacers. Short documents, raw text and the editor preview + // stay on the single TextView, which is cheaper when there is nothing to window. + val windowed = text is Spanned && text.length > ReadingWindow.WINDOW_THRESHOLD_CHARS val contentKey = remember(text, fontSizeSp, lineHeight, readingFont, codeFont, isMonospaced, textAlignment) { ContentKey( textHash = text.hashCode(), @@ -195,10 +199,23 @@ fun RenderedTextView( ) } setOnScrollChangeListener { _, _, scrollY, _, _ -> - onScrollChanged(scrollY, computeMaxScrollY(this)) + val host = controller.windowHost + if (host != null) { + val density = resources.displayMetrics.density + val contentTopPx = (16 * density).toInt() + + (controller.contentTopInsetDp * density).toInt() + host.sync(scrollY, height, contentTopPx) + onScrollChanged(scrollY, host.totalHeight().coerceAtLeast(computeMaxScrollY(this))) + } else { + onScrollChanged(scrollY, computeMaxScrollY(this)) + } val list = currentHeadings if (list.isEmpty()) return@setOnScrollChangeListener val firstChild = getChildAt(0) ?: return@setOnScrollChangeListener + if (host != null) { + onActiveHeadingChangedState(host.activeHeading(list, scrollY)) + return@setOnScrollChangeListener + } if (firstChild is LinearLayout) { onActiveHeadingChangedState( findActiveHeadingInSplit( @@ -312,11 +329,52 @@ fun RenderedTextView( } // Text / style updates - val currentChild = scrollView.getChildAt(0) ?: return@AndroidView + if (scrollView.getChildAt(0) == null) return@AndroidView - if (currentChild is LinearLayout) { + if (windowed && text is Spanned) { + val host = controller.windowHost ?: WindowedReaderHost( + scrollView.context, scrollView, paddingPx + ).also { created -> + controller.windowHost = created + scrollView.removeAllViews() + scrollView.addView( + created.container, + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + ) + } + controller.contentTopInsetDp = contentTopInset.value + host.setText(text, chunkStyleOf( + ChunkColors(textColor, selectionHighlightColor, codeBlockBackgroundColor), + ChunkTypography( + fontSizeSp, lineHeight, readingFont, codeFont, isMonospaced, + textAlignment, isCodeBlockWrapEnabled, density + ) + )) + host.sync(scrollView.scrollY, scrollView.height, contentTopPx) + controller.lastTextRef = text + controller.lastStyleKey = contentKey + controller.lastTextColor = textColor + controller.lastSelectionHighlightColor = selectionHighlightColor + controller.lastCodeBlockBackgroundColor = codeBlockBackgroundColor + } else if (controller.windowHost != null) { + // The document shrank below the window threshold, or the view switched to raw + // text. Remember where the reader was, then let the branch below rebuild. + controller.pendingAnchorOffset = controller.windowHost?.anchorAt(scrollView.scrollY) + controller.windowHost = null + scrollView.removeAllViews() + controller.lastTextHash = 0 + controller.lastTextRef = null + } + + val resolvedChild = scrollView.getChildAt(0) + if (controller.windowHost != null || resolvedChild == null) { + // The window owns its children; the branches below build the single view. + } else if (resolvedChild is LinearLayout) { // Split mode - val container = currentChild + val container = resolvedChild val spanned = text as Spanned val textRefChanged = spanned !== controller.lastTextRef val textHash = if (textRefChanged || controller.lastTextHash == 0) { @@ -520,7 +578,7 @@ fun RenderedTextView( } } else { // Single-TV mode - val textView = currentChild as TextView + val textView = resolvedChild as TextView when (text) { is Spanned -> { @@ -566,9 +624,8 @@ fun RenderedTextView( controller.pendingAnchorOffset = null controller.lastRestoredKey = restoreKey scrollView.post { - val y = resolveScrollY( - scrollView, controller.splitBoundaries, targetOffset - ) + val y = controller.windowHost?.scrollYFor(targetOffset) + ?: resolveScrollY(scrollView, controller.splitBoundaries, targetOffset) // Unresolvable anchor — hold position rather than jumping. if (y != null) scrollView.smoothScrollTo(0, y) } @@ -585,15 +642,18 @@ fun RenderedTextView( if (scrollToOffset != null) { controller.lastRestoredKey = restoreKey scrollView.post { - val y = resolveScrollY( - scrollView, controller.splitBoundaries, scrollToOffset - ) + val host = controller.windowHost + val y = host?.scrollYFor(scrollToOffset) + ?: resolveScrollY(scrollView, controller.splitBoundaries, scrollToOffset) + if (y == null && host != null) { + // The target chunk was just mounted and has no layout yet. Leave the + // request pending so the next pass, after layout, scrolls to it. + return@post + } if (y != null) { val centeredY = (y - scrollView.height / 3).coerceAtLeast(0) scrollView.smoothScrollTo(0, centeredY) } - // Consumed either way, so an unresolvable target does not - // leave the request pending forever. onScrollConsumed() } } @@ -624,6 +684,7 @@ fun RenderedTextView( } }, onRelease = { scrollView -> + controller.windowHost = null controller.extentListener?.let { listener -> scrollView.viewTreeObserver?.removeOnGlobalLayoutListener(listener) controller.extentListener = null diff --git a/app/src/main/java/com/markreader/ui/reader/ReadingWindow.kt b/app/src/main/java/com/markreader/ui/reader/ReadingWindow.kt new file mode 100644 index 0000000..e184a85 --- /dev/null +++ b/app/src/main/java/com/markreader/ui/reader/ReadingWindow.kt @@ -0,0 +1,141 @@ +package com.markreader.ui.reader + +import android.text.Spanned + +/** + * How a long document is cut into pieces a windowed reader can mount and unmount. + * + * A few hundred kilobytes of Markdown becomes a [Spanned] of similar size, and a single + * [android.widget.TextView] lays all of it out and draws all of it. Layout is the part that + * stalls: it is one pass over the whole text, and it runs again on every content or style + * change. Cutting the text into chunks bounds that pass to the chunks actually on screen. + * + * The cut is by character count, not by a rendered height, because the height does not exist + * until a chunk has been laid out — which is the cost being avoided. [targetChars] is large + * enough that a chunk is a page or more, so the seams between chunks are rare, and it is snapped + * forward to the next paragraph break so a chunk never ends mid-paragraph (and never mid-span: + * a paragraph boundary is a span boundary too). + */ +internal object ReadingWindow { + /** Below this, one TextView is cheaper than a window of them. */ + const val WINDOW_THRESHOLD_CHARS = 24_000 + + /** Aim for a chunk about this long, then finish the paragraph it lands in. */ + const val TARGET_CHUNK_CHARS = 8_000 + + /** Never scan more than this far past the target looking for a paragraph break. */ + const val MAX_CHUNK_CHARS = 12_000 + + /** + * Chunks kept mounted either side of the viewport. Two is enough to absorb a fling without + * mounting mid-gesture, and few enough that the mounted text stays a small fraction of a + * large document. + */ + const val BUFFER_CHUNKS = 2 + + /** A chunk shorter than this is not worth a view of its own; fold it into the previous one. */ + private const val MIN_TAIL_CHARS = 1_000 + + /** An empty window. `0 until 0` reads as a range that is never iterated, which lint rejects. */ + private val EMPTY_RANGE = IntRange.EMPTY + + /** + * Half-open `[start, end)` ranges covering [length], split on paragraph boundaries. + * + * Returns a single range when the text is at or under [WINDOW_THRESHOLD_CHARS]: the window + * exists to bound the cost of a large document, and splitting a short one only adds views. + */ + fun chunkRanges(text: CharSequence, length: Int = text.length): List { + if (length <= WINDOW_THRESHOLD_CHARS) return listOf(0 until length.coerceAtLeast(0)) + val ranges = mutableListOf() + var start = 0 + while (start < length) { + val remaining = length - start + if (remaining <= MAX_CHUNK_CHARS) { + ranges.add(start until length) + break + } + val target = (start + TARGET_CHUNK_CHARS).coerceAtMost(length) + val limit = (start + MAX_CHUNK_CHARS).coerceAtMost(length) + var end = paragraphEnd(text, target, limit) + // No paragraph break within the cap: cut anyway. A chunk must stay bounded even in a + // document that is one long paragraph, which is exactly the case that stalls. + if (end <= start) end = limit + ranges.add(start until end) + start = end + } + if (ranges.size >= 2) { + val tail = ranges.last() + if (tail.last - tail.first + 1 < MIN_TAIL_CHARS) { + val previous = ranges[ranges.lastIndex - 1] + ranges[ranges.lastIndex - 1] = previous.first until tail.last + 1 + ranges.removeAt(ranges.lastIndex) + } + } + return ranges + } + + /** + * The end offset of the paragraph containing [target], or [limit] when no paragraph break + * falls inside the window. A paragraph break is a blank line, which is where both Markdown + * and the renderer put a boundary nothing else crosses. + */ + private fun paragraphEnd(text: CharSequence, target: Int, limit: Int): Int { + var index = target + while (index < limit) { + if (text[index] == '\n') { + val next = index + 1 + if (next < limit && text[next] == '\n') return next + 1 + if (next >= text.length) return text.length + } + index++ + } + return limit + } + + /** + * The chunks that should be mounted for a viewport, plus [buffer] chunks of headroom on + * either side. + * + * [heights] are the measured heights of mounted chunks and [estimate] for the rest, which is + * all that exists before a chunk has been laid out. The estimate only decides which chunks + * to mount; once mounted, a chunk reports its real height and the next pass corrects the + * window, so a wrong estimate shifts the window by a chunk rather than misplacing text. + */ + fun visibleRange( + heights: IntArray, + scrollY: Int, + viewportHeight: Int, + buffer: Int = BUFFER_CHUNKS + ): IntRange { + if (heights.isEmpty()) return EMPTY_RANGE + val viewBottom = scrollY + viewportHeight.coerceAtLeast(1) + var top = 0 + var first = heights.lastIndex + var last = 0 + for (index in heights.indices) { + val bottom = top + heights[index] + if (bottom > scrollY && top < viewBottom) { + if (index < first) first = index + if (index > last) last = index + } + top = bottom + } + if (first > last) { + // Past the end of the estimated content: pin to the last chunk. + first = heights.lastIndex + last = heights.lastIndex + } + return (first - buffer).coerceAtLeast(0)..(last + buffer).coerceAtMost(heights.lastIndex) + } + + /** Height of the content above chunk [index], from per-chunk heights. */ + fun offsetOf(heights: IntArray, index: Int): Int { + var sum = 0 + for (i in 0 until index.coerceAtMost(heights.size)) sum += heights[i] + return sum + } + + /** Total content height. */ + fun totalHeight(heights: IntArray): Int = offsetOf(heights, heights.size) +} diff --git a/app/src/main/java/com/markreader/ui/reader/WindowedReader.kt b/app/src/main/java/com/markreader/ui/reader/WindowedReader.kt new file mode 100644 index 0000000..a976e90 --- /dev/null +++ b/app/src/main/java/com/markreader/ui/reader/WindowedReader.kt @@ -0,0 +1,447 @@ +package com.markreader.ui.reader + +import android.content.Context +import android.text.Spanned +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.HorizontalScrollView +import android.widget.LinearLayout +import android.widget.ScrollView +import com.markreader.data.CodeFontPreference +import com.markreader.data.ReadingFontPreference +import com.markreader.data.TextAlignmentPreference +import com.markreader.format.HeadingItem + +/** + * Reads one chunk of a windowed document. + * + * Everything that differs between a call that builds the view tree and a call that only + * restyles it lives here, so [WindowedReaderHost.sync] does not grow a parameter list of its + * own and the two call sites cannot drift apart. + */ +/** The three colours a chunk is drawn with. */ +internal data class ChunkColors( + val text: Int, + val selection: Int, + val codeBackground: Int +) + +/** The typography a chunk is drawn with. */ +internal data class ChunkTypography( + val fontSizeSp: Float, + val lineHeight: Float, + val readingFont: ReadingFontPreference, + val codeFont: CodeFontPreference, + val isMonospaced: Boolean, + val textAlignment: TextAlignmentPreference, + val isCodeBlockWrapEnabled: Boolean, + val density: Float +) + +/** Everything that differs between building a chunk and restyling one. */ +internal data class ChunkStyle( + val colors: ChunkColors, + val typography: ChunkTypography +) + +/** Content height, measured where a chunk has been laid out and estimated elsewhere. */ +internal fun WindowedReaderHost.totalHeight(): Int = ReadingWindow.totalHeight(measuredHeights()) + +/** The last heading in [chunk] whose line starts at or above [top] and below [limit]. */ +private fun latestHeading( + chunk: WindowedReaderHost.Chunk, + lines: LineMetrics, + headings: List, + top: Int, + limit: Int +): Int { + var result = -1 + for (headingIndex in headings.indices) { + val offset = headings[headingIndex].offset + if (offset < chunk.start || offset >= chunk.end) continue + val local = (offset - chunk.start).coerceIn(0, (lines.textLength - 1).coerceAtLeast(0)) + if (top + lines.getLineTop(lines.getLineForOffset(local)) <= limit) result = headingIndex + } + return result +} + +/** Groups the two bundles a chunk is drawn with. */ +internal fun chunkStyleOf(colors: ChunkColors, typography: ChunkTypography): ChunkStyle = + ChunkStyle(colors, typography) + +/** + * A document split into chunks, of which only the ones near the viewport are real views. + * + * Everything else is a blank spacer whose height is either the height that chunk last measured + * at, or an estimate derived from chunks that have been measured. The scroll range is therefore + * the whole document from the start, while the cost of laying text out stays proportional to the + * viewport rather than to the document. + * + * Re-windowing never moves the scroll position. A chunk that grows from its estimate to its real + * height changes the content above the viewport; that difference is added back to [ScrollView] + * so the text under the user's finger stays where it was. Without that, mounting a chunk above + * the viewport yanks the page. + */ +internal class WindowedReaderHost( + context: Context, + private val scrollView: ScrollView, + private val paddingPx: Int +) { + val container: LinearLayout = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + setPadding(paddingPx, paddingPx, paddingPx, paddingPx) + } + + private var chunks: MutableList = mutableListOf() + private var source: Spanned? = null + private var style: ChunkStyle? = null + private var mounted = IntRange.EMPTY + private var averageHeight = 0 + private var layoutPending = false + + /** + * One listener, re-armed per sync, so a fling cannot stack a listener per frame. + * + * A lambda here would reference the property it is assigned to, which the compiler cannot + * type-check. An object can refer to itself as `this` instead. + */ + private val layoutListener = object : android.view.ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + layoutPending = false + container.viewTreeObserver.removeOnGlobalLayoutListener(this) + val changed = chunks.any { chunk -> + val view = chunk.view + view != null && view.height > 0 && view.height != chunk.measuredHeight + } + if (changed) sync(scrollView.scrollY, scrollView.height, container.paddingTop) + } + } + + internal class Chunk(val start: Int, val end: Int, val text: Spanned) { + var view: View? = null + var measuredHeight = 0 + } + + /** + * Rebuilds the chunk list from [text]. An unchanged document is left untouched, so a search + * highlight — which replaces the [Spanned] but not its characters — updates the mounted + * chunks in place instead of throwing the window away. + */ + fun setText(text: Spanned, newStyle: ChunkStyle) { + style = newStyle + val previous = source + source = text + if (previous != null && previous.toString() == text.toString() && chunks.isNotEmpty()) { + refill(text) + return + } + val ranges = ReadingWindow.chunkRanges(text) + chunks = ranges.map { range -> + Chunk(range.first, range.last + 1, text.subSequence(range.first, range.last + 1) as Spanned) + }.toMutableList() + mounted = IntRange.EMPTY + container.removeAllViews() + averageHeight = estimateChunkHeight(scrollView, newStyle) + for (chunk in chunks) { + container.addView(spacerView(container.context, averageHeight), chunkLayout()) + } + } + + /** The text changed but its characters did not: push the new spans into mounted chunks. */ + private fun refill(text: Spanned) { + val current = style ?: return + for ((index, chunk) in chunks.withIndex()) { + val slice = text.subSequence(chunk.start, chunk.end) as Spanned + chunks[index] = Chunk(chunk.start, chunk.end, slice).also { + it.view = chunk.view + it.measuredHeight = chunk.measuredHeight + } + val view = chunk.view ?: continue + if (view is ChunkBody) { + view.bind(slice, current) + } + } + } + + /** + * Mounts whatever the viewport now covers and records the real height of anything that has + * been laid out since last time. Returns false when nothing changed, so the caller can skip + * the scroll-extent bookkeeping. + */ + fun sync(scrollY: Int, viewportHeight: Int, contentTopPx: Int): Boolean { + val current = style ?: return false + if (container.paddingTop != contentTopPx) { + container.setPadding(container.paddingLeft, contentTopPx, container.paddingRight, paddingPx) + } + recordMeasuredHeights() + val heights = measuredHeights() + val next = ReadingWindow.visibleRange(heights, scrollY, viewportHeight) + val unchanged = next == mounted && chunks.indices.all { (chunks[it].view != null) == (it in next) } + if (unchanged) return false + val before = ReadingWindow.offsetOf(heights, next.first) + remount(next, current) + val after = ReadingWindow.offsetOf(measuredHeights(), next.first) + val shift = after - before + if (shift != 0) { + // A chunk above the viewport took on its real height. Put that growth back into the + // scroll offset, or the viewport slides to a different part of the document. + scrollView.scrollTo(0, (scrollY + shift).coerceAtLeast(0)) + } + mounted = next + // Chunks mount with an unknown height. One layout pass later they have one, and the + // scroll listener only fires on a scroll, so ask for that pass explicitly. + if (!layoutPending) { + layoutPending = true + container.viewTreeObserver.addOnGlobalLayoutListener(layoutListener) + } + return true + } + + /** Mounts [range] and turns everything outside it back into a spacer of the remembered height. */ + private fun remount(range: IntRange, current: ChunkStyle) { + val heights = measuredHeights() + for (index in chunks.indices) { + val chunk = chunks[index] + val shouldMount = index in range + if (shouldMount && chunk.view == null) { + val body = ChunkBody(container.context, current) + body.bind(chunk.text, current) + chunk.view = body + container.removeViewAt(index) + container.addView(body, index, chunkLayout()) + } else if (!shouldMount && chunk.view != null) { + chunk.view = null + container.removeViewAt(index) + container.addView(spacerView(container.context, heights[index]), index, chunkLayout()) + } else if (shouldMount) { + (chunk.view as? ChunkBody)?.restyle(current) + } + } + } + + /** Copies laid-out heights back onto their chunks and refreshes the per-chunk estimate. */ + private fun recordMeasuredHeights() { + var measured = 0 + var count = 0 + for ((index, chunk) in chunks.withIndex()) { + val view = chunk.view ?: continue + if (view.height > 0) { + chunk.measuredHeight = view.height + measured += view.height + count++ + } + if (container.getChildAt(index) !== view && chunk.measuredHeight > 0) { + container.getChildAt(index)?.layoutParams?.height = chunk.measuredHeight + } + } + if (count > 0) averageHeight = measured / count + } + + internal fun measuredHeights(): IntArray = IntArray(chunks.size) { index -> + chunks[index].measuredHeight.takeIf { it > 0 } ?: averageHeight + } + + /** + * Scroll offset that puts rendered-text offset [offset] at the top of the viewport, mounting + * the chunk that contains it first. Returns null until that chunk has been laid out, which + * tells the caller to ask again rather than guess. + */ + fun scrollYFor(offset: Int): Int? { + val index = chunks.indexOfFirst { offset >= it.start && offset < it.end } + .takeIf { it >= 0 } ?: chunks.lastIndex + val current = style + val resolved = if (index < 0 || current == null) { + null + } else { + scrollYInChunk(index, offset, current) + } + return resolved + } + + /** Line position of [offset] inside chunk [index], mounting the chunk first if needed. */ + private fun scrollYInChunk(index: Int, offset: Int, current: ChunkStyle): Int? { + val chunk = chunks[index] + if (chunk.view == null) { + remount(index..index, current) + mounted = index..index + container.requestLayout() + // The caller retries. Posting the retry here covers the case where mounting this + // chunk changes nothing the caller is observing, so no recomposition would retry it. + scrollView.post { scrollView.requestLayout() } + } + val lines = chunk.view?.let { segmentPlacement(it).lines } ?: return null + val local = (offset - chunk.start).coerceIn(0, (lines.textLength - 1).coerceAtLeast(0)) + val lineTop = lines.getLineTop(lines.getLineForOffset(local)) + return ReadingWindow.offsetOf(measuredHeights(), index) + lineTop + } + + /** Rendered-text offset at the top of the viewport, for restoring a position by content. */ + fun anchorAt(scrollY: Int): Int? { + val heights = measuredHeights() + var top = 0 + for ((index, chunk) in chunks.withIndex()) { + val bottom = top + heights[index] + if (bottom > scrollY) { + val lines = chunk.view?.let { segmentPlacement(it).lines } + if (lines == null) return chunk.start + val localY = (scrollY - top).coerceAtLeast(0) + return chunk.start + lines.getLineStart(lines.getLineForVertical(localY)) + } + top = bottom + } + return chunks.lastOrNull()?.start + } + + /** Index of the last heading whose line starts above [scrollY], across mounted chunks. */ + fun activeHeading(headings: List, scrollY: Int): Int { + if (headings.isEmpty()) return -1 + val heights = measuredHeights() + var top = 0 + var result = -1 + val limit = scrollY + container.paddingTop + for ((index, chunk) in chunks.withIndex()) { + val lines = chunk.view?.let { segmentPlacement(it).lines } + result = if (lines == null) result else latestHeading(chunk, lines, headings, top, limit) + top += heights[index] + } + return result + } + + +} + +/** + * The view for one chunk: its own segments for code blocks and tables, exactly as the + * single-view reader splits them, only bounded to this chunk's text. + */ +private class ChunkBody(context: Context, style: ChunkStyle) : LinearLayout(context) { + private var builtForWrap = style.typography.isCodeBlockWrapEnabled + + init { + orientation = VERTICAL + } + + fun bind(text: Spanned, style: ChunkStyle) { + if (builtForWrap != style.typography.isCodeBlockWrapEnabled || childCount == 0) { + builtForWrap = style.typography.isCodeBlockWrapEnabled + removeAllViews() + val segments = splitByMarkers( + text, + splitCode = !style.typography.isCodeBlockWrapEnabled, + splitTables = true + ) + for ((start, end, type) in segments) { + addView(segmentView(text.subSequence(start, end) as Spanned, type, style), matchWrap()) + } + } else { + // Same structure, new spans: write them into the existing views. + val segments = splitByMarkers(text, splitCode = !builtForWrap, splitTables = true) + if (segments.size != childCount) { + builtForWrap = !style.typography.isCodeBlockWrapEnabled + bind(text, style) + return + } + for ((index, segment) in segments.withIndex()) { + val slice = text.subSequence(segment.start, segment.end) as Spanned + val content = if (segment.type == SegmentType.Code) stripBackgroundSpans(slice) else slice + extractTextView(getChildAt(index))?.text = content + } + } + restyle(style) + } + + fun restyle(style: ChunkStyle) { + for (index in 0 until childCount) { + val child = getChildAt(index) + val target = if (child is FrameLayout) { + child.setBackgroundColor(style.colors.codeBackground) + child.getChildAt(0) + } else { + child + } + val inner = if (target is HorizontalScrollView) target.getChildAt(0) else target + if (inner is android.widget.TextView) { + val type = style.typography + applyStyleToTextView( + inner, type.fontSizeSp, type.lineHeight, type.readingFont, type.codeFont, + type.isMonospaced, type.textAlignment, style.colors.text, style.colors.selection + ) + } + } + } + + private fun segmentView(text: Spanned, type: SegmentType, style: ChunkStyle): View { + val content = if (type == SegmentType.Code) stripBackgroundSpans(text) else text + if (type == SegmentType.Table) { + val table = buildTableLayout( + content, context, style.colors.text, style.typography.fontSizeSp, style.typography.lineHeight, + style.typography.readingFont, style.colors.selection, style.typography.density + ) + if (table != null) { + return HorizontalScrollView(context).apply { + isHorizontalScrollBarEnabled = true + addView(table, frameWrap()) + } + } + } + val scroll = type == SegmentType.Code + val typeFace = style.typography + val textView = createStyledTextView( + context, 0, typeFace.fontSizeSp, typeFace.lineHeight, typeFace.readingFont, + typeFace.codeFont, typeFace.isMonospaced || type == SegmentType.Code, + typeFace.textAlignment, style.colors.text, style.colors.selection, scroll + ) + textView.text = content + val body: View = if (scroll) { + HorizontalScrollView(context).apply { + isHorizontalScrollBarEnabled = true + addView(textView, frameWrap()) + } + } else { + textView + } + if (type != SegmentType.Code) return body + return FrameLayout(context).apply { + setBackgroundColor(style.colors.codeBackground) + addView(body, matchWrap()) + } + } + + private fun matchWrap() = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + + private fun frameWrap() = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, + FrameLayout.LayoutParams.WRAP_CONTENT + ) +} + +/** A blank stand-in for a chunk that is not mounted, holding the height it will occupy. */ +private fun spacerView(context: Context, height: Int): View = View(context).apply { + minimumHeight = height +} + +/** Layout params shared by every chunk row: full width, as tall as the content. */ +private fun chunkLayout() = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT +) + +/** + * Rough height of one chunk before any has been measured: characters per line times the line + * height. Only the order of magnitude matters; measured heights replace it as chunks mount. + */ +private fun estimateChunkHeight(scrollView: android.widget.ScrollView, current: ChunkStyle): Int { + val widthPx = scrollView.width.takeIf { it > 0 } ?: scrollView.resources.displayMetrics.widthPixels + val em = current.typography.fontSizeSp * current.typography.density * CHAR_WIDTH_EM + val charsPerLine = (widthPx / em).toInt().coerceAtLeast(MIN_CHARS_PER_LINE) + val lines = (ReadingWindow.TARGET_CHUNK_CHARS / charsPerLine).coerceAtLeast(1) + val lineHeightPx = current.typography.fontSizeSp * current.typography.lineHeight + val estimated = (lines * lineHeightPx * current.typography.density).toInt() + return estimated.coerceAtLeast(MIN_CHUNK_HEIGHT) +} + +private const val CHAR_WIDTH_EM = 0.5f +private const val MIN_CHARS_PER_LINE = 20 +private const val MIN_CHUNK_HEIGHT = 48 + diff --git a/app/src/test/java/com/markreader/ui/reader/ReadingWindowTest.kt b/app/src/test/java/com/markreader/ui/reader/ReadingWindowTest.kt new file mode 100644 index 0000000..9b6c3d5 --- /dev/null +++ b/app/src/test/java/com/markreader/ui/reader/ReadingWindowTest.kt @@ -0,0 +1,108 @@ +package com.markreader.ui.reader + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The window is what keeps a large document from being laid out all at once, so the cases here + * pin the properties that make that true: short documents are not split, long ones are split + * into bounded pieces, no piece is empty, and the pieces cover the text exactly once. + */ +class ReadingWindowTest { + + /** Paragraphs of [words] words, separated by blank lines, repeated [times] times. */ + private fun document(times: Int, words: Int = 40): String { + val paragraph = (1..words).joinToString(" ") { "word$it" } + return (1..times).joinToString("\n\n") { paragraph } + } + + @Test + fun `a short document stays one chunk`() { + val text = document(times = 3) + + assertTrue(text.length < ReadingWindow.WINDOW_THRESHOLD_CHARS) + assertEquals(listOf(0 until text.length), ReadingWindow.chunkRanges(text)) + } + + @Test + fun `an empty document is one empty chunk`() { + assertEquals(listOf(IntRange.EMPTY), ReadingWindow.chunkRanges("")) + } + + @Test + fun `a long document is split into bounded chunks on paragraph boundaries`() { + val text = document(times = 400) + assertTrue(text.length > ReadingWindow.WINDOW_THRESHOLD_CHARS) + + val ranges = ReadingWindow.chunkRanges(text) + + assertTrue("expected several chunks, got ${ranges.size}", ranges.size > 1) + assertEquals(0, ranges.first().first) + assertEquals(text.length, ranges.last().last + 1) + ranges.zipWithNext().forEach { (previous, next) -> + assertEquals(previous.last + 1, next.first) + } + ranges.forEach { range -> + assertTrue(range.last >= range.first) + assertTrue( + "chunk of ${range.last - range.first + 1} chars exceeds the cap", + range.last - range.first + 1 <= ReadingWindow.MAX_CHUNK_CHARS + ) + } + } + + @Test + fun `a chunk never ends in the middle of a paragraph`() { + val text = document(times = 400) + val ranges = ReadingWindow.chunkRanges(text) + + // Every chunk but the last ends where a paragraph ended, i.e. right after a blank line. + ranges.dropLast(1).forEach { range -> + val end = range.last + 1 + assertEquals('\n', text[end - 1]) + assertEquals('\n', text[end - 2]) + } + } + + @Test + fun `one enormous paragraph is still cut into bounded chunks`() { + val text = "word ".repeat(20_000) + val ranges = ReadingWindow.chunkRanges(text) + + assertTrue(ranges.size > 1) + assertEquals(text.length, ranges.sumOf { it.last - it.first + 1 }) + ranges.forEach { range -> + assertTrue(range.last - range.first + 1 <= ReadingWindow.MAX_CHUNK_CHARS) + } + } + + @Test + fun `the visible window covers the viewport plus a buffer and nothing else`() { + val heights = IntArray(20) { 1_000 } + + val range = ReadingWindow.visibleRange(heights, scrollY = 5_000, viewportHeight = 2_000) + + // Chunks 5 and 6 overlap the viewport; the buffer mounts two more on each side. + assertEquals(3, range.first) + assertEquals(8, range.last) + } + + @Test + fun `the visible window clamps at both ends of the document`() { + val heights = IntArray(6) { 1_000 } + + assertEquals(0, ReadingWindow.visibleRange(heights, scrollY = 0, viewportHeight = 500).first) + assertEquals(5, ReadingWindow.visibleRange(heights, scrollY = 9_000, viewportHeight = 500).last) + } + + @Test + fun `offsets reconstruct the scroll position of every chunk`() { + val heights = intArrayOf(100, 250, 400) + + assertEquals(0, ReadingWindow.offsetOf(heights, 0)) + assertEquals(100, ReadingWindow.offsetOf(heights, 1)) + assertEquals(350, ReadingWindow.offsetOf(heights, 2)) + assertEquals(750, ReadingWindow.totalHeight(heights)) + } +}