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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user