diff --git a/app/src/main/java/com/markreader/format/DocumentFormat.kt b/app/src/main/java/com/markreader/format/DocumentFormat.kt new file mode 100644 index 0000000..60ccc13 --- /dev/null +++ b/app/src/main/java/com/markreader/format/DocumentFormat.kt @@ -0,0 +1,66 @@ +package com.markreader.format + +import androidx.annotation.StringRes +import com.markreader.file.DocumentType + +/** + * One document format: how to turn its text into something drawable, and what the chrome + * around it may offer. + * + * **Formats do not recognise files.** [com.markreader.file.DocumentTypeResolver] is the single + * detection authority and [FormatRegistry] is a [DocumentType] to [DocumentFormat] lookup over + * it. The reasoning is written up in `docs/plan/architecture.md` seam 2 and is not re-litigated + * here; the short version is that `DocumentResourceHandler` in the WebView host has to ask "is + * this file text", and moving detection into formats would drag Markwon and Prism4j into that + * layer to answer it. + * + * There is also no `type` property. Nothing asks a format what it reads — the registry's `when` + * is that mapping, and having the answer in two places is how the two drift apart. + */ +interface DocumentFormat { + /** What the viewer and editor may offer for documents of this format. */ + val capabilities: FormatCapabilities + + /** + * The format's own name, for chrome that has to say what a document is rather than which + * view of it is showing. Only read where [FormatCapabilities.hasRawView] is false. + */ + @get:StringRes + val nameRes: Int + + /** What the editor shows in an empty document of this format. */ + @get:StringRes + val editorPlaceholderRes: Int + + /** The MIME type to ask the system picker for when saving a new document of this format. */ + val creationMimeType: String + + /** + * What to tell the user when [load] returns null. + * + * Null for a format that cannot fail, which is one decision rather than two: a format whose + * rendering *is* its source has nothing to fall back to and so no failure to describe. + */ + @get:StringRes + val renderFailureWarningRes: Int? + + /** + * Render [text] for the given surface, or null if this format's renderer could not — in + * which case the caller shows the source and says so with [renderFailureWarningRes]. + */ + suspend fun load(text: String, theme: RenderTheme): ReaderContent? + + /** + * The document's table of contents. Empty unless [FormatCapabilities.hasHeadings]. + * + * Takes both the source and its rendering because the offsets have to index the text the + * reader actually scrolls, not the markup that produced it. + */ + fun headings(text: String, content: ReaderContent): List = emptyList() + + /** + * Drop any cached renderer. The reader palette changed, so a renderer built for the old one + * would paint the previous theme's colours. + */ + fun invalidateRenderers() {} +} diff --git a/app/src/main/java/com/markreader/format/FormatCapabilities.kt b/app/src/main/java/com/markreader/format/FormatCapabilities.kt new file mode 100644 index 0000000..fe68bc3 --- /dev/null +++ b/app/src/main/java/com/markreader/format/FormatCapabilities.kt @@ -0,0 +1,28 @@ +package com.markreader.format + +/** + * What the chrome around a document may offer. + * + * These used to be ten separate `is this Markdown` / `is this source code` tests spread across + * the viewer and the editor, which is why adding a third format meant editing ten call sites. + * Each field here exists because one of those sites asked the question — a capability with no + * caller is a comment pretending to be code, so none are declared ahead of a use. + * + * The defaults describe the least capable document there is: text that renders as itself. + */ +data class FormatCapabilities( + /** The document has a heading structure, so a table of contents is meaningful. */ + val hasHeadings: Boolean = false, + /** + * The rendered form differs from the source, so the raw/rendered toggle does something. + * Where it is false the chrome names the format instead of claiming a view mode, because + * "Rendered" is not a true thing to say about a document whose rendering *is* its source. + */ + val hasRawView: Boolean = false, + /** Read and edited in the code font rather than the reading font. */ + val isMonospaced: Boolean = false, + /** The editor offers Edit/Preview tabs and can render a live preview. */ + val hasEditorPreview: Boolean = false, + /** The editor offers the markup formatting toolbar. */ + val hasFormattingToolbar: Boolean = false +) diff --git a/app/src/main/java/com/markreader/format/FormatRegistry.kt b/app/src/main/java/com/markreader/format/FormatRegistry.kt new file mode 100644 index 0000000..21d77c3 --- /dev/null +++ b/app/src/main/java/com/markreader/format/FormatRegistry.kt @@ -0,0 +1,51 @@ +package com.markreader.format + +import android.content.Context +import com.markreader.file.DocumentType +import com.markreader.format.code.SourceCodeFormat +import com.markreader.format.markdown.MarkdownFormat +import com.markreader.format.plaintext.PlainTextFormat + +/** + * The [DocumentType] to [DocumentFormat] lookup. + * + * `architecture.md` seam 4 sketched this as a plain list, ordered by precedence. Precedence was + * for *recognition* — walk the list, first format that claims the file wins — and formats no + * longer recognise anything, so a list would carry an ordering that means nothing. What protects + * us instead is exhaustiveness: [formatFor] is a `when` over a sealed interface, so adding a + * document type fails to compile until someone decides which format reads it. P5 is still one + * field and one branch. + * + * Not a singleton, and not injected. One instance per view model, holding the renderers for as + * long as the screen lives. + */ +class FormatRegistry(private val markdown: DocumentFormat) { + + constructor(context: Context) : this(MarkdownFormat(context.applicationContext)) + + /** + * One format per language seen, so the highlighter cache survives a theme toggle. Bounded by + * the languages opened on one screen, which is one for all but the curious. + */ + private val sourceCode = mutableMapOf() + + /** + * The format that reads [type], or **null for [DocumentType.Binary]** — bytes that are not + * text have no format, and that is the one case the caller has to handle rather than render. + */ + fun formatFor(type: DocumentType): DocumentFormat? = when (type) { + DocumentType.Markdown -> markdown + is DocumentType.SourceCode -> sourceCode.getOrPut(type.language) { + SourceCodeFormat(type.language) + } + // Csv reads as what it also is until P5 brings a format of its own. + DocumentType.Csv, DocumentType.PlainText -> PlainTextFormat + DocumentType.Binary -> null + } + + /** The reader palette changed, so every renderer built against the old one is stale. */ + fun invalidateRenderers() { + markdown.invalidateRenderers() + sourceCode.values.forEach { it.invalidateRenderers() } + } +} diff --git a/app/src/main/java/com/markreader/format/HeadingItem.kt b/app/src/main/java/com/markreader/format/HeadingItem.kt new file mode 100644 index 0000000..8b6c3fb --- /dev/null +++ b/app/src/main/java/com/markreader/format/HeadingItem.kt @@ -0,0 +1,13 @@ +package com.markreader.format + +/** + * One entry in a document's table of contents. + * + * [offset] is a character offset into the *rendered* text, because that is what the reader + * scrolls. Only formats whose [FormatCapabilities.hasHeadings] is true produce these. + */ +data class HeadingItem( + val text: String, + val level: Int, + val offset: Int +) diff --git a/app/src/main/java/com/markreader/format/ReaderContent.kt b/app/src/main/java/com/markreader/format/ReaderContent.kt new file mode 100644 index 0000000..b5217d3 --- /dev/null +++ b/app/src/main/java/com/markreader/format/ReaderContent.kt @@ -0,0 +1,36 @@ +package com.markreader.format + +import android.text.Spanned + +/** + * What a [DocumentFormat] produces, and the only thing the reader surface knows how to draw. + * + * This is Fossify Documents' `StructuredDocumentContent` idea — one renderer, several loaders — + * and it is what lets a format that produces a table share a screen with one that produces + * 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 + * 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. + */ + data class Spans(val spanned: Spanned) : ReaderContent + + /** A tabular document, drawn by `TableRenderer`. Produced from P5, by the CSV format. */ + data class Table(val rows: List>) : ReaderContent +} diff --git a/app/src/main/java/com/markreader/format/RenderTheme.kt b/app/src/main/java/com/markreader/format/RenderTheme.kt new file mode 100644 index 0000000..1c42a6e --- /dev/null +++ b/app/src/main/java/com/markreader/format/RenderTheme.kt @@ -0,0 +1,12 @@ +package com.markreader.format + +/** + * What a format needs to know about the surface it is rendering onto. + * + * One field, and deliberately a type rather than a bare `Boolean`: this is the parameter that + * renderer settings arrive through, it is half of the render cache key, and the option-3 host + * already has the grown-up version of the same idea in + * [com.markreader.webview.RendererSettings]. A boolean threaded through five signatures would + * have to be widened at every one of them. + */ +data class RenderTheme(val isDark: Boolean) diff --git a/app/src/main/java/com/markreader/ui/markdown/Prism4jThemes.kt b/app/src/main/java/com/markreader/format/code/Prism4jThemes.kt similarity index 99% rename from app/src/main/java/com/markreader/ui/markdown/Prism4jThemes.kt rename to app/src/main/java/com/markreader/format/code/Prism4jThemes.kt index 189a853..12c97f4 100644 --- a/app/src/main/java/com/markreader/ui/markdown/Prism4jThemes.kt +++ b/app/src/main/java/com/markreader/format/code/Prism4jThemes.kt @@ -1,4 +1,4 @@ -package com.markreader.ui.markdown +package com.markreader.format.code import android.graphics.Typeface import android.text.SpannableStringBuilder diff --git a/app/src/main/java/com/markreader/ui/markdown/PrismGrammarLocator.java b/app/src/main/java/com/markreader/format/code/PrismGrammarLocator.java similarity index 83% rename from app/src/main/java/com/markreader/ui/markdown/PrismGrammarLocator.java rename to app/src/main/java/com/markreader/format/code/PrismGrammarLocator.java index cbf7e64..ab3100b 100644 --- a/app/src/main/java/com/markreader/ui/markdown/PrismGrammarLocator.java +++ b/app/src/main/java/com/markreader/format/code/PrismGrammarLocator.java @@ -1,4 +1,4 @@ -package com.markreader.ui.markdown; +package com.markreader.format.code; import io.noties.prism4j.annotations.PrismBundle; diff --git a/app/src/main/java/com/markreader/format/code/SourceCodeFormat.kt b/app/src/main/java/com/markreader/format/code/SourceCodeFormat.kt new file mode 100644 index 0000000..a152749 --- /dev/null +++ b/app/src/main/java/com/markreader/format/code/SourceCodeFormat.kt @@ -0,0 +1,59 @@ +package com.markreader.format.code + +import com.markreader.R +import com.markreader.format.DocumentFormat +import com.markreader.format.FormatCapabilities +import com.markreader.format.ReaderContent +import com.markreader.format.RenderTheme +import kotlinx.coroutines.CancellationException + +/** + * Source code, syntax-highlighted by Prism4j. + * + * One instance per language, because the language is a property of the document and not of the + * call — a format that took it as a `load()` argument would be asking every caller to remember + * something the registry already knows. + * + * There is no raw view: the highlighted form is the source, coloured. That is also why the + * viewer names the format in its chrome instead of claiming to show a "rendered" document. + */ +class SourceCodeFormat(private val language: String) : DocumentFormat { + + /** Cheap to build, unlike Markwon — but the reader toggles themes often enough to keep. */ + private val renderers = mutableMapOf() + + override val capabilities = FormatCapabilities( + hasHeadings = false, + hasRawView = false, + isMonospaced = true, + hasEditorPreview = false, + hasFormattingToolbar = false + ) + + override val nameRes = R.string.viewer_subtitle_source_code + + override val editorPlaceholderRes = R.string.editor_placeholder_text + + /** + * `text/plain`, not a per-language MIME type. The picker uses this to name a *new* file, and + * offering `text/x-kotlin` there would have the system suggest an extension for a language + * the user has not chosen yet. + */ + override val creationMimeType = "text/plain" + + override val renderFailureWarningRes = R.string.viewer_warning_source_code + + override suspend fun load(text: String, theme: RenderTheme): ReaderContent? = try { + ReaderContent.Spans(renderer(theme.isDark).highlight(text, language)) + } catch (ex: CancellationException) { + throw ex + } catch (_: Exception) { + // A grammar Prism4j mishandles, or a pathological file. Either way: show the source. + null + } + + override fun invalidateRenderers() = renderers.clear() + + private fun renderer(isDark: Boolean): SourceCodeRenderer = + renderers.getOrPut(isDark) { SourceCodeRenderer(isDark) } +} diff --git a/app/src/main/java/com/markreader/ui/markdown/SourceCodeRenderer.kt b/app/src/main/java/com/markreader/format/code/SourceCodeRenderer.kt similarity index 98% rename from app/src/main/java/com/markreader/ui/markdown/SourceCodeRenderer.kt rename to app/src/main/java/com/markreader/format/code/SourceCodeRenderer.kt index d9fdb47..64c749a 100644 --- a/app/src/main/java/com/markreader/ui/markdown/SourceCodeRenderer.kt +++ b/app/src/main/java/com/markreader/format/code/SourceCodeRenderer.kt @@ -1,4 +1,4 @@ -package com.markreader.ui.markdown +package com.markreader.format.code import android.text.SpannableStringBuilder import android.text.Spanned diff --git a/app/src/main/java/com/markreader/ui/markdown/InlineCodeSpan.kt b/app/src/main/java/com/markreader/format/markdown/InlineCodeSpan.kt similarity index 98% rename from app/src/main/java/com/markreader/ui/markdown/InlineCodeSpan.kt rename to app/src/main/java/com/markreader/format/markdown/InlineCodeSpan.kt index 4af0555..dd279a2 100644 --- a/app/src/main/java/com/markreader/ui/markdown/InlineCodeSpan.kt +++ b/app/src/main/java/com/markreader/format/markdown/InlineCodeSpan.kt @@ -1,4 +1,4 @@ -package com.markreader.ui.markdown +package com.markreader.format.markdown import android.graphics.Canvas import android.graphics.Paint diff --git a/app/src/main/java/com/markreader/format/markdown/MarkdownFormat.kt b/app/src/main/java/com/markreader/format/markdown/MarkdownFormat.kt new file mode 100644 index 0000000..260b6f2 --- /dev/null +++ b/app/src/main/java/com/markreader/format/markdown/MarkdownFormat.kt @@ -0,0 +1,113 @@ +package com.markreader.format.markdown + +import android.content.Context +import android.graphics.Color +import androidx.core.graphics.ColorUtils +import com.markreader.R +import com.markreader.format.DocumentFormat +import com.markreader.format.FormatCapabilities +import com.markreader.format.HeadingItem +import com.markreader.format.ReaderContent +import com.markreader.format.RenderTheme +import kotlinx.coroutines.CancellationException + +/** Headings deeper than this are left out of the table of contents; it stops being navigation. */ +private const val MAX_TOC_LEVEL = 3 + +/** Markwon's own default: the text colour at ~10% alpha, so inline and block code agree. */ +private const val CODE_BACKGROUND_ALPHA = 25 + +private fun codeBackground(isDark: Boolean): Int = + ColorUtils.setAlphaComponent(if (isDark) Color.WHITE else Color.BLACK, CODE_BACKGROUND_ALPHA) + +/** + * Markdown, rendered by Markwon. + * + * Takes a renderer factory rather than building one, for two reasons: the renderer is expensive + * enough to be worth caching per surface theme, and a factory is the only part of this class + * that needs a [Context] — so everything else about the format can be asserted in a JVM test. + */ +class MarkdownFormat(private val newRenderer: (isDark: Boolean) -> MarkwonRenderer) : DocumentFormat { + + constructor(context: Context) : this({ isDark -> + MarkwonRenderer(context, isDark, codeBackground(isDark)) + }) + + /** One renderer per surface theme. Building Markwon costs milliseconds; toggling is instant. */ + private val renderers = mutableMapOf() + + override val capabilities = FormatCapabilities( + hasHeadings = true, + hasRawView = true, + isMonospaced = false, + hasEditorPreview = true, + hasFormattingToolbar = true + ) + + override val nameRes = R.string.viewer_subtitle_markdown + + override val editorPlaceholderRes = R.string.editor_placeholder_markdown + + override val creationMimeType = "text/markdown" + + override val renderFailureWarningRes = R.string.viewer_warning_markdown + + override suspend fun load(text: String, theme: RenderTheme): ReaderContent? = try { + ReaderContent.Spans(renderer(theme.isDark).render(text)) + } catch (ex: CancellationException) { + throw ex + } catch (_: Exception) { + // Markwon and commonmark between them can fail in a great many ways on a malformed + // document, and they all have the same remedy: show the source instead. + null + } + + override fun headings(text: String, content: ReaderContent): List = + parseHeadings(text, (content as? ReaderContent.Spans)?.spanned?.toString()) + + override fun invalidateRenderers() = renderers.clear() + + private fun renderer(isDark: Boolean): MarkwonRenderer = + renderers.getOrPut(isDark) { newRenderer(isDark) } +} + +/** + * ATX headings, with each offset mapped from the source onto [renderedText]. + * + * The mapping is a forward scan rather than a lookup: heading text can repeat, and a cursor that + * only moves forward keeps the second "Usage" pointing at the second one. Where the text cannot + * be found the source offset is used, which is wrong by however much markup the renderer ate — + * but it is monotonic, so the table of contents still leads down the document. + */ +private fun parseHeadings(markdown: String, renderedText: String?): List { + val headings = mutableListOf() + var sourceOffset = 0 + var renderedCursor = 0 + markdown.lineSequence().forEach { line -> + val lineStart = sourceOffset + sourceOffset += line.length + 1 + val heading = markdownHeading(line) ?: return@forEach + val found = renderedText?.indexOf(heading.text, renderedCursor) ?: -1 + val offset = when { + renderedText.isNullOrEmpty() -> lineStart + found >= 0 -> { + renderedCursor = (found + heading.text.length).coerceAtMost(renderedText.length) + found + } + else -> lineStart.coerceAtMost(renderedText.length - 1) + } + headings.add(heading.copy(offset = offset)) + } + return headings +} + +/** The heading on this line, with [HeadingItem.offset] left for [parseHeadings] to fill in. */ +private fun markdownHeading(line: String): HeadingItem? { + val trimmed = line.trimStart() + if (!trimmed.startsWith("#")) return null + val level = trimmed.takeWhile { it == '#' }.length + if (level !in 1..MAX_TOC_LEVEL) return null + val text = trimmed.drop(level).trim() + if (text.isBlank()) return null + return HeadingItem(text = text, level = level, offset = 0) +} diff --git a/app/src/main/java/com/markreader/ui/markdown/MarkwonRenderer.kt b/app/src/main/java/com/markreader/format/markdown/MarkwonRenderer.kt similarity index 96% rename from app/src/main/java/com/markreader/ui/markdown/MarkwonRenderer.kt rename to app/src/main/java/com/markreader/format/markdown/MarkwonRenderer.kt index a43349c..261ad30 100644 --- a/app/src/main/java/com/markreader/ui/markdown/MarkwonRenderer.kt +++ b/app/src/main/java/com/markreader/format/markdown/MarkwonRenderer.kt @@ -1,9 +1,12 @@ -package com.markreader.ui.markdown +package com.markreader.format.markdown import android.content.Context import android.text.SpannableStringBuilder import android.text.Spanned import androidx.annotation.ColorInt +import com.markreader.format.code.Prism4jThemeOneDark +import com.markreader.format.code.Prism4jThemeOneLight +import com.markreader.format.code.PrismGrammarLocatorImpl import io.noties.markwon.AbstractMarkwonPlugin import io.noties.markwon.Markwon import io.noties.markwon.MarkwonSpansFactory diff --git a/app/src/main/java/com/markreader/format/plaintext/PlainTextFormat.kt b/app/src/main/java/com/markreader/format/plaintext/PlainTextFormat.kt new file mode 100644 index 0000000..ffefd8b --- /dev/null +++ b/app/src/main/java/com/markreader/format/plaintext/PlainTextFormat.kt @@ -0,0 +1,35 @@ +package com.markreader.format.plaintext + +import android.text.SpannableString +import com.markreader.R +import com.markreader.format.DocumentFormat +import com.markreader.format.FormatCapabilities +import com.markreader.format.ReaderContent +import com.markreader.format.RenderTheme + +/** + * Text that renders as itself. + * + * Every capability is false and [load] cannot fail, which is what makes this the floor the other + * formats are measured against: a `.txt` gets no table of contents, no view-mode toggle, no + * preview pane and no formatting toolbar, because none of those mean anything without markup. + * + * It also stands in for CSV until P5. A `.csv` read as plain text is at least honest about what + * it is showing, which is more than Markdown prose was. + */ +object PlainTextFormat : DocumentFormat { + + override val capabilities = FormatCapabilities() + + override val nameRes = R.string.viewer_subtitle_plain_text + + override val editorPlaceholderRes = R.string.editor_placeholder_text + + override val creationMimeType = "text/plain" + + /** Nothing to fall back to: this format's rendering is its source. */ + override val renderFailureWarningRes: Int? = null + + override suspend fun load(text: String, theme: RenderTheme): ReaderContent = + ReaderContent.Spans(SpannableString(text)) +} 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 4891f1e..03aed0e 100644 --- a/app/src/main/java/com/markreader/ui/reader/ReaderContentController.kt +++ b/app/src/main/java/com/markreader/ui/reader/ReaderContentController.kt @@ -12,7 +12,7 @@ internal data class ContentKey( val lineHeight: Float, val readingFont: ReadingFontPreference, val codeFont: CodeFontPreference, - val isSourceCode: Boolean, + val isMonospaced: Boolean, val textAlignment: TextAlignmentPreference ) @@ -100,7 +100,7 @@ internal class ReaderContentController( lineHeight = -1f, readingFont = ReadingFontPreference.Merriweather, codeFont = CodeFontPreference.JetBrainsMono, - isSourceCode = false, + isMonospaced = false, textAlignment = TextAlignmentPreference.Left ) } 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 ec59e0d..3627c8d 100644 --- a/app/src/main/java/com/markreader/ui/reader/ReaderContentView.kt +++ b/app/src/main/java/com/markreader/ui/reader/ReaderContentView.kt @@ -30,7 +30,7 @@ import androidx.compose.ui.viewinterop.AndroidView import com.markreader.data.CodeFontPreference import com.markreader.data.ReadingFontPreference import com.markreader.data.TextAlignmentPreference -import com.markreader.ui.screens.HeadingItem +import com.markreader.format.HeadingItem /** * A [ScrollView] that never scrolls itself to keep a focused descendant on screen. @@ -110,18 +110,18 @@ fun RenderedTextView( lineHeight: Float, readingFont: ReadingFontPreference, codeFont: CodeFontPreference = CodeFontPreference.JetBrainsMono, - isSourceCode: Boolean = false, + isMonospaced: Boolean = false, textAlignment: TextAlignmentPreference, codeBlockBackgroundColor: Int ) { - val contentKey = remember(text, fontSizeSp, lineHeight, readingFont, codeFont, isSourceCode, textAlignment) { + val contentKey = remember(text, fontSizeSp, lineHeight, readingFont, codeFont, isMonospaced, textAlignment) { ContentKey( textHash = text.hashCode(), fontSizeSp = fontSizeSp, lineHeight = lineHeight, readingFont = readingFont, codeFont = codeFont, - isSourceCode = isSourceCode, + isMonospaced = isMonospaced, textAlignment = textAlignment ) } @@ -271,7 +271,7 @@ fun RenderedTextView( } else { val tv = createStyledTextView( scrollView.context, paddingPx, fontSizeSp, lineHeight, - readingFont, codeFont, isSourceCode, textAlignment, textColor, selectionHighlightColor, + readingFont, codeFont, isMonospaced, textAlignment, textColor, selectionHighlightColor, horizontalScroll = false ) scrollView.addView( @@ -362,7 +362,7 @@ fun RenderedTextView( // Fallback: plain text val tv = createStyledTextView( container.context, 0, fontSizeSp, lineHeight, - readingFont, codeFont, isSourceCode, textAlignment, + readingFont, codeFont, isMonospaced, textAlignment, textColor, selectionHighlightColor, horizontalScroll = false ) @@ -381,7 +381,7 @@ fun RenderedTextView( val tv = createStyledTextView( container.context, 0, fontSizeSp, lineHeight, readingFont, codeFont, - isSourceCode = isSourceCode || isCode, + isMonospaced = isMonospaced || isCode, textAlignment = textAlignment, textColor = textColor, selectionHighlightColor = selectionHighlightColor, @@ -508,7 +508,7 @@ fun RenderedTextView( ) is TextView -> applyStyleToTextView( inner, fontSizeSp, lineHeight, readingFont, codeFont, - isSourceCode, textAlignment, textColor, + isMonospaced, textAlignment, textColor, selectionHighlightColor ) } @@ -543,7 +543,7 @@ fun RenderedTextView( } if (controller.lastStyleKey != contentKey) { applyStyleToTextView( - textView, fontSizeSp, lineHeight, readingFont, codeFont, isSourceCode, + textView, fontSizeSp, lineHeight, readingFont, codeFont, isMonospaced, textAlignment, textColor, selectionHighlightColor ) controller.lastStyleKey = contentKey diff --git a/app/src/main/java/com/markreader/ui/reader/ReaderTextStyle.kt b/app/src/main/java/com/markreader/ui/reader/ReaderTextStyle.kt index 97732da..556de5f 100644 --- a/app/src/main/java/com/markreader/ui/reader/ReaderTextStyle.kt +++ b/app/src/main/java/com/markreader/ui/reader/ReaderTextStyle.kt @@ -17,10 +17,10 @@ import com.markreader.data.TextAlignmentPreference internal fun resolveTypeface( context: Context, - isSourceCode: Boolean, + isMonospaced: Boolean, readingFont: ReadingFontPreference, codeFont: CodeFontPreference -): Typeface = if (isSourceCode) { +): Typeface = if (isMonospaced) { when (codeFont) { CodeFontPreference.JetBrainsMono -> try { ResourcesCompat.getFont( @@ -52,7 +52,7 @@ internal fun createStyledTextView( lineHeight: Float, readingFont: ReadingFontPreference, codeFont: CodeFontPreference, - isSourceCode: Boolean, + isMonospaced: Boolean, textAlignment: TextAlignmentPreference, textColor: Int, selectionHighlightColor: Int, @@ -67,7 +67,7 @@ internal fun createStyledTextView( setTextIsSelectable(true) setTextColor(textColor) highlightColor = selectionHighlightColor - typeface = resolveTypeface(context, isSourceCode, readingFont, codeFont) + typeface = resolveTypeface(context, isMonospaced, readingFont, codeFont) // Derive paragraph direction from the text, not from the app locale. // TEXT_ALIGNMENT_VIEW_START below resolves against the *view's* layout // direction, which follows the locale — so an Arabic or Hebrew document opened @@ -94,7 +94,7 @@ internal fun applyStyleToTextView( lineHeight: Float, readingFont: ReadingFontPreference, codeFont: CodeFontPreference, - isSourceCode: Boolean, + isMonospaced: Boolean, textAlignment: TextAlignmentPreference, textColor: Int, selectionHighlightColor: Int @@ -103,7 +103,7 @@ internal fun applyStyleToTextView( tv.setLineSpacing(0f, lineHeight) tv.setTextColor(textColor) tv.highlightColor = selectionHighlightColor - tv.typeface = resolveTypeface(tv.context, isSourceCode, readingFont, codeFont) + tv.typeface = resolveTypeface(tv.context, isMonospaced, readingFont, codeFont) // See createStyledTextView: content-derived direction, set outside the // justification branch because it applies either way. tv.textDirection = TextView.TEXT_DIRECTION_FIRST_STRONG diff --git a/app/src/main/java/com/markreader/ui/reader/ScrollAnchor.kt b/app/src/main/java/com/markreader/ui/reader/ScrollAnchor.kt index 6f1286d..e5156fc 100644 --- a/app/src/main/java/com/markreader/ui/reader/ScrollAnchor.kt +++ b/app/src/main/java/com/markreader/ui/reader/ScrollAnchor.kt @@ -7,7 +7,7 @@ import android.widget.HorizontalScrollView import android.widget.LinearLayout import android.widget.ScrollView import android.widget.TextView -import com.markreader.ui.screens.HeadingItem +import com.markreader.format.HeadingItem /** * The part of a laid-out [Layout] the anchor math reads. diff --git a/app/src/main/java/com/markreader/ui/reader/SegmentSplitter.kt b/app/src/main/java/com/markreader/ui/reader/SegmentSplitter.kt index 99aa703..884ce10 100644 --- a/app/src/main/java/com/markreader/ui/reader/SegmentSplitter.kt +++ b/app/src/main/java/com/markreader/ui/reader/SegmentSplitter.kt @@ -1,8 +1,8 @@ package com.markreader.ui.reader import android.text.Spanned -import com.markreader.ui.markdown.CodeBlockMarkerSpan -import com.markreader.ui.markdown.TableMarkerSpan +import com.markreader.format.markdown.CodeBlockMarkerSpan +import com.markreader.format.markdown.TableMarkerSpan internal enum class SegmentType { Text, Code, Table } 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 50086ad..160ac98 100644 --- a/app/src/main/java/com/markreader/ui/screens/EditorScreen.kt +++ b/app/src/main/java/com/markreader/ui/screens/EditorScreen.kt @@ -126,9 +126,7 @@ fun EditorScreen( val saveAsLabel = stringResource(R.string.editor_save_as) val saveAsLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.CreateDocument( - if (uiState.isMarkdown) "text/markdown" else "text/plain" - ), + contract = ActivityResultContracts.CreateDocument(uiState.creationMimeType), onResult = { uri -> if (uri != null) viewModel.onSaveAs(uri) } ) @@ -222,8 +220,7 @@ fun EditorScreen( } } else -> { - // Tabs for markdown - if (uiState.isMarkdown) { + if (uiState.capabilities.hasEditorPreview) { PrimaryTabRow(selectedTabIndex = if (uiState.activeTab == EditorTab.Edit) 0 else 1) { Tab( selected = uiState.activeTab == EditorTab.Edit, @@ -250,20 +247,21 @@ fun EditorScreen( // Editor or Preview content val prefs = uiState.userPreferences - val editorFontFamily = if (uiState.isMarkdown) { + val editorFontFamily = if (uiState.capabilities.isMonospaced) { + when (prefs.codeFont) { + CodeFontPreference.JetBrainsMono -> CodeFontFamily + CodeFontPreference.SystemMono -> FontFamily.Monospace + } + } else { when (prefs.readingFont) { ReadingFontPreference.Merriweather -> ReadingFontFamily ReadingFontPreference.SystemSerif -> FontFamily.Serif ReadingFontPreference.MerriweatherSans -> SansReadingFontFamily } - } else { - when (prefs.codeFont) { - CodeFontPreference.JetBrainsMono -> CodeFontFamily - CodeFontPreference.SystemMono -> FontFamily.Monospace - } } - val showEdit = !uiState.isMarkdown || uiState.activeTab == EditorTab.Edit + val showEdit = !uiState.capabilities.hasEditorPreview || + uiState.activeTab == EditorTab.Edit if (showEdit) { val scrollState = rememberScrollState() @@ -293,13 +291,7 @@ fun EditorScreen( Box { if (uiState.textFieldValue.text.isEmpty()) { Text( - text = stringResource( - if (uiState.isMarkdown) { - R.string.editor_placeholder_markdown - } else { - R.string.editor_placeholder_text - } - ), + text = stringResource(uiState.placeholderRes), style = TextStyle( fontFamily = editorFontFamily, fontSize = prefs.fontSizeSp.sp, @@ -348,7 +340,7 @@ fun EditorScreen( lineHeight = prefs.lineHeight, readingFont = prefs.readingFont, codeFont = prefs.codeFont, - isSourceCode = false, + isMonospaced = false, textAlignment = prefs.textAlignment, codeBlockBackgroundColor = if (previewIsDark) 0x19FFFFFF.toInt() else 0x19000000.toInt() ) @@ -356,8 +348,8 @@ fun EditorScreen( } } - // Formatting toolbar — markdown edit mode, visible when keyboard is up - if (uiState.isMarkdown && uiState.activeTab == EditorTab.Edit) { + // Visible when the keyboard is up, for formats with markup to insert. + if (uiState.capabilities.hasFormattingToolbar && uiState.activeTab == EditorTab.Edit) { AnimatedVisibility(visible = keyboardVisible) { FormattingToolbar(viewModel = viewModel) } diff --git a/app/src/main/java/com/markreader/ui/screens/EditorViewModel.kt b/app/src/main/java/com/markreader/ui/screens/EditorViewModel.kt index 5106264..63842c1 100644 --- a/app/src/main/java/com/markreader/ui/screens/EditorViewModel.kt +++ b/app/src/main/java/com/markreader/ui/screens/EditorViewModel.kt @@ -1,16 +1,15 @@ package com.markreader.ui.screens import android.app.Application -import android.graphics.Color import android.net.Uri import android.provider.OpenableColumns import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue +import androidx.annotation.StringRes import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import androidx.core.graphics.ColorUtils import com.markreader.R import com.markreader.file.DocumentSource import com.markreader.file.FileTooLargeException @@ -24,8 +23,12 @@ import com.markreader.data.RecentFilesRepository import com.markreader.data.UserPreferences import com.markreader.file.DocumentType import com.markreader.file.DocumentTypeResolver +import com.markreader.format.DocumentFormat +import com.markreader.format.FormatCapabilities +import com.markreader.format.FormatRegistry +import com.markreader.format.ReaderContent +import com.markreader.format.RenderTheme import com.markreader.ui.UiMessage -import com.markreader.ui.markdown.MarkwonRenderer import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -34,6 +37,9 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +/** Default for a format that has nothing more specific to ask the picker for. */ +private const val PLAIN_TEXT_MIME = "text/plain" + enum class EditorTab { Edit, Preview } sealed class SaveResult { @@ -52,7 +58,12 @@ data class EditorUiState( val isLoading: Boolean = true, val fileName: String = "", val textFieldValue: TextFieldValue = TextFieldValue(""), - val isMarkdown: Boolean = false, + /** What the editor may offer, decided by the document's format rather than by its type. */ + val capabilities: FormatCapabilities = FormatCapabilities(), + /** What an empty document of this format invites the user to type. */ + @StringRes val placeholderRes: Int = R.string.editor_placeholder_text, + /** The MIME type Save As asks the system picker for. */ + val creationMimeType: String = PLAIN_TEXT_MIME, val isModified: Boolean = false, val isSaving: Boolean = false, val saveResult: SaveResult? = null, @@ -73,7 +84,8 @@ class EditorViewModel( private val recentFilesRepository = RecentFilesRepository.getInstance(application) private var currentUri: String? = initialUri private var systemDarkTheme = false - private var renderer: MarkwonRenderer? = null + private val formats = FormatRegistry(application) + private var format: DocumentFormat? = null private val _uiState = MutableStateFlow(EditorUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -84,20 +96,21 @@ class EditorViewModel( loadContent() } else { // No document to resolve a type from, and this app's default is Markdown. - _uiState.value = _uiState.value.copy( - isLoading = false, - fileName = UNTITLED_MARKDOWN, - isMarkdown = true, - encoding = TextEncoding.Utf8 - ) + _uiState.value = _uiState.value + .withFormat(formats.formatFor(DocumentType.Markdown)) + .copy( + isLoading = false, + fileName = UNTITLED_MARKDOWN, + encoding = TextEncoding.Utf8 + ) } } fun onSystemDarkThemeChanged(isDark: Boolean) { if (systemDarkTheme == isDark) return systemDarkTheme = isDark - renderer = null - if (_uiState.value.isMarkdown && _uiState.value.activeTab == EditorTab.Preview) { + formats.invalidateRenderers() + if (isPreviewShowing()) { viewModelScope.launch { renderPreview(_uiState.value.textFieldValue.text) } } } @@ -126,10 +139,9 @@ class EditorViewModel( } catch (e: Exception) { null } - _uiState.value = _uiState.value.copy( + _uiState.value = _uiState.value.withFormatFor(fileName).copy( isLoading = false, fileName = fileName, - isMarkdown = DocumentTypeResolver.resolveFromHints(fileName) == DocumentType.Markdown, textFieldValue = TextFieldValue(decoded?.text ?: ""), encoding = decoded?.encoding, errorMessage = when { @@ -143,14 +155,14 @@ class EditorViewModel( fun onTextChanged(value: TextFieldValue) { _uiState.value = _uiState.value.copy(textFieldValue = value, isModified = true) - if (_uiState.value.isMarkdown && _uiState.value.activeTab == EditorTab.Preview) { + if (isPreviewShowing()) { viewModelScope.launch { renderPreview(value.text) } } } fun onTabChanged(tab: EditorTab) { _uiState.value = _uiState.value.copy(activeTab = tab) - if (tab == EditorTab.Preview && _uiState.value.isMarkdown) { + if (tab == EditorTab.Preview && _uiState.value.capabilities.hasEditorPreview) { viewModelScope.launch { renderPreview(_uiState.value.textFieldValue.text) } } } @@ -265,11 +277,9 @@ class EditorViewModel( val newFileName = resolveFileName(newUri) ?: newUri.lastPathSegment ?: _uiState.value.fileName recentFilesRepository.recordOpen(newUri.toString(), newFileName) currentUri = newUri.toString() - _uiState.value = _uiState.value.copy( + _uiState.value = _uiState.value.withFormatFor(newFileName).copy( isSaving = false, isModified = false, fileName = newFileName, saveResult = SaveResult.Success, - isMarkdown = DocumentTypeResolver.resolveFromHints(newFileName) == - DocumentType.Markdown, // The copy is written in a known encoding, so this one can be saved to. encoding = encoding ) @@ -285,27 +295,45 @@ class EditorViewModel( _uiState.value = _uiState.value.copy(saveResult = null) } + private fun isPreviewShowing(): Boolean = + _uiState.value.capabilities.hasEditorPreview && + _uiState.value.activeTab == EditorTab.Preview + private suspend fun renderPreview(text: String) { - val prefs = _uiState.value.userPreferences - val isDark = when (prefs.appThemeMode) { + val isDark = when (_uiState.value.userPreferences.appThemeMode) { AppThemeModePreference.Dark -> true AppThemeModePreference.Light -> false AppThemeModePreference.System -> systemDarkTheme } - if (renderer == null) { - val bg = ColorUtils.setAlphaComponent(if (isDark) Color.WHITE else Color.BLACK, 25) - renderer = MarkwonRenderer(getApplication(), isDark, bg) - } - val spanned = withContext(Dispatchers.Default) { - try { - renderer!!.render(text) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - null - } - } - _uiState.value = _uiState.value.copy(previewText = spanned) + // 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 + ) + } + + /** As [withFormat], for the format a document named [fileName] resolves to. */ + private fun EditorUiState.withFormatFor(fileName: String): EditorUiState = + withFormat(formats.formatFor(DocumentTypeResolver.resolveFromHints(fileName))) + + /** + * The state that follows from [next] — and **adopts it as the current format**, which the + * preview renders through. The side effect is why the two travel together: a state that said + * "Markdown" while [format] still held the previous document's would show a preview tab that + * rendered the wrong thing. + * + * Null cannot arrive from a name alone, since `resolveFromHints` never answers + * [DocumentType.Binary]. The editor is honest about it rather than asserting, and plain text + * is the right floor if it ever does. + */ + private fun EditorUiState.withFormat(next: DocumentFormat?): EditorUiState { + format = next + return copy( + capabilities = next?.capabilities ?: FormatCapabilities(), + placeholderRes = next?.editorPlaceholderRes ?: R.string.editor_placeholder_text, + creationMimeType = next?.creationMimeType ?: PLAIN_TEXT_MIME + ) } private suspend fun resolveFileName(uri: Uri): String? = withContext(Dispatchers.IO) { 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 f43c493..b8e53cc 100644 --- a/app/src/main/java/com/markreader/ui/screens/ViewerScreen.kt +++ b/app/src/main/java/com/markreader/ui/screens/ViewerScreen.kt @@ -111,7 +111,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.markreader.R import com.markreader.data.AppThemeModePreference import com.markreader.data.ReaderThemePreference -import com.markreader.file.DocumentType import com.markreader.file.OPENABLE_MIME_TYPES import com.markreader.file.tryTakePersistablePermission import com.markreader.ui.components.GroupInnerRadius @@ -119,6 +118,7 @@ import com.markreader.ui.components.GroupOuterRadius 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.ui.export.ExportManager import com.markreader.ui.resolve import com.markreader.ui.reader.RenderedTextView @@ -243,14 +243,16 @@ fun ViewerScreen( } else { stringResource(R.string.untitled) } + // Naming a view mode is only truthful where there is more than one. Where there is not, + // the chip names the format instead — see FormatCapabilities.hasRawView. val viewModeLabel = stringResource( when { - uiState.documentType is DocumentType.SourceCode -> R.string.viewer_subtitle_source_code + !uiState.capabilities.hasRawView -> uiState.formatNameRes uiState.viewMode == ViewMode.Raw -> R.string.viewer_subtitle_raw_mode else -> R.string.viewer_subtitle_rendered_mode } ) - val canToggleViewMode = uiState.documentType == DocumentType.Markdown && uiState.rendered != null + val canToggleViewMode = uiState.capabilities.hasRawView && uiState.rendered != null val dynamicLightScheme = remember(context, prefs.useDynamicColors) { if (prefs.useDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -447,7 +449,7 @@ fun ViewerScreen( } }, navigationIcon = { - if (uiState.documentType == DocumentType.Markdown) { + if (uiState.capabilities.hasHeadings) { FilledTonalIconButton( onClick = { haptics.performHapticFeedback(HapticFeedbackType.Confirm) @@ -790,7 +792,7 @@ fun ViewerScreen( lineHeight = prefs.lineHeight, readingFont = prefs.readingFont, codeFont = prefs.codeFont, - isSourceCode = uiState.documentType is DocumentType.SourceCode, + isMonospaced = uiState.capabilities.isMonospaced, textAlignment = prefs.textAlignment, codeBlockBackgroundColor = if (isSurfaceDark) { 0x19FFFFFF.toInt() 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 0cfa4c7..5fc5d8a 100644 --- a/app/src/main/java/com/markreader/ui/screens/ViewerViewModel.kt +++ b/app/src/main/java/com/markreader/ui/screens/ViewerViewModel.kt @@ -8,6 +8,7 @@ import android.text.Spanned import android.graphics.Typeface import android.text.style.ForegroundColorSpan import android.text.style.StyleSpan +import androidx.annotation.StringRes import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider @@ -18,8 +19,12 @@ import com.markreader.data.RecentFilesRepository import com.markreader.data.UserPreferences import com.markreader.file.DocumentType import com.markreader.file.DocumentTypeResolver -import com.markreader.ui.markdown.MarkwonRenderer -import com.markreader.ui.markdown.SourceCodeRenderer +import com.markreader.format.DocumentFormat +import com.markreader.format.FormatCapabilities +import com.markreader.format.FormatRegistry +import com.markreader.format.HeadingItem +import com.markreader.format.ReaderContent +import com.markreader.format.RenderTheme import com.markreader.ui.UiMessage import com.markreader.ui.reader.SearchHighlightSpan import com.markreader.R @@ -28,8 +33,6 @@ import com.markreader.file.FileTooLargeException import com.markreader.file.MAX_FILE_BYTES import com.markreader.file.TextCodec import com.markreader.file.tryTakePersistablePermission -import android.graphics.Color -import androidx.core.graphics.ColorUtils import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -44,12 +47,6 @@ enum class ViewMode { Raw } -data class HeadingItem( - val text: String, - val level: Int, - val offset: Int -) - data class ViewerUiState( val isLoading: Boolean = true, val isLoadingVisible: Boolean = false, @@ -69,7 +66,11 @@ data class ViewerUiState( val searchMatchCount: Int = 0, val searchMatchIndex: Int = 0, val userPreferences: UserPreferences = UserPreferences(), - val documentType: DocumentType = DocumentType.PlainText + val documentType: DocumentType = DocumentType.PlainText, + /** What the chrome may offer, decided by the document's format rather than by its type. */ + val capabilities: FormatCapabilities = FormatCapabilities(), + /** Shown in place of a view mode where [capabilities] says there is no raw view. */ + @StringRes val formatNameRes: Int = R.string.viewer_subtitle_plain_text ) class ViewerViewModel( @@ -78,14 +79,15 @@ class ViewerViewModel( ) : AndroidViewModel(application) { private data class RenderCacheKey( val documentType: DocumentType, - val isDark: Boolean + val theme: RenderTheme ) private val repository = PreferencesRepository.getInstance(application) private val recentFilesRepository = RecentFilesRepository.getInstance(application) private var systemDarkTheme = false - private val rendererCache = mutableMapOf() - private val sourceCodeRendererCache = mutableMapOf() + private val formats = FormatRegistry(application) + /** The format for the document on screen, held so its renderers survive a theme toggle. */ + private var currentFormat: DocumentFormat? = null private var currentUri: String? = null private var loadJob: Job? = null private var loadingJob: Job? = null @@ -227,15 +229,14 @@ class ViewerViewModel( } val documentType = DocumentTypeResolver.resolve(fileName, mimeType, markdown) - val isDark = renderDarkModeOverride ?: isDarkTheme(_uiState.value.userPreferences, systemDarkTheme) - val rendered = render(documentType, isDark, markdown) - val headings = if (documentType == DocumentType.Markdown) { - parseHeadings(markdown, rendered?.toString()) - } else { - emptyList() - } + val format = formats.formatFor(documentType) + currentFormat = format + val theme = RenderTheme( + renderDarkModeOverride ?: isDarkTheme(_uiState.value.userPreferences, systemDarkTheme) + ) + val loaded = format?.load(markdown, theme).asSpans() - if (rendered == null) { + if (format == null || loaded == null) { loadingJob?.cancel() _uiState.value = _uiState.value.copy( isLoading = false, @@ -247,33 +248,37 @@ class ViewerViewModel( searchQuery = "", searchMatchCount = 0, searchMatchIndex = 0, + documentType = documentType, + // A null format is DocumentType.Binary: bytes that are not text, so there + // is no format to ask and nothing but the raw view to offer. + capabilities = format?.capabilities ?: FormatCapabilities(), + formatNameRes = format?.nameRes ?: R.string.viewer_subtitle_raw_mode, warningMessage = UiMessage( - when (documentType) { - DocumentType.Binary -> R.string.viewer_warning_binary - is DocumentType.SourceCode -> R.string.viewer_warning_source_code - else -> R.string.viewer_warning_markdown - } + format?.renderFailureWarningRes ?: R.string.viewer_warning_binary ) ) baseRendered = null return@launch } + val headings = format.headings(markdown, loaded) loadingJob?.cancel() - baseRendered = rendered - renderCache[RenderCacheKey(documentType, isDark)] = rendered + baseRendered = loaded.spanned + renderCache[RenderCacheKey(documentType, theme)] = loaded.spanned _uiState.value = _uiState.value.copy( isLoading = false, isLoadingVisible = false, fileName = fileName, rawText = markdown, rawHighlighted = null, - rendered = rendered, + rendered = loaded.spanned, errorMessage = null, warningMessage = null, headings = headings, activeHeadingIndex = clampActiveHeadingIndex(_uiState.value.activeHeadingIndex, headings), - documentType = documentType + documentType = documentType, + capabilities = format.capabilities, + formatNameRes = format.nameRes ) applySearchQuery(_uiState.value.searchQuery) @@ -417,7 +422,7 @@ class ViewerViewModel( prefs.lineHeight != previous.lineHeight if (themeChanged) { - invalidateRendererCaches() + formats.invalidateRenderers() } _uiState.value = _uiState.value.copy(userPreferences = prefs) @@ -434,99 +439,48 @@ class ViewerViewModel( } } - private fun getMarkwonRenderer(isDark: Boolean): MarkwonRenderer { - return rendererCache.getOrPut(isDark) { - MarkwonRenderer(getApplication(), isDark, codeBackground(isDark)) - } - } - - private fun getSourceCodeRenderer(isDark: Boolean): SourceCodeRenderer { - return sourceCodeRendererCache.getOrPut(isDark) { - SourceCodeRenderer(isDark) - } - } - - private fun invalidateRendererCaches() { - rendererCache.clear() - sourceCodeRendererCache.clear() - } - - private suspend fun renderMarkdown(isDark: Boolean, markdown: String): Spanned? { - return try { - getMarkwonRenderer(isDark).render(markdown) - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - null - } - } - - /** - * The one dispatch point. Null means "there is nothing to render" — only [DocumentType.Binary] - * says so outright; the others return null when their renderer fails, and the caller drops - * to raw text with a warning either way. - */ - private suspend fun render(documentType: DocumentType, isDark: Boolean, text: String): Spanned? = - when (documentType) { - DocumentType.Binary -> null - DocumentType.Markdown -> renderMarkdown(isDark, text) - is DocumentType.SourceCode -> renderSourceCode(isDark, text, documentType.language) - // Csv keeps its own reader for later; until then it reads as what it also is, - // which is text — and text that is not pretending to be Markdown prose. - DocumentType.Csv, DocumentType.PlainText -> SpannableString(text) - } - - private suspend fun renderSourceCode(isDark: Boolean, code: String, language: String): Spanned? { - return try { - getSourceCodeRenderer(isDark).highlight(code, language) - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - null - } - } - private fun rebuildRenderedForTheme() { val markdown = _uiState.value.rawText if (markdown.isBlank()) return - val prefs = _uiState.value.userPreferences - val isDark = renderDarkModeOverride ?: isDarkTheme(prefs, systemDarkTheme) + val format = currentFormat ?: return + val theme = currentTheme() val documentType = _uiState.value.documentType viewModelScope.launch { - if (tryApplyCachedRender(documentType, isDark)) { + if (tryApplyCachedRender(documentType, theme)) { return@launch } - val rendered = render(documentType, isDark, markdown) - if (rendered != null) { - baseRendered = rendered - renderCache[RenderCacheKey(documentType, isDark)] = rendered - _uiState.value = _uiState.value.copy(rendered = rendered) - applySearchQuery(_uiState.value.searchQuery) - } + 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) + applySearchQuery(_uiState.value.searchQuery) } } private fun scheduleRebuild() { rebuildJob?.cancel() - val prefs = _uiState.value.userPreferences - val isDark = renderDarkModeOverride ?: isDarkTheme(prefs, systemDarkTheme) - if (tryApplyCachedRender(_uiState.value.documentType, isDark)) { + if (tryApplyCachedRender(_uiState.value.documentType, currentTheme())) { return } rebuildJob = viewModelScope.launch { - kotlinx.coroutines.delay(50) + kotlinx.coroutines.delay(REBUILD_DEBOUNCE_MS) rebuildRenderedForTheme() } } - private fun tryApplyCachedRender(documentType: DocumentType, isDark: Boolean): Boolean { - val cached = renderCache[RenderCacheKey(documentType, isDark)] ?: return false + private fun tryApplyCachedRender(documentType: DocumentType, theme: RenderTheme): Boolean { + val cached = renderCache[RenderCacheKey(documentType, theme)] ?: return false baseRendered = cached _uiState.value = _uiState.value.copy(rendered = cached) applySearchQuery(_uiState.value.searchQuery) return true } + /** The surface the document is being drawn on: the reader's own override, else the app theme. */ + private fun currentTheme(): RenderTheme = RenderTheme( + renderDarkModeOverride ?: isDarkTheme(_uiState.value.userPreferences, systemDarkTheme) + ) + /** * The read failure, with whatever the exception gave us appended. The class name * and message are diagnostics rather than prose, so they are passed in as @@ -699,37 +653,6 @@ class ViewerViewModel( } } - private fun parseHeadings(markdown: String, renderedText: String?): List { - val headings = mutableListOf() - var offset = 0 - var renderedCursor = 0 - markdown.lineSequence().forEach { line -> - val trimmed = line.trimStart() - if (trimmed.startsWith("#")) { - val level = trimmed.takeWhile { it == '#' }.length - if (level in 1..3) { - val text = trimmed.drop(level).trim() - if (text.isNotBlank()) { - val renderedOffset = if (!renderedText.isNullOrEmpty()) { - val found = renderedText.indexOf(text, renderedCursor) - if (found >= 0) { - renderedCursor = (found + text.length).coerceAtMost(renderedText.length) - found - } else { - offset.coerceAtMost(renderedText.length - 1) - } - } else { - offset - } - headings.add(HeadingItem(text = text, level = level, offset = renderedOffset)) - } - } - } - offset += line.length + 1 - } - return headings - } - private fun clampActiveHeadingIndex(current: Int, headings: List): Int { if (headings.isEmpty()) return -1 if (current < 0) return -1 @@ -737,11 +660,8 @@ class ViewerViewModel( } companion object { - /** Matches Markwon's default: text color at ~10% alpha. */ - private fun codeBackground(isDark: Boolean): Int { - val textColor = if (isDark) Color.WHITE else Color.BLACK - return ColorUtils.setAlphaComponent(textColor, 25) - } + /** Coalesces the burst of preference emissions a single theme switch produces. */ + private const val REBUILD_DEBOUNCE_MS = 50L private fun isDarkTheme(prefs: UserPreferences, isSystemDarkTheme: Boolean): Boolean { return when (prefs.appThemeMode) { @@ -764,3 +684,10 @@ 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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index de52241..a938c8e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -27,7 +27,9 @@ Text + Markdown Source code + Plain text Raw mode Rendered mode Edit file diff --git a/app/src/test/java/com/markreader/format/FormatRegistryTest.kt b/app/src/test/java/com/markreader/format/FormatRegistryTest.kt new file mode 100644 index 0000000..4ae7025 --- /dev/null +++ b/app/src/test/java/com/markreader/format/FormatRegistryTest.kt @@ -0,0 +1,120 @@ +package com.markreader.format + +import com.markreader.R +import com.markreader.file.DocumentType +import com.markreader.format.markdown.MarkdownFormat +import com.markreader.format.plaintext.PlainTextFormat +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test + +/** + * The registry's whole job is the [DocumentType] to [DocumentFormat] mapping and the capability + * table that hangs off it — which is what replaced ten `is this Markdown` tests in the UI, so it + * is worth pinning rather than trusting to a reading of the `when`. + * + * The markdown format is constructed with a factory that throws, which is the point of taking + * one: everything asserted here is decided without a `Context` or a renderer. + */ +class FormatRegistryTest { + + private val markdown = MarkdownFormat { error("no renderer is needed for these assertions") } + private val registry = FormatRegistry(markdown) + + @Test + fun `markdown maps to the markdown format`() { + assertSame(markdown, registry.formatFor(DocumentType.Markdown)) + } + + @Test + fun `markdown offers everything`() { + assertEquals( + FormatCapabilities( + hasHeadings = true, + hasRawView = true, + isMonospaced = false, + hasEditorPreview = true, + hasFormattingToolbar = true + ), + markdown.capabilities + ) + assertEquals("text/markdown", markdown.creationMimeType) + assertEquals(R.string.viewer_warning_markdown, markdown.renderFailureWarningRes) + } + + @Test + fun `source code is monospaced and nothing else`() { + val format = registry.formatFor(DocumentType.SourceCode("kotlin"))!! + assertEquals(FormatCapabilities(isMonospaced = true), format.capabilities) + assertEquals(R.string.viewer_subtitle_source_code, format.nameRes) + // Naming the language here would have the picker suggest an extension for a language + // the user has not chosen yet. + assertEquals("text/plain", format.creationMimeType) + } + + @Test + fun `a language keeps its format, so the highlighter cache survives a theme toggle`() { + val first = registry.formatFor(DocumentType.SourceCode("kotlin")) + assertSame(first, registry.formatFor(DocumentType.SourceCode("kotlin"))) + assertNotSame(first, registry.formatFor(DocumentType.SourceCode("python"))) + } + + @Test + fun `plain text offers nothing, and cannot fail`() { + val format = registry.formatFor(DocumentType.PlainText)!! + assertSame(PlainTextFormat, format) + assertEquals(FormatCapabilities(), format.capabilities) + assertNull(format.renderFailureWarningRes) + } + + /** Until P5. A `.csv` read as text is at least honest, which Markdown prose was not. */ + @Test + fun `csv reads as plain text for now`() { + assertSame(PlainTextFormat, registry.formatFor(DocumentType.Csv)) + } + + /** The one case a caller has to handle rather than render. */ + @Test + fun `binary has no format`() { + assertNull(registry.formatFor(DocumentType.Binary)) + } + + @Test + fun `only formats with headings produce any`() { + val content = ReaderContent.Table(emptyList()) + assertEquals(emptyList(), PlainTextFormat.headings("# Heading", content)) + assertEquals( + emptyList(), + registry.formatFor(DocumentType.SourceCode("kotlin"))!!.headings("# Heading", content) + ) + } + + @Test + fun `invalidation reaches every format the registry has handed out`() { + val recording = RecordingFormat() + val withRecording = FormatRegistry(recording) + val code = withRecording.formatFor(DocumentType.SourceCode("kotlin")) + + withRecording.invalidateRenderers() + + assertEquals(1, recording.invalidations) + // The source-code format caches renderers too, so a stale one there would paint the + // previous palette just as visibly. + assertSame(code, withRecording.formatFor(DocumentType.SourceCode("kotlin"))) + } + + private class RecordingFormat : DocumentFormat { + var invalidations = 0 + override val capabilities = FormatCapabilities() + override val nameRes = R.string.viewer_subtitle_markdown + override val editorPlaceholderRes = R.string.editor_placeholder_markdown + override val creationMimeType = "text/markdown" + override val renderFailureWarningRes = R.string.viewer_warning_markdown + override suspend fun load(text: String, theme: RenderTheme): ReaderContent? = null + override fun invalidateRenderers() { + invalidations++ + } + } +} diff --git a/app/src/test/java/com/markreader/format/markdown/MarkdownHeadingsTest.kt b/app/src/test/java/com/markreader/format/markdown/MarkdownHeadingsTest.kt new file mode 100644 index 0000000..79c71fd --- /dev/null +++ b/app/src/test/java/com/markreader/format/markdown/MarkdownHeadingsTest.kt @@ -0,0 +1,87 @@ +package com.markreader.format.markdown + +import com.markreader.format.HeadingItem +import com.markreader.format.ReaderContent +import com.markreader.ui.reader.FakeSpanned +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The table of contents, exercised through the format rather than the parser, because the offset + * mapping is the part that goes wrong and it depends on the rendering being passed in. + * + * Offsets index the *rendered* text, since that is what the reader scrolls. Markwon eats the + * `#` markers, so a source offset would be wrong by the accumulated width of every marker above + * it — which is why this mapping exists at all. + */ +class MarkdownHeadingsTest { + + private val format = MarkdownFormat { error("no renderer is needed for these assertions") } + + private fun headings(markdown: String, rendered: String?): List = format.headings( + markdown, + if (rendered == null) { + ReaderContent.Table(emptyList()) + } else { + ReaderContent.Spans(FakeSpanned(rendered)) + } + ) + + @Test + fun `levels one to three are navigation, deeper ones are not`() { + val result = headings("# One\n## Two\n### Three\n#### Four\n##### Five", null) + + assertEquals(listOf("One", "Two", "Three"), result.map { it.text }) + assertEquals(listOf(1, 2, 3), result.map { it.level }) + } + + @Test + fun `a heading with no text is not a heading`() { + assertEquals(emptyList(), headings("#\n## \nbody", null)) + } + + @Test + fun `leading whitespace does not hide a heading`() { + assertEquals(listOf("Indented"), headings(" ## Indented", null).map { it.text }) + } + + @Test + fun `a hash inside a line is not a heading`() { + assertEquals(emptyList(), headings("see issue #42 for detail", null)) + } + + @Test + fun `offsets point into the rendered text, not the source`() { + val result = headings("# Title\n\nBody text.\n\n## Section\n", "Title\n\nBody text.\n\nSection\n") + + assertEquals(listOf(0, 19), result.map { it.offset }) + } + + /** + * The mapping is a forward scan for exactly this: a cursor that only moves forward keeps the + * second "Usage" pointing at the second one rather than back at the first. + */ + @Test + fun `repeated heading text resolves to successive occurrences`() { + val result = headings("## Usage\ntext\n## Usage\n", "Usage\ntext\nUsage\n") + + assertEquals(listOf(0, 11), result.map { it.offset }) + } + + @Test + fun `a heading absent from the rendering falls back to a clamped source offset`() { + // Markwon dropped the second heading entirely; the first still maps. The fallback is the + // source offset (8) clamped into the rendering, which is shorter — scrolling past the end + // is not a thing the reader can do, so the last character is as far as it goes. + val result = headings("# Kept\n\n## Gone\n", "Kept\n\n") + + assertEquals(listOf(0, 5), result.map { it.offset }) + } + + @Test + fun `with no rendering to map onto, offsets stay in the source`() { + val result = headings("# One\nbody\n## Two\n", null) + + assertEquals(listOf(0, 11), result.map { it.offset }) + } +} diff --git a/app/src/test/java/com/markreader/ui/reader/ScrollAnchorTest.kt b/app/src/test/java/com/markreader/ui/reader/ScrollAnchorTest.kt index 5e01db6..da0bb48 100644 --- a/app/src/test/java/com/markreader/ui/reader/ScrollAnchorTest.kt +++ b/app/src/test/java/com/markreader/ui/reader/ScrollAnchorTest.kt @@ -1,6 +1,6 @@ package com.markreader.ui.reader -import com.markreader.ui.screens.HeadingItem +import com.markreader.format.HeadingItem import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test diff --git a/app/src/test/java/com/markreader/ui/reader/SegmentSplitterTest.kt b/app/src/test/java/com/markreader/ui/reader/SegmentSplitterTest.kt index a6d17e8..bef21d3 100644 --- a/app/src/test/java/com/markreader/ui/reader/SegmentSplitterTest.kt +++ b/app/src/test/java/com/markreader/ui/reader/SegmentSplitterTest.kt @@ -1,7 +1,7 @@ package com.markreader.ui.reader -import com.markreader.ui.markdown.CodeBlockMarkerSpan -import com.markreader.ui.markdown.TableMarkerSpan +import com.markreader.format.markdown.CodeBlockMarkerSpan +import com.markreader.format.markdown.TableMarkerSpan import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue diff --git a/config/detekt/baseline.xml b/config/detekt/baseline.xml index cbb96bc..48b236f 100644 --- a/config/detekt/baseline.xml +++ b/config/detekt/baseline.xml @@ -8,20 +8,18 @@ CyclomaticComplexMethod:EditorScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun EditorScreen( uriString: String?, onNavigateBack: () -> Unit, onFileSaved: () -> Unit, modifier: Modifier = Modifier, viewModel: EditorViewModel = viewModel( factory = EditorViewModel.factory( LocalContext.current.applicationContext as Application, uriString ) ) ) CyclomaticComplexMethod:Prism4jThemes.kt$Prism4jThemeOneDark$private fun colorFor(type: String): Int? CyclomaticComplexMethod:Prism4jThemes.kt$Prism4jThemeOneLight$private fun colorFor(type: String): Int? - CyclomaticComplexMethod:ReaderContentView.kt$@Composable fun RenderedTextView( text: Any, textColor: Int, padding: PaddingValues, /** * Height of the chrome the reader draws underneath, added to the content's own top * padding *inside* the ScrollView. Kept out of [padding] deliberately: [padding] insets * the viewport, which resizes the scroll container and moves `maxScrollY`, and that is * the feedback loop this avoids. */ contentTopInset: Dp = 0.dp, // 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<Int>, scrollToOffset: Int?, onScrollChanged: (scrollY: Int, maxScrollY: Int) -> Unit, onScrollExtentChanged: (maxScrollY: Int) -> Unit, onScrollConsumed: () -> Unit, headings: List<HeadingItem>, onActiveHeadingChanged: (Int) -> Unit, isCodeBlockWrapEnabled: Boolean, selectionHighlightColor: Int, fontSizeSp: Float, lineHeight: Float, readingFont: ReadingFontPreference, codeFont: CodeFontPreference = CodeFontPreference.JetBrainsMono, isSourceCode: Boolean = false, textAlignment: TextAlignmentPreference, codeBlockBackgroundColor: Int ) + CyclomaticComplexMethod:ReaderContentView.kt$@Composable fun RenderedTextView( text: Any, textColor: Int, padding: PaddingValues, /** * Height of the chrome the reader draws underneath, added to the content's own top * padding *inside* the ScrollView. Kept out of [padding] deliberately: [padding] insets * the viewport, which resizes the scroll container and moves `maxScrollY`, and that is * the feedback loop this avoids. */ contentTopInset: Dp = 0.dp, // 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<Int>, scrollToOffset: Int?, onScrollChanged: (scrollY: Int, maxScrollY: Int) -> Unit, onScrollExtentChanged: (maxScrollY: Int) -> Unit, onScrollConsumed: () -> Unit, headings: List<HeadingItem>, onActiveHeadingChanged: (Int) -> Unit, isCodeBlockWrapEnabled: Boolean, selectionHighlightColor: Int, fontSizeSp: Float, lineHeight: Float, readingFont: ReadingFontPreference, codeFont: CodeFontPreference = CodeFontPreference.JetBrainsMono, isMonospaced: Boolean = false, textAlignment: TextAlignmentPreference, codeBlockBackgroundColor: Int ) CyclomaticComplexMethod:TableRenderer.kt$internal fun buildTableLayout( text: Spanned, context: Context, textColor: Int, fontSizeSp: Float, lineHeight: Float, readingFont: ReadingFontPreference, selectionHighlightColor: Int, density: Float ): TableLayout? CyclomaticComplexMethod:ViewerScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ViewerScreen( onOpenSettings: () -> Unit, onOpenEditor: (String) -> Unit = {}, uriString: String?, fileSaved: Boolean = false, onFileSavedConsumed: () -> Unit = {} ) CyclomaticComplexMethod:ViewerTheme.kt$fun resolveReaderColors( readerLightTheme: ReaderThemePreference, readerDarkTheme: ReaderThemePreference, dynamicLightScheme: ColorScheme?, dynamicDarkScheme: ColorScheme? ): Pair<ViewerColors, ViewerColors> CyclomaticComplexMethod:ViewerViewModel.kt$ViewerViewModel$fun loadUri(uriString: String, forceReload: Boolean = false) CyclomaticComplexMethod:ViewerViewModel.kt$ViewerViewModel$private fun applySearchQuery(query: String) - CyclomaticComplexMethod:ViewerViewModel.kt$ViewerViewModel.Companion$private fun detectCodeLanguage(fileName: String): String? EmptyCatchBlock:EditorViewModel.kt$EditorViewModel${ } LongMethod:ViewerViewModel.kt$ViewerViewModel$fun loadUri(uriString: String, forceReload: Boolean = false) - LongParameterList:ReaderTextStyle.kt$( context: Context, paddingPx: Int, fontSizeSp: Float, lineHeight: Float, readingFont: ReadingFontPreference, codeFont: CodeFontPreference, isSourceCode: Boolean, textAlignment: TextAlignmentPreference, textColor: Int, selectionHighlightColor: Int, horizontalScroll: Boolean ) + LongParameterList:ReaderTextStyle.kt$( context: Context, paddingPx: Int, fontSizeSp: Float, lineHeight: Float, readingFont: ReadingFontPreference, codeFont: CodeFontPreference, isMonospaced: Boolean, textAlignment: TextAlignmentPreference, textColor: Int, selectionHighlightColor: Int, horizontalScroll: Boolean ) LoopWithTooManyJumpStatements:ScrollAnchor.kt$for LoopWithTooManyJumpStatements:SearchHighlightTextView.kt$SearchHighlightTextView$for LoopWithTooManyJumpStatements:TableRenderer.kt$for - MagicNumber:EditorViewModel.kt$EditorViewModel$25 MagicNumber:HomeViewModel.kt$HomeViewModel$5_000 MagicNumber:MainActivity.kt$MainActivity$33 MagicNumber:MarkwonRenderer.kt$MarkwonRenderer$4f @@ -38,11 +36,8 @@ MagicNumber:TableRenderer.kt$8 MagicNumber:ViewerViewModel.kt$ViewerViewModel$1024 MagicNumber:ViewerViewModel.kt$ViewerViewModel$200 - MagicNumber:ViewerViewModel.kt$ViewerViewModel$3 MagicNumber:ViewerViewModel.kt$ViewerViewModel$300 MagicNumber:ViewerViewModel.kt$ViewerViewModel$400 - MagicNumber:ViewerViewModel.kt$ViewerViewModel$50 - MagicNumber:ViewerViewModel.kt$ViewerViewModel.Companion$25 MatchingDeclarationName:ExpressiveList.kt$SegmentPosition MatchingDeclarationName:ViewerTheme.kt$ViewerColors MaxLineLength:EditorScreen.kt$FormatIconButton(Icons.AutoMirrored.Filled.FormatListBulleted, "Bullet list") { viewModel.formatBullet() } @@ -56,7 +51,6 @@ ModifierMissing:ViewerChrome.kt$ContentContainer ModifierMissing:ViewerScreen.kt$ViewerScreen NestedBlockDepth:TableRenderer.kt$internal fun buildTableLayout( text: Spanned, context: Context, textColor: Int, fontSizeSp: Float, lineHeight: Float, readingFont: ReadingFontPreference, selectionHighlightColor: Int, density: Float ): TableLayout? - NestedBlockDepth:ViewerViewModel.kt$ViewerViewModel$private fun parseHeadings(markdown: String, renderedText: String?): List<HeadingItem> ParameterNaming:EditorScreen.kt$onFileSaved ParameterNaming:ReaderContentView.kt$onActiveHeadingChanged ParameterNaming:ReaderContentView.kt$onScrollChanged