Arcology Engine

Org Document Renderer

Contents

The renderer package converts a parsed OrgDocument AST (from parser.org) into a tree of Compose @Composable functions. It is the rendering engine used by ReadOnlyView (in screen.org) and by QuizScreen (via RenderMode.BODY_ONLY).

Architecture

The rendering pipeline flows top-down:

  • OrgDocumentRenderer — public entry point, dispatches by RenderMode

  • SectionRenderer — renders a heading (via HeadingRenderer) and its body chunks (via BlockRenderer), recursively renders child sections

  • BlockRenderer — dispatches OrgChunk subtypes to specific renderers

  • InlineMarkupRenderer — renders inline text elements (bold, italic, links, clozes)

  • DrawerRenderer — renders :PROPERTIES:, :LOGBOOK:, and :REVIEW_DATA: drawers as collapsible widgets

Rendering Modes

Four RenderMode values select how the AST is traversed:

Mode Use case Renders
FULL_DOCUMENT ReadOnlyView editor Preamble, preface body, all sections recursively
SINGLE_NODE Narrowed view One section: heading + body, no children
NODE_WITH_CHILDREN Quiz card front One section: heading + body + nested children
BODY_ONLY Quiz card back Document preface body only (no headings)

Core Types

Three small files define the renderer's type vocabulary.

arcology.app.ui.components.renderer.RenderMode

kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/ui/components/renderer/RenderMode.kt
package computer.whatthefuck.arcology.app.ui.components.renderer

/**
 * Rendering mode for OrgDocumentRenderer.
 *
 * - FULL_DOCUMENT: Render entire document with preamble, preface, and all sections
 * - SINGLE_NODE: Render one section (heading + body only, no children)
 * - NODE_WITH_CHILDREN: Render one section (heading + body + nested child sections)
 * - BODY_ONLY: Render just body content (preface/chunks - for flashcards without headings)
 */
enum class RenderMode {
    FULL_DOCUMENT,
    SINGLE_NODE,
    NODE_WITH_CHILDREN,
    BODY_ONLY
}

/**
 * Fold state for headings.
 */
enum class HeadingFoldState {
    Expanded,      // Show heading + body + child headings
    HeadingsOnly,  // Show heading + child headings only (body hidden)
    Folded         // Show only heading
}

arcology.app.ui.components.renderer.ClozeState

kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/ui/components/renderer/ClozeState.kt
package computer.whatthefuck.arcology.app.ui.components.renderer

import computer.whatthefuck.arcology.domain.ClozeType

/**
 * State for cloze rendering in quiz context.
 * @param currentClozeId The ID of the current cloze being tested (null for non-quiz context)
 * @param isRevealed Whether the answer is revealed (true for back of card)
 * @param clozeType The cloze type driving which holes are visible/hidden.
 */
data class ClozeState(
    val currentClozeId: Int? = null,
    val isRevealed: Boolean = false,
    val clozeType: ClozeType? = null
)

arcology.app.ui.components.renderer.InlineMarkupPatterns

kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/ui/components/renderer/InlineMarkupPatterns.kt
package computer.whatthefuck.arcology.app.ui.components.renderer

/**
 * Regex patterns for inline markup (bold, italic, verbatim, code).
 *
 * IMPORTANT: These are the ONLY place where regex parsing is used in the new
 * OrgDocumentRenderer. All other parsing (links, clozes, blocks, lists, tables,
 * properties, etc.) is handled by orgmode-kmp's proper parser.
 *
 * These patterns apply styling to inline text markup like *bold*, /italic/, =verbatim=, ~code~
 * They match the org-mode syntax and are copied from the deprecated LinkParser.kt.
 */
internal val BOLD_PATTERN = Regex("""(?<=^|[\s({"'])\*([^*\n]+)\*(?=$|[\s)}.,:;!?"'])""")
internal val ITALIC_PATTERN = Regex("""(?<=^|[\s({"'])/([^/\n]+)/(?=$|[\s)}.,:;!?"'])""")
internal val VERBATIM_PATTERN = Regex("""(?<=^|[\s({"'])=([^=\n]+)=(?=$|[\s)}.,:;!?"'])""")
internal val CODE_PATTERN = Regex("""(?<=^|[\s({"'])~([^~\n]+)~(?=$|[\s)}.,:;!?"'])""")

LinkParser — Org-mode text parsing utilities

Legacy regex-based parsers for extracting links, drawers, blocks, headings, keywords, and clozes from raw org text. These predate the orgmode-kmp AST parser and are still used by the OrgBodyEditor (link autocomplete tap detection) and InlineMarkupRenderer (link rendering).

Design decision: kept alongside the AST renderer. The orgmode-kmp parser handles the document AST; these regex parsers handle raw text inline (e.g., detecting `label` in a single text field value without parsing the full document). They operate on String fragments, not OrgDocument.

These are in the ui.components package (not ui.components.renderer) because they're also consumed by OrgBodyEditor in the editor, which lives in the parent package.

arcology.app.ui.components.LinkParser

kotlin#+name: linkparser-data
data class ParsedLink(
    val startOffset: Int,
    val endOffset: Int,
    val protocol: String,  // "id", "http", "https", "file", etc.
    val target: String,    // The target (UUID for id links, URL for http/https, path for file)
    val displayText: String
) {
    val isHttpLink: Boolean get() = protocol == "http" || protocol == "https"
    val isIdLink: Boolean get() = protocol == "id"
}

data class ParsedImageLink(
    val startOffset: Int,
    val endOffset: Int,
    val filename: String,
    val displayText: String,
    val isFileLink: Boolean
)

data class ParsedDrawer(
    val startOffset: Int,
    val endOffset: Int,
    val name: String,
    val content: String
)

data class ParsedBlock(
    val startOffset: Int,
    val endOffset: Int,
    val type: String,        // "QUOTE", "SRC", etc.
    val language: String?,   // For src blocks: "kotlin", "python", etc.
    val content: String
)

enum class HeadingFoldState {
    Expanded,    // Show all children (default)
    HeadingsOnly, // Show only immediate child headings
    Folded       // Hide all children
}

data class ParsedHeading(
    val startOffset: Int,
    val endOffset: Int,
    val level: Int,
    val title: String,
    val parentId: String? = null  // ID of parent heading (level:startOffset)
)

data class ParsedKeyword(
    val startOffset: Int,
    val endOffset: Int,
    val key: String,
    val value: String
)

sealed class OrgContentSegment {
    data class PlainText(val text: String, val startOffset: Int) : OrgContentSegment()
    data class Heading(val heading: ParsedHeading) : OrgContentSegment()
    data class Drawer(val drawer: ParsedDrawer) : OrgContentSegment()
    data class Block(val block: ParsedBlock) : OrgContentSegment()
    data class Keyword(val keyword: ParsedKeyword) : OrgContentSegment()
}

data class ParsedCloze(
    val startOffset: Int,
    val endOffset: Int,
    val text: String,
    val hint: String?,
    val id: Int
)
kotlin#+name: linkparser-parsers
fun parseLinks(text: String): List<ParsedLink> {
    val links = mutableListOf<ParsedLink>()

    val pattern = Regex("""\[\[(id|https?|file|attachment):([^\]]+)\]\[([^\]]*)\]\]|\[\[(id|https?|file|attachment):([^\]]+)\]\]""")

    for (match in pattern.findAll(text)) {
        val protocol: String
        val target: String
        val displayText: String

        if (match.groupValues[1].isNotEmpty()) {
            protocol = match.groupValues[1]
            target = match.groupValues[2]
            displayText = match.groupValues[3].ifEmpty { target }
        } else {
            protocol = match.groupValues[4]
            target = match.groupValues[5]
            displayText = target
        }

        links.add(
            ParsedLink(
                startOffset = match.range.first,
                endOffset = match.range.last + 1,
                protocol = protocol,
                target = target,
                displayText = displayText
            )
        )
    }

    return links
}

fun parseDrawers(text: String): List<ParsedDrawer> {
    val drawers = mutableListOf<ParsedDrawer>()
    val pattern = Regex(
        """^:([A-Z_]+):\s*\n(.*?)^:END:\s*$""",
        setOf(RegexOption.MULTILINE, RegexOption.DOT_MATCHES_ALL)
    )
    for (match in pattern.findAll(text)) {
        drawers.add(
            ParsedDrawer(
                startOffset = match.range.first,
                endOffset = match.range.last + 1,
                name = match.groupValues[1],
                content = match.groupValues[2].trim()
            )
        )
    }
    return drawers
}

fun parseBlocks(text: String): List<ParsedBlock> {
    val blocks = mutableListOf<ParsedBlock>()
    val pattern = Regex(
        """^#\+(?:BEGIN|begin)_(\w+)(?:\s+(\S+))?\s*\n(.*?)^#\+(?:END|end)_\1\s*$""",
        setOf(RegexOption.MULTILINE, RegexOption.DOT_MATCHES_ALL)
    )
    for (match in pattern.findAll(text)) {
        val blockType = match.groupValues[1].uppercase()
        blocks.add(
            ParsedBlock(
                startOffset = match.range.first,
                endOffset = match.range.last + 1,
                type = blockType,
                language = if (blockType == "SRC") match.groupValues[2].ifEmpty { null } else null,
                content = match.groupValues[3].trimEnd()
            )
        )
    }
    return blocks
}

fun parseHeadings(text: String): List<ParsedHeading> {
    val headings = mutableListOf<ParsedHeading>()
    val pattern = Regex("""^(\*+)\s+(.*)$""", RegexOption.MULTILINE)
    for (match in pattern.findAll(text)) {
        headings.add(
            ParsedHeading(
                startOffset = match.range.first,
                endOffset = match.range.last + 1,
                level = match.groupValues[1].length,
                title = match.groupValues[2]
            )
        )
    }
    return headings
}

fun assignParentIds(headings: List<ParsedHeading>): List<ParsedHeading> {
    if (headings.isEmpty()) return emptyList()
    val result = mutableListOf<ParsedHeading>()
    val levelStack = mutableMapOf<Int, Pair<ParsedHeading, Int>>()
    for ((index, heading) in headings.withIndex()) {
        val parentId = levelStack
            .filterKeys { it < heading.level }
            .maxByOrNull { it.key }
        val parentHeading = parentId?.value?.first
        val parentIdStr = parentHeading?.let { "${it.level}:${it.startOffset}" }
        result.add(heading.copy(parentId = parentIdStr))
        levelStack[heading.level] = heading to index
    }
    return result
}

fun parseKeywords(text: String): List<ParsedKeyword> {
    val keywords = mutableListOf<ParsedKeyword>()
    val pattern = Regex("""^#\+([A-Za-z_]+):\s*(.*)$""", RegexOption.MULTILINE)
    for (match in pattern.findAll(text)) {
        val key = match.groupValues[1].uppercase()
        if (key.startsWith("BEGIN_") || key.startsWith("END_")) continue
        keywords.add(
            ParsedKeyword(
                startOffset = match.range.first,
                endOffset = match.range.last + 1,
                key = key,
                value = match.groupValues[2].trim()
            )
        )
    }
    return keywords
}

fun parseOrgContent(text: String): List<OrgContentSegment> {
    if (text.isEmpty()) return emptyList()
    data class Region(val start: Int, val end: Int, val segment: OrgContentSegment)
    val regions = mutableListOf<Region>()
    for (drawer in parseDrawers(text)) {
        regions.add(Region(drawer.startOffset, drawer.endOffset, OrgContentSegment.Drawer(drawer)))
    }
    for (block in parseBlocks(text)) {
        regions.add(Region(block.startOffset, block.endOffset, OrgContentSegment.Block(block)))
    }
    val headingsWithParents = assignParentIds(parseHeadings(text))
    for (heading in headingsWithParents) {
        regions.add(Region(heading.startOffset, heading.endOffset, OrgContentSegment.Heading(heading)))
    }
    for (keyword in parseKeywords(text)) {
        regions.add(Region(keyword.startOffset, keyword.endOffset, OrgContentSegment.Keyword(keyword)))
    }
    regions.sortBy { it.start }
    val filteredRegions = mutableListOf<Region>()
    for (region in regions) {
        val overlaps = filteredRegions.any { existing ->
            region.start < existing.end && region.end > existing.start
        }
        if (!overlaps) filteredRegions.add(region)
    }
    val segments = mutableListOf<OrgContentSegment>()
    var currentPos = 0
    for (region in filteredRegions) {
        if (region.start > currentPos) {
            val plainText = text.substring(currentPos, region.start)
            if (plainText.isNotBlank()) segments.add(OrgContentSegment.PlainText(plainText, currentPos))
        }
        segments.add(region.segment)
        currentPos = region.end
    }
    if (currentPos < text.length) {
        val plainText = text.substring(currentPos)
        if (plainText.isNotBlank()) segments.add(OrgContentSegment.PlainText(plainText, currentPos))
    }
    return segments
}

private val imageExtensions = setOf(
    "jpg", "jpeg", "png", "gif", "webp", "svg", "heic", "heif", "bmp", "tiff", "tif"
)

fun parseImageLinks(text: String): List<ParsedImageLink> {
    val links = mutableListOf<ParsedImageLink>()
    val attachmentPattern = Regex("""\[\[attachment:([^\]]+)\]\]""")
    for (match in attachmentPattern.findAll(text)) {
        val filename = match.groupValues[1]
        links.add(ParsedImageLink(
            startOffset = match.range.first, endOffset = match.range.last + 1,
            filename = filename, displayText = filename, isFileLink = false
        ))
    }
    val filePattern = Regex("""\[\[file:([^\]]+\.(?:${imageExtensions.joinToString("|")}))\]\]""")
    for (match in filePattern.findAll(text)) {
        val fullPath = match.groupValues[1]
        val filename = fullPath.substringAfterLast("/")
        links.add(ParsedImageLink(
            startOffset = match.range.first, endOffset = match.range.last + 1,
            filename = filename, displayText = fullPath, isFileLink = true
        ))
    }
    return links
}

fun parseClozes(text: String): List<ParsedCloze> {
    val clozes = mutableListOf<ParsedCloze>()
    val pattern = Regex("""\{\{([^{}]+)\}(?:\{([^{}]*)\})?@(\d+)\}""")
    for (match in pattern.findAll(text)) {
        clozes.add(ParsedCloze(
            startOffset = match.range.first, endOffset = match.range.last + 1,
            text = match.groupValues[1],
            hint = match.groupValues[2].takeIf { it.isNotEmpty() },
            id = match.groupValues[3].toInt()
        ))
    }
    return clozes
}

internal fun String.stripLinksToSourceOnly(): String {
    val links = parseLinks(this)
    if (links.isEmpty()) return this
    var result = this
    links.reversed().forEach { link ->
        val oldFormat = "[[${link.protocol}:${link.target}][${link.displayText}]]"
        val newFormat = "[[${link.displayText}]]"
        val index = result.lastIndexOf(oldFormat)
        if (index != -1) {
            result = result.substring(0, index) + newFormat + result.substring(index + oldFormat.length)
        }
    }
    return result
}
kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/ui/components/LinkParser.kt:noweb yes
package computer.whatthefuck.arcology.app.ui.components

<<linkparser-data>>
<<linkparser-parsers>>

OrgStringBuilder — Inline markup annotation builder

Builds AnnotatedString objects from raw org text by applying inline markup styles (bold, italic, verbatim, code, links, _underline_, +strikethrough+) and adding clickable string annotations for node IDs and URLs. The core function appendProcessedSegment is the shared building block used by InlineMarkupRenderer (in the renderer).

Design decision: same-package internal visibility. InlineMarkupConfig and appendProcessedSegment are marked internal in the ui.components package, accessible to siblings (OrgBodyEditor) and the renderer subpackage via import. This avoids a public API surface for what is fundamentally an internal implementation detail.

arcology.app.ui.components.OrgStringBuilder

kotlin#+name: orgstringbuilder-full
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration

internal data class InlineMarkupConfig(
    val linkColor: Color,
    val textColor: Color = Color.Unspecified,
    val verbatimColor: Color,
    val codeColor: Color,
    val onSurfaceVariant: Color = Color.Unspecified,
    val monospaceFont: FontFamily = FontFamily.Monospace
)

internal fun buildOrgAnnotatedString(
    text: String,
    config: InlineMarkupConfig
): AnnotatedString {
    return buildAnnotatedString {
        appendProcessedSegment(text, config)
    }
}

internal fun AnnotatedString.Builder.appendProcessedSegment(
    text: String,
    config: InlineMarkupConfig
) {
    var remaining = text
    val verbatimPattern = Regex("""(?<=^|[\s({"'])=([^=\n]+)=(?=$|[\s)}.,:;!?"'])""")
    val codePattern = Regex("""(?<=^|[\s({"'])~([^~\n]+)~(?=$|[\s)}.,:;!?"'])""")

    while (true) {
        val verbatimMatch = verbatimPattern.find(remaining)
        val codeMatch = codePattern.find(remaining)

        val match = when {
            verbatimMatch != null && codeMatch != null ->
                if (verbatimMatch.range.first < codeMatch.range.first) verbatimMatch else codeMatch
            verbatimMatch != null -> verbatimMatch
            codeMatch != null -> codeMatch
            else -> break
        }

        val isVerbatim = match == verbatimMatch
        val matchedText = match.groupValues[1]

        append(processLinksAndFormatting(remaining.substring(0, match.range.first), config))

        pushStyle(SpanStyle(
            fontFamily = config.monospaceFont,
            color = if (isVerbatim) config.verbatimColor else config.codeColor
        ))
        append(matchedText)
        pop()

        remaining = remaining.substring(match.range.last + 1)
    }

    append(processLinksAndFormatting(remaining, config))
}

internal fun processLinksAndFormatting(
    text: String,
    config: InlineMarkupConfig
): AnnotatedString {
    return buildAnnotatedString {
        var remaining = text

        val linkPattern = Regex("""\[\[([^\]]+)\](?:\[([^\]]+)\])?\]""")
        var linkMatch: MatchResult? = linkPattern.find(remaining)

        while (linkMatch != null) {
            val target = linkMatch.groupValues[1]
            val description = linkMatch.groupValues[2].takeIf { it.isNotEmpty() } ?: target

            append(applyInlineFormatting(remaining.substring(0, linkMatch.range.first), config))

            val isInternalLink = target.startsWith("id:")
            val nodeId = if (isInternalLink) target.removePrefix("id:") else null

            val start = this.length
            pushStyle(SpanStyle(
                color = config.linkColor,
                textDecoration = TextDecoration.Underline,
                fontWeight = FontWeight.Medium
            ))
            append(description)
            pop()
            val end = this.length

            if (nodeId != null) {
                addStringAnnotation("NODE_ID", nodeId, start, end)
            } else {
                val url = if (target.contains(":")) target else "https://$target"
                addStringAnnotation("URL", url, start, end)
            }

            remaining = remaining.substring(linkMatch.range.last + 1)
            linkMatch = linkPattern.find(remaining)
        }

        append(applyInlineFormatting(remaining, config))
    }
}

internal fun applyInlineFormatting(
    text: String,
    config: InlineMarkupConfig
): AnnotatedString {
    return buildAnnotatedString {
        var remaining = text

        val patterns = listOf(
            Regex("""(?<=^|[\s({"'])\*([^*\n]+)\*(?=$|[\s)}.,:;!?"'])""") to SpanStyle(fontWeight = FontWeight.Bold),
            Regex("""(?<=^|[\s({"'])/([^/\n]+)/(?=$|[\s)}.,:;!?"'])""") to SpanStyle(fontStyle = FontStyle.Italic),
            Regex("""(?<=^|[\s({"'])_([^_\n]+)_(?=$|[\s)}.,:;!?"'])""") to SpanStyle(textDecoration = TextDecoration.Underline),
            Regex("""(?<=^|[\s({"'])\+([^+\n]+)\+(?=$|[\s)}.,:;!?"'])""") to SpanStyle(textDecoration = TextDecoration.LineThrough)
        )

        while (true) {
            var earliestMatch: MatchResult? = null
            var earliestPattern: SpanStyle? = null

            for ((pattern, style) in patterns) {
                val match = pattern.find(remaining)
                if (match != null) {
                    if (earliestMatch == null || match.range.first < earliestMatch.range.first) {
                        earliestMatch = match
                        earliestPattern = style
                    }
                }
            }

            if (earliestMatch == null) break

            append(applyInlineFormatting(remaining.substring(0, earliestMatch.range.first), config))

            pushStyle(earliestPattern!!)
            append(applyInlineFormatting(earliestMatch.groupValues[1], config))
            pop()

            remaining = remaining.substring(earliestMatch.range.last + 1)
        }

        append(remaining)
    }
}
kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/ui/components/OrgStringBuilder.kt:noweb yes
package computer.whatthefuck.arcology.app.ui.components

<<orgstringbuilder-full>>

InlineMarkupRenderer

Handles inline text styling and tap-to-link detection. The buildAnnotatedStringWithMarkup function applies SpanStyle to bold, italic, =verbatim==, and code markup, plus cloze annotations. Link taps are detected via pointerInput coordinate lookup against TextLayoutResult.

arcology.app.ui.components.renderer.InlineMarkupRenderer

kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/ui/components/renderer/InlineMarkupRenderer.kt
package computer.whatthefuck.arcology.app.ui.components.renderer

import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import computer.whatthefuck.arcology.app.ui.components.InlineMarkupConfig
import computer.whatthefuck.arcology.app.ui.components.appendProcessedSegment
import computer.whatthefuck.arcology.app.ui.theme.VulfMono
import computer.whatthefuck.arcology.app.ui.theme.VulfMonoLightItalic
import computer.whatthefuck.arcology.domain.ClozeType

@Composable
fun renderInlineMarkup(
    text: String,
    onLinkClick: (String) -> Unit,
    clozeState: ClozeState? = null,
    baseFontSize: Float? = null
) {
    val uriHandler = LocalUriHandler.current

    val colors = MarkupColors(
        primary = MaterialTheme.colorScheme.primary,
        secondary = MaterialTheme.colorScheme.secondary,
        tertiary = MaterialTheme.colorScheme.tertiary,
        onSurface = MaterialTheme.colorScheme.onSurface,
        onSurfaceVariant = MaterialTheme.colorScheme.onSurfaceVariant
    )
    val annotatedString = remember(text, clozeState, colors) {
        buildAnnotatedStringWithMarkup(
            text = text,
            clozeState = clozeState,
            colors = colors
        )
    }

    val textLayoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }

    Text(
        text = annotatedString,
        style = MaterialTheme.typography.bodyMedium.copy(
            fontFamily = VulfMonoLightItalic,
            color = colors.onSurface,
            fontSize = baseFontSize?.sp ?: MaterialTheme.typography.bodyMedium.fontSize,
            lineHeight = baseFontSize?.sp ?: MaterialTheme.typography.bodyMedium.fontSize,
            platformStyle = PlatformTextStyle(includeFontPadding = false)
        ),
        onTextLayout = { textLayoutResult.value = it },
        modifier = Modifier.pointerInput(annotatedString, onLinkClick, uriHandler) {
            awaitEachGesture {
                val downEvent = awaitFirstDown(pass = PointerEventPass.Main)
                val upEvent = waitForUpOrCancellation(pass = PointerEventPass.Main)
                if (upEvent != null && !downEvent.isConsumed) {
                    textLayoutResult.value?.let { layout ->
                        val charOffset = layout.getOffsetForPosition(upEvent.position)
                        annotatedString.getStringAnnotations(start = charOffset, end = charOffset + 1)
                            .firstOrNull()?.let { annotation ->
                                when (annotation.tag) {
                                    "NODE_ID" -> {
                                        onLinkClick(annotation.item)
                                        upEvent.consume()
                                    }
                                    "URL" -> {
                                        uriHandler.openUri(annotation.item)
                                        upEvent.consume()
                                    }
                                }
                            }
                    }
                }
            }
        }
    )
}

private data class MarkupColors(
    val primary: Color,
    val secondary: Color,
    val tertiary: Color,
    val onSurface: Color,
    val onSurfaceVariant: Color
)

private fun buildAnnotatedStringWithMarkup(
    text: String,
    clozeState: ClozeState?,
    colors: MarkupColors
): AnnotatedString {
    val linkConfig = colors.toLinkConfig()

    return buildAnnotatedString {
        var workingText = text

        val clozePattern = Regex("""\{\{([^{}]+)\}(?:\{([^{}]*)\})?@(\d+)\}""")
        var clozeMatch: MatchResult? = clozePattern.find(workingText)
        while (clozeMatch != null) {
            val clozeText = clozeMatch.groupValues[1]
            val hint = clozeMatch.groupValues[2]
            val id = clozeMatch.groupValues[3].toInt()

            val isCurrentCloze = clozeState?.currentClozeId == id
            val isRevealed = isCurrentCloze && clozeState!!.isRevealed
            val isInQuiz = clozeState?.currentClozeId != null

            val effectiveClozeType = clozeState?.clozeType ?: ClozeType.DELETION
            val shouldHide = if (!isInQuiz) {
                false
            } else {
                when (effectiveClozeType) {
                    ClozeType.DELETION -> isCurrentCloze
                    ClozeType.ENUMERATION -> id >= clozeState!!.currentClozeId!!
                    ClozeType.SINGLE -> isCurrentCloze
                    ClozeType.CONTEXT -> isCurrentCloze
                }
            }

            val displayText = when {
                isRevealed -> clozeText
                shouldHide -> "[...]"
                else -> clozeText
            }

            appendProcessedSegment(workingText.substring(0, clozeMatch.range.first), linkConfig)

            when {
                isRevealed -> {
                    pushStyle(SpanStyle(
                        color = colors.onSurface,
                        fontFamily = VulfMonoLightItalic
                    ))
                    append(displayText)
                    pop()
                }
                shouldHide -> {
                    if (isCurrentCloze) {
                        pushStyle(SpanStyle(
                            color = colors.primary,
                            fontFamily = VulfMono,
                            background = colors.primary.copy(alpha = 0.1f)
                        ))
                    } else {
                        pushStyle(SpanStyle(
                            color = colors.onSurfaceVariant.copy(alpha = 0.5f),
                            fontFamily = VulfMono
                        ))
                    }
                    append(displayText)
                    pop()

                    if (isCurrentCloze && hint.isNotEmpty()) {
                        pushStyle(SpanStyle(
                            color = colors.tertiary,
                            fontFamily = VulfMono,
                            fontSize = 11.sp,
                            fontWeight = FontWeight.SemiBold
                        ))
                        append(" (hint: $hint)")
                        pop()
                    }
                }
                else -> {
                    pushStyle(SpanStyle(
                        color = colors.onSurface,
                        fontFamily = VulfMonoLightItalic
                    ))
                    append(displayText)
                    pop()
                }
            }

            workingText = workingText.substring(clozeMatch.range.last + 1)
            clozeMatch = clozePattern.find(workingText)
        }
        appendProcessedSegment(workingText, linkConfig)
    }
}

private fun MarkupColors.toLinkConfig() = InlineMarkupConfig(
    linkColor = primary,
    textColor = onSurface,
    verbatimColor = secondary,
    codeColor = tertiary,
    onSurfaceVariant = onSurfaceVariant,
    monospaceFont = VulfMono
)

DrawerRenderer

Renders :LOGBOOK: and :REVIEW_DATA: drawers as collapsed/expandable chips. LogbookDrawerRenderer formats state change and clock entries with UTC timestamps. ReviewDataDrawerRenderer displays raw drawer content in monospace.

arcology.app.ui.components.renderer.DrawerRenderer

kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/ui/components/renderer/DrawerRenderer.kt
@file:OptIn(kotlin.time.ExperimentalTime::class)
package computer.whatthefuck.arcology.app.ui.components.renderer

import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.*
import androidx.compose.ui.graphics.*
import androidx.compose.ui.text.*
import androidx.compose.ui.unit.*
import computer.whatthefuck.arcology.app.ui.theme.VulfMono
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
import xyz.lepisma.orgmode.*

private fun formatInstant(instant: Instant): String {
    val localDateTime = instant.toLocalDateTime(TimeZone.UTC)
    return "${localDateTime.year}-${localDateTime.monthNumber.toString().padStart(2, '0')}-${localDateTime.dayOfMonth.toString().padStart(2, '0')} ${localDateTime.hour.toString().padStart(2, '0')}:${localDateTime.minute.toString().padStart(2, '0')}"
}

@Composable
fun LogbookDrawerRenderer(drawer: OrgChunk.OrgLogbookDrawer) {
    var expanded by remember { mutableStateOf(false) }
    
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(vertical = 2.dp)
    ) {
        // Collapsed header chip
        Text(
            text = if (expanded) ":LOGBOOK:" else ":LOGBOOK:...",
            style = MaterialTheme.typography.bodySmall.copy(
                fontFamily = VulfMono,
                color = MaterialTheme.colorScheme.onSurfaceVariant
            ),
            modifier = Modifier
                .clip(RoundedCornerShape(4.dp))
                .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
                .clickable { expanded = !expanded }
                .padding(horizontal = 8.dp, vertical = 4.dp)
        )
        
        // Expanded content
        AnimatedVisibility(
            visible = expanded,
            enter = expandVertically(),
            exit = shrinkVertically()
        ) {
            Column(
                modifier = Modifier
                    .padding(start = 16.dp, top = 4.dp)
            ) {
                drawer.entries.forEach { entry ->
                    val entryText = when (entry) {
                        is xyz.lepisma.orgmode.OrgLogbookEntry.StateChange ->
                            "- State \"${entry.toState}\" from \"${entry.fromState}\" [${formatInstant(entry.timestamp)}]"
                        is xyz.lepisma.orgmode.OrgLogbookEntry.ClockEntry ->
                            "CLOCK: [${formatInstant(entry.startTime)}]${entry.endTime?.let { "--[${formatInstant(it)}]" } ?: ""}${entry.duration?.let { " => $it" } ?: ""}"
                    }
                    Text(
                        text = entryText,
                        style = MaterialTheme.typography.bodySmall.copy(
                            fontFamily = VulfMono,
                            color = MaterialTheme.colorScheme.onSurfaceVariant
                        )
                    )
                }
                Text(
                    text = ":END:",
                    style = MaterialTheme.typography.bodySmall.copy(
                        fontFamily = VulfMono,
                        color = MaterialTheme.colorScheme.onSurfaceVariant
                    ),
                    modifier = Modifier
                        .clip(RoundedCornerShape(4.dp))
                        .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
                        .padding(horizontal = 8.dp, vertical = 4.dp)
                )
            }
        }
    }
}

@Composable
fun ReviewDataDrawerRenderer(drawer: OrgChunk.OrgReviewDataDrawer) {
    var expanded by remember { mutableStateOf(false) }
    
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(vertical = 2.dp)
    ) {
        // Collapsed header chip
        Text(
            text = if (expanded) ":${drawer.drawerName}:" else ":${drawer.drawerName}:...",
            style = MaterialTheme.typography.bodySmall.copy(
                fontFamily = VulfMono,
                color = MaterialTheme.colorScheme.onSurfaceVariant
            ),
            modifier = Modifier
                .clip(RoundedCornerShape(4.dp))
                .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
                .clickable { expanded = !expanded }
                .padding(horizontal = 8.dp, vertical = 4.dp)
        )
        
        // Expanded content
        AnimatedVisibility(
            visible = expanded,
            enter = expandVertically(),
            exit = shrinkVertically()
        ) {
            Column(
                modifier = Modifier
                    .padding(start = 16.dp, top = 4.dp)
                    .horizontalScroll(rememberScrollState())
            ) {
                drawer.content.forEach { line ->
                    Text(
                        text = line,
                        style = MaterialTheme.typography.bodySmall.copy(
                            fontFamily = VulfMono,
                            color = MaterialTheme.colorScheme.onSurfaceVariant
                        ),
                        softWrap = false
                    )
                }
                Text(
                    text = ":END:",
                    style = MaterialTheme.typography.bodySmall.copy(
                        fontFamily = VulfMono,
                        color = MaterialTheme.colorScheme.onSurfaceVariant
                    ),
                    modifier = Modifier
                        .clip(RoundedCornerShape(4.dp))
                        .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
                        .padding(horizontal = 8.dp, vertical = 4.dp)
                )
            }
        }
    }
}

BlockRenderer

Renders org block types (#+begin_src, #+begin_quote, #+begin_example, tables, lists, etc.) as Material 3 composables. Source blocks use a dark-background monospace box. Lists render with proper markers (bullets, numbers, checkboxes). Nested lists are indented recursively.

arcology.app.ui.components.renderer.BlockRenderer

kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/ui/components/renderer/BlockRenderer.kt
package computer.whatthefuck.arcology.app.ui.components.renderer

import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.*
import androidx.compose.foundation.text.BasicText
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CheckBoxOutlineBlank
import androidx.compose.material.icons.filled.IndeterminateCheckBox
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.*
import androidx.compose.ui.graphics.*
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.*
import androidx.compose.ui.unit.*
import computer.whatthefuck.arcology.app.ui.theme.VulfMono
import computer.whatthefuck.arcology.app.ui.theme.VulfMonoLightItalic
import xyz.lepisma.orgmode.*

@Composable
fun SourceBlockRenderer(block: OrgBlock.OrgSourceBlock, baseFontSize: Float?) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(vertical = 4.dp)
    ) {
        // BEGIN_SRC marker with language
        Text(
            text = "#+begin_src${block.language.ifEmpty { "" }.let { " $it" }}",
            style = MaterialTheme.typography.bodySmall.copy(
                fontFamily = VulfMono,
                color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
            )
        )
        
        // Source content
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .padding(vertical = 4.dp)
                .background(
                    Color(0xFF1E1E1E).copy(alpha = 0.1f),
                    RoundedCornerShape(4.dp)
                )
                .padding(12.dp)
        ) {
            BasicText(
                text = block.body,
                style = MaterialTheme.typography.bodyMedium.copy(
                    fontFamily = VulfMono,
                    color = MaterialTheme.colorScheme.onSurface,
                    fontSize = baseFontSize?.sp ?: MaterialTheme.typography.bodyMedium.fontSize
                )
            )
        }
        
        // END_SRC marker
        Text(
            text = "#+end_src",
            style = MaterialTheme.typography.bodySmall.copy(
                fontFamily = VulfMono,
                color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
            )
        )
    }
}

@Composable
fun QuoteBlockRenderer(
    block: OrgBlock.OrgQuoteBlock,
    baseFontSize: Float?,
    onLinkClick: (String) -> Unit = {},
    clozeState: ClozeState? = null
) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(vertical = 4.dp)
            .background(
                MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.2f),
                RoundedCornerShape(4.dp)
            )
            .padding(horizontal = 12.dp, vertical = 8.dp)
    ) {
        // BEGIN_QUOTE marker
        Text(
            text = "#+BEGIN_QUOTE",
            style = MaterialTheme.typography.bodySmall.copy(
                fontFamily = VulfMono,
                color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
            )
        )
        
        // Quote content
        block.body.forEachIndexed { index, chunk ->
            when (chunk) {
                is OrgChunk.OrgParagraph -> {
                    val text = chunk.plainText()
                    renderInlineMarkup(
                        text = text,
                        onLinkClick = onLinkClick,
                        clozeState = clozeState,
                        baseFontSize = baseFontSize
                    )
                }
                is OrgList.OrgUnorderedList -> {
                    ListRenderer(
                        list = chunk,
                        onLinkClick = onLinkClick,
                        clozeState = clozeState,
                        baseFontSize = baseFontSize
                    )
                }
                is OrgList.OrgOrderedList -> {
                    ListRenderer(
                        list = chunk,
                        onLinkClick = onLinkClick,
                        clozeState = clozeState,
                        baseFontSize = baseFontSize
                    )
                }
                else -> {
                }
            }
            if (index < block.body.size - 1) {
                Spacer(modifier = Modifier.height(4.dp))
            }
        }
        
        Text(
            text = "#+END_QUOTE",
            style = MaterialTheme.typography.bodySmall.copy(
                fontFamily = VulfMono,
                color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
            )
        )
    }
}

@Composable
fun ExampleBlockRenderer(block: OrgBlock.OrgExampleBlock, baseFontSize: Float?) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(vertical = 4.dp)
    ) {
        Text(
            text = "#+BEGIN_EXAMPLE",
            style = MaterialTheme.typography.bodySmall.copy(
                fontFamily = VulfMono,
                color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
            )
        )
        
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .padding(vertical = 4.dp)
                .background(
                    Color(0xFF1E1E1E).copy(alpha = 0.1f),
                    RoundedCornerShape(4.dp)
                )
                .padding(12.dp)
        ) {
            BasicText(
                text = block.text,
                style = MaterialTheme.typography.bodyMedium.copy(
                    fontFamily = VulfMono,
                    color = MaterialTheme.colorScheme.onSurface,
                    fontSize = baseFontSize?.sp ?: MaterialTheme.typography.bodyMedium.fontSize
                )
            )
        }
        
        Text(
            text = "#+END_EXAMPLE",
            style = MaterialTheme.typography.bodySmall.copy(
                fontFamily = VulfMono,
                color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
            )
        )
    }
}

@Composable
fun VerseBlockRenderer(
    block: OrgBlock.OrgVerseBlock,
    baseFontSize: Float?,
    onLinkClick: (String) -> Unit = {},
    clozeState: ClozeState? = null
) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(vertical = 4.dp)
            .background(
                MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f),
                RoundedCornerShape(4.dp)
            )
            .padding(horizontal = 12.dp, vertical = 8.dp)
    ) {
        Text(
            text = "#+BEGIN verse",
            style = MaterialTheme.typography.bodySmall.copy(
                fontFamily = VulfMono,
                color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
            )
        )
        
        renderInlineMarkup(
            text = block.body,
            onLinkClick = onLinkClick,
            clozeState = clozeState,
            baseFontSize = baseFontSize
        )
        
        Text(
            text = "#+END verse",
            style = MaterialTheme.typography.bodySmall.copy(
                fontFamily = VulfMono,
                color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
            )
        )
    }
}

@Composable
fun GenericBlockRenderer(
    block: OrgBlock.OrgAsideBlock,
    baseFontSize: Float?,
    onLinkClick: (String) -> Unit = {},
    clozeState: ClozeState? = null
) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(vertical = 4.dp)
            .background(
                MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.1f),
                RoundedCornerShape(4.dp)
            )
            .padding(horizontal = 12.dp, vertical = 8.dp)
    ) {
        Text(
            text = "#+BEGIN_ASIDE",
            style = MaterialTheme.typography.bodySmall.copy(
                fontFamily = VulfMono,
                color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
            )
        )
        
        block.body.forEachIndexed { index, chunk ->
            when (chunk) {
                is OrgChunk.OrgParagraph -> {
                    val text = chunk.plainText()
                    renderInlineMarkup(
                        text = text,
                        onLinkClick = onLinkClick,
                        clozeState = clozeState,
                        baseFontSize = baseFontSize
                    )
                }
                is OrgList.OrgUnorderedList -> {
                    ListRenderer(
                        list = chunk,
                        onLinkClick = onLinkClick,
                        clozeState = clozeState,
                        baseFontSize = baseFontSize
                    )
                }
                is OrgList.OrgOrderedList -> {
                    ListRenderer(
                        list = chunk,
                        onLinkClick = onLinkClick,
                        clozeState = clozeState,
                        baseFontSize = baseFontSize
                    )
                }
                else -> {
                }
            }
            if (index < block.body.size - 1) {
                Spacer(modifier = Modifier.height(4.dp))
            }
        }
        
        Text(
            text = "#+END_ASIDE",
            style = MaterialTheme.typography.bodySmall.copy(
                fontFamily = VulfMono,
                color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
            )
        )
    }
}

@Composable
fun KeywordRenderer(keyword: OrgChunk.OrgKeywordLine) {
    if (keyword.keyword.equals("TITLE", ignoreCase = true)) {
        Text(
            text = keyword.value,
            style = MaterialTheme.typography.bodyMedium.copy(
                fontWeight = FontWeight.Bold,
                color = MaterialTheme.colorScheme.primary
            ),
            modifier = Modifier.padding(vertical = 8.dp)
        )
    } else {
        Text(
            text = "#+${keyword.keyword}: ${keyword.value}",
            style = MaterialTheme.typography.bodySmall.copy(
                fontFamily = VulfMono,
                color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
            ),
            modifier = Modifier.padding(vertical = 2.dp)
        )
    }
}

@Composable
fun ListRenderer(
    list: OrgList.OrgUnorderedList,
    onLinkClick: (String) -> Unit,
    clozeState: ClozeState?,
    baseFontSize: Float?,
    isNested: Boolean = false
) {
    Column(
        modifier = Modifier
            .padding(start = 8.dp)
            .then(if (isNested) Modifier else Modifier.padding(vertical = 4.dp))
    ) {
        list.items.forEach { item ->
            ListItemRenderer(
                item = item,
                marker = when (list.markerStyle) {
                    OrgUnorderedListMarker.DASH -> "\u2013"
                    OrgUnorderedListMarker.PLUS -> "+"
                },
                onLinkClick = onLinkClick,
                clozeState = clozeState,
                baseFontSize = baseFontSize
            )
        }
    }
}

@Composable
fun ListRenderer(
    list: OrgList.OrgOrderedList,
    onLinkClick: (String) -> Unit,
    clozeState: ClozeState?,
    baseFontSize: Float?,
    isNested: Boolean = false
) {
    Column(
        modifier = Modifier
            .padding(start = 8.dp)
            .then(if (isNested) Modifier else Modifier.padding(vertical = 4.dp))
    ) {
        list.items.forEachIndexed { index, item ->
            ListItemRenderer(
                item = item,
                marker = "${index + 1}${
                    when (list.markerStyle) {
                        OrgOrderedListMarker.PERIOD -> "."
                        OrgOrderedListMarker.PARENTHESIS -> ")"
                    }
                }",
                onLinkClick = onLinkClick,
                clozeState = clozeState,
                baseFontSize = baseFontSize
            )
        }
    }
}

@Composable
private fun ListItemRenderer(
    item: OrgList.OrgListItem,
    marker: String,
    onLinkClick: (String) -> Unit,
    clozeState: ClozeState?,
    baseFontSize: Float?
) {
    Column(modifier = Modifier.padding(vertical = 0.dp)) {
        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.Start,
            verticalAlignment = Alignment.Top
        ) {
            Text(
                text = marker,
                style = MaterialTheme.typography.bodyMedium.copy(
                    fontFamily = VulfMono,
                    color = MaterialTheme.colorScheme.onSurface,
                    fontSize = baseFontSize?.sp ?: MaterialTheme.typography.bodyMedium.fontSize,
                    lineHeight = baseFontSize?.sp ?: MaterialTheme.typography.bodyMedium.fontSize,
                    platformStyle = PlatformTextStyle(includeFontPadding = false)
                ),
                modifier = Modifier
                    .width(24.dp)
                    .padding(end = 4.dp)
            )
            
            item.checkbox?.let { checkbox ->
                val icon = when (checkbox) {
                    OrgListCheckState.CHECKED -> Icons.Default.Check
                    OrgListCheckState.UNCHECKED -> Icons.Default.CheckBoxOutlineBlank
                    OrgListCheckState.PARTIAL -> Icons.Default.IndeterminateCheckBox
                }
                Icon(
                    imageVector = icon,
                    contentDescription = checkbox.name,
                    tint = MaterialTheme.colorScheme.onSurface,
                    modifier = Modifier
                        .size(20.dp)
                        .padding(end = 4.dp)
                )
            }
            
            Column(
                modifier = Modifier
                    .weight(1f)
                    .padding(start = 4.dp)
            ) {
                item.content.forEach { chunk ->
                    when (chunk) {
                        is OrgChunk.OrgParagraph -> {
                            val text = chunk.plainText()
                            renderInlineMarkup(
                                text = text,
                                onLinkClick = onLinkClick,
                                clozeState = clozeState,
                                baseFontSize = baseFontSize
                            )
                        }
                        is OrgList.OrgUnorderedList -> {
                            ListRenderer(
                                list = chunk,
                                onLinkClick = onLinkClick,
                                clozeState = clozeState,
                                baseFontSize = baseFontSize,
                                isNested = true
                            )
                        }
                        is OrgList.OrgOrderedList -> {
                            ListRenderer(
                                list = chunk,
                                onLinkClick = onLinkClick,
                                clozeState = clozeState,
                                baseFontSize = baseFontSize,
                                isNested = true
                            )
                        }
                        else -> {
                        }
                    }
                }
            }
        }
    }
}

OrgDocumentRenderer

The public entry point that dispatches by RenderMode. Also contains SectionRenderer (heading fold logic), HeadingRenderer (fold/long-press dialogs), BlockRenderer (chunk type dispatch), ParagraphWithImages (inline images interleaved with text), InlineImage (attachment image resolution with EXIF), and FlashcardInfoPanel / FlashcardPositionCard (review data display).

Design: Fold state via rememberSaveable

Heading fold state is persisted across configuration changes using rememberSaveable. Each heading gets a stable key derived from its level and title. This means fold positions survive device rotation.

Design: Heading long-press context menu

In ReadOnlyView, long-pressing a heading opens an AlertDialog:

  • If the heading has an :ID: property → "Open" (navigate) and "Refile" options

  • If it has no :ID: → "Create Node" option

This exposes the refile/create-ID workflows without requiring edit mode.

Design: Inline image resolution

Images referenced via =photo.jpg or id/image.jpg= are resolved through AttachmentResolver and AndroidFileSystem at render time. Bitmaps are EXIF-rotated and cached in Compose remember state.

Design: Single parse, many renders

The AST is parsed once (by OrgDocumentCache in app/data.org) and the render tree is built from it on every recomposition. Compose's diffing handles minimal recomposition — only changed sections re-render. Inline image loading is async and won't block the UI thread.

arcology.app.ui.components.renderer.OrgDocumentRenderer

kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/ui/components/renderer/OrgDocumentRenderer.kt
package computer.whatthefuck.arcology.app.ui.components.renderer

import android.graphics.BitmapFactory
import android.util.Log
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Event
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material.icons.filled.StarHalf
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import computer.whatthefuck.arcology.domain.FlashcardPosition
import computer.whatthefuck.arcology.domain.FlashcardReview
import computer.whatthefuck.arcology.domain.FlashcardType
import computer.whatthefuck.arcology.indexer.AndroidFileSystem
import computer.whatthefuck.arcology.indexer.AttachmentResolver
import computer.whatthefuck.arcology.app.viewmodel.PerNodeMetadata
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import xyz.lepisma.orgmode.*

data class ImageRenderContext(
    val nodeId: String?,
    val fileSystem: AndroidFileSystem?,
    val fileUri: String?,
    val orgRoamRoot: String
)

@Composable
fun OrgDocumentRenderer(
    document: OrgDocument,
    mode: RenderMode = RenderMode.FULL_DOCUMENT,
    sectionFocus: OrgSection? = null,
    onLinkClick: (nodeId: String) -> Unit = {},
    clozeState: ClozeState? = null,
    baseFontSize: Float? = null,
    modifier: Modifier = Modifier,
    onHeadingOpen: ((nodeId: String) -> Unit)? = null,
    onHeadingRefile: ((nodeId: String) -> Unit)? = null,
    onHeadingCreateId: ((section: OrgSection) -> Unit)? = null,
    onTodoClick: ((file: String, position: Int, currentTodo: String?) -> Unit)? = null,
    nodeId: String? = null,
    fileSystem: AndroidFileSystem? = null,
    fileUri: String? = null,
    orgRoamRoot: String = "",
    tags: List<String> = emptyList(),
    refs: List<String> = emptyList(),
    onTagClick: (tag: String) -> Unit = {},
    flashcardType: FlashcardType? = null,
    flashcardPositions: List<FlashcardPosition> = emptyList(),
    reviewHistory: Map<String, List<FlashcardReview>> = emptyMap(),
    childMetadata: Map<String, PerNodeMetadata> = emptyMap()
) {
    val imageContext = ImageRenderContext(
        nodeId = nodeId,
        fileSystem = fileSystem,
        fileUri = fileUri,
        orgRoamRoot = orgRoamRoot
    )
    when (mode) {
        RenderMode.FULL_DOCUMENT -> {
            renderFullDocument(
                document = document,
                onLinkClick = onLinkClick,
                clozeState = clozeState,
                baseFontSize = baseFontSize,
                modifier = modifier,
                onHeadingOpen = onHeadingOpen,
                onHeadingRefile = onHeadingRefile,
                onHeadingCreateId = onHeadingCreateId,
                onTodoClick = onTodoClick,
                imageContext = imageContext,
                tags = tags,
                refs = refs,
                onTagClick = onTagClick,
                flashcardType = flashcardType,
                flashcardPositions = flashcardPositions,
                reviewHistory = reviewHistory,
                childMetadata = childMetadata
            )
        }
        RenderMode.SINGLE_NODE -> {
            requireNotNull(sectionFocus) { "sectionFocus required for SINGLE_NODE mode" }
            renderSingleSection(
                section = sectionFocus,
                onLinkClick = onLinkClick,
                clozeState = clozeState,
                baseFontSize = baseFontSize,
                modifier = modifier,
                onHeadingOpen = onHeadingOpen,
                onHeadingRefile = onHeadingRefile,
                onHeadingCreateId = onHeadingCreateId,
                onTodoClick = onTodoClick,
                imageContext = imageContext,
                tags = tags,
                refs = refs,
                onTagClick = onTagClick,
                flashcardType = flashcardType,
                flashcardPositions = flashcardPositions,
                reviewHistory = reviewHistory,
                childMetadata = childMetadata
            )
        }
        RenderMode.NODE_WITH_CHILDREN -> {
            requireNotNull(sectionFocus) { "sectionFocus required for NODE_WITH_CHILDREN mode" }
            renderSectionWithChildren(
                section = sectionFocus,
                onLinkClick = onLinkClick,
                clozeState = clozeState,
                baseFontSize = baseFontSize,
                modifier = modifier,
                onHeadingOpen = onHeadingOpen,
                onHeadingRefile = onHeadingRefile,
                onHeadingCreateId = onHeadingCreateId,
                onTodoClick = onTodoClick,
                imageContext = imageContext,
                tags = tags,
                refs = refs,
                onTagClick = onTagClick,
                flashcardType = flashcardType,
                flashcardPositions = flashcardPositions,
                reviewHistory = reviewHistory,
                childMetadata = childMetadata
            )
        }
        RenderMode.BODY_ONLY -> {
            renderBodyOnly(
                document = document,
                onLinkClick = onLinkClick,
                clozeState = clozeState,
                baseFontSize = baseFontSize,
                modifier = modifier,
                imageContext = imageContext
            )
        }
    }
}

@Composable
private fun renderFullDocument(
    document: OrgDocument,
    onLinkClick: (String) -> Unit,
    clozeState: ClozeState?,
    baseFontSize: Float?,
    modifier: Modifier = Modifier,
    onHeadingOpen: ((String) -> Unit)? = null,
    onHeadingRefile: ((String) -> Unit)? = null,
    onHeadingCreateId: ((OrgSection) -> Unit)? = null,
    onTodoClick: ((String, Int, String?) -> Unit)? = null,
    imageContext: ImageRenderContext,
    tags: List<String>,
    refs: List<String>,
    onTagClick: (String) -> Unit,
    flashcardType: FlashcardType?,
    flashcardPositions: List<FlashcardPosition>,
    reviewHistory: Map<String, List<FlashcardReview>>,
    childMetadata: Map<String, PerNodeMetadata> = emptyMap()
) {
    val titleText = document.preamble.title.plainText()

    Column(
        modifier = modifier
    ) {
        if (titleText.isNotEmpty()) {
            Text(
                text = titleText,
                style = MaterialTheme.typography.bodyMedium.copy(
                    fontWeight = FontWeight.Bold,
                    color = MaterialTheme.colorScheme.primary
                ),
                modifier = Modifier.padding(vertical = 8.dp),
                fontSize = 26.sp
            )

            if (tags.isNotEmpty() || refs.isNotEmpty()) {
                NodeMetadataChips(
                    tags = tags,
                    refs = refs,
                    onTagClick = onTagClick,
                    modifier = Modifier.padding(start = 16.dp, top = 4.dp, bottom = 4.dp)
                )
            }

            if (flashcardType != null) {
                FlashcardInfoPanel(
                    flashcardType = flashcardType,
                    flashcardPositions = flashcardPositions,
                    reviewHistory = reviewHistory,
                    modifier = Modifier.padding(start = 16.dp)
                )
            }
        }

        document.preface.body.forEach { chunk ->
            BlockRenderer(
                chunk = chunk,
                level = 1,
                onLinkClick = onLinkClick,
                clozeState = clozeState,
                baseFontSize = baseFontSize,
                imageContext = imageContext
            )
        }

        document.content.forEach { section ->
            val headingKey = remember(section) {
                val titlePart = section.heading.title.plainText().take(60)
                "fold_${section.heading.level.level}_$titlePart"
            }
            val foldState = rememberSaveable(key = headingKey) {
                mutableStateOf(HeadingFoldState.Expanded)
            }
            SectionRenderer(
                section = section,
                level = 1,
                foldState = foldState,
                onLinkClick = onLinkClick,
                clozeState = clozeState,
                baseFontSize = baseFontSize,
                onHeadingOpen = onHeadingOpen,
                onHeadingRefile = onHeadingRefile,
                onHeadingCreateId = onHeadingCreateId,
                onTodoClick = onTodoClick,
                imageContext = imageContext,
                tags = tags,
                refs = refs,
                onTagClick = onTagClick,
                flashcardType = flashcardType,
                flashcardPositions = flashcardPositions,
                reviewHistory = reviewHistory,
                childMetadata = childMetadata
            )
        }
    }
}

@Composable
private fun renderBodyOnly(
    document: OrgDocument,
    onLinkClick: (String) -> Unit,
    clozeState: ClozeState?,
    baseFontSize: Float?,
    modifier: Modifier = Modifier,
    imageContext: ImageRenderContext
) {
    Column(
        modifier = modifier
    ) {
        document.preface.body.forEach { chunk ->
            BlockRenderer(
                chunk = chunk,
                level = 1,
                onLinkClick = onLinkClick,
                clozeState = clozeState,
                baseFontSize = baseFontSize,
                imageContext = imageContext
            )
        }
    }
}

@Composable
private fun renderSingleSection(
    section: OrgSection,
    onLinkClick: (String) -> Unit,
    clozeState: ClozeState?,
    baseFontSize: Float?,
    modifier: Modifier = Modifier,
    onHeadingOpen: ((String) -> Unit)? = null,
    onHeadingRefile: ((String) -> Unit)? = null,
    onHeadingCreateId: ((OrgSection) -> Unit)? = null,
    onTodoClick: ((String, Int, String?) -> Unit)? = null,
    imageContext: ImageRenderContext,
    tags: List<String>,
    refs: List<String>,
    onTagClick: (String) -> Unit,
    flashcardType: FlashcardType?,
    flashcardPositions: List<FlashcardPosition>,
    reviewHistory: Map<String, List<FlashcardReview>>,
    childMetadata: Map<String, PerNodeMetadata> = emptyMap()
) {
    Column(
        modifier = modifier
    ) {
        val headingKey = remember(section) {
            val titlePart = section.heading.title.plainText().take(60)
            "fold_${section.heading.level.level}_$titlePart"
        }
        val foldState = rememberSaveable(headingKey) {
            mutableStateOf(HeadingFoldState.Expanded)
        }
        SectionRenderer(
            section = section,
            level = 1,
            foldState = foldState,
            onLinkClick = onLinkClick,
            clozeState = clozeState,
            baseFontSize = baseFontSize,
            onHeadingOpen = onHeadingOpen,
            onHeadingRefile = onHeadingRefile,
            onHeadingCreateId = onHeadingCreateId,
            onTodoClick = onTodoClick,
            imageContext = imageContext,
            tags = tags,
            refs = refs,
            onTagClick = onTagClick,
            flashcardType = flashcardType,
            flashcardPositions = flashcardPositions,
            reviewHistory = reviewHistory,
            childMetadata = childMetadata
        )
    }
}

@Composable
private fun renderSectionWithChildren(
    section: OrgSection,
    onLinkClick: (String) -> Unit,
    clozeState: ClozeState?,
    baseFontSize: Float?,
    modifier: Modifier = Modifier,
    onHeadingOpen: ((String) -> Unit)? = null,
    onHeadingRefile: ((String) -> Unit)? = null,
    onHeadingCreateId: ((OrgSection) -> Unit)? = null,
    onTodoClick: ((String, Int, String?) -> Unit)? = null,
    imageContext: ImageRenderContext,
    tags: List<String>,
    refs: List<String>,
    onTagClick: (String) -> Unit,
    flashcardType: FlashcardType?,
    flashcardPositions: List<FlashcardPosition>,
    reviewHistory: Map<String, List<FlashcardReview>>,
    childMetadata: Map<String, PerNodeMetadata> = emptyMap()
) {
    Column(
        modifier = modifier
    ) {
        val headingKey = remember(section) {
            val titlePart = section.heading.title.plainText().take(60)
            "fold_${section.heading.level.level}_$titlePart"
        }
        val foldState = rememberSaveable(headingKey) {
            mutableStateOf(HeadingFoldState.Expanded)
        }
        SectionRenderer(
            section = section,
            level = 1,
            foldState = foldState,
            onLinkClick = onLinkClick,
            clozeState = clozeState,
            baseFontSize = baseFontSize,
            onHeadingOpen = onHeadingOpen,
            onHeadingRefile = onHeadingRefile,
            onHeadingCreateId = onHeadingCreateId,
            onTodoClick = onTodoClick,
            imageContext = imageContext,
            tags = tags,
            refs = refs,
            onTagClick = onTagClick,
            flashcardType = flashcardType,
            flashcardPositions = flashcardPositions,
            reviewHistory = reviewHistory,
            childMetadata = childMetadata
        )
    }
}

@Composable
private fun SectionRenderer(
    section: OrgSection,
    level: Int,
    foldState: MutableState<HeadingFoldState>,
    onLinkClick: (String) -> Unit,
    clozeState: ClozeState?,
    baseFontSize: Float?,
    onHeadingOpen: ((String) -> Unit)? = null,
    onHeadingRefile: ((String) -> Unit)? = null,
    onHeadingCreateId: ((OrgSection) -> Unit)? = null,
    onTodoClick: ((String, Int, String?) -> Unit)? = null,
    imageContext: ImageRenderContext,
    tags: List<String>,
    refs: List<String>,
    onTagClick: (String) -> Unit,
    flashcardType: FlashcardType?,
    flashcardPositions: List<FlashcardPosition>,
    reviewHistory: Map<String, List<FlashcardReview>>,
    childMetadata: Map<String, PerNodeMetadata> = emptyMap()
) {
    HeadingRenderer(
        heading = section.heading,
        section = section,
        level = level,
        foldState = foldState,
        onLinkClick = onLinkClick,
        clozeState = clozeState,
        baseFontSize = baseFontSize,
        onHeadingOpen = onHeadingOpen,
        onHeadingRefile = onHeadingRefile,
        onHeadingCreateId = onHeadingCreateId,
        onTodoClick = onTodoClick,
        fileUri = imageContext.fileUri
    )

    val sectionNodeId = section.heading.properties?.map?.get("ID")?.plainText()
        ?: imageContext.nodeId
    val sectionImageContext = imageContext.copy(nodeId = sectionNodeId)

    val sectionMeta = sectionNodeId?.let { childMetadata[it] }
    val effectiveTags = sectionMeta?.tags ?: tags
    val effectiveRefs = sectionMeta?.refs ?: refs

    when (foldState.value) {
        HeadingFoldState.Expanded -> {
            if (effectiveTags.isNotEmpty() || effectiveRefs.isNotEmpty() ||
                section.heading.planningInfo?.let { it.scheduled != null || it.deadline != null || it.closed != null } == true) {
                NodeMetadataChips(
                    tags = effectiveTags,
                    refs = effectiveRefs,
                    planningInfo = section.heading.planningInfo,
                    onTagClick = onTagClick,
                    modifier = Modifier.padding(start = (32 + ((level - 1) * 16)).dp, top = 4.dp, bottom = 4.dp)
                )
            }

            if (flashcardType != null && level == 1) {
                FlashcardInfoPanel(
                    flashcardType = flashcardType,
                    flashcardPositions = flashcardPositions,
                    reviewHistory = reviewHistory,
                    modifier = Modifier.padding(start = (16 + ((level - 1) * 16)).dp)
                )
            }

            section.body.forEach { chunk ->
                BlockRenderer(chunk, level + 1, onLinkClick, clozeState, baseFontSize, sectionImageContext)
            }
            val childSections = section.body.filterIsInstance<OrgSection>()
            childSections.forEach { child ->
                val childHeadingKey = remember(child) {
                    val titlePart = child.heading.title.plainText().take(60)
                    "fold_${child.heading.level.level}_$titlePart"
                }
                val childFoldState = rememberSaveable(childHeadingKey) {
                    mutableStateOf(HeadingFoldState.Expanded)
                }
                SectionRenderer(
                    section = child,
                    level = level + 1,
                    foldState = childFoldState,
                    onLinkClick = onLinkClick,
                    clozeState = clozeState,
                    baseFontSize = baseFontSize,
                    onHeadingOpen = onHeadingOpen,
                    onHeadingRefile = onHeadingRefile,
                    onHeadingCreateId = onHeadingCreateId,
                    onTodoClick = onTodoClick,
                    imageContext = sectionImageContext,
                    tags = emptyList(),
                    refs = emptyList(),
                    onTagClick = onTagClick,
                    flashcardType = null,
                    flashcardPositions = emptyList(),
                    reviewHistory = emptyMap(),
                    childMetadata = childMetadata
                )
            }
        }
        HeadingFoldState.HeadingsOnly -> {
            section.body.forEach { chunk ->
                BlockRenderer(chunk, level + 1, onLinkClick, clozeState, baseFontSize, sectionImageContext)
            }
            val childSections = section.body.filterIsInstance<OrgSection>()
            childSections.forEach { child ->
                val childHeadingKey = remember(child) {
                    val titlePart = child.heading.title.plainText().take(60)
                    "fold_${child.heading.level.level}_$titlePart"
                }
                val childFoldState = rememberSaveable(childHeadingKey) {
                    mutableStateOf(HeadingFoldState.Folded)
                }
                SectionRenderer(
                    section = child,
                    level = level + 1,
                    foldState = childFoldState,
                    onLinkClick = onLinkClick,
                    clozeState = clozeState,
                    baseFontSize = baseFontSize,
                    onHeadingOpen = onHeadingOpen,
                    onHeadingRefile = onHeadingRefile,
                    onHeadingCreateId = onHeadingCreateId,
                    onTodoClick = onTodoClick,
                    imageContext = sectionImageContext,
                    tags = emptyList(),
                    refs = emptyList(),
                    onTagClick = onTagClick,
                    flashcardType = null,
                    flashcardPositions = emptyList(),
                    reviewHistory = emptyMap(),
                    childMetadata = childMetadata
                )
            }
        }
        HeadingFoldState.Folded -> {
        }
    }
}

@Composable
private fun NodeMetadataChips(
    tags: List<String>,
    refs: List<String>,
    onTagClick: (String) -> Unit,
    modifier: Modifier = Modifier,
    planningInfo: OrgPlanningInfo? = null
) {
    val uriHandler = LocalUriHandler.current

    Column(
        modifier = modifier.fillMaxWidth(),
        verticalArrangement = Arrangement.spacedBy(4.dp)
    ) {
        if (refs.isNotEmpty()) {
            Text(
                text = "References",
                style = MaterialTheme.typography.labelSmall,
                color = MaterialTheme.colorScheme.onSurfaceVariant,
                modifier = Modifier.padding(bottom = 2.dp)
            )
        }
        FlowRow(
            horizontalArrangement = Arrangement.spacedBy(6.dp),
            verticalArrangement = Arrangement.spacedBy(4.dp)
        ) {
            // Timestamp chips first
            planningInfo?.scheduled?.let { ts ->
                AssistChip(
                    onClick = {},
                    leadingIcon = {
                        Icon(
                            imageVector = Icons.Default.Event,
                            contentDescription = null,
                            modifier = Modifier.size(14.dp)
                        )
                    },
                    label = {
                        Text(
                            text = "Sched · " + ts.toCompactString(),
                            style = MaterialTheme.typography.labelSmall
                        )
                    },
                    modifier = Modifier.heightIn(max = 28.dp),
                    shape = RoundedCornerShape(14.dp)
                )
            }
            planningInfo?.deadline?.let { ts ->
                AssistChip(
                    onClick = {},
                    leadingIcon = {
                        Icon(
                            imageVector = Icons.Default.Schedule,
                            contentDescription = null,
                            modifier = Modifier.size(14.dp)
                        )
                    },
                    label = {
                        Text(
                            text = "Due · " + ts.toCompactString(),
                            style = MaterialTheme.typography.labelSmall
                        )
                    },
                    modifier = Modifier.heightIn(max = 28.dp),
                    shape = RoundedCornerShape(14.dp)
                )
            }
            planningInfo?.closed?.let { ts ->
                AssistChip(
                    onClick = {},
                    leadingIcon = {
                        Icon(
                            imageVector = Icons.Default.CheckCircle,
                            contentDescription = null,
                            modifier = Modifier.size(14.dp)
                        )
                    },
                    label = {
                        Text(
                            text = "Closed · " + ts.toCompactString(),
                            style = MaterialTheme.typography.labelSmall
                        )
                    },
                    modifier = Modifier.heightIn(max = 28.dp),
                    shape = RoundedCornerShape(14.dp)
                )
            }

            refs.forEach { refUrl ->
                val domain = try {
                    val uri = java.net.URI(refUrl)
                    val host = uri.host ?: refUrl
                    host.removePrefix("www.")
                } catch (_: Exception) {
                    refUrl
                }
                AssistChip(
                    onClick = { uriHandler.openUri(refUrl) },
                    label = {
                        Text(
                            text = domain,
                            style = MaterialTheme.typography.labelSmall
                        )
                    },
                    modifier = Modifier.heightIn(max = 28.dp),
                    shape = RoundedCornerShape(14.dp)
                )
            }

            tags.forEach { tag ->
                InputChip(
                    selected = false,
                    onClick = { onTagClick(tag) },
                    label = {
                        Text(
                            text = tag,
                            style = MaterialTheme.typography.labelSmall
                        )
                    },
                    modifier = Modifier.heightIn(max = 28.dp),
                    shape = RoundedCornerShape(14.dp)
                )
            }
        }
    }
}

private val MONTH_ABBREV = listOf(
    "Jan", "Feb", "Mar", "Apr", "May", "Jun",
    "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
)

/**
 * Compact display form for a timestamp, e.g. "Aug 15 14:00 +1w".
 * Weekday is omitted; date uses month abbrev + day; time and repeater included if present.
 */
private fun OrgInlineElem.DTStamp.toCompactString(): String {
    val parts = mutableListOf<String>()
    parts.add("${MONTH_ABBREV[date.monthNumber - 1]} ${date.dayOfMonth}")
    time?.let { (start, _) ->
        parts.add("${start.hour.toString().padStart(2, '0')}:${start.minute.toString().padStart(2, '0')}")
    }
    repeater?.let { parts.add(it) }
    return parts.joinToString(" ")
}

@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun HeadingRenderer(
    heading: OrgHeading,
    section: OrgSection,
    level: Int,
    foldState: MutableState<HeadingFoldState>,
    onLinkClick: (String) -> Unit,
    clozeState: ClozeState?,
    baseFontSize: Float?,
    onHeadingOpen: ((String) -> Unit)? = null,
    onHeadingRefile: ((String) -> Unit)? = null,
    onHeadingCreateId: ((OrgSection) -> Unit)? = null,
    onTodoClick: ((String, Int, String?) -> Unit)? = null,
    fileUri: String? = null
) {
    val headingNodeId = heading.properties?.map?.get("ID")?.plainText()
    val isNode = headingNodeId != null

    val arrowIcon = when (foldState.value) {
        HeadingFoldState.Expanded -> Icons.Default.Star
        HeadingFoldState.HeadingsOnly -> Icons.Default.StarHalf
        HeadingFoldState.Folded -> Icons.Default.StarBorder
    }

    val fontSize = when (level) {
        1 -> 24.sp
        2 -> 22.sp
        3 -> 20.sp
        else -> 18.sp
    }

    val fontWeight = when (level) {
        1, 2 -> FontWeight.Bold
        3 -> FontWeight.SemiBold
        else -> FontWeight.Medium
    }

    val titleText = heading.title.plainText()

    var showDialog by remember { mutableStateOf(false) }

    if (showDialog) {
        if (isNode) {
            val nodeId = headingNodeId!!
            AlertDialog(
                onDismissRequest = { showDialog = false },
                title = { Text(titleText) },
                text = { Text("ID: $nodeId") },
                confirmButton = {
                    if (onHeadingOpen != null) {
                        TextButton(onClick = {
                            showDialog = false
                            onHeadingOpen(nodeId)
                        }) { Text("Open") }
                    }
                },
                dismissButton = {
                    if (onHeadingRefile != null) {
                        TextButton(onClick = {
                            showDialog = false
                            onHeadingRefile(nodeId)
                        }) { Text("Refile") }
                    }
                }
            )
        } else {
            AlertDialog(
                onDismissRequest = { showDialog = false },
                title = { Text(titleText) },
                text = { Text("This heading is not a node yet.") },
                confirmButton = {
                    if (onHeadingCreateId != null) {
                        TextButton(onClick = {
                            showDialog = false
                            onHeadingCreateId(section)
                        }) { Text("Create Node") }
                    }
                },
                dismissButton = {
                    TextButton(onClick = { showDialog = false }) { Text("Cancel") }
                }
            )
        }
    }

    Column(
        modifier = Modifier
            .fillMaxWidth()
            .clickable { foldState.value = cycleFoldState(foldState.value) }
            .padding(start = ((level - 1) * 16).dp)
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(4.dp),
            horizontalArrangement = Arrangement.Start,
            verticalAlignment = Alignment.CenterVertically
        ) {
            Icon(
                imageVector = arrowIcon,
                contentDescription = if (foldState.value == HeadingFoldState.Folded) "Expand" else "Collapse",
                modifier = Modifier
                    .size(24.dp)
                    .padding(end = 4.dp)
                    .combinedClickable(
                        onClick = { foldState.value = cycleFoldState(foldState.value) },
                        onLongClick = { showDialog = true }
                    )
            )

            heading.todoState?.let { todoState ->
                val headingPosition = section.heading.tokens.firstOrNull()?.range?.first ?: 0
                TodoStateChip(
                    state = todoState.text,
                    modifier = Modifier.padding(end = 6.dp),
                    onClick = onTodoClick?.let { cb -> { cb(fileUri ?: "", headingPosition, todoState.text) } }
                )
            }

            renderInlineMarkup(
                text = titleText,
                onLinkClick = onLinkClick,
                clozeState = clozeState,
                baseFontSize = fontSize.value
            )
        }
    }
}

/**
 * Small colored chip showing the heading's TODO-state keyword.
 * Colors follow org-mode faces:
 * - TODO/NEXT/INPROGRESS (active states): orange/blue
 * - DONE: green
 * - CANCELLED/ARCHIVED: grey
 */
@Composable
private fun TodoStateChip(state: String, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null) {
    val upper = state.uppercase()
    val (bg, fg) = when (upper) {
        "TODO" -> MaterialTheme.colorScheme.errorContainer to MaterialTheme.colorScheme.onErrorContainer
        "NEXT" -> MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.onPrimaryContainer
        "INPROGRESS" -> MaterialTheme.colorScheme.tertiaryContainer to MaterialTheme.colorScheme.onTertiaryContainer
        "DONE" -> MaterialTheme.colorScheme.secondaryContainer to MaterialTheme.colorScheme.onSecondaryContainer
        "CANCELLED", "ARCHIVED" -> MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant
        else -> MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant
    }
    Surface(
        color = bg,
        shape = RoundedCornerShape(4.dp),
        modifier = modifier
            .heightIn(max = 20.dp)
            .let { m -> if (onClick != null) m.clickable(onClick = onClick) else m }
    ) {
        Text(
            text = state,
            color = fg,
            style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold),
            modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
        )
    }
}

private fun cycleFoldState(current: HeadingFoldState): HeadingFoldState {
    return when (current) {
        HeadingFoldState.Expanded -> HeadingFoldState.HeadingsOnly
        HeadingFoldState.HeadingsOnly -> HeadingFoldState.Folded
        HeadingFoldState.Folded -> HeadingFoldState.Expanded
    }
}

private val IMAGE_EXTENSIONS = setOf("png", "jpg", "jpeg", "gif", "svg", "webp", "bmp")

@Composable
private fun BlockRenderer(
    chunk: OrgChunk,
    level: Int,
    onLinkClick: (String) -> Unit,
    clozeState: ClozeState?,
    baseFontSize: Float?,
    imageContext: ImageRenderContext = ImageRenderContext(null, null, null, "")
) {
    val indent = ((level - 1).coerceAtLeast(0) * 16).dp
    Column(
        modifier = Modifier.padding(start = indent)
    ) {
        when (chunk) {
        is OrgChunk.OrgParagraph -> {
            val imageLinks = chunk.items.filterIsInstance<OrgInlineElem.Link>()
                .filter { it.isImage() }
            if (imageLinks.isEmpty() || imageContext.fileSystem == null) {
                val text = chunk.plainText()
                renderInlineMarkup(
                    text = text,
                    onLinkClick = onLinkClick,
                    clozeState = clozeState,
                    baseFontSize = baseFontSize
                )
            } else {
                ParagraphWithImages(
                    elements = chunk.items,
                    imageLinks = imageLinks,
                    onLinkClick = onLinkClick,
                    clozeState = clozeState,
                    baseFontSize = baseFontSize,
                    imageContext = imageContext
                )
            }
        }

        is OrgChunk.OrgKeywordLine -> {
            KeywordRenderer(keyword = chunk)
        }

        is OrgChunk.OrgTable -> {
        }

        is OrgBlock.OrgSourceBlock -> SourceBlockRenderer(block = chunk, baseFontSize = baseFontSize)
        is OrgBlock.OrgQuoteBlock -> QuoteBlockRenderer(block = chunk, baseFontSize = baseFontSize, onLinkClick = onLinkClick, clozeState = clozeState)
        is OrgBlock.OrgExampleBlock -> ExampleBlockRenderer(block = chunk, baseFontSize = baseFontSize)
        is OrgBlock.OrgVerseBlock -> VerseBlockRenderer(block = chunk, baseFontSize = baseFontSize, onLinkClick = onLinkClick, clozeState = clozeState)
        is OrgBlock.OrgAsideBlock -> GenericBlockRenderer(block = chunk, baseFontSize = baseFontSize, onLinkClick = onLinkClick, clozeState = clozeState)

        is OrgChunk.OrgLogbookDrawer -> {
            LogbookDrawerRenderer(drawer = chunk)
        }

        is OrgChunk.OrgReviewDataDrawer -> {
            ReviewDataDrawerRenderer(drawer = chunk)
        }

        is OrgList.OrgUnorderedList -> {
            ListRenderer(
                list = chunk,
                onLinkClick = onLinkClick,
                clozeState = clozeState,
                baseFontSize = baseFontSize
            )
        }

        is OrgList.OrgOrderedList -> {
            ListRenderer(
                list = chunk,
                onLinkClick = onLinkClick,
                clozeState = clozeState,
                baseFontSize = baseFontSize
            )
        }

        else -> {
        }
    }
    }
}

private fun OrgInlineElem.Link.isImage(): Boolean {
    val ext = target.substringAfterLast(".", "").lowercase()
    return ext in IMAGE_EXTENSIONS
}

@Composable
private fun ParagraphWithImages(
    elements: List<OrgInlineElem>,
    imageLinks: List<OrgInlineElem.Link>,
    onLinkClick: (String) -> Unit,
    clozeState: ClozeState?,
    baseFontSize: Float?,
    imageContext: ImageRenderContext
) {
    val textElements = mutableListOf<OrgInlineElem>()

    for (elem in elements) {
        if (elem is OrgInlineElem.Link && elem.isImage()) {
            if (textElements.isNotEmpty()) {
                val text = elementsToText(textElements)
                textElements.clear()
                renderInlineMarkup(text = text, onLinkClick = onLinkClick, clozeState = clozeState, baseFontSize = baseFontSize)
            }
            Column(modifier = Modifier.padding(vertical = 4.dp)) {
                InlineImage(link = elem, imageContext = imageContext)
            }
        } else {
            textElements.add(elem)
        }
    }

    if (textElements.isNotEmpty()) {
        val text = elementsToText(textElements)
        renderInlineMarkup(text = text, onLinkClick = onLinkClick, clozeState = clozeState, baseFontSize = baseFontSize)
    }
}

private fun elementsToText(elements: List<OrgInlineElem>): String {
    val fakeParagraph = OrgChunk.OrgParagraph(elements, emptyList())
    return fakeParagraph.plainText()
}

@Composable
private fun InlineImage(
    link: OrgInlineElem.Link,
    imageContext: ImageRenderContext
) {
    var imageBitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
    var isLoading by remember { mutableStateOf(true) }
    var errorMessage by remember { mutableStateOf<String?>(null) }
    var resolvedPath by remember { mutableStateOf<String?>(null) }
    val target = link.target

    LaunchedEffect(target, imageContext) {
        isLoading = true
        errorMessage = null
        imageBitmap = null
        resolvedPath = null

        try {
            val (bytes, path) = resolveAndReadImage(target, link.type, imageContext)
            resolvedPath = path
            Log.d("InlineImage", "Resolved $target (${link.type}) to: $path")
            if (bytes != null) {
                val bitmap = decodeBitmapWithExif(bytes)
                if (bitmap != null) {
                    imageBitmap = bitmap
                } else {
                    errorMessage = "Failed to decode image at: ${path ?: "unknown"}"
                }
            } else {
                errorMessage = "Image not found: $target (resolved: ${path ?: "null"})"
            }
        } catch (e: Exception) {
            errorMessage = "Failed to load: ${e.message} (path: ${resolvedPath ?: "unknown"})"
            Log.e("InlineImage", "Error loading $target", e)
        }
        isLoading = false
    }

    Box(
        modifier = Modifier.fillMaxWidth(),
        contentAlignment = Alignment.Center
    ) {
        when {
            errorMessage != null -> {
                Column(horizontalAlignment = Alignment.CenterHorizontally) {
                    Text(
                        text = "[$target]",
                        style = MaterialTheme.typography.bodySmall,
                        color = MaterialTheme.colorScheme.error
                    )
                    resolvedPath?.let {
                        Text(
                            text = it,
                            style = MaterialTheme.typography.labelSmall,
                            color = MaterialTheme.colorScheme.onSurfaceVariant
                        )
                    }
                }
            }
            imageBitmap != null -> {
                Image(
                    bitmap = imageBitmap!!.asImageBitmap(),
                    contentDescription = target,
                    contentScale = ContentScale.Fit,
                    modifier = Modifier
                        .fillMaxWidth()
                        .clip(RoundedCornerShape(4.dp))
                )
            }
            isLoading -> {
                CircularProgressIndicator(modifier = Modifier.size(24.dp))
            }
        }
    }
}

private suspend fun resolveAndReadImage(
    target: String,
    linkType: String?,
    imageContext: ImageRenderContext
): Pair<ByteArray?, String?> {
    val fs = imageContext.fileSystem ?: return Pair(null, null)

    return when {
        linkType == "attachment" && imageContext.nodeId != null -> {
            val resolver = AttachmentResolver(fs)
            val path = resolver.resolveAttachFile(
                nodeId = imageContext.nodeId,
                filename = target,
                orgRoamRoot = imageContext.orgRoamRoot
            )
            Log.d("InlineImage", "Attachment resolve: nodeId=${imageContext.nodeId}, filename=$target, root=${imageContext.orgRoamRoot}, path=$path")
            if (path != null) {
                try {
                    Pair(fs.readFileBytes(path), path)
                } catch (_: Exception) {
                    Pair(null, path)
                }
            } else {
                Pair(null, null)
            }
        }
        linkType == "file" && imageContext.fileUri != null -> {
            val parentDir = imageContext.fileUri.substringBeforeLast("/")
            val resolvedPath = "$parentDir/$target"
            Log.d("InlineImage", "File resolve: parent=$parentDir, target=$target, resolved=$resolvedPath")
            try {
                Pair(fs.readFileBytes(resolvedPath), resolvedPath)
            } catch (_: Exception) {
                Pair(null, resolvedPath)
            }
        }
        else -> {
            Log.d("InlineImage", "Unhandled link type: linkType=$linkType, nodeId=${imageContext.nodeId}, fileUri=${imageContext.fileUri}")
            Pair(null, null)
        }
    }
}

private fun decodeBitmapWithExif(bytes: ByteArray): android.graphics.Bitmap? {
    val sourceBitmap = android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
        ?: return null

    val exifStream = java.io.ByteArrayInputStream(bytes)
    val exif = try {
        androidx.exifinterface.media.ExifInterface(exifStream)
    } catch (_: Exception) {
        null
    }

    val orientation = exif?.getAttributeInt(
        androidx.exifinterface.media.ExifInterface.TAG_ORIENTATION,
        androidx.exifinterface.media.ExifInterface.ORIENTATION_NORMAL
    ) ?: androidx.exifinterface.media.ExifInterface.ORIENTATION_NORMAL

    exifStream.close()

    val (angle, flipX, flipY) = when (orientation) {
        androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_90 -> Triple(90f, false, false)
        androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_180 -> Triple(180f, false, false)
        androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_270 -> Triple(270f, false, false)
        androidx.exifinterface.media.ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> Triple(0f, true, false)
        androidx.exifinterface.media.ExifInterface.ORIENTATION_FLIP_VERTICAL -> Triple(0f, false, true)
        androidx.exifinterface.media.ExifInterface.ORIENTATION_TRANSPOSE -> Triple(90f, true, false)
        androidx.exifinterface.media.ExifInterface.ORIENTATION_TRANSVERSE -> Triple(270f, true, false)
        else -> Triple(0f, false, false)
    }

    if (angle == 0f && !flipX && !flipY) return sourceBitmap

    val matrix = android.graphics.Matrix()
    if (angle != 0f) matrix.postRotate(angle)
    if (flipX) matrix.postScale(-1f, 1f)
    if (flipY) matrix.postScale(1f, -1f)

    return try {
        android.graphics.Bitmap.createBitmap(
            sourceBitmap, 0, 0, sourceBitmap.width, sourceBitmap.height, matrix, true
        )
    } catch (_: Exception) {
        sourceBitmap
    }
}

@Composable
fun FlashcardInfoPanel(
    flashcardType: computer.whatthefuck.arcology.domain.FlashcardType,
    flashcardPositions: List<computer.whatthefuck.arcology.domain.FlashcardPosition>,
    reviewHistory: Map<String, List<computer.whatthefuck.arcology.domain.FlashcardReview>>,
    modifier: Modifier = Modifier
) {
    var expanded by remember { mutableStateOf(false) }

    Column(
        modifier = modifier.fillMaxWidth(),
        verticalArrangement = Arrangement.spacedBy(4.dp)
    ) {
        AssistChip(
            onClick = { expanded = !expanded },
            label = {
                Row(
                    verticalAlignment = Alignment.CenterVertically,
                    horizontalArrangement = Arrangement.spacedBy(4.dp)
                ) {
                    Icon(
                        imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
                        contentDescription = null,
                        modifier = Modifier.size(16.dp)
                    )
                    Text(
                        text = "Flashcard: ${flashcardType.name}",
                        style = MaterialTheme.typography.labelSmall
                    )
                }
            },
            shape = RoundedCornerShape(14.dp)
        )

        AnimatedVisibility(
            visible = expanded,
            enter = expandVertically(),
            exit = shrinkVertically()
        ) {
            Column(
                modifier = Modifier
                    .padding(start = 8.dp, top = 4.dp)
                    .fillMaxWidth(),
                verticalArrangement = Arrangement.spacedBy(8.dp)
            ) {
                flashcardPositions.forEach { position ->
                    FlashcardPositionCard(
                        position = position,
                        reviews = reviewHistory[position.positionName] ?: emptyList()
                    )
                }
            }
        }
    }
}

@Composable
fun FlashcardPositionCard(
    position: computer.whatthefuck.arcology.domain.FlashcardPosition,
    reviews: List<computer.whatthefuck.arcology.domain.FlashcardReview>
) {
    var reviewsExpanded by remember { mutableStateOf(false) }

    val dueText = try {
        val instant = kotlinx.datetime.Instant.fromEpochMilliseconds(position.dueDate.toEpochMilliseconds())
        val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault())
        "${localDateTime.year}-${localDateTime.monthNumber.toString().padStart(2, '0')}-${localDateTime.dayOfMonth.toString().padStart(2, '0')}"
    } catch (_: Exception) {
        "unknown"
    }

    Card(
        modifier = Modifier.fillMaxWidth(),
        colors = CardDefaults.cardColors(
            containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
        )
    ) {
        Column(
            modifier = Modifier.padding(8.dp)
        ) {
            Text(
                text = "Position: ${position.positionName}",
                style = MaterialTheme.typography.labelMedium,
                fontWeight = FontWeight.SemiBold
            )
            Spacer(modifier = Modifier.height(2.dp))
            Text(
                text = "Due: $dueText  \u2022  Ease: ${"%.2f".format(position.easeFactor)}  \u2022  Box: ${position.box}  \u2022  Interval: ${"%.1f".format(position.intervalDays)}d",
                style = MaterialTheme.typography.bodySmall,
                color = MaterialTheme.colorScheme.onSurfaceVariant
            )
            Text(
                text = "${position.reviewCount} reviews total",
                style = MaterialTheme.typography.bodySmall,
                color = MaterialTheme.colorScheme.onSurfaceVariant
            )

            if (reviews.isNotEmpty()) {
                TextButton(
                    onClick = { reviewsExpanded = !reviewsExpanded },
                    modifier = Modifier.padding(top = 4.dp),
                    contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp)
                ) {
                    Text(
                        text = if (reviewsExpanded) "Hide history" else "Show history (${reviews.size})",
                        style = MaterialTheme.typography.labelSmall
                    )
                }

                AnimatedVisibility(
                    visible = reviewsExpanded,
                    enter = expandVertically(),
                    exit = shrinkVertically()
                ) {
                    Column(
                        modifier = Modifier.padding(top = 4.dp)
                    ) {
                        reviews.takeLast(20).reversed().forEach { review ->
                            val reviewDate = try {
                                val instant = kotlinx.datetime.Instant.fromEpochMilliseconds(review.reviewDate.toEpochMilliseconds())
                                val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault())
                                "${localDateTime.year}-${localDateTime.monthNumber.toString().padStart(2, '0')}-${localDateTime.dayOfMonth.toString().padStart(2, '0')}"
                            } catch (_: Exception) {
                                "unknown"
                            }
                            Text(
                                text = "$reviewDate \u2014 ${review.rating.label} (ease: ${"%.2f".format(review.easeFactor)}, interval: ${"%.1f".format(review.intervalDays)}d)",
                                style = MaterialTheme.typography.bodySmall,
                                color = MaterialTheme.colorScheme.onSurfaceVariant,
                                modifier = Modifier.padding(start = 4.dp, top = 1.dp)
                            )
                        }
                    }
                }
            }
        }
    }
}

Related Modules

  • Uses the org-mode-kmp AST from parser.org

  • Uses AndroidFileSystem and AttachmentResolver from indexer.org

  • Uses OrgDocumentCache from app/data.org (indirectly via ViewModel)

  • Consumed by screen.org (ReadOnlyView)

  • Also consumed by QuizScreen (via RenderMode.BODY_ONLY)