Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,15 @@ internal class ReaderContentController(
/** Segment ranges the split container's children were built from. */
var splitBoundaries: List<Segment> = 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.
Expand Down
93 changes: 77 additions & 16 deletions app/src/main/java/com/markreader/ui/reader/ReaderContentView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

/**
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -520,7 +578,7 @@ fun RenderedTextView(
}
} else {
// Single-TV mode
val textView = currentChild as TextView
val textView = resolvedChild as TextView

when (text) {
is Spanned -> {
Expand Down Expand Up @@ -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)
}
Expand All @@ -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()
}
}
Expand Down Expand Up @@ -624,6 +684,7 @@ fun RenderedTextView(
}
},
onRelease = { scrollView ->
controller.windowHost = null
controller.extentListener?.let { listener ->
scrollView.viewTreeObserver?.removeOnGlobalLayoutListener(listener)
controller.extentListener = null
Expand Down
141 changes: 141 additions & 0 deletions app/src/main/java/com/markreader/ui/reader/ReadingWindow.kt
Original file line number Diff line number Diff line change
@@ -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<IntRange> {
if (length <= WINDOW_THRESHOLD_CHARS) return listOf(0 until length.coerceAtLeast(0))
val ranges = mutableListOf<IntRange>()
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)
}
Loading