app: smooth streaming markdown via incremental parser + typewriter reveal
- MarkdownText: streaming branch on the library's StreamingMarkdownState (rememberStreamingMarkdownPipeline) — incremental append, no full re-parse per snapshot; static path keeps retainState=true - client-side reveal: buffer snapshots, reveal at a steady rate (streamCharsPerSecond = 60/smoothness, 300..75 c/s) with rate-relative catch-up (jump when backlog > 2s of reveal time) - keep the streaming renderer alive after message.stop until the reveal catches up (useStreaming gate); artifact card waits for the same - cursor drawn via annotator, never part of the parse input - new 'Streaming speed' setting (0.2-0.8s) in Settings, persisted per device via SecureStore, live-updatable - docs/05-streaming: rate mapping, catch-up, wait-for-reveal semantics
This commit is contained in:
1 parent
c7a16d51e3
commit
83a67f6fb1
10 files changed
+555
-100
No files matched your search
@@ -2,9 +2,9 @@ package iris.util
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class MarkdownTest {
|
||||
|
||||
@Test
|
||||
fun stripsLeadingNewlines() {
|
||||
assertEquals("Hello", "\n\nHello".prepareForMarkdown())
|
||||
@@ -61,4 +61,90 @@ class MarkdownTest {
|
||||
val md = "**Title:** x\n**Model:** y"
|
||||
assertEquals("**Title:** x \n**Model:** y ", md.preserveNewlinesAsHardBreaks())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun streamingTransformSkipsLastLineOnly() {
|
||||
// Identical to the static transform except the (still-incomplete)
|
||||
// last line gets no trailing hard-break spaces.
|
||||
assertEquals("a \nb", "a\nb".preserveNewlinesAsHardBreaksStreaming())
|
||||
assertEquals("a \n\nb", "a\n\nb".preserveNewlinesAsHardBreaksStreaming())
|
||||
assertEquals("a", "a".preserveNewlinesAsHardBreaksStreaming())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun streamingTransformLeavesFencedCodeBlocksUntouched() {
|
||||
val md = "```kotlin\nval a = 1\nval b = 2\n```"
|
||||
assertEquals(md, md.preserveNewlinesAsHardBreaksStreaming())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun streamingTransformIsPrefixPreserving() {
|
||||
// The core invariant the incremental renderer relies on: for every
|
||||
// prefix p of s, transform(p) is a prefix of transform(s).
|
||||
val docs =
|
||||
listOf(
|
||||
"a\nb",
|
||||
"a\n\nb\nc",
|
||||
"**bold** and `code`",
|
||||
"```kotlin\nval a = 1\n```\nthen text",
|
||||
"~~~\nx\n~~~\ny",
|
||||
"| a | b |\n| - | - |\n| 1 | 2 |",
|
||||
"- item\n- item2",
|
||||
"line with trailing spaces \nnext",
|
||||
"",
|
||||
"\n\n \n",
|
||||
)
|
||||
for (s in docs) {
|
||||
val whole = s.preserveNewlinesAsHardBreaksStreaming()
|
||||
for (i in 0..s.length) {
|
||||
val p = s.take(i)
|
||||
val tp = p.preserveNewlinesAsHardBreaksStreaming()
|
||||
assertTrue(
|
||||
whole.startsWith(tp),
|
||||
"prefix <$tp> not a prefix of <$whole> (doc=<$s>)",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appendChunkReturnsSuffixOnExtension() {
|
||||
assertEquals(" world", appendChunk("Hello", "Hello world"))
|
||||
assertEquals("", appendChunk("abc", "abc"))
|
||||
assertEquals("abc", appendChunk("", "abc"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appendChunkReturnsNullOnRewrite() {
|
||||
assertEquals(null, appendChunk("Hello", "Hi"))
|
||||
assertEquals(null, appendChunk("Hello world", "Hello"))
|
||||
assertEquals(null, appendChunk("a ", "ab")) // hard-break spaces shift
|
||||
}
|
||||
|
||||
@Test
|
||||
fun revealStepRevealsAtLeastOneChar() {
|
||||
assertEquals(1, revealStep(2, charsPerSecond = 1.0, tickSeconds = 0.05))
|
||||
assertEquals(3, revealStep(100, charsPerSecond = 60.0, tickSeconds = 0.05))
|
||||
assertEquals(0, revealStep(0, charsPerSecond = 60.0, tickSeconds = 0.05))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun revealStepCatchesUpWhenBacklogIsLarge() {
|
||||
// Backlog beyond ~2 s of reveal time (120 * 2 = 240) jumps at once.
|
||||
assertEquals(500, revealStep(500, charsPerSecond = 120.0, tickSeconds = 0.05))
|
||||
assertEquals(241, revealStep(241, charsPerSecond = 120.0, tickSeconds = 0.05))
|
||||
// At exactly 2 s of backlog it still steps normally (6 = 120 * 0.05).
|
||||
assertEquals(6, revealStep(240, charsPerSecond = 120.0, tickSeconds = 0.05))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun streamCharsPerSecondMapsSmoothnessRange() {
|
||||
// 0.2 → 300 chars/s (fast), 0.8 → 75 chars/s (slow); out-of-range
|
||||
// values are clamped to the ends.
|
||||
assertEquals(300.0, streamCharsPerSecond(0.2f), 0.001)
|
||||
assertEquals(150.0, streamCharsPerSecond(0.4f), 0.001)
|
||||
assertEquals(75.0, streamCharsPerSecond(0.8f), 0.001)
|
||||
assertEquals(300.0, streamCharsPerSecond(0.05f), 0.001)
|
||||
assertEquals(75.0, streamCharsPerSecond(2.0f), 0.001)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user