From e16f01fe95c61dceaa25a233ea0303eccf9d1ce8 Mon Sep 17 00:00:00 2001 From: Usama <83345144+usamaiqb@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:15:20 +0500 Subject: [PATCH] refactor(reader): type the reader surface with ReaderContent The reader surface took `text: Any` and probed it with `is Spanned` at four sites. Retype it to the sealed `ReaderContent`, so the question becomes which of three known shapes it is rather than what it could possibly be. `ReaderContent.Spans` carries a `CharSequence` rather than a `Spanned`, because unstyled text is a real case and not a placeholder for one: the raw view and the plain-text format both hand over a `String`, and wrapping those in a `SpannableString` copies up to the maximum file size for no styling benefit. `PlainTextFormat` was paying that copy to satisfy the old signature and now hands its input straight through. `RenderedTextView` opens with an exhaustive `when` that yields the text to draw, so a shape it cannot render cannot gain a producer without a decision at that site. One `text as? Spanned` below it feeds every consumer that needs marker spans -- the span scans, split-mode detection and the split update pass -- which is where the narrowing genuinely belongs. Search keeps its own, for the same reason: counting matches and painting them with spans is a property of that implementation, not of the reader. Both casts at the format boundary go. The view models, the render cache and the editor preview all carry a `ReaderContent` now. The detekt baseline entry for `RenderedTextView` is re-keyed for its new parameter list rather than regenerated. --- .../com/markreader/format/ReaderContent.kt | 23 +++---- .../format/markdown/MarkdownFormat.kt | 2 +- .../format/plaintext/PlainTextFormat.kt | 5 +- .../ui/reader/ReaderContentController.kt | 2 +- .../markreader/ui/reader/ReaderContentView.kt | 60 +++++++++++-------- .../com/markreader/ui/screens/EditorScreen.kt | 2 +- .../markreader/ui/screens/EditorViewModel.kt | 5 +- .../com/markreader/ui/screens/ViewerScreen.kt | 11 ++-- .../markreader/ui/screens/ViewerViewModel.kt | 47 +++++++-------- .../format/plaintext/PlainTextFormatTest.kt | 56 +++++++++++++++++ config/detekt/baseline.xml | 2 +- 11 files changed, 144 insertions(+), 71 deletions(-) create mode 100644 app/src/test/java/com/markreader/format/plaintext/PlainTextFormatTest.kt diff --git a/app/src/main/java/com/markreader/format/ReaderContent.kt b/app/src/main/java/com/markreader/format/ReaderContent.kt index b5217d3..f0ae0f4 100644 --- a/app/src/main/java/com/markreader/format/ReaderContent.kt +++ b/app/src/main/java/com/markreader/format/ReaderContent.kt @@ -1,7 +1,5 @@ package com.markreader.format -import android.text.Spanned - /** * What a [DocumentFormat] produces, and the only thing the reader surface knows how to draw. * @@ -10,26 +8,31 @@ import android.text.Spanned * prose. The set is closed on purpose: three shapes, and a fourth format has to fit one of them * or argue for a fourth. * - * P3 defines it; the reader surface still takes `text: Any` and P4 is where that dies. So today - * every producer returns [Spans] and the view models unwrap it at one place each — which is the - * point of defining it a phase early rather than changing `load()`'s signature later. - * * Lives here rather than beside `ReaderContentView` so that the format layer never imports UI. * It moves to `render/` if and when that package is carved out with the dependency running that * way. */ sealed interface ReaderContent { /** - * A rendered document as HTML, for the WebView host to load. Produced from P6, when the + * A rendered document as HTML, for the WebView host to load. Produced from P6a, when the * shared renderer replaces Markwon. */ data class Html(val html: String) : ReaderContent /** - * A rendered document as styled text. The Markwon and Prism paths today; after P6 it - * survives for raw mode, which needs no renderer at all. + * A document as text, styled or not. + * + * [text] is a `CharSequence` rather than a `Spanned` because unstyled text is a real case, + * not a placeholder for one: raw mode and the plain-text format both hand over a `String`, + * and wrapping those in a `SpannableString` would copy up to `MAX_FILE_BYTES` for no styling + * benefit. Consumers that need markers — split mode, which reads them to give code blocks + * and tables their own scrollable panes — narrow to `Spanned` at the point of that + * requirement. + * + * The Markwon and Prism paths today; after P6a it survives for raw mode, which needs no + * renderer at all. */ - data class Spans(val spanned: Spanned) : ReaderContent + data class Spans(val text: CharSequence) : ReaderContent /** A tabular document, drawn by `TableRenderer`. Produced from P5, by the CSV format. */ data class Table(val rows: List>) : ReaderContent diff --git a/app/src/main/java/com/markreader/format/markdown/MarkdownFormat.kt b/app/src/main/java/com/markreader/format/markdown/MarkdownFormat.kt index 260b6f2..52f5747 100644 --- a/app/src/main/java/com/markreader/format/markdown/MarkdownFormat.kt +++ b/app/src/main/java/com/markreader/format/markdown/MarkdownFormat.kt @@ -63,7 +63,7 @@ class MarkdownFormat(private val newRenderer: (isDark: Boolean) -> MarkwonRender } override fun headings(text: String, content: ReaderContent): List = - parseHeadings(text, (content as? ReaderContent.Spans)?.spanned?.toString()) + parseHeadings(text, (content as? ReaderContent.Spans)?.text?.toString()) override fun invalidateRenderers() = renderers.clear() diff --git a/app/src/main/java/com/markreader/format/plaintext/PlainTextFormat.kt b/app/src/main/java/com/markreader/format/plaintext/PlainTextFormat.kt index ffefd8b..61bd559 100644 --- a/app/src/main/java/com/markreader/format/plaintext/PlainTextFormat.kt +++ b/app/src/main/java/com/markreader/format/plaintext/PlainTextFormat.kt @@ -1,6 +1,5 @@ package com.markreader.format.plaintext -import android.text.SpannableString import com.markreader.R import com.markreader.format.DocumentFormat import com.markreader.format.FormatCapabilities @@ -30,6 +29,8 @@ object PlainTextFormat : DocumentFormat { /** Nothing to fall back to: this format's rendering is its source. */ override val renderFailureWarningRes: Int? = null + // Handed over as-is. [ReaderContent.Spans] takes a CharSequence precisely so that a format + // with no styling to add does not copy the whole document into a Spannable to say so. override suspend fun load(text: String, theme: RenderTheme): ReaderContent = - ReaderContent.Spans(SpannableString(text)) + ReaderContent.Spans(text) } diff --git a/app/src/main/java/com/markreader/ui/reader/ReaderContentController.kt b/app/src/main/java/com/markreader/ui/reader/ReaderContentController.kt index 03aed0e..afc941e 100644 --- a/app/src/main/java/com/markreader/ui/reader/ReaderContentController.kt +++ b/app/src/main/java/com/markreader/ui/reader/ReaderContentController.kt @@ -68,7 +68,7 @@ internal class ReaderContentController( /** Content identity of the text currently applied to the views. */ var lastTextHash: Int = 0 - var lastTextRef: Any? = null + var lastTextRef: CharSequence? = null /** Styling currently applied to the views. */ var lastStyleKey: ContentKey = UNSTYLED diff --git a/app/src/main/java/com/markreader/ui/reader/ReaderContentView.kt b/app/src/main/java/com/markreader/ui/reader/ReaderContentView.kt index 3627c8d..1047c5b 100644 --- a/app/src/main/java/com/markreader/ui/reader/ReaderContentView.kt +++ b/app/src/main/java/com/markreader/ui/reader/ReaderContentView.kt @@ -31,6 +31,7 @@ import com.markreader.data.CodeFontPreference import com.markreader.data.ReadingFontPreference import com.markreader.data.TextAlignmentPreference import com.markreader.format.HeadingItem +import com.markreader.format.ReaderContent /** * A [ScrollView] that never scrolls itself to keep a focused descendant on screen. @@ -82,7 +83,7 @@ private class FocusStableScrollView(context: Context) : ScrollView(context) { @Composable fun RenderedTextView( - text: Any, + content: ReaderContent, textColor: Int, padding: PaddingValues, /** @@ -114,6 +115,14 @@ fun RenderedTextView( textAlignment: TextAlignmentPreference, codeBlockBackgroundColor: Int ) { + // The reader surface draws text and nothing else. The other two shapes have no producer + // yet — P6a hosts a WebView for [ReaderContent.Html], P5 draws [ReaderContent.Table] + // natively — and this `when` is exhaustive, so neither can start producing one without + // deciding here what the surface does with it. + val text: CharSequence = when (content) { + is ReaderContent.Spans -> content.text + is ReaderContent.Html, is ReaderContent.Table -> "" + } val contentKey = remember(text, fontSizeSp, lineHeight, readingFont, codeFont, isMonospaced, textAlignment) { ContentKey( textHash = text.hashCode(), @@ -125,13 +134,17 @@ fun RenderedTextView( textAlignment = textAlignment ) } + // Split mode gives code blocks and tables their own horizontally scrollable views, and it + // is driven by the marker spans the renderer left behind — so unstyled text can never be in + // it. That is the one place the narrowing genuinely belongs: not "what could this be", but + // "does this carry the markers the splitter reads". + val markers = text as? Spanned // Span scans over the whole document — cached per text, never per frame. - val docHasCodeBlocks = remember(text) { text is Spanned && hasCodeBlocks(text) } - val docHasTables = remember(text) { text is Spanned && hasTables(text) } - // Split mode gives code blocks and tables their own horizontally scrollable - // views. Only worth the cost when the document actually contains one. - val isSplitMode = remember(text, isCodeBlockWrapEnabled, docHasCodeBlocks, docHasTables) { - text is Spanned && ((!isCodeBlockWrapEnabled && docHasCodeBlocks) || docHasTables) + val docHasCodeBlocks = remember(markers) { markers != null && hasCodeBlocks(markers) } + val docHasTables = remember(markers) { markers != null && hasTables(markers) } + // Only worth the cost when the document actually contains one. + val isSplitMode = remember(markers, isCodeBlockWrapEnabled, docHasCodeBlocks, docHasTables) { + markers != null && ((!isCodeBlockWrapEnabled && docHasCodeBlocks) || docHasTables) } val restoreKey = remember(contentKey, isCodeBlockWrapEnabled) { RestoreKey(contentKey, isCodeBlockWrapEnabled) @@ -317,7 +330,7 @@ fun RenderedTextView( if (currentChild is LinearLayout) { // Split mode val container = currentChild - val spanned = text as Spanned + val spanned = markers ?: return@AndroidView val textRefChanged = spanned !== controller.lastTextRef val textHash = if (textRefChanged || controller.lastTextHash == 0) { spanned.toString().hashCode() @@ -522,24 +535,23 @@ fun RenderedTextView( // Single-TV mode val textView = currentChild as TextView - when (text) { - is Spanned -> { - if (text !== controller.lastTextRef) { - textView.text = text - controller.lastTextHash = text.toString().hashCode() - controller.lastTextRef = text - } + if (markers != null) { + // Spans can change while the string does not — a search highlight + // re-render is exactly that — so identity is the only safe test. + if (text !== controller.lastTextRef) { + textView.text = text + controller.lastTextHash = text.toString().hashCode() + controller.lastTextRef = text } - else -> { - if (text !== controller.lastTextRef) { - val textHash = text.hashCode() - if (textHash != controller.lastTextHash) { - textView.text = text.toString() - controller.lastTextHash = textHash - } - controller.lastTextRef = text - } + } else if (text !== controller.lastTextRef) { + // Nothing but characters, so equal content is the same render — and + // no Spannable is allocated to draw it. + val textHash = text.hashCode() + if (textHash != controller.lastTextHash) { + textView.text = text.toString() + controller.lastTextHash = textHash } + controller.lastTextRef = text } if (controller.lastStyleKey != contentKey) { applyStyleToTextView( 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 160ac98..fe7a35e 100644 --- a/app/src/main/java/com/markreader/ui/screens/EditorScreen.kt +++ b/app/src/main/java/com/markreader/ui/screens/EditorScreen.kt @@ -324,7 +324,7 @@ fun EditorScreen( val previewScrollY = remember { mutableIntStateOf(0) } Box(modifier = Modifier.weight(1f).fillMaxWidth()) { RenderedTextView( - text = previewText, + content = previewText, textColor = previewTextColor, padding = PaddingValues(0.dp), savedScrollY = previewScrollY, 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 63842c1..a9073e6 100644 --- a/app/src/main/java/com/markreader/ui/screens/EditorViewModel.kt +++ b/app/src/main/java/com/markreader/ui/screens/EditorViewModel.kt @@ -67,7 +67,7 @@ data class EditorUiState( val isModified: Boolean = false, val isSaving: Boolean = false, val saveResult: SaveResult? = null, - val previewText: android.text.Spanned? = null, + val previewText: ReaderContent? = null, /** Null once a document has loaded means it cannot be written back — see [SaveResult.UnknownEncoding]. */ val encoding: TextEncoding? = null, val activeTab: EditorTab = EditorTab.Edit, @@ -307,9 +307,8 @@ class EditorViewModel( } // The format owns the renderer and its per-theme cache, so the preview and the viewer // now render through one Markwon configuration rather than two built separately. - val content = format?.load(text, RenderTheme(isDark)) _uiState.value = _uiState.value.copy( - previewText = (content as? ReaderContent.Spans)?.spanned + previewText = format?.load(text, RenderTheme(isDark)) ) } 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 b8e53cc..3c5c695 100644 --- a/app/src/main/java/com/markreader/ui/screens/ViewerScreen.kt +++ b/app/src/main/java/com/markreader/ui/screens/ViewerScreen.kt @@ -119,6 +119,7 @@ import com.markreader.ui.components.SegmentPosition import com.markreader.ui.components.segmentPositionFor import com.markreader.ui.components.segmentShape import com.markreader.format.HeadingItem +import com.markreader.format.ReaderContent import com.markreader.ui.export.ExportManager import com.markreader.ui.resolve import com.markreader.ui.reader.RenderedTextView @@ -760,10 +761,12 @@ fun ViewerScreen( } } else -> { - val text = if (uiState.viewMode == ViewMode.Raw) { - uiState.rawHighlighted ?: uiState.rawText + // The fallback wraps the raw String rather than rendering it: no format + // produced anything, so there is nothing to draw but the characters. + val content = if (uiState.viewMode == ViewMode.Raw) { + uiState.rawHighlighted ?: ReaderContent.Spans(uiState.rawText) } else { - uiState.rendered ?: uiState.rawText + uiState.rendered ?: ReaderContent.Spans(uiState.rawText) } // A warning banner sits above the reader and outside its scroll // container, so it takes the inset itself and the reader starts below @@ -775,7 +778,7 @@ fun ViewerScreen( topInset = if (hasWarning) chromeTopInset else 0.dp ) { RenderedTextView( - text = text, + content = content, textColor = contentColor.toArgb(), padding = PaddingValues(0.dp), contentTopInset = if (hasWarning) 0.dp else chromeTopInset, 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 5fc5d8a..55fe377 100644 --- a/app/src/main/java/com/markreader/ui/screens/ViewerViewModel.kt +++ b/app/src/main/java/com/markreader/ui/screens/ViewerViewModel.kt @@ -52,8 +52,8 @@ data class ViewerUiState( val isLoadingVisible: Boolean = false, val fileName: String = "", val rawText: String = "", - val rawHighlighted: Spanned? = null, - val rendered: Spanned? = null, + val rawHighlighted: ReaderContent? = null, + val rendered: ReaderContent? = null, val errorMessage: UiMessage? = null, val warningMessage: UiMessage? = null, val isEmptyFile: Boolean = false, @@ -93,12 +93,12 @@ class ViewerViewModel( private var loadingJob: Job? = null private var rebuildJob: Job? = null private var searchJob: Job? = null - private var baseRendered: Spanned? = null + private var baseRendered: ReaderContent? = null private var renderDarkModeOverride: Boolean? = null private var preferencesLoaded = false private var pendingExternalRerender = initialUri != null private var renderCacheTextHash = 0 - private val renderCache = mutableMapOf() + private val renderCache = mutableMapOf() private val _uiState = MutableStateFlow(ViewerUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -234,7 +234,7 @@ class ViewerViewModel( val theme = RenderTheme( renderDarkModeOverride ?: isDarkTheme(_uiState.value.userPreferences, systemDarkTheme) ) - val loaded = format?.load(markdown, theme).asSpans() + val loaded = format?.load(markdown, theme) if (format == null || loaded == null) { loadingJob?.cancel() @@ -263,15 +263,15 @@ class ViewerViewModel( val headings = format.headings(markdown, loaded) loadingJob?.cancel() - baseRendered = loaded.spanned - renderCache[RenderCacheKey(documentType, theme)] = loaded.spanned + baseRendered = loaded + renderCache[RenderCacheKey(documentType, theme)] = loaded _uiState.value = _uiState.value.copy( isLoading = false, isLoadingVisible = false, fileName = fileName, rawText = markdown, rawHighlighted = null, - rendered = loaded.spanned, + rendered = loaded, errorMessage = null, warningMessage = null, headings = headings, @@ -449,10 +449,10 @@ class ViewerViewModel( if (tryApplyCachedRender(documentType, theme)) { return@launch } - val loaded = format.load(markdown, theme).asSpans() ?: return@launch - baseRendered = loaded.spanned - renderCache[RenderCacheKey(documentType, theme)] = loaded.spanned - _uiState.value = _uiState.value.copy(rendered = loaded.spanned) + val loaded = format.load(markdown, theme) ?: return@launch + baseRendered = loaded + renderCache[RenderCacheKey(documentType, theme)] = loaded + _uiState.value = _uiState.value.copy(rendered = loaded) applySearchQuery(_uiState.value.searchQuery) } } @@ -544,12 +544,18 @@ class ViewerViewModel( private fun applySearchQuery(query: String) { val currentState = _uiState.value val activeViewMode = currentState.viewMode + // Search is span-based: it counts matches in a string and paints them with spans. That + // is a requirement of this implementation, not of the reader, so the narrowing lives + // here rather than at the format boundary — a rendered document that is not text has + // no offsets to walk and gets no matches. P6a hands the rendered view's search to the + // WebView's own find-in-page and this path keeps raw mode. + val renderedSpans = (baseRendered as? ReaderContent.Spans)?.text val baseText = when (activeViewMode) { ViewMode.Raw -> currentState.rawText - ViewMode.Rendered -> baseRendered?.toString().orEmpty() + ViewMode.Rendered -> renderedSpans?.toString().orEmpty() } - if (activeViewMode == ViewMode.Rendered && baseRendered == null) { + if (activeViewMode == ViewMode.Rendered && renderedSpans == null) { _matchOffsets = emptyList() _uiState.value = currentState.copy( rawHighlighted = null, @@ -577,7 +583,7 @@ class ViewerViewModel( _matchOffsets = offsets val highlighted = when (activeViewMode) { - ViewMode.Rendered -> SpannableString(baseRendered) + ViewMode.Rendered -> SpannableString(renderedSpans) ViewMode.Raw -> SpannableString(baseText) } val prefs = _uiState.value.userPreferences @@ -639,14 +645,14 @@ class ViewerViewModel( _uiState.value = when (activeViewMode) { ViewMode.Rendered -> _uiState.value.copy( - rendered = highlighted, + rendered = ReaderContent.Spans(highlighted), rawHighlighted = null, searchMatchCount = offsets.size, searchMatchIndex = if (offsets.isEmpty()) 0 else activeIndex ) ViewMode.Raw -> _uiState.value.copy( rendered = baseRendered, - rawHighlighted = highlighted, + rawHighlighted = ReaderContent.Spans(highlighted), searchMatchCount = offsets.size, searchMatchIndex = if (offsets.isEmpty()) 0 else activeIndex ) @@ -684,10 +690,3 @@ class ViewerViewModel( } } } - -/** - * The reader surface still draws styled text and nothing else, so this is where the other two - * [ReaderContent] shapes stop. P4 retypes the surface and this narrowing goes with it — until - * then no format produces a [ReaderContent.Html] or [ReaderContent.Table] to lose. - */ -private fun ReaderContent?.asSpans(): ReaderContent.Spans? = this as? ReaderContent.Spans diff --git a/app/src/test/java/com/markreader/format/plaintext/PlainTextFormatTest.kt b/app/src/test/java/com/markreader/format/plaintext/PlainTextFormatTest.kt new file mode 100644 index 0000000..ae4f0af --- /dev/null +++ b/app/src/test/java/com/markreader/format/plaintext/PlainTextFormatTest.kt @@ -0,0 +1,56 @@ +package com.markreader.format.plaintext + +import com.markreader.format.ReaderContent +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The floor format, and the one that pins down why [ReaderContent.Spans] carries a + * `CharSequence` rather than a `Spanned`. + */ +class PlainTextFormatTest { + + private fun load(text: String): ReaderContent = + runBlocking { PlainTextFormat.load(text, com.markreader.format.RenderTheme(isDark = false)) } + + @Test + fun `the document is handed over without being copied`() { + val source = "plain text, no markup\nand a second line" + + val content = load(source) + + // Identity, not equality: a format with no styling to add must not copy the document + // into a Spannable to say so — at MAX_FILE_BYTES that copy is 10 MB. + assertSame(source, (content as ReaderContent.Spans).text) + } + + @Test + fun `an empty document still loads`() { + assertSame("", (load("") as ReaderContent.Spans).text) + } + + @Test + fun `rendering is the source, so there is no failure to warn about`() { + assertNull(PlainTextFormat.renderFailureWarningRes) + } + + @Test + fun `no capability survives without markup`() { + val capabilities = PlainTextFormat.capabilities + + assertFalse(capabilities.hasHeadings) + assertFalse(capabilities.hasRawView) + assertFalse(capabilities.hasEditorPreview) + assertFalse(capabilities.hasFormattingToolbar) + assertFalse(capabilities.isMonospaced) + } + + @Test + fun `headings are not offered, whatever the document says`() { + assertTrue(PlainTextFormat.headings("# Not a heading here", load("# Not a heading here")).isEmpty()) + } +} diff --git a/config/detekt/baseline.xml b/config/detekt/baseline.xml index 48b236f..22c1c92 100644 --- a/config/detekt/baseline.xml +++ b/config/detekt/baseline.xml @@ -8,7 +8,7 @@ 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, isMonospaced: Boolean = false, textAlignment: TextAlignmentPreference, codeBlockBackgroundColor: Int ) + CyclomaticComplexMethod:ReaderContentView.kt$@Composable fun RenderedTextView( content: ReaderContent, 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>