Skip to content
Merged
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
23 changes: 13 additions & 10 deletions app/src/main/java/com/markreader/format/ReaderContent.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
package com.markreader.format

import android.text.Spanned

/**
* What a [DocumentFormat] produces, and the only thing the reader surface knows how to draw.
*
Expand All @@ -10,26 +8,31 @@ import android.text.Spanned
* prose. The set is closed on purpose: three shapes, and a fourth format has to fit one of them
* or argue for a fourth.
*
* P3 defines it; the reader surface still takes `text: Any` and P4 is where that dies. So today
* every producer returns [Spans] and the view models unwrap it at one place each — which is the
* point of defining it a phase early rather than changing `load()`'s signature later.
*
* Lives here rather than beside `ReaderContentView` so that the format layer never imports UI.
* It moves to `render/` if and when that package is carved out with the dependency running that
* way.
*/
sealed interface ReaderContent {
/**
* A rendered document as HTML, for the WebView host to load. Produced from P6, when the
* A rendered document as HTML, for the WebView host to load. Produced from P6a, when the
* shared renderer replaces Markwon.
*/
data class Html(val html: String) : ReaderContent

/**
* A rendered document as styled text. The Markwon and Prism paths today; after P6 it
* survives for raw mode, which needs no renderer at all.
* A document as text, styled or not.
*
* [text] is a `CharSequence` rather than a `Spanned` because unstyled text is a real case,
* not a placeholder for one: raw mode and the plain-text format both hand over a `String`,
* and wrapping those in a `SpannableString` would copy up to `MAX_FILE_BYTES` for no styling
* benefit. Consumers that need markers — split mode, which reads them to give code blocks
* and tables their own scrollable panes — narrow to `Spanned` at the point of that
* requirement.
*
* The Markwon and Prism paths today; after P6a it survives for raw mode, which needs no
* renderer at all.
*/
data class Spans(val spanned: Spanned) : ReaderContent
data class Spans(val text: CharSequence) : ReaderContent

/** A tabular document, drawn by `TableRenderer`. Produced from P5, by the CSV format. */
data class Table(val rows: List<List<String>>) : ReaderContent
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ class MarkdownFormat(private val newRenderer: (isDark: Boolean) -> MarkwonRender
}

override fun headings(text: String, content: ReaderContent): List<HeadingItem> =
parseHeadings(text, (content as? ReaderContent.Spans)?.spanned?.toString())
parseHeadings(text, (content as? ReaderContent.Spans)?.text?.toString())

override fun invalidateRenderers() = renderers.clear()

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.markreader.format.plaintext

import android.text.SpannableString
import com.markreader.R
import com.markreader.format.DocumentFormat
import com.markreader.format.FormatCapabilities
Expand Down Expand Up @@ -30,6 +29,8 @@ object PlainTextFormat : DocumentFormat {
/** Nothing to fall back to: this format's rendering is its source. */
override val renderFailureWarningRes: Int? = null

// Handed over as-is. [ReaderContent.Spans] takes a CharSequence precisely so that a format
// with no styling to add does not copy the whole document into a Spannable to say so.
override suspend fun load(text: String, theme: RenderTheme): ReaderContent =
ReaderContent.Spans(SpannableString(text))
ReaderContent.Spans(text)
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ internal class ReaderContentController(

/** Content identity of the text currently applied to the views. */
var lastTextHash: Int = 0
var lastTextRef: Any? = null
var lastTextRef: CharSequence? = null

/** Styling currently applied to the views. */
var lastStyleKey: ContentKey = UNSTYLED
Expand Down
60 changes: 36 additions & 24 deletions app/src/main/java/com/markreader/ui/reader/ReaderContentView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import com.markreader.data.CodeFontPreference
import com.markreader.data.ReadingFontPreference
import com.markreader.data.TextAlignmentPreference
import com.markreader.format.HeadingItem
import com.markreader.format.ReaderContent

/**
* A [ScrollView] that never scrolls itself to keep a focused descendant on screen.
Expand Down Expand Up @@ -82,7 +83,7 @@ private class FocusStableScrollView(context: Context) : ScrollView(context) {

@Composable
fun RenderedTextView(
text: Any,
content: ReaderContent,
textColor: Int,
padding: PaddingValues,
/**
Expand Down Expand Up @@ -114,6 +115,14 @@ fun RenderedTextView(
textAlignment: TextAlignmentPreference,
codeBlockBackgroundColor: Int
) {
// The reader surface draws text and nothing else. The other two shapes have no producer
// yet — P6a hosts a WebView for [ReaderContent.Html], P5 draws [ReaderContent.Table]
// natively — and this `when` is exhaustive, so neither can start producing one without
// deciding here what the surface does with it.
val text: CharSequence = when (content) {
is ReaderContent.Spans -> content.text
is ReaderContent.Html, is ReaderContent.Table -> ""
}
val contentKey = remember(text, fontSizeSp, lineHeight, readingFont, codeFont, isMonospaced, textAlignment) {
ContentKey(
textHash = text.hashCode(),
Expand All @@ -125,13 +134,17 @@ fun RenderedTextView(
textAlignment = textAlignment
)
}
// Split mode gives code blocks and tables their own horizontally scrollable views, and it
// is driven by the marker spans the renderer left behind — so unstyled text can never be in
// it. That is the one place the narrowing genuinely belongs: not "what could this be", but
// "does this carry the markers the splitter reads".
val markers = text as? Spanned
// 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, isCodeBlockWrapEnabled, docHasCodeBlocks, docHasTables) {
text is Spanned && ((!isCodeBlockWrapEnabled && docHasCodeBlocks) || docHasTables)
val docHasCodeBlocks = remember(markers) { markers != null && hasCodeBlocks(markers) }
val docHasTables = remember(markers) { markers != null && hasTables(markers) }
// Only worth the cost when the document actually contains one.
val isSplitMode = remember(markers, isCodeBlockWrapEnabled, docHasCodeBlocks, docHasTables) {
markers != null && ((!isCodeBlockWrapEnabled && docHasCodeBlocks) || docHasTables)
}
val restoreKey = remember(contentKey, isCodeBlockWrapEnabled) {
RestoreKey(contentKey, isCodeBlockWrapEnabled)
Expand Down Expand Up @@ -317,7 +330,7 @@ fun RenderedTextView(
if (currentChild is LinearLayout) {
// Split mode
val container = currentChild
val spanned = text as Spanned
val spanned = markers ?: return@AndroidView
val textRefChanged = spanned !== controller.lastTextRef
val textHash = if (textRefChanged || controller.lastTextHash == 0) {
spanned.toString().hashCode()
Expand Down Expand Up @@ -522,24 +535,23 @@ fun RenderedTextView(
// Single-TV mode
val textView = currentChild as TextView

when (text) {
is Spanned -> {
if (text !== controller.lastTextRef) {
textView.text = text
controller.lastTextHash = text.toString().hashCode()
controller.lastTextRef = text
}
if (markers != null) {
// Spans can change while the string does not — a search highlight
// re-render is exactly that — so identity is the only safe test.
if (text !== controller.lastTextRef) {
textView.text = text
controller.lastTextHash = text.toString().hashCode()
controller.lastTextRef = text
}
else -> {
if (text !== controller.lastTextRef) {
val textHash = text.hashCode()
if (textHash != controller.lastTextHash) {
textView.text = text.toString()
controller.lastTextHash = textHash
}
controller.lastTextRef = text
}
} else if (text !== controller.lastTextRef) {
// Nothing but characters, so equal content is the same render — and
// no Spannable is allocated to draw it.
val textHash = text.hashCode()
if (textHash != controller.lastTextHash) {
textView.text = text.toString()
controller.lastTextHash = textHash
}
controller.lastTextRef = text
}
if (controller.lastStyleKey != contentKey) {
applyStyleToTextView(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ fun EditorScreen(
val previewScrollY = remember { mutableIntStateOf(0) }
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
RenderedTextView(
text = previewText,
content = previewText,
textColor = previewTextColor,
padding = PaddingValues(0.dp),
savedScrollY = previewScrollY,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ data class EditorUiState(
val isModified: Boolean = false,
val isSaving: Boolean = false,
val saveResult: SaveResult? = null,
val previewText: android.text.Spanned? = null,
val previewText: ReaderContent? = null,
/** Null once a document has loaded means it cannot be written back — see [SaveResult.UnknownEncoding]. */
val encoding: TextEncoding? = null,
val activeTab: EditorTab = EditorTab.Edit,
Expand Down Expand Up @@ -307,9 +307,8 @@ class EditorViewModel(
}
// The format owns the renderer and its per-theme cache, so the preview and the viewer
// now render through one Markwon configuration rather than two built separately.
val content = format?.load(text, RenderTheme(isDark))
_uiState.value = _uiState.value.copy(
previewText = (content as? ReaderContent.Spans)?.spanned
previewText = format?.load(text, RenderTheme(isDark))
)
}

Expand Down
11 changes: 7 additions & 4 deletions app/src/main/java/com/markreader/ui/screens/ViewerScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ import com.markreader.ui.components.SegmentPosition
import com.markreader.ui.components.segmentPositionFor
import com.markreader.ui.components.segmentShape
import com.markreader.format.HeadingItem
import com.markreader.format.ReaderContent
import com.markreader.ui.export.ExportManager
import com.markreader.ui.resolve
import com.markreader.ui.reader.RenderedTextView
Expand Down Expand Up @@ -760,10 +761,12 @@ fun ViewerScreen(
}
}
else -> {
val text = if (uiState.viewMode == ViewMode.Raw) {
uiState.rawHighlighted ?: uiState.rawText
// The fallback wraps the raw String rather than rendering it: no format
// produced anything, so there is nothing to draw but the characters.
val content = if (uiState.viewMode == ViewMode.Raw) {
uiState.rawHighlighted ?: ReaderContent.Spans(uiState.rawText)
} else {
uiState.rendered ?: uiState.rawText
uiState.rendered ?: ReaderContent.Spans(uiState.rawText)
}
// A warning banner sits above the reader and outside its scroll
// container, so it takes the inset itself and the reader starts below
Expand All @@ -775,7 +778,7 @@ fun ViewerScreen(
topInset = if (hasWarning) chromeTopInset else 0.dp
) {
RenderedTextView(
text = text,
content = content,
textColor = contentColor.toArgb(),
padding = PaddingValues(0.dp),
contentTopInset = if (hasWarning) 0.dp else chromeTopInset,
Expand Down
47 changes: 23 additions & 24 deletions app/src/main/java/com/markreader/ui/screens/ViewerViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ data class ViewerUiState(
val isLoadingVisible: Boolean = false,
val fileName: String = "",
val rawText: String = "",
val rawHighlighted: Spanned? = null,
val rendered: Spanned? = null,
val rawHighlighted: ReaderContent? = null,
val rendered: ReaderContent? = null,
val errorMessage: UiMessage? = null,
val warningMessage: UiMessage? = null,
val isEmptyFile: Boolean = false,
Expand Down Expand Up @@ -93,12 +93,12 @@ class ViewerViewModel(
private var loadingJob: Job? = null
private var rebuildJob: Job? = null
private var searchJob: Job? = null
private var baseRendered: Spanned? = null
private var baseRendered: ReaderContent? = null
private var renderDarkModeOverride: Boolean? = null
private var preferencesLoaded = false
private var pendingExternalRerender = initialUri != null
private var renderCacheTextHash = 0
private val renderCache = mutableMapOf<RenderCacheKey, Spanned>()
private val renderCache = mutableMapOf<RenderCacheKey, ReaderContent>()

private val _uiState = MutableStateFlow(ViewerUiState())
val uiState: StateFlow<ViewerUiState> = _uiState.asStateFlow()
Expand Down Expand Up @@ -234,7 +234,7 @@ class ViewerViewModel(
val theme = RenderTheme(
renderDarkModeOverride ?: isDarkTheme(_uiState.value.userPreferences, systemDarkTheme)
)
val loaded = format?.load(markdown, theme).asSpans()
val loaded = format?.load(markdown, theme)

if (format == null || loaded == null) {
loadingJob?.cancel()
Expand Down Expand Up @@ -263,15 +263,15 @@ class ViewerViewModel(

val headings = format.headings(markdown, loaded)
loadingJob?.cancel()
baseRendered = loaded.spanned
renderCache[RenderCacheKey(documentType, theme)] = loaded.spanned
baseRendered = loaded
renderCache[RenderCacheKey(documentType, theme)] = loaded
_uiState.value = _uiState.value.copy(
isLoading = false,
isLoadingVisible = false,
fileName = fileName,
rawText = markdown,
rawHighlighted = null,
rendered = loaded.spanned,
rendered = loaded,
errorMessage = null,
warningMessage = null,
headings = headings,
Expand Down Expand Up @@ -449,10 +449,10 @@ class ViewerViewModel(
if (tryApplyCachedRender(documentType, theme)) {
return@launch
}
val loaded = format.load(markdown, theme).asSpans() ?: return@launch
baseRendered = loaded.spanned
renderCache[RenderCacheKey(documentType, theme)] = loaded.spanned
_uiState.value = _uiState.value.copy(rendered = loaded.spanned)
val loaded = format.load(markdown, theme) ?: return@launch
baseRendered = loaded
renderCache[RenderCacheKey(documentType, theme)] = loaded
_uiState.value = _uiState.value.copy(rendered = loaded)
applySearchQuery(_uiState.value.searchQuery)
}
}
Expand Down Expand Up @@ -544,12 +544,18 @@ class ViewerViewModel(
private fun applySearchQuery(query: String) {
val currentState = _uiState.value
val activeViewMode = currentState.viewMode
// Search is span-based: it counts matches in a string and paints them with spans. That
// is a requirement of this implementation, not of the reader, so the narrowing lives
// here rather than at the format boundary — a rendered document that is not text has
// no offsets to walk and gets no matches. P6a hands the rendered view's search to the
// WebView's own find-in-page and this path keeps raw mode.
val renderedSpans = (baseRendered as? ReaderContent.Spans)?.text
val baseText = when (activeViewMode) {
ViewMode.Raw -> currentState.rawText
ViewMode.Rendered -> baseRendered?.toString().orEmpty()
ViewMode.Rendered -> renderedSpans?.toString().orEmpty()
}

if (activeViewMode == ViewMode.Rendered && baseRendered == null) {
if (activeViewMode == ViewMode.Rendered && renderedSpans == null) {
_matchOffsets = emptyList()
_uiState.value = currentState.copy(
rawHighlighted = null,
Expand Down Expand Up @@ -577,7 +583,7 @@ class ViewerViewModel(
_matchOffsets = offsets

val highlighted = when (activeViewMode) {
ViewMode.Rendered -> SpannableString(baseRendered)
ViewMode.Rendered -> SpannableString(renderedSpans)
ViewMode.Raw -> SpannableString(baseText)
}
val prefs = _uiState.value.userPreferences
Expand Down Expand Up @@ -639,14 +645,14 @@ class ViewerViewModel(

_uiState.value = when (activeViewMode) {
ViewMode.Rendered -> _uiState.value.copy(
rendered = highlighted,
rendered = ReaderContent.Spans(highlighted),
rawHighlighted = null,
searchMatchCount = offsets.size,
searchMatchIndex = if (offsets.isEmpty()) 0 else activeIndex
)
ViewMode.Raw -> _uiState.value.copy(
rendered = baseRendered,
rawHighlighted = highlighted,
rawHighlighted = ReaderContent.Spans(highlighted),
searchMatchCount = offsets.size,
searchMatchIndex = if (offsets.isEmpty()) 0 else activeIndex
)
Expand Down Expand Up @@ -684,10 +690,3 @@ class ViewerViewModel(
}
}
}

/**
* The reader surface still draws styled text and nothing else, so this is where the other two
* [ReaderContent] shapes stop. P4 retypes the surface and this narrowing goes with it — until
* then no format produces a [ReaderContent.Html] or [ReaderContent.Table] to lose.
*/
private fun ReaderContent?.asSpans(): ReaderContent.Spans? = this as? ReaderContent.Spans
Loading