From bbb0468ac00b003218cfdf43178fb3abd6582243 Mon Sep 17 00:00:00 2001 From: Usama <83345144+usamaiqb@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:01:30 +0500 Subject: [PATCH] refactor(format): add a document format registry with capabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new com.markreader.format holds DocumentFormat, FormatCapabilities, RenderTheme, ReaderContent and a FormatRegistry keyed on DocumentType, with Markdown, source-code and plain-text formats under it. The Markwon and Prism4j renderers move out of ui/markdown/ to sit with the formats that own them. The ten "is this Markdown" and "is this source code" tests in the viewer and the editor become capability reads, and isMarkdown leaves the codebase. Each format owns its renderer and its per-theme cache, so the editor preview and the viewer render through one Markwon configuration rather than two configured separately. DocumentFormat carries no type. Nothing asks a format what it reads, the registry's `when` is that mapping, and the same answer kept twice is an answer that drifts. The registry is that `when` rather than the list first sketched: precedence existed for recognition, formats do not recognise anything, and a list would carry a meaningless ordering plus a scan. Exhaustiveness is what protects us instead — adding a document type now fails to compile until a format is chosen for it. load() returns a nullable ReaderContent and renderFailureWarningRes is nullable as one decision: a format whose rendering is its source has nothing to fall back to and so no failure to describe. ReaderContent is defined here, with only Spans produced; the two places that narrow to it are what the reader surface's own retyping deletes. One user-visible change. Where a format has no raw view the chrome names the format instead of a view mode, because "Rendered mode" is untrue of a document whose rendering is its source, so a .txt chip reads "Plain text". One layer down, ReaderContentView's isSourceCode is isMonospaced: the render layer keeps no opinion about what a document is, only about how to draw it. Also fixes a stale-state bug. The failure path in loadUri never wrote documentType, so a binary file opened after a Markdown one still offered a table-of-contents button over empty headings. The detekt baseline is not regenerated: two entries re-keyed for the isSourceCode rename, six deleted as dead. --- .../com/markreader/format/DocumentFormat.kt | 66 ++++++ .../markreader/format/FormatCapabilities.kt | 28 +++ .../com/markreader/format/FormatRegistry.kt | 51 +++++ .../java/com/markreader/format/HeadingItem.kt | 13 ++ .../com/markreader/format/ReaderContent.kt | 36 ++++ .../java/com/markreader/format/RenderTheme.kt | 12 ++ .../markdown => format/code}/Prism4jThemes.kt | 2 +- .../code}/PrismGrammarLocator.java | 2 +- .../format/code/SourceCodeFormat.kt | 59 ++++++ .../code}/SourceCodeRenderer.kt | 2 +- .../{ui => format}/markdown/InlineCodeSpan.kt | 2 +- .../format/markdown/MarkdownFormat.kt | 113 ++++++++++ .../markdown/MarkwonRenderer.kt | 5 +- .../format/plaintext/PlainTextFormat.kt | 35 +++ .../ui/reader/ReaderContentController.kt | 4 +- .../markreader/ui/reader/ReaderContentView.kt | 18 +- .../markreader/ui/reader/ReaderTextStyle.kt | 12 +- .../com/markreader/ui/reader/ScrollAnchor.kt | 2 +- .../markreader/ui/reader/SegmentSplitter.kt | 4 +- .../com/markreader/ui/screens/EditorScreen.kt | 36 ++-- .../markreader/ui/screens/EditorViewModel.kt | 100 +++++---- .../com/markreader/ui/screens/ViewerScreen.kt | 12 +- .../markreader/ui/screens/ViewerViewModel.kt | 199 ++++++------------ app/src/main/res/values/strings.xml | 2 + .../markreader/format/FormatRegistryTest.kt | 120 +++++++++++ .../format/markdown/MarkdownHeadingsTest.kt | 87 ++++++++ .../markreader/ui/reader/ScrollAnchorTest.kt | 2 +- .../ui/reader/SegmentSplitterTest.kt | 4 +- config/detekt/baseline.xml | 10 +- 29 files changed, 803 insertions(+), 235 deletions(-) create mode 100644 app/src/main/java/com/markreader/format/DocumentFormat.kt create mode 100644 app/src/main/java/com/markreader/format/FormatCapabilities.kt create mode 100644 app/src/main/java/com/markreader/format/FormatRegistry.kt create mode 100644 app/src/main/java/com/markreader/format/HeadingItem.kt create mode 100644 app/src/main/java/com/markreader/format/ReaderContent.kt create mode 100644 app/src/main/java/com/markreader/format/RenderTheme.kt rename app/src/main/java/com/markreader/{ui/markdown => format/code}/Prism4jThemes.kt (99%) rename app/src/main/java/com/markreader/{ui/markdown => format/code}/PrismGrammarLocator.java (83%) create mode 100644 app/src/main/java/com/markreader/format/code/SourceCodeFormat.kt rename app/src/main/java/com/markreader/{ui/markdown => format/code}/SourceCodeRenderer.kt (98%) rename app/src/main/java/com/markreader/{ui => format}/markdown/InlineCodeSpan.kt (98%) create mode 100644 app/src/main/java/com/markreader/format/markdown/MarkdownFormat.kt rename app/src/main/java/com/markreader/{ui => format}/markdown/MarkwonRenderer.kt (96%) create mode 100644 app/src/main/java/com/markreader/format/plaintext/PlainTextFormat.kt create mode 100644 app/src/test/java/com/markreader/format/FormatRegistryTest.kt create mode 100644 app/src/test/java/com/markreader/format/markdown/MarkdownHeadingsTest.kt 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