Colour list markers by depth, highlight fences, draw images as links, and stream a list an item at a time
Items 1-5 of TRANSCRIPT_RENDERING.md's list, plus the AGP 9.4.0 bump from 7. MarkdownRoot provides the renderer's locals itself instead of calling its Markdown() composable; fences and indented blocks go through CodeFence.kt, which shares the tool-input highlighter and a fence-language alias table; an image in a paragraph is a link carrying its alt text, so every paragraph is now platform text; LiveParse freezes the finished items of the tail list so a forty-item list streams as forty paragraphs would. Measured before, on the emulator (report from transcript-bench.sh over the 200-line fence fixture): draw phase 0.72ms per frame, transcript 0.36ms. stream-bench.sh (new) streaming forty linked bullets on the old build: markdown reparsed while streaming 483, 3.9ms mean, 11.6ms worst; record: one block worst 1.6ms. The after runs, the on-screen check of the glyphs and lint are recorded as owed in the doc's "What is next". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
801618ba0e
commit
6892dc7caf
9 files changed
+533
-161
No files matched your search
@@ -0,0 +1,194 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.isTraversalGroup
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownColors
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownDimens
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownPadding
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownCodeBlock
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownCodeFence
|
||||
import dev.snipme.highlights.Highlights
|
||||
import dev.snipme.highlights.model.BoldHighlight
|
||||
import dev.snipme.highlights.model.ColorHighlight
|
||||
import dev.snipme.highlights.model.SyntaxLanguage
|
||||
import org.intellij.markdown.ast.ASTNode
|
||||
|
||||
/**
|
||||
* A fenced code block in a reply: the code highlighted, on the dark surface every verbatim thing
|
||||
* sits on, scrolling sideways rather than wrapping.
|
||||
*
|
||||
* The renderer's own fence drew the same block in plain text. The lexer that colours a tool call's
|
||||
* command colours a reply's code the same way, through [highlighted] and one theme, so a `kotlin`
|
||||
* fence and the Kotlin a tool wrote are the same colours. A fence in a language the lexer has no
|
||||
* rules for is plain rather than wrongly coloured: [fenceLanguage] answers null for those, and
|
||||
* plain is what the reader would have seen before.
|
||||
*
|
||||
* Finding the code is still the library's: which children of the node are the fence markers, the
|
||||
* language word and the code between them is its knowledge of the parser, and [MarkdownCodeFence]
|
||||
* hands out the code and the language and leaves the drawing to the block it is given.
|
||||
*/
|
||||
@Composable
|
||||
fun CodeFence(content: String, node: ASTNode, style: TextStyle) {
|
||||
MarkdownCodeFence(content, node, style) { code, language, codeStyle ->
|
||||
CodeBlockText(code, language, codeStyle)
|
||||
}
|
||||
}
|
||||
|
||||
/** An indented code block, which is a fence with no language word. */
|
||||
@Composable
|
||||
fun CodeBlock(content: String, node: ASTNode, style: TextStyle) {
|
||||
MarkdownCodeBlock(content, node, style) { code, language, codeStyle ->
|
||||
CodeBlockText(code, language, codeStyle)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The renderer's own block, less what nothing here needs: the same background, corner, padding and
|
||||
* sideways scroll, without the shadow, the border and the empty pointer handler it also carried.
|
||||
* The vertical margin is the renderer's too, kept so a reply's fences sit where they always have.
|
||||
*/
|
||||
@Composable
|
||||
private fun CodeBlockText(code: String, language: String?, style: TextStyle) {
|
||||
val colors = LocalMarkdownColors.current
|
||||
val dimens = LocalMarkdownDimens.current
|
||||
val padding = LocalMarkdownPadding.current
|
||||
Box(
|
||||
Modifier.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
.background(colors.codeBackground, RoundedCornerShape(dimens.codeBackgroundCornerSize))
|
||||
.semantics { isTraversalGroup = true }
|
||||
) {
|
||||
BasicText(
|
||||
highlighted(code, fenceLanguage(language)),
|
||||
style = style,
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()).padding(padding.codeBlock),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The lexer's language for a fence's info word, or null for one it has no lexer for.
|
||||
*
|
||||
* The aliases are what people actually write after the backticks: the file extension as often as
|
||||
* the name. A word not here gets no colour rather than the nearest lexer's, because a fence
|
||||
* coloured by the wrong language's rules looks highlighted and is wrong in a way the reader cannot
|
||||
* see.
|
||||
*/
|
||||
fun fenceLanguage(name: String?): SyntaxLanguage? =
|
||||
FENCE_LANGUAGES[name?.trim()?.lowercase() ?: return null]
|
||||
|
||||
private val FENCE_LANGUAGES: Map<String, SyntaxLanguage> =
|
||||
mapOf(
|
||||
"kotlin" to SyntaxLanguage.KOTLIN,
|
||||
"kt" to SyntaxLanguage.KOTLIN,
|
||||
"kts" to SyntaxLanguage.KOTLIN,
|
||||
"rust" to SyntaxLanguage.RUST,
|
||||
"rs" to SyntaxLanguage.RUST,
|
||||
"sh" to SyntaxLanguage.SHELL,
|
||||
"bash" to SyntaxLanguage.SHELL,
|
||||
"shell" to SyntaxLanguage.SHELL,
|
||||
"zsh" to SyntaxLanguage.SHELL,
|
||||
"console" to SyntaxLanguage.SHELL,
|
||||
"python" to SyntaxLanguage.PYTHON,
|
||||
"py" to SyntaxLanguage.PYTHON,
|
||||
"javascript" to SyntaxLanguage.JAVASCRIPT,
|
||||
"js" to SyntaxLanguage.JAVASCRIPT,
|
||||
"jsx" to SyntaxLanguage.JAVASCRIPT,
|
||||
"typescript" to SyntaxLanguage.TYPESCRIPT,
|
||||
"ts" to SyntaxLanguage.TYPESCRIPT,
|
||||
"tsx" to SyntaxLanguage.TYPESCRIPT,
|
||||
"java" to SyntaxLanguage.JAVA,
|
||||
"c" to SyntaxLanguage.C,
|
||||
"h" to SyntaxLanguage.C,
|
||||
"cpp" to SyntaxLanguage.CPP,
|
||||
"c++" to SyntaxLanguage.CPP,
|
||||
"cc" to SyntaxLanguage.CPP,
|
||||
"hpp" to SyntaxLanguage.CPP,
|
||||
"csharp" to SyntaxLanguage.CSHARP,
|
||||
"cs" to SyntaxLanguage.CSHARP,
|
||||
"c#" to SyntaxLanguage.CSHARP,
|
||||
"go" to SyntaxLanguage.GO,
|
||||
"golang" to SyntaxLanguage.GO,
|
||||
"swift" to SyntaxLanguage.SWIFT,
|
||||
"dart" to SyntaxLanguage.DART,
|
||||
"ruby" to SyntaxLanguage.RUBY,
|
||||
"rb" to SyntaxLanguage.RUBY,
|
||||
"php" to SyntaxLanguage.PHP,
|
||||
"perl" to SyntaxLanguage.PERL,
|
||||
"pl" to SyntaxLanguage.PERL,
|
||||
"coffeescript" to SyntaxLanguage.COFFEESCRIPT,
|
||||
"coffee" to SyntaxLanguage.COFFEESCRIPT,
|
||||
)
|
||||
|
||||
/**
|
||||
* [code] with its keywords and strings coloured, or plain if there is no language for it.
|
||||
*
|
||||
* The lexing is dev.snipme:highlights. The colours are this app's, mapped in [catppuccinSyntax] --
|
||||
* a library's default theme would be the one place in the app whose palette came from somewhere
|
||||
* else. Shared by a tool call's input ([ToolInputView]) and a reply's fences ([CodeFence]), so the
|
||||
* same code is the same colours wherever it appears.
|
||||
*
|
||||
* Timed, because a fence is highlighted whole and a reply still arriving re-highlights its last
|
||||
* block on every delta; the counter says what that costs before anybody has to guess.
|
||||
*/
|
||||
@Composable
|
||||
fun highlighted(code: String, language: SyntaxLanguage?): AnnotatedString {
|
||||
val theme = catppuccinSyntax()
|
||||
val plain = MaterialTheme.colorScheme.onSurface
|
||||
return remember(code, language, theme, plain) {
|
||||
if (language == null) return@remember AnnotatedString(code)
|
||||
val marks =
|
||||
DebugStats.timed("code highlighted") {
|
||||
Highlights.Builder(code = code, language = language, theme = theme)
|
||||
.build()
|
||||
.getHighlights()
|
||||
// highlights 1.1.0's shell lexer answers a quoted glob that looks like a
|
||||
// comment -- `x '*/a/*'` is the smallest input -- with a span whose end is
|
||||
// before its start, and AnnotatedString refuses such a range. That crashed
|
||||
// the app the moment a card holding `-path '*/.git/*'` was opened. Dropped
|
||||
// rather than clamped: a span the lexer got backwards is not one it knows
|
||||
// the colour of. Delete when snipme/highlights fixes it.
|
||||
.filter {
|
||||
it.location.start in 0..it.location.end && it.location.end <= code.length
|
||||
}
|
||||
}
|
||||
buildAnnotatedString {
|
||||
append(code)
|
||||
marks.forEach { mark ->
|
||||
when (mark) {
|
||||
is ColorHighlight ->
|
||||
addStyle(
|
||||
SpanStyle(color = Color(mark.rgb or 0xFF000000.toInt())),
|
||||
mark.location.start,
|
||||
mark.location.end,
|
||||
)
|
||||
is BoldHighlight ->
|
||||
addStyle(
|
||||
SpanStyle(fontWeight = FontWeight.Bold),
|
||||
mark.location.start,
|
||||
mark.location.end,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.key
|
||||
@@ -33,21 +34,30 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mikepenz.markdown.compose.LocalImageTransformer
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownAnimations
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownColors
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownComponents
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownDimens
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownPadding
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownTypography
|
||||
import com.mikepenz.markdown.compose.LocalReferenceLinkHandler
|
||||
import com.mikepenz.markdown.compose.components.markdownComponents
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownDivider
|
||||
import com.mikepenz.markdown.compose.elements.listDepth
|
||||
import com.mikepenz.markdown.m3.Markdown
|
||||
import com.mikepenz.markdown.m3.elements.MarkdownCheckBox
|
||||
import com.mikepenz.markdown.m3.markdownColor
|
||||
import com.mikepenz.markdown.m3.markdownTypography
|
||||
import com.mikepenz.markdown.model.NoOpImageTransformerImpl
|
||||
import com.mikepenz.markdown.model.State
|
||||
import com.mikepenz.markdown.model.markdownAnimations
|
||||
import com.mikepenz.markdown.model.markdownDimens
|
||||
import com.mikepenz.markdown.model.markdownPadding
|
||||
import com.mikepenz.markdown.model.parseMarkdown
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.intellij.markdown.MarkdownTokenTypes
|
||||
import org.intellij.markdown.ast.ASTNode
|
||||
import org.intellij.markdown.ast.findChildOfType
|
||||
import org.intellij.markdown.flavours.gfm.GFMElementTypes
|
||||
@@ -79,13 +89,15 @@ fun MarkdownText(
|
||||
Column(modifier.fillMaxWidth()) {
|
||||
var previous: Piece? = null
|
||||
var previousSegment: Segment? = null
|
||||
segments.forEach { segment ->
|
||||
segments.forEachIndexed { at, segment ->
|
||||
val nextContinues = segments.getOrNull(at + 1)?.continues == true
|
||||
MarkdownRoot(segment.parse) {
|
||||
segment.pieces.forEach { piece ->
|
||||
segment.pieces.forEachIndexed { index, piece ->
|
||||
val gap =
|
||||
when {
|
||||
previousSegment == null -> 0.dp
|
||||
previousSegment !== segment -> BLOCK_SPACING
|
||||
previousSegment !== segment ->
|
||||
if (segment.continues) 0.dp else BLOCK_SPACING
|
||||
else -> gapBefore(previous, piece)
|
||||
}
|
||||
// Keyed by where the piece starts in the message rather than by its position
|
||||
@@ -106,6 +118,8 @@ fun MarkdownText(
|
||||
System.nanoTime() - started,
|
||||
)
|
||||
},
|
||||
continuesList = segment.continues && index == 0,
|
||||
listContinues = nextContinues && index == segment.pieces.lastIndex,
|
||||
)
|
||||
}
|
||||
previous = piece
|
||||
@@ -117,10 +131,19 @@ fun MarkdownText(
|
||||
}
|
||||
|
||||
/**
|
||||
* A stretch of a message with a parse of its own: the whole of a settled message, or one block or
|
||||
* the unfinished tail of a live one. [start] is where [text] begins in the message.
|
||||
* A stretch of a message with a parse of its own: the whole of a settled message, or one block, the
|
||||
* finished items of one list, or the unfinished tail of a live one. [start] is where [text] begins
|
||||
* in the message. [continues] says the first piece is an item of the list the segment before it
|
||||
* ended with, so the two draw as one list: no block gap between them, and neither the item above
|
||||
* the seam nor the one below it takes the padding of a list's edge.
|
||||
*/
|
||||
private class Segment(val text: String, val start: Int, val parse: State, val pieces: List<Piece>)
|
||||
private class Segment(
|
||||
val text: String,
|
||||
val start: Int,
|
||||
val parse: State,
|
||||
val pieces: List<Piece>,
|
||||
val continues: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* The live reply's segments: parsed on the composing thread the first time the row is drawn, and
|
||||
@@ -168,6 +191,13 @@ private fun liveSegments(text: String): List<Segment> {
|
||||
* that finished it, and only the tail -- the last block and whatever has arrived since -- is parsed
|
||||
* again.
|
||||
*
|
||||
* A list is cut once more, at its last item, by the same reasoning one level down: an item is
|
||||
* finished once the next item has begun, since a line can only continue the item it is indented
|
||||
* under or start a new one. Without this a reply that is one long list -- forty sources -- parsed
|
||||
* the whole list per delta, and a list streams as forty paragraphs would. The item the cut lands on
|
||||
* has to have begun in earnest: a bare `-` is an empty item now and the first character of a
|
||||
* paragraph line once `-x` arrives, and cutting on it would draw that line as a new item.
|
||||
*
|
||||
* What the cut gives up is one thing: a reference definition arriving later than a link that uses
|
||||
* it, since the frozen block's parse never sees it. The link draws as its brackets until the reply
|
||||
* settles and is parsed whole by [warm], which is the same moment every other transient of
|
||||
@@ -190,30 +220,77 @@ private class LiveParse(
|
||||
val tailText = next.substring(consumed)
|
||||
val parse = parseMarkdown(tailText)
|
||||
val all = pieces(parse)
|
||||
val blocks = all.map { it.block }.distinct()
|
||||
if (blocks.size <= 1 || parse !is State.Success) {
|
||||
return LiveParse(next, frozen, consumed, Segment(tailText, consumed, parse, all))
|
||||
val open = (parse as? State.Success)?.let { openPiece(it, all) }
|
||||
if (open == null || parse !is State.Success) {
|
||||
return LiveParse(
|
||||
next,
|
||||
frozen,
|
||||
consumed,
|
||||
Segment(tailText, consumed, parse, all, tail.continues),
|
||||
)
|
||||
}
|
||||
val done =
|
||||
blocks.dropLast(1).map { block ->
|
||||
Segment(tailText, consumed, parse, all.filter { it.block == block })
|
||||
all.subList(0, all.indexOf(open))
|
||||
.groupBy { it.block }
|
||||
.values
|
||||
.mapIndexed { at, pieces ->
|
||||
Segment(
|
||||
tailText,
|
||||
consumed,
|
||||
parse,
|
||||
pieces,
|
||||
continues = at == 0 && tail.continues,
|
||||
)
|
||||
}
|
||||
// Cut at the start of the open piece's line rather than at the piece, so an indented item
|
||||
// or block keeps the indentation the parse of the rest reads its nesting from.
|
||||
val node =
|
||||
parse.node.children[open.block].let {
|
||||
if (open.item == Piece.WHOLE_BLOCK) it else it.listItems()[open.item]
|
||||
}
|
||||
val cut = parse.node.children[blocks.last()].startOffset
|
||||
val cut = tailText.lastIndexOf('\n', node.startOffset) + 1
|
||||
val rest = tailText.substring(cut)
|
||||
val restParse = parseMarkdown(rest)
|
||||
return LiveParse(
|
||||
next,
|
||||
frozen + done,
|
||||
consumed + cut,
|
||||
Segment(rest, consumed + cut, restParse, pieces(restParse)),
|
||||
Segment(rest, consumed + cut, restParse, pieces(restParse), continues = open.item > 0),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The piece of the tail still being written: the last item of a list of several, or the first
|
||||
* piece of the last block when there is more than one block. Null when nothing before it is
|
||||
* finished, so the tail stays whole.
|
||||
*/
|
||||
private fun openPiece(parse: State.Success, all: List<Piece>): Piece? {
|
||||
val last = all.lastOrNull() ?: return null
|
||||
val lastBlockStart = all.indexOfFirst { it.block == last.block }
|
||||
return when {
|
||||
last.item > 0 && parse.node.children[last.block].listItems()[last.item].hasBegun -> last
|
||||
lastBlockStart > 0 -> all[lastBlockStart]
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a list item holds anything beyond its marker yet. */
|
||||
private val ASTNode.hasBegun: Boolean
|
||||
get() = children.any { it.type !in MARKER_TOKENS }
|
||||
|
||||
companion object {
|
||||
fun whole(text: String): LiveParse {
|
||||
val parse = parseMarkdown(text)
|
||||
return LiveParse(text, emptyList(), 0, Segment(text, 0, parse, pieces(parse)))
|
||||
}
|
||||
|
||||
private val MARKER_TOKENS =
|
||||
setOf(
|
||||
MarkdownTokenTypes.LIST_BULLET,
|
||||
MarkdownTokenTypes.LIST_NUMBER,
|
||||
MarkdownTokenTypes.WHITE_SPACE,
|
||||
MarkdownTokenTypes.EOL,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,6 +318,12 @@ fun MarkdownPiece(
|
||||
* piece be drawn anywhere -- in a message's column, or as one item of the transcript list.
|
||||
* Everything below this is the mapping onto the app's palette and type scale.
|
||||
*
|
||||
* The locals are provided directly rather than through the renderer's `Markdown()` composable,
|
||||
* which was the last of its composables on the hot path and was here only to provide them. What
|
||||
* that buys is that nothing between a piece and the screen is the library's but the leaf
|
||||
* composables named in the component table, so a different parser could stand behind [State]
|
||||
* without the renderer's entry point being involved.
|
||||
*
|
||||
* Colours come from the theme rather than from the renderer's defaults, so code, links and rules
|
||||
* are the same Catppuccin values the rest of the app uses. Nothing here picks a colour of its own.
|
||||
*/
|
||||
@@ -252,9 +335,15 @@ private fun MarkdownRoot(parse: State, content: @Composable () -> Unit) {
|
||||
return
|
||||
}
|
||||
val body = MaterialTheme.typography.bodyLarge
|
||||
Markdown(
|
||||
parse,
|
||||
colors =
|
||||
CompositionLocalProvider(
|
||||
LocalReferenceLinkHandler provides parse.referenceLinkHandler,
|
||||
LocalMarkdownPadding provides markdownPadding(),
|
||||
// Read by the renderer's own text composable, which no paragraph reaches any more, and
|
||||
// by its checkbox. Provided so a path that does reach them draws no image rather than
|
||||
// failing to compose.
|
||||
LocalImageTransformer provides remember { NoOpImageTransformerImpl() },
|
||||
LocalMarkdownAnimations provides markdownAnimations(),
|
||||
LocalMarkdownColors provides
|
||||
markdownColor(
|
||||
text = MaterialTheme.colorScheme.onSurface,
|
||||
dividerColor = MaterialTheme.colorScheme.outlineVariant,
|
||||
@@ -269,7 +358,7 @@ private fun MarkdownRoot(parse: State, content: @Composable () -> Unit) {
|
||||
// so the table would have had a border-less grid and nothing saying where it began.
|
||||
tableBackground = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
typography =
|
||||
LocalMarkdownTypography provides
|
||||
markdownTypography(
|
||||
// A ladder that starts near the body text and descends, because these are headings
|
||||
// inside a chat message rather than the top of a document. The renderer's defaults
|
||||
@@ -325,7 +414,7 @@ private fun MarkdownRoot(parse: State, content: @Composable () -> Unit) {
|
||||
.toSpanStyle()
|
||||
),
|
||||
),
|
||||
dimens =
|
||||
LocalMarkdownDimens provides
|
||||
markdownDimens(
|
||||
// Half the renderer's 16dp. Padding is charged on both sides of every cell, so at
|
||||
// the default a fifth of the narrowest column went on space rather than on words
|
||||
@@ -345,7 +434,7 @@ private fun MarkdownRoot(parse: State, content: @Composable () -> Unit) {
|
||||
// that keeps three columns on screen, which is the trade the number is making.
|
||||
tableCellWidth = 136.dp,
|
||||
),
|
||||
components =
|
||||
LocalMarkdownComponents provides
|
||||
markdownComponents(
|
||||
// The m3 renderer's own default, restored: supplying `components` at all replaces
|
||||
// the whole set, and this is the only member of it the Material layer overrides.
|
||||
@@ -368,9 +457,11 @@ private fun MarkdownRoot(parse: State, content: @Composable () -> Unit) {
|
||||
orderedList = { MarkdownList(it.content, it.node, it.listDepth) },
|
||||
unorderedList = { MarkdownList(it.content, it.node, it.listDepth) },
|
||||
table = { LinkedTable(it.content, it.node, it.typography.table) },
|
||||
// Code is highlighted the way a tool call's input is; see [CodeFence].
|
||||
codeFence = { CodeFence(it.content, it.node, it.typography.code) },
|
||||
codeBlock = { CodeBlock(it.content, it.node, it.typography.code) },
|
||||
),
|
||||
modifier = Modifier,
|
||||
success = { _, _, _ -> content() },
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,9 +21,10 @@ import com.mikepenz.markdown.annotator.annotatorSettings
|
||||
import com.mikepenz.markdown.annotator.buildMarkdownAnnotatedString
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownColors
|
||||
import com.mikepenz.markdown.compose.components.MarkdownComponentModel
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownText
|
||||
import com.mikepenz.markdown.model.markdownAnnotator
|
||||
import com.mikepenz.markdown.utils.getUnescapedTextInNode
|
||||
import com.mikepenz.markdown.utils.resolveImageAlt
|
||||
import com.mikepenz.markdown.utils.resolveImageLink
|
||||
import org.intellij.markdown.MarkdownElementTypes
|
||||
import org.intellij.markdown.MarkdownTokenTypes
|
||||
import org.intellij.markdown.ast.ASTNode
|
||||
@@ -51,6 +52,12 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes
|
||||
* paragraphs inside lists, quotes and alerts, and so does every table cell through
|
||||
* [LinkedTableRow]. Reference-style links are the one kind still drawn the renderer's way; it
|
||||
* resolves those against its definitions.
|
||||
*
|
||||
* An image is a link too, carrying its alt text. The app has no image loader and the renderer's
|
||||
* transformer was the no-op one, so an image in a reply drew as nothing at all -- a hole where the
|
||||
* model put something, with no sign of what fell out. The link says what was there and where, and
|
||||
* opens it. It also means no paragraph needs the renderer's own text composable, which existed to
|
||||
* place inline images and charged every paragraph for the possibility.
|
||||
*/
|
||||
@Composable
|
||||
fun LinkedText(model: MarkdownComponentModel, style: TextStyle) {
|
||||
@@ -82,41 +89,23 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
|
||||
}
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val layout = remember { Ref<TextLayoutResult>() }
|
||||
val tapping =
|
||||
modifier.pointerInput(text) {
|
||||
detectTapGestures { position ->
|
||||
val url = text.linkAt(layout.value, position) ?: return@detectTapGestures
|
||||
uriHandler.openUri(url)
|
||||
}
|
||||
}
|
||||
// The renderer's text composable exists to place inline images, and it charges every text
|
||||
// for the possibility: a placement callback, a derived map of inline content, a semantics
|
||||
// group and a size animation, per paragraph. Almost no paragraph has an image, so those go
|
||||
// straight to the platform text; the few that do keep the renderer's path.
|
||||
if (remember(node) { node.hasImage() }) {
|
||||
MarkdownText(
|
||||
content = text,
|
||||
node = node,
|
||||
modifier = tapping,
|
||||
style = style,
|
||||
onTextLayout = { result, _ -> layout.value = result },
|
||||
)
|
||||
} else {
|
||||
// The renderer's own rule for a style that names no colour: the theme's text colour.
|
||||
val color = if (style.color.isSpecified) style.color else LocalMarkdownColors.current.text
|
||||
BasicText(
|
||||
text = text,
|
||||
modifier = tapping,
|
||||
style = style,
|
||||
color = { color },
|
||||
onTextLayout = { layout.value = it },
|
||||
)
|
||||
}
|
||||
// The renderer's own rule for a style that names no colour: the theme's text colour.
|
||||
val color = if (style.color.isSpecified) style.color else LocalMarkdownColors.current.text
|
||||
BasicText(
|
||||
text = text,
|
||||
modifier =
|
||||
modifier.pointerInput(text) {
|
||||
detectTapGestures { position ->
|
||||
val url = text.linkAt(layout.value, position) ?: return@detectTapGestures
|
||||
uriHandler.openUri(url)
|
||||
}
|
||||
},
|
||||
style = style,
|
||||
color = { color },
|
||||
onTextLayout = { layout.value = it },
|
||||
)
|
||||
}
|
||||
|
||||
private fun ASTNode.hasImage(): Boolean =
|
||||
type == MarkdownElementTypes.IMAGE || children.any { it.hasImage() }
|
||||
|
||||
/**
|
||||
* The address under [position], if a link's glyph is there rather than merely nearest to it.
|
||||
*
|
||||
@@ -153,7 +142,7 @@ private fun plainLinkSettings(): AnnotatorSettings {
|
||||
|
||||
/**
|
||||
* Appends [node] as a styled, annotated span if it is a link the renderer would otherwise emit a
|
||||
* `LinkAnnotation` for; false leaves anything else to the renderer.
|
||||
* `LinkAnnotation` for, or an image it would place; false leaves anything else to the renderer.
|
||||
*/
|
||||
private fun appendPlainLink(
|
||||
builder: AnnotatedString.Builder,
|
||||
@@ -162,7 +151,10 @@ private fun appendPlainLink(
|
||||
settings: AnnotatorSettings,
|
||||
): Boolean {
|
||||
val destination: String
|
||||
val label: List<ASTNode>?
|
||||
/** The label's own inline nodes, when it has markup of its own to draw. */
|
||||
var label: List<ASTNode>? = null
|
||||
/** Plain words for the label; the address itself when there are none. */
|
||||
var words: String? = null
|
||||
when (node.type) {
|
||||
MarkdownElementTypes.INLINE_LINK -> {
|
||||
val text = node.findChildOfType(MarkdownElementTypes.LINK_TEXT) ?: return false
|
||||
@@ -174,20 +166,20 @@ private fun appendPlainLink(
|
||||
// The brackets are the first and last children of the label.
|
||||
label = text.children.drop(1).dropLast(1)
|
||||
}
|
||||
MarkdownElementTypes.AUTOLINK -> {
|
||||
MarkdownElementTypes.AUTOLINK ->
|
||||
destination = node.getUnescapedTextInNode(content).removeSurrounding("<", ">")
|
||||
label = null
|
||||
}
|
||||
GFMTokenTypes.GFM_AUTOLINK -> {
|
||||
destination = node.getUnescapedTextInNode(content)
|
||||
label = null
|
||||
GFMTokenTypes.GFM_AUTOLINK -> destination = node.getUnescapedTextInNode(content)
|
||||
MarkdownElementTypes.IMAGE -> {
|
||||
destination =
|
||||
node.resolveImageLink(content, settings.referenceLinkHandler) ?: return false
|
||||
words = node.resolveImageAlt(content)
|
||||
}
|
||||
else -> return false
|
||||
}
|
||||
builder.pushStringAnnotation(LINK_URL, destination)
|
||||
builder.pushStyle(settings.linkTextSpanStyle.style ?: SpanStyle())
|
||||
if (label == null) builder.append(destination)
|
||||
else builder.buildMarkdownAnnotatedString(content, label, settings)
|
||||
if (label != null) builder.buildMarkdownAnnotatedString(content, label, settings)
|
||||
else builder.append(words ?: destination)
|
||||
builder.pop()
|
||||
builder.pop()
|
||||
return true
|
||||
|
||||
@@ -16,7 +16,6 @@ import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownColors
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownComponents
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownPadding
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownTypography
|
||||
@@ -98,9 +97,20 @@ val BLOCK_SPACING: Dp = 6.dp
|
||||
* or heading is a [LinkedText], a table is [LinkedTableRow]s, and a nested list comes back here
|
||||
* through [MarkdownList]. Only the list item is drawn directly, because a list item is the one
|
||||
* piece the renderer has no element for.
|
||||
*
|
||||
* [continuesList] and [listContinues] are for a list cut across the segments of a live reply (see
|
||||
* `LiveParse`): an item that is the first or last of its own parse but not of the list the reader
|
||||
* sees keeps an inner item's padding, so nothing moves when the seam between segments does.
|
||||
*/
|
||||
@Composable
|
||||
fun MarkdownPiece(parse: State, text: String, piece: Piece, modifier: Modifier = Modifier) {
|
||||
fun MarkdownPiece(
|
||||
parse: State,
|
||||
text: String,
|
||||
piece: Piece,
|
||||
modifier: Modifier = Modifier,
|
||||
continuesList: Boolean = false,
|
||||
listContinues: Boolean = false,
|
||||
) {
|
||||
if (parse !is State.Success) {
|
||||
// The parser threw. Nothing else in the app has seen this happen; if it does, the words
|
||||
// are still worth more than a blank.
|
||||
@@ -124,8 +134,8 @@ fun MarkdownPiece(parse: State, text: String, piece: Piece, modifier: Modifier =
|
||||
list = node,
|
||||
item = items[piece.item],
|
||||
index = piece.item,
|
||||
first = piece.item == 0,
|
||||
last = piece.item == items.lastIndex,
|
||||
first = piece.item == 0 && !continuesList,
|
||||
last = piece.item == items.lastIndex && !listContinues,
|
||||
depth = 0,
|
||||
modifier = modifier,
|
||||
)
|
||||
@@ -195,7 +205,7 @@ private fun MarkdownListItem(
|
||||
} else if (list.type == MarkdownElementTypes.ORDERED_LIST) {
|
||||
Marker("${list.startNumber(content) + index}. ", typography.ordered)
|
||||
} else {
|
||||
Marker("• ", typography.bullet)
|
||||
Marker(BULLETS[depth % BULLETS.size], typography.bullet)
|
||||
}
|
||||
Column {
|
||||
item.children.forEach { child ->
|
||||
@@ -212,16 +222,24 @@ private fun MarkdownListItem(
|
||||
}
|
||||
}
|
||||
|
||||
/** The renderer's text colour on the marker; its styles carry none of their own. */
|
||||
/** The marker in [listMarkerColor]; the renderer's styles carry no colour of their own. */
|
||||
@Composable
|
||||
private fun Marker(text: String, style: TextStyle) {
|
||||
BasicText(text, style = style.copy(color = LocalMarkdownColors.current.text))
|
||||
BasicText(text, style = style.copy(color = listMarkerColor))
|
||||
}
|
||||
|
||||
private val ASTNode.isList: Boolean
|
||||
/**
|
||||
* The bullet at each depth, cycling past the third: a disc, a ring, a square -- the ladder a
|
||||
* browser draws, so a nested list is told from its parent by the glyph as well as by the indent.
|
||||
* Checked on the emulator's system fonts, which is what makes them safe to rely on; a glyph the
|
||||
* platform lacks draws as a box, and that check is the price of adding one here.
|
||||
*/
|
||||
private val BULLETS = listOf("• ", "◦ ", "▪ ")
|
||||
|
||||
internal val ASTNode.isList: Boolean
|
||||
get() = type == MarkdownElementTypes.ORDERED_LIST || type == MarkdownElementTypes.UNORDERED_LIST
|
||||
|
||||
private fun ASTNode.listItems(): List<ASTNode> = children.filter {
|
||||
internal fun ASTNode.listItems(): List<ASTNode> = children.filter {
|
||||
it.type == MarkdownElementTypes.LIST_ITEM
|
||||
}
|
||||
|
||||
|
||||
@@ -224,6 +224,19 @@ fun catppuccinSyntax(): SyntaxTheme =
|
||||
val linkColor: Color
|
||||
@Composable get() = Mocha.Blue
|
||||
|
||||
/**
|
||||
* A list's markers: the bullets and numbers down its left edge.
|
||||
*
|
||||
* The scheme's secondary accent rather than the text colour, because a marker is structure rather
|
||||
* than words: coloured, the items of a list can be counted without reading them, and a nested list
|
||||
* reads as a shape before it reads as text. Lavender is not one of the colours that mean something
|
||||
* here -- green, red, peach and yellow are states and actions -- and it is the same at every depth,
|
||||
* since depth is said by the glyph and the indent; a colour per depth would make a difference in
|
||||
* degree look like one in kind.
|
||||
*/
|
||||
val listMarkerColor: Color
|
||||
@Composable get() = Mocha.Lavender
|
||||
|
||||
/** Past a limit. The scheme's error colour, for the reason [failedColor] gives. */
|
||||
val overLimitColor: Color
|
||||
@Composable get() = MaterialTheme.colorScheme.error
|
||||
|
||||
@@ -9,15 +9,8 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
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.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.snipme.highlights.Highlights
|
||||
import dev.snipme.highlights.model.BoldHighlight
|
||||
import dev.snipme.highlights.model.ColorHighlight
|
||||
import dev.snipme.highlights.model.SyntaxLanguage
|
||||
import org.json.JSONObject
|
||||
|
||||
@@ -142,56 +135,3 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [code] with its keywords and strings coloured, or plain if there is no language for it.
|
||||
*
|
||||
* The lexing is dev.snipme:highlights. The colours are this app's, mapped in [catppuccinSyntax] --
|
||||
* a library's default theme would be the one place in the app whose palette came from somewhere
|
||||
* else.
|
||||
*/
|
||||
@Composable
|
||||
private fun highlighted(code: String, language: SyntaxLanguage?): AnnotatedString {
|
||||
val theme = catppuccinSyntax()
|
||||
val plain = MaterialTheme.colorScheme.onSurface
|
||||
return remember(code, language, theme, plain) {
|
||||
if (language == null) return@remember AnnotatedString(code)
|
||||
val marks =
|
||||
Highlights.Builder(code = code, language = language, theme = theme)
|
||||
.build()
|
||||
.getHighlights()
|
||||
// highlights 1.1.0's shell lexer answers a quoted glob that looks like a
|
||||
// comment -- `x '*/a/*'` is the smallest input -- with a span whose end is
|
||||
// before its start, and AnnotatedString refuses such a range. That crashed the
|
||||
// app the moment a card holding `-path '*/.git/*'` was opened. Dropped rather
|
||||
// than clamped: a span the lexer got backwards is not one it knows the colour
|
||||
// of. Delete when snipme/highlights fixes it.
|
||||
.filter {
|
||||
it.location.start in 0..it.location.end && it.location.end <= code.length
|
||||
}
|
||||
buildAnnotatedString {
|
||||
append(code)
|
||||
marks.forEach { mark ->
|
||||
when (mark) {
|
||||
is ColorHighlight ->
|
||||
addStyle(
|
||||
SpanStyle(
|
||||
color =
|
||||
androidx.compose.ui.graphics.Color(
|
||||
mark.rgb or 0xFF000000.toInt()
|
||||
)
|
||||
),
|
||||
mark.location.start,
|
||||
mark.location.end,
|
||||
)
|
||||
is BoldHighlight ->
|
||||
addStyle(
|
||||
SpanStyle(fontWeight = FontWeight.Bold),
|
||||
mark.location.start,
|
||||
mark.location.end,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user