Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba2afbaedb | ||
|
|
10267dec27 | ||
|
|
7e7cbb5402 | ||
|
|
a200ddbddd | ||
|
|
b332873894 | ||
|
|
a4809b3026 | ||
|
|
1ad2f9ec6e | ||
|
|
33e8ab83a2 | ||
|
|
f5b88932b4 | ||
|
|
9079276ec8 | ||
|
|
3cb18ac5c2 | ||
|
|
69525bd131 | ||
|
|
64f64b54e5 | ||
|
|
20303e0b4c | ||
|
|
6973a89815 | ||
|
|
c3cfc67bb3 | ||
|
|
155d899e55 | ||
|
|
e63e923d44 | ||
|
|
a56a928b0c | ||
|
|
0449a324ef | ||
|
|
e1030d69f6 | ||
|
|
167862ca1b | ||
|
|
d73db97629 | ||
|
|
fb6b459c2c | ||
|
|
76b1f99277 | ||
|
|
3e72a4ef19 | ||
|
|
c02152a4f4 | ||
|
|
d9872989fa | ||
|
|
2fed8b34b3 | ||
|
|
1f379e8384 | ||
|
|
bf3479f5c4 | ||
|
|
312455956d | ||
|
|
73251d6b8b | ||
|
|
2e00e71552 | ||
|
|
f802de94b5 | ||
|
|
9717d1c4b0 | ||
|
|
9458f443ad | ||
|
|
e12c708246 | ||
|
|
543f6d92f0 | ||
|
|
27ca5b2349 | ||
|
|
20b12255e1 | ||
|
|
71a3fae655 | ||
|
|
c3984da623 | ||
|
|
2e3f4ada38 | ||
|
|
03c6be80a3 | ||
|
|
4afc453faa | ||
|
|
1aab61bf26 | ||
|
|
dc01f88d75 | ||
|
|
c589a75fa0 | ||
|
|
4b62cc642e | ||
|
|
80c2eadec9 | ||
|
|
0b587629e6 | ||
|
|
3163256d2c | ||
|
|
6102e0d4d9 | ||
|
|
f0da383e28 | ||
|
|
2d3695a1d3 | ||
|
|
f06ee259b4 | ||
|
|
560a74caf8 | ||
|
|
fd7e17523d | ||
|
|
c7682297fa | ||
|
|
27511302f2 | ||
|
|
184a6c5b33 | ||
|
|
5b2ca039f1 | ||
|
|
a8d24553d5 | ||
|
|
3b80a88f3b | ||
|
|
d8e6bc6e9b | ||
|
|
b887a96765 | ||
|
|
2aaa3733c3 | ||
|
|
46246ea511 | ||
|
|
a27fbdb029 | ||
|
|
c07d544aeb | ||
|
|
46d3a6fd41 | ||
|
|
5655fa8093 | ||
|
|
b3b1d47dd6 | ||
|
|
50fe4828a2 |
No files matched your search
@@ -261,6 +261,13 @@ Each exists because something was invisible without it.
|
||||
framework, from `atrace` text output with no trace processor needed. It is
|
||||
how the cost of a layout node per link was attributed to the framework
|
||||
rather than guessed at.
|
||||
- **`iris/android-app/build-apk.sh [debug|release] [--abi ...] [--features
|
||||
...]`** builds iris-android-app's cdylib (`cargo ndk`) and its APK
|
||||
(Gradle) in one step and verifies the result (`aapt2`/`apksigner`), and
|
||||
**`iris/android-app/run-bench.sh [--apk PATH]`** installs it on this
|
||||
checkout's own emulator, taps "Run benchmark" by label, and prints the
|
||||
report -- written so the P0 build/install/tap/read-report cycle stops
|
||||
being retyped by hand each time (docs/RUST.md's P0 box).
|
||||
|
||||
### Driving the UI
|
||||
|
||||
|
||||
@@ -50,6 +50,12 @@ version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
@@ -77,6 +83,7 @@ name = "client-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"event-model",
|
||||
"pulldown-cmark",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"ureq",
|
||||
@@ -206,6 +213,15 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getopts"
|
||||
version = "0.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -490,6 +506,25 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"getopts",
|
||||
"memchr",
|
||||
"pulldown-cmark-escape",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark-escape"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
@@ -783,12 +818,24 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
||||
@@ -3,9 +3,13 @@ package com.example.aiapp
|
||||
import android.content.Context
|
||||
import android.os.BatteryManager
|
||||
import android.os.Process
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import android.view.View
|
||||
import androidx.compose.foundation.gestures.FlingBehavior
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -19,27 +23,84 @@ import kotlinx.coroutines.launch
|
||||
* here against [LazyListState] and [BenchFixture] directly. Only reachable from the `bench` build
|
||||
* (see [SessionSettingsDialog]'s `onRunBenchmark`), but compiled into every build for the reason
|
||||
* [BenchFixture]'s doc comment gives.
|
||||
*
|
||||
* **v2 (2026-09-06)**, asked for by Iris because the v1 fling was too gentle to stress-test the
|
||||
* scroll path and said nothing about typing or the keyboard. Four phases now, each a slice of the
|
||||
* same [FrameStats] recording ([FrameStats.markPhase]/[FrameStats.phaseLines] -- one recorder, not
|
||||
* two): **fling** (real `FlingBehavior`, not `animateScrollBy`), **stream** (unchanged from v1),
|
||||
* **type** (600 fixed characters into the real composer `TextFieldValue`, then deleted), and
|
||||
* **keyboard** (five show/hide cycles). The exact constants below are also written into
|
||||
* `docs/RUST.md`'s P0 box, "Benchmark v2 (2026-09-06)", so the iris half implements the identical
|
||||
* spec -- changing a number here without updating that box makes the two apps measure different
|
||||
* things while looking like the same benchmark.
|
||||
*/
|
||||
object BenchRun {
|
||||
/** transcript-bench.sh's default: 6 cycles of 4 swipes each, 900px over 200ms, 500ms apart. */
|
||||
/** transcript-bench.sh's default: 6 cycles of 4 swipes each, kept as the pre-v2 comparison. */
|
||||
private const val CYCLES = 6
|
||||
private const val SWIPE_PX = 900f
|
||||
private const val SWIPE_MS = 200
|
||||
private const val SWIPE_PAUSE_MS = 500L
|
||||
|
||||
/**
|
||||
* Fling phase (v2): a real fling through the list's own [FlingBehavior], not `animateScrollBy`
|
||||
* -- Iris's ask was that it "travel way faster" than the old tween-based swipe, and a tween can
|
||||
* never exceed the distance it is told to cover in the time it is given, while a real fling
|
||||
* decays from an initial velocity the way a finger flick does. 12,000 px/s is roughly a hard,
|
||||
* fast flick on a ~420dp/in device (about 30 dp/ms-equivalent initial speed); chosen well above
|
||||
* the ~4,500 px/s a moderate `animateScrollBy` swipe implies, so this phase exercises the fast
|
||||
* end of what the platform's fling decay produces rather than the gentle one v1 measured.
|
||||
*/
|
||||
private const val FLING_VELOCITY_PX_S = 12_000f
|
||||
|
||||
private const val FLING_COUNT = 8
|
||||
private const val FLING_SETTLE_CAP_MS = 3_000L
|
||||
private const val FLING_PAUSE_MS = 300L
|
||||
|
||||
/** stream-bench.sh's shape: a real reply arrives as many small deltas, not one big write. */
|
||||
private const val STREAM_EVENTS_PER_SEC = 20
|
||||
private const val STREAM_SECONDS = 20
|
||||
|
||||
/**
|
||||
* Scrolls, then streams, then returns the extra report lines P0 asked for (CPU time, peak RSS,
|
||||
* battery current) -- [FrameStats] and [DebugStats] are reset first, exactly as
|
||||
* `copyRenderReport` resets them, so the two accountings cover the same stretch of work.
|
||||
* Type phase (v2): sentences built from long, multisyllabic words so the composer actually
|
||||
* wraps across lines rather than fitting one, and long enough (600 chars) that the composer's
|
||||
* own height grows over several frames, pushing the transcript above it upward the same way a
|
||||
* real long message does. Exactly this string is also in `docs/RUST.md`'s P0 box so the iris
|
||||
* half types the identical content.
|
||||
*/
|
||||
const val TYPE_TEXT =
|
||||
"Benchmarking this transcript screen requires unusually long, multisyllabic words so " +
|
||||
"wrapping and reflow are properly exercised: internationalization, " +
|
||||
"counterproductiveness, disproportionately, incomprehensibility, " +
|
||||
"deinstitutionalization, uncharacteristically, overenthusiastically, " +
|
||||
"misunderstanding, straightforwardness, telecommunications, and interdisciplinary " +
|
||||
"collaboration all push a narrow composer field to wrap across several lines while " +
|
||||
"the transcript above is pushed upward by the growing keyboard-adjacent box, which " +
|
||||
"is exactly what a real reader typing a long message sees happening now!!!"
|
||||
|
||||
private const val TYPE_CHAR_DELAY_MS = 50L
|
||||
|
||||
/**
|
||||
* Keyboard phase (v2): five show/hide cycles, a second apart, is enough to see whether the
|
||||
* transition is ever actually observed rather than being a one-off fluke either way.
|
||||
*/
|
||||
private const val KEYBOARD_CYCLES = 5
|
||||
private const val KEYBOARD_SHOW_WAIT_MS = 1_000L
|
||||
private const val KEYBOARD_HIDE_WAIT_MS = 1_000L
|
||||
|
||||
/**
|
||||
* Scrolls, flings, streams, types and toggles the keyboard, then returns the extra report lines
|
||||
* P0 asked for (per-phase travel/typing/keyboard counts, plus CPU time, peak RSS, battery
|
||||
* current) -- [FrameStats] and [DebugStats] are reset first, exactly as `copyRenderReport`
|
||||
* resets them, so the two accountings cover the same stretch of work.
|
||||
*/
|
||||
suspend fun run(
|
||||
context: Context,
|
||||
scope: CoroutineScope,
|
||||
listState: LazyListState,
|
||||
flingBehavior: FlingBehavior,
|
||||
composerFocus: FocusRequester,
|
||||
setComposerText: (String) -> Unit,
|
||||
view: View,
|
||||
): List<String> {
|
||||
FrameStats.reset()
|
||||
DebugStats.reset()
|
||||
@@ -55,34 +116,10 @@ object BenchRun {
|
||||
}
|
||||
}
|
||||
|
||||
// The swipe loop: transcript-bench.sh's four swipes per cycle are two drags toward newer
|
||||
// content and two back, so a cycle returns to where it started and the whole loop measures
|
||||
// steady-state scrolling rather than travelling somewhere new each time.
|
||||
repeat(CYCLES) {
|
||||
repeat(2) {
|
||||
listState.animateScrollBy(SWIPE_PX, tween(SWIPE_MS))
|
||||
delay(SWIPE_PAUSE_MS)
|
||||
}
|
||||
repeat(2) {
|
||||
listState.animateScrollBy(-SWIPE_PX, tween(SWIPE_MS))
|
||||
delay(SWIPE_PAUSE_MS)
|
||||
}
|
||||
}
|
||||
|
||||
// Pinned to the newest end before streaming starts, the way stream-bench.sh's "Jump to
|
||||
// latest" tap is -- a reply streamed into a list parked further back arrives off-screen and
|
||||
// the report would show nothing happened.
|
||||
listState.scrollToItem(0)
|
||||
|
||||
var sent = 0
|
||||
val total = STREAM_EVENTS_PER_SEC * STREAM_SECONDS
|
||||
while (sent < total && BenchFixture.remainingStreamEvents() > 0) {
|
||||
BenchFixture.pushNextLiveEvent()
|
||||
sent++
|
||||
delay(1000L / STREAM_EVENTS_PER_SEC)
|
||||
}
|
||||
// Lets the last few deltas land and draw before the report is read.
|
||||
delay(300)
|
||||
val travel = runFlingPhase(listState, flingBehavior)
|
||||
val sent = runStreamPhase()
|
||||
runTypePhase(listState, composerFocus, setComposerText, view)
|
||||
val keyboard = runKeyboardPhase(context, view)
|
||||
|
||||
samplerJob.cancel()
|
||||
val cpuMs = Process.getElapsedCpuTime() - cpuStartMs
|
||||
@@ -90,13 +127,158 @@ object BenchRun {
|
||||
val batteryLine = battery.finish()
|
||||
|
||||
return listOf(
|
||||
" scroll: $CYCLES cycles (${CYCLES * 4} swipes), streamed $sent/$total fixture events",
|
||||
" fling: $FLING_COUNT flings out + $FLING_COUNT back at" +
|
||||
" ${FLING_VELOCITY_PX_S.toInt()}px/s, travel $travel",
|
||||
" scroll: $CYCLES cycles (${CYCLES * 4} swipes, legacy tween), " +
|
||||
"streamed $sent/${STREAM_EVENTS_PER_SEC * STREAM_SECONDS} fixture events",
|
||||
" type: ${TYPE_TEXT.length} characters inserted then deleted, one per" +
|
||||
" ${TYPE_CHAR_DELAY_MS}ms",
|
||||
keyboard,
|
||||
" process CPU time over this run: ${cpuMs}ms",
|
||||
rssLine,
|
||||
batteryLine,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 1: starting pinned at the newest end, [FLING_COUNT] flings away from it (toward older
|
||||
* messages) through the list's real fling path, then [FLING_COUNT] back. Positive velocity here
|
||||
* matches this list's existing scroll-offset convention (`TranscriptList`'s `reverseLayout`
|
||||
* pins index 0 -- the newest item -- at the bottom; a positive scroll offset moves the viewport
|
||||
* toward higher indices, i.e. away from the newest end and toward older content), the same sign
|
||||
* the pre-v2 swipe loop below already used for its first two swipes.
|
||||
*/
|
||||
private suspend fun runFlingPhase(
|
||||
listState: LazyListState,
|
||||
flingBehavior: FlingBehavior,
|
||||
): String {
|
||||
FrameStats.markPhase("fling")
|
||||
listState.scrollToItem(0)
|
||||
val start = position(listState)
|
||||
repeat(FLING_COUNT) {
|
||||
listState.scroll { with(flingBehavior) { performFling(FLING_VELOCITY_PX_S) } }
|
||||
waitForSettle(listState)
|
||||
delay(FLING_PAUSE_MS)
|
||||
}
|
||||
val outward = position(listState)
|
||||
repeat(FLING_COUNT) {
|
||||
listState.scroll { with(flingBehavior) { performFling(-FLING_VELOCITY_PX_S) } }
|
||||
waitForSettle(listState)
|
||||
delay(FLING_PAUSE_MS)
|
||||
}
|
||||
val back = position(listState)
|
||||
return "start=$start outward=$outward end=$back"
|
||||
}
|
||||
|
||||
private fun position(listState: LazyListState) =
|
||||
"idx=${listState.firstVisibleItemIndex}/off=${listState.firstVisibleItemScrollOffset}px"
|
||||
|
||||
/** Belt-and-suspenders on top of `performFling` already suspending until its own decay ends. */
|
||||
private suspend fun waitForSettle(listState: LazyListState) {
|
||||
val startedAt = System.currentTimeMillis()
|
||||
while (
|
||||
listState.isScrollInProgress &&
|
||||
System.currentTimeMillis() - startedAt < FLING_SETTLE_CAP_MS
|
||||
) {
|
||||
delay(16)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 2 (unchanged from v1): pinned to the newest end before streaming starts, the way
|
||||
* stream-bench.sh's "Jump to latest" tap is -- a reply streamed into a list parked further back
|
||||
* arrives off-screen and the report would show nothing happened.
|
||||
*/
|
||||
private suspend fun runStreamPhase(): Int {
|
||||
FrameStats.markPhase("stream")
|
||||
var sent = 0
|
||||
val total = STREAM_EVENTS_PER_SEC * STREAM_SECONDS
|
||||
while (sent < total && BenchFixture.remainingStreamEvents() > 0) {
|
||||
BenchFixture.pushNextLiveEvent()
|
||||
sent++
|
||||
delay(1000L / STREAM_EVENTS_PER_SEC)
|
||||
}
|
||||
// Lets the last few deltas land and draw before the next phase starts.
|
||||
delay(300)
|
||||
return sent
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 3: focuses the real composer, shows the keyboard if the platform allows it, then types
|
||||
* [TYPE_TEXT] one character at a time through the same `TextFieldValue` state a real keystroke
|
||||
* updates, and deletes it the same way -- this is what exercises wrapping and the transcript
|
||||
* being pushed upward, not a single big write.
|
||||
*/
|
||||
private suspend fun runTypePhase(
|
||||
listState: LazyListState,
|
||||
composerFocus: FocusRequester,
|
||||
setComposerText: (String) -> Unit,
|
||||
view: View,
|
||||
) {
|
||||
FrameStats.markPhase("type")
|
||||
listState.scrollToItem(0)
|
||||
composerFocus.requestFocus()
|
||||
showIme(view.context, view)
|
||||
// Lets focus and the keyboard's opening animation land before typing starts, so the frames
|
||||
// this phase records are the wrap/reflow it is measuring, not the keyboard opening.
|
||||
delay(300)
|
||||
var typed = ""
|
||||
for (ch in TYPE_TEXT) {
|
||||
typed += ch
|
||||
setComposerText(typed)
|
||||
delay(TYPE_CHAR_DELAY_MS)
|
||||
}
|
||||
delay(200)
|
||||
while (typed.isNotEmpty()) {
|
||||
typed = typed.dropLast(1)
|
||||
setComposerText(typed)
|
||||
delay(TYPE_CHAR_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 4: [KEYBOARD_CYCLES] show/hide cycles through the same [WindowInsetsControllerCompat]
|
||||
* path a real IME toggle goes through, reporting how many of each were actually confirmed by
|
||||
* [android.view.WindowInsets.isVisible] rather than assumed from having asked -- UI_RULES:
|
||||
* never present an inferred value as a measured one. If the platform never shows it even once,
|
||||
* this says so in words rather than reporting a phase with no keyboard in it.
|
||||
*/
|
||||
private suspend fun runKeyboardPhase(context: Context, view: View): String {
|
||||
FrameStats.markPhase("keyboard")
|
||||
var shown = 0
|
||||
var hidden = 0
|
||||
repeat(KEYBOARD_CYCLES) {
|
||||
showIme(context, view)
|
||||
delay(KEYBOARD_SHOW_WAIT_MS)
|
||||
if (imeVisible(view)) shown++
|
||||
hideIme(context, view)
|
||||
delay(KEYBOARD_HIDE_WAIT_MS)
|
||||
if (!imeVisible(view)) hidden++
|
||||
}
|
||||
return if (shown == 0) {
|
||||
" keyboard: could not be shown ($KEYBOARD_CYCLES attempts, 0 confirmed visible)"
|
||||
} else {
|
||||
" keyboard: shown $shown/$KEYBOARD_CYCLES, hidden $hidden/$KEYBOARD_CYCLES" +
|
||||
" (confirmed via isImeVisible)"
|
||||
}
|
||||
}
|
||||
|
||||
private fun controller(context: Context, view: View): WindowInsetsControllerCompat? {
|
||||
val window = context.activity()?.window ?: return null
|
||||
return WindowInsetsControllerCompat(window, view)
|
||||
}
|
||||
|
||||
private fun showIme(context: Context, view: View) {
|
||||
controller(context, view)?.show(WindowInsetsCompat.Type.ime())
|
||||
}
|
||||
|
||||
private fun hideIme(context: Context, view: View) {
|
||||
controller(context, view)?.hide(WindowInsetsCompat.Type.ime())
|
||||
}
|
||||
|
||||
private fun imeVisible(view: View): Boolean =
|
||||
ViewCompat.getRootWindowInsets(view)?.isVisible(WindowInsetsCompat.Type.ime()) ?: false
|
||||
|
||||
/** VmHWM from /proc/self/status: the process's high-water mark, in kB, since it started. */
|
||||
private fun peakRssLine(): String {
|
||||
val kb =
|
||||
|
||||
@@ -134,6 +134,13 @@ fun debugReport(
|
||||
* render-report button reads exactly as it did before this existed.
|
||||
*/
|
||||
extra: List<String> = emptyList(),
|
||||
/**
|
||||
* Bench v2's per-phase frame accounting ([FrameStats.phaseLines]) --
|
||||
* fling/stream/type/keyboard, each a slice of the same frames the whole-run sections below
|
||||
* still cover in full. Empty on every path but the scripted bench run, same reasoning as
|
||||
* [extra].
|
||||
*/
|
||||
phaseFrames: List<String> = emptyList(),
|
||||
): String = buildString {
|
||||
appendLine("ai-app render report")
|
||||
appendLine(device)
|
||||
@@ -148,6 +155,11 @@ fun debugReport(
|
||||
appendLine("transcript:")
|
||||
transcript.forEach { appendLine(it) }
|
||||
appendLine()
|
||||
if (phaseFrames.isNotEmpty()) {
|
||||
appendLine("per phase:")
|
||||
phaseFrames.forEach { appendLine(it) }
|
||||
appendLine()
|
||||
}
|
||||
appendLine("frames:")
|
||||
frames.forEach { appendLine(it) }
|
||||
appendLine()
|
||||
|
||||
@@ -42,6 +42,21 @@ object FrameStats {
|
||||
private val gpu = ArrayList<Long>()
|
||||
private var since = System.currentTimeMillis()
|
||||
|
||||
/**
|
||||
* Where a named phase of a scripted run (bench v2's fling/stream/type/keyboard) started, as an
|
||||
* index into [total] and a wall-clock time -- not a second recorder, just a mark on this one,
|
||||
* so a phase's frames are the same [FrameMetrics] the whole-run report already has, sliced.
|
||||
*/
|
||||
private data class PhaseMark(val name: String, val startIndex: Int, val startMs: Long)
|
||||
|
||||
private val phaseMarks = ArrayList<PhaseMark>()
|
||||
|
||||
/** Call at the start of each named phase of a scripted run; see [BenchRun]. */
|
||||
@Synchronized
|
||||
fun markPhase(name: String) {
|
||||
phaseMarks += PhaseMark(name, total.size, System.currentTimeMillis())
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun add(metrics: FrameMetrics) {
|
||||
// The first frame after a window opens includes inflating it and is nobody's scroll.
|
||||
@@ -69,6 +84,7 @@ object FrameStats {
|
||||
listOf(total, waited, input, animation, layout, draw, sync, issue, swap, gpu).forEach {
|
||||
it.clear()
|
||||
}
|
||||
phaseMarks.clear()
|
||||
since = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
@@ -95,6 +111,38 @@ object FrameStats {
|
||||
) + if (gpu.isEmpty()) emptyList() else listOf(phase("gpu ", gpu))
|
||||
}
|
||||
|
||||
/**
|
||||
* One block per [markPhase] call: how many frames landed between that mark and the next (or the
|
||||
* end of the run, for the last one), how many were late, the total/p50/p90/p99, the worst
|
||||
* single frame, and how long the phase actually ran. Marks with no frames between them (a phase
|
||||
* that finished before a frame was drawn) still get a line rather than being silently dropped
|
||||
* -- UI_RULES' "say what you don't know" applies to a phase as much as to a single number.
|
||||
*/
|
||||
@Synchronized
|
||||
fun phaseLines(refreshHz: Float): List<String> {
|
||||
if (phaseMarks.isEmpty()) return emptyList()
|
||||
val budget = if (refreshHz > 0) 1000.0 / refreshHz else 16.7
|
||||
val lines = ArrayList<String>()
|
||||
phaseMarks.forEachIndexed { i, mark ->
|
||||
val endIndex = if (i + 1 < phaseMarks.size) phaseMarks[i + 1].startIndex else total.size
|
||||
val endMs =
|
||||
if (i + 1 < phaseMarks.size) phaseMarks[i + 1].startMs
|
||||
else System.currentTimeMillis()
|
||||
val samples = total.subList(mark.startIndex, endIndex)
|
||||
val seconds = (endMs - mark.startMs) / 1000.0
|
||||
lines += " ${mark.name}: ${samples.size} frames over ${"%.1f".format(seconds)}s"
|
||||
if (samples.isEmpty()) {
|
||||
lines += " no frames recorded in this phase"
|
||||
} else {
|
||||
val late = samples.count { it / 1_000_000.0 > budget }
|
||||
lines += " late: $late (${percent(late, samples.size)})"
|
||||
lines += " " + phase("total ", samples)
|
||||
lines += " worst ${"%.1fms".format(samples.max() / 1_000_000.0)}"
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/** How long the frames recorded here spent in their draw phase, and how many there were. */
|
||||
@Synchronized fun drawPhase(): Pair<Long, Int> = draw.sum() to draw.size
|
||||
|
||||
|
||||
@@ -187,13 +187,20 @@ class MainActivity : ComponentActivity() {
|
||||
model = null,
|
||||
keepsOwnTranscript = false,
|
||||
permissionMode = null,
|
||||
effort = null,
|
||||
takesEffort = false,
|
||||
imported = false,
|
||||
notify = false,
|
||||
autoResume = false,
|
||||
autoResumeMessage = "",
|
||||
resumeAt = null,
|
||||
cwd = null,
|
||||
contextTokens = null,
|
||||
maxImageEdge = null,
|
||||
usageProvider = null,
|
||||
status = "idle",
|
||||
lastActivity = 0.0,
|
||||
subagents = 0,
|
||||
)
|
||||
|
||||
// launchMode="singleTop": an enrollment scan, or a notification tapped while the app is open,
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.ScrollableDefaults
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -64,12 +65,15 @@ import androidx.compose.runtime.snapshots.Snapshot
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.TextRange
|
||||
@@ -354,6 +358,17 @@ fun SessionScreen(
|
||||
// `rememberSaveable`, and this screen restores by its own anchor instead -- two restores would
|
||||
// fight over the first frame.
|
||||
val listState = remember(address) { LazyListState() }
|
||||
// The list's own fling path -- what a real flick decays through -- captured here so BenchRun's
|
||||
// fling phase can drive `LazyListState.scroll` through exactly the `FlingBehavior` this
|
||||
// screen's
|
||||
// `TranscriptList` already uses by not overriding it (its `LazyColumn` takes no `flingBehavior`
|
||||
// argument, so this is the same default it gets).
|
||||
val flingBehavior = ScrollableDefaults.flingBehavior()
|
||||
// Where BenchRun's type phase focuses before it types, and the view it toggles the keyboard on
|
||||
// -- both bench-only, but cheap enough (a remembered object, a CompositionLocal read) to hold
|
||||
// unconditionally rather than behind a second code path only the bench build compiles.
|
||||
val composerFocus = remember { FocusRequester() }
|
||||
val view = LocalView.current
|
||||
// Whether the newest message is on screen right now. The list is reversed, so the newest end is
|
||||
// the scrolling start: nothing behind you is exactly being at the bottom. Asked of the scroll
|
||||
// state rather than of item indices, because a zero-height first item makes an index ambiguous.
|
||||
@@ -1256,6 +1271,10 @@ fun SessionScreen(
|
||||
FrameStats.drawPhase().let { (nanos, count) -> drawAccounting(nanos, count) },
|
||||
crash = lastCrash(context),
|
||||
extra = extra,
|
||||
// Empty outside a BenchRun.run pass -- copyRenderReport's own reset below clears
|
||||
// the
|
||||
// marks along with everything else, so an ordinary copy never has any to show.
|
||||
phaseFrames = FrameStats.phaseLines(context.refreshHz()),
|
||||
)
|
||||
context.copyToClipboard("ai-app render report", report)
|
||||
// Also to the log, so a session driving the app over adb can read the same report the
|
||||
@@ -1270,15 +1289,24 @@ fun SessionScreen(
|
||||
Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
val copyRenderReport = { buildAndCopyReport() }
|
||||
// Bench build only: P0's scripted scroll-and-stream benchmark (BenchRun.kt), against the
|
||||
// fixture session opened below instead of a real server. Null everywhere else -- see
|
||||
// Bench build only: P0's scripted fling/stream/type/keyboard benchmark (BenchRun.kt), against
|
||||
// the fixture session opened below instead of a real server. Null everywhere else -- see
|
||||
// [SessionSettingsDialog]'s onRunBenchmark.
|
||||
val runBenchmark: (() -> Unit)? =
|
||||
if (BuildConfig.FIXTURE_MODE) {
|
||||
{
|
||||
settingsOpen = false
|
||||
scope.launch {
|
||||
val extra = BenchRun.run(context, scope, listState)
|
||||
val extra =
|
||||
BenchRun.run(
|
||||
context = context,
|
||||
scope = scope,
|
||||
listState = listState,
|
||||
flingBehavior = flingBehavior,
|
||||
composerFocus = composerFocus,
|
||||
setComposerText = { text -> input = atEnd(text) },
|
||||
view = view,
|
||||
)
|
||||
buildAndCopyReport(extra)
|
||||
}
|
||||
}
|
||||
@@ -1777,7 +1805,10 @@ fun SessionScreen(
|
||||
input = it
|
||||
saveDraft(context, summary.id, it.text)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
// BenchRun's type phase requests focus on this exact field
|
||||
// (`composerFocus`)
|
||||
// so it types through the real composer rather than a stand-in.
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(composerFocus),
|
||||
// No longer "(+image)": the images are on screen above this, and a
|
||||
// placeholder saying so said it in words beside the thing itself.
|
||||
placeholder = { Text("Message") },
|
||||
|
||||
@@ -47,6 +47,7 @@ name = "client-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"event-model",
|
||||
"pulldown-cmark",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
@@ -173,6 +174,15 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getopts"
|
||||
version = "0.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -425,6 +435,25 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"getopts",
|
||||
"memchr",
|
||||
"pulldown-cmark-escape",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark-escape"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
@@ -661,12 +690,24 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
||||
@@ -17,7 +17,12 @@ edition = "2024"
|
||||
[dependencies]
|
||||
event-model = { path = "../event-model" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
# "raw_value" is `fetch_transcript_lines`'s reason -- it needs the exact
|
||||
# bytes the server sent, not this crate's own re-serialization of a parsed
|
||||
# `Value`, so a cached line and a live SSE frame for the same event agree
|
||||
# byte-for-byte (see that method's doc). "float_roundtrip" is why they
|
||||
# agree on a `ts` at all -- see server/Cargo.toml's identical comment.
|
||||
serde_json = { version = "1", features = ["float_roundtrip", "raw_value"] }
|
||||
# The blocking HTTP client for the REST calls and the long-lived SSE GETs.
|
||||
# `server/` already depends on ureq for its own outbound HTTPS (the usage
|
||||
# poll in usage.rs) and it is rustls-backed like the rest of this project's
|
||||
@@ -27,6 +32,12 @@ serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
# no need of an async runtime, and RUST.md's brief for this port is
|
||||
# "lightweight" throughout.
|
||||
ureq = { version = "3", features = ["json"] }
|
||||
# The markdown block split (`markdown_blocks`), which has to agree with the
|
||||
# renderer in `iris/transcript-ui` about where a block begins -- so it is
|
||||
# the same parser at the same version, rather than a hand-written splitter
|
||||
# that would drift from it.
|
||||
pulldown-cmark = "0.13.4"
|
||||
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use event_model::SeqEvent;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -116,6 +117,14 @@ impl<T: Transport> ApiClient<T> {
|
||||
Self { transport }
|
||||
}
|
||||
|
||||
/// The transport underneath, for a caller that needs the raw SSE
|
||||
/// stream (`event_stream::follow_session_events`) rather than one of
|
||||
/// this client's typed REST calls -- `transcript_source::TranscriptSource`
|
||||
/// is the one that does.
|
||||
pub fn transport(&self) -> &T {
|
||||
&self.transport
|
||||
}
|
||||
|
||||
fn json_request<R: for<'de> Deserialize<'de>>(
|
||||
&self,
|
||||
method: &str,
|
||||
@@ -266,15 +275,72 @@ impl<T: Transport> ApiClient<T> {
|
||||
limit: u32,
|
||||
coalesce: bool,
|
||||
) -> Result<Vec<Value>, ApiError> {
|
||||
let mut path = format!("/sessions/{session_id}/transcript?limit={limit}");
|
||||
if let Some(before) = before {
|
||||
path.push_str(&format!("&before={before}"));
|
||||
}
|
||||
if coalesce {
|
||||
path.push_str("&coalesce=true");
|
||||
}
|
||||
self.json_request("GET", &path, None)
|
||||
self.json_request(
|
||||
"GET",
|
||||
&transcript_path(session_id, before, limit, coalesce, None),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// A page of transcript history, each line handed back paired with the
|
||||
/// exact text it came from, and bounded below by `after` -- the shape
|
||||
/// `crate::transcript_source::TranscriptSource` needs to store what it
|
||||
/// fetched in the transcript cache without a second round trip to fetch
|
||||
/// the raw text separately. Ported from `Api.kt`'s `fetchTranscript`.
|
||||
///
|
||||
/// Uses [`serde_json::value::RawValue`] rather than re-serializing a
|
||||
/// parsed [`Value`], so the stored line is the exact bytes the server
|
||||
/// sent (key order and float literal included) rather than this
|
||||
/// crate's own idea of how to write them back out -- the cache and a
|
||||
/// live SSE frame must agree byte-for-byte on the same event, which is
|
||||
/// exactly what caught the `serde_json` float-rounding bug this
|
||||
/// project's `AGENTS.md` records.
|
||||
pub fn fetch_transcript_lines(
|
||||
&self,
|
||||
session_id: &str,
|
||||
before: Option<u64>,
|
||||
limit: u32,
|
||||
coalesce: bool,
|
||||
after: Option<u64>,
|
||||
) -> Result<Vec<(String, SeqEvent)>, ApiError> {
|
||||
let path = transcript_path(session_id, before, limit, coalesce, after);
|
||||
let raw: Vec<Box<serde_json::value::RawValue>> = self.json_request("GET", &path, None)?;
|
||||
raw.into_iter()
|
||||
.map(|value| {
|
||||
let line = value.get().to_string();
|
||||
let event: SeqEvent = serde_json::from_str(&line).map_err(|e| ApiError {
|
||||
message: format!(
|
||||
"the server sent a transcript line this build couldn't parse: {e}"
|
||||
),
|
||||
status: None,
|
||||
})?;
|
||||
Ok((line, event))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The query string shared by [`ApiClient::fetch_transcript_page`] and
|
||||
/// [`ApiClient::fetch_transcript_lines`], so the two agree on how each
|
||||
/// parameter is written rather than keeping two copies to drift.
|
||||
fn transcript_path(
|
||||
session_id: &str,
|
||||
before: Option<u64>,
|
||||
limit: u32,
|
||||
coalesce: bool,
|
||||
after: Option<u64>,
|
||||
) -> String {
|
||||
let mut path = format!("/sessions/{session_id}/transcript?limit={limit}");
|
||||
if let Some(before) = before {
|
||||
path.push_str(&format!("&before={before}"));
|
||||
}
|
||||
if coalesce {
|
||||
path.push_str("&coalesce=true");
|
||||
}
|
||||
if let Some(after) = after {
|
||||
path.push_str(&format!("&after={after}"));
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// The blocking [`Transport`] backed by `ureq`, the same crate `server/`
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
//! A span of milliseconds, written the way somebody reads it -- the port
|
||||
//! of `Durations.kt`'s `formatMillis`/`formatMillisText`, with its tests.
|
||||
//!
|
||||
//! Only the tool-timeout half is here. `formatSpan` (the usage
|
||||
//! countdown's rounding-up rule) belongs with whatever draws the usage
|
||||
//! bar, and nothing in this crate needs it yet.
|
||||
|
||||
/// A span of milliseconds, written the way somebody reads it.
|
||||
///
|
||||
/// A tool's timeout arrives as `480000`, which nobody reads as eight
|
||||
/// minutes. The rule has two halves, because a short span and a long one
|
||||
/// are read for different things. Under a minute the question is "roughly
|
||||
/// how long", so only the largest unit is shown and a fraction carries the
|
||||
/// rest -- `2.5s`. At a minute or more the question is "how long exactly",
|
||||
/// so every unit with something in it is written out -- `5d 12h 4m`. Empty
|
||||
/// units are left out rather than written as zero.
|
||||
///
|
||||
/// Sub-second precision is dropped past a minute: nothing that takes days
|
||||
/// is measured in milliseconds.
|
||||
pub fn format_millis(ms: i64) -> String {
|
||||
if ms < 0 {
|
||||
return format!("-{}", format_millis(-ms));
|
||||
}
|
||||
if ms < 1000 {
|
||||
return format!("{ms}ms");
|
||||
}
|
||||
if ms < 60_000 {
|
||||
let tenths = (ms + 50) / 100;
|
||||
let (whole, rest) = (tenths / 10, tenths % 10);
|
||||
return if rest == 0 {
|
||||
format!("{whole}s")
|
||||
} else {
|
||||
format!("{whole}.{rest}s")
|
||||
};
|
||||
}
|
||||
let seconds = ms / 1000;
|
||||
[
|
||||
("d", seconds / 86_400),
|
||||
("h", seconds / 3600 % 24),
|
||||
("m", seconds / 60 % 60),
|
||||
("s", seconds % 60),
|
||||
]
|
||||
.iter()
|
||||
.filter(|(_, n)| *n > 0)
|
||||
.map(|(unit, n)| format!("{n}{unit}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// `text` as a span when it is a whole number of milliseconds, and
|
||||
/// unchanged when it is not.
|
||||
pub fn format_millis_text(text: &str) -> String {
|
||||
match text.trim().parse::<i64>() {
|
||||
Ok(ms) => format_millis(ms),
|
||||
Err(_) => text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The two ways a span of time is written here, and the rule each of
|
||||
/// them follows -- ported from `DurationsTest.kt`, whose doc says why:
|
||||
/// both are read off a screen to make a decision, so what matters is
|
||||
/// that the shortest form that answers the question is what appears.
|
||||
#[test]
|
||||
fn under_a_minute_is_the_largest_unit_alone() {
|
||||
assert_eq!(format_millis(30), "30ms");
|
||||
assert_eq!(format_millis(999), "999ms");
|
||||
assert_eq!(format_millis(1000), "1s");
|
||||
assert_eq!(format_millis(2500), "2.5s");
|
||||
// One decimal, rounded rather than cut: 2.46s is nearer two and a
|
||||
// half than two and four.
|
||||
assert_eq!(format_millis(2460), "2.5s");
|
||||
assert_eq!(format_millis(59_900), "59.9s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_minute_or_more_is_every_unit_that_has_something_in_it() {
|
||||
// The figure this rule was written for: a tool timeout, which
|
||||
// arrives as milliseconds and is unreadable as 480000.
|
||||
assert_eq!(format_millis(480_000), "8m");
|
||||
assert_eq!(format_millis(60_000), "1m");
|
||||
assert_eq!(format_millis(90_000), "1m 30s");
|
||||
assert_eq!(format_millis(475_440_000), "5d 12h 4m");
|
||||
// Empty units are left out rather than written as zero: the labels
|
||||
// say which is which, and "5d 0h 4m" is only longer.
|
||||
assert_eq!(format_millis(432_240_000), "5d 4m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_whole_number_of_milliseconds_is_rewritten() {
|
||||
assert_eq!(format_millis_text(" 480000 "), "8m");
|
||||
// A timeout a tool expressed some other way is its own words,
|
||||
// passed through rather than guessed at.
|
||||
assert_eq!(format_millis_text("2 minutes"), "2 minutes");
|
||||
assert_eq!(format_millis_text(""), "");
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,15 @@
|
||||
pub mod ansi;
|
||||
pub mod api;
|
||||
pub mod config;
|
||||
pub mod durations;
|
||||
pub mod event_stream;
|
||||
pub mod highlight;
|
||||
pub mod markdown_blocks;
|
||||
pub mod notifications;
|
||||
pub mod sse;
|
||||
pub mod tool_summary;
|
||||
pub mod transcript_cache;
|
||||
pub mod transcript_fold;
|
||||
pub mod transcript_source;
|
||||
|
||||
pub use event_model::*;
|
||||
@@ -0,0 +1,325 @@
|
||||
//! Split a markdown message into its top-level **blocks** -- one
|
||||
//! paragraph, heading, fenced code block, list, table or quote each, as a
|
||||
//! byte slice of the original source.
|
||||
//!
|
||||
//! This exists for streaming. A transcript row used to be one text widget
|
||||
//! holding the whole message, so a single streamed delta re-shaped every
|
||||
//! paragraph of it through the text engine again; the phone's bench v2 put
|
||||
//! the stream phase at p50 18.2ms against Compose's 13.4ms for exactly
|
||||
//! that reason (docs/IRIS_TODO.md). A row is a column of one widget per
|
||||
//! block now, and a delta that lands in the last block leaves every
|
||||
//! earlier block's layout alone. `docs/DECISIONS.md`'s 2026-09-06 entry has
|
||||
//! what that rejected and why the split lives here rather than in the UI
|
||||
//! crate: `docs/CLIENT_CORE.md` already wanted a block model for P1, and
|
||||
//! keeping it here means iris stays a text renderer that knows nothing
|
||||
//! about markdown.
|
||||
//!
|
||||
//! **Blocks only.** Inline styling (bold, links, inline code) is still the
|
||||
//! renderer's own job, per block -- this deliberately does not build a
|
||||
//! full AST, because nothing needs one yet.
|
||||
//!
|
||||
//! ## Appending is not guaranteed to leave earlier blocks alone
|
||||
//!
|
||||
//! It nearly always does, which is what makes the fast path worth having,
|
||||
//! but markdown has no such rule: appending a "```" line can turn text
|
||||
//! that was three paragraphs into one fenced block, and appending "---"
|
||||
//! under a paragraph turns that paragraph into a heading. So a caller
|
||||
//! taking the O(last block) path **must compare the prefix it is about to
|
||||
//! keep** rather than assume it. [`common_prefix`] is that comparison, and
|
||||
//! it is cheap next to laying the text out again.
|
||||
|
||||
use pulldown_cmark::{Event, Options, Parser, Tag};
|
||||
|
||||
/// What a block is, for a renderer that wants to style or space blocks
|
||||
/// differently. `Other` is deliberately present rather than a panic or a
|
||||
/// silent fallback to `Paragraph`: markdown has more block kinds than this
|
||||
/// list and more get added, and a renderer treating an unknown one as
|
||||
/// prose is right, but it should be able to *tell* that is what it is
|
||||
/// doing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BlockKind {
|
||||
Paragraph,
|
||||
Heading,
|
||||
/// A fenced or indented code block.
|
||||
Code,
|
||||
List,
|
||||
Table,
|
||||
Quote,
|
||||
/// A thematic break, raw HTML, a footnote -- anything with no
|
||||
/// distinguished treatment here.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// One top-level block: its kind and the exact source that produced it.
|
||||
/// `source` is a slice of the input with trailing whitespace removed, so
|
||||
/// two splits of the same prefix compare equal even when one of them had a
|
||||
/// delta arriving after it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Block {
|
||||
pub kind: BlockKind,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
fn kind_of(tag: &Tag) -> BlockKind {
|
||||
match tag {
|
||||
Tag::Paragraph => BlockKind::Paragraph,
|
||||
Tag::Heading { .. } => BlockKind::Heading,
|
||||
Tag::CodeBlock(_) => BlockKind::Code,
|
||||
Tag::List(_) => BlockKind::List,
|
||||
Tag::Table(_) => BlockKind::Table,
|
||||
Tag::BlockQuote(_) => BlockKind::Quote,
|
||||
_ => BlockKind::Other,
|
||||
}
|
||||
}
|
||||
|
||||
fn options() -> Options {
|
||||
// The same set `transcript-ui`'s renderer parses with, so a block
|
||||
// boundary here and the styling there cannot disagree about what the
|
||||
// source means.
|
||||
Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS
|
||||
}
|
||||
|
||||
/// Split `src` into its top-level blocks, in source order. An empty or
|
||||
/// whitespace-only input gives no blocks; text the parser does not put
|
||||
/// inside any block (a stray fence marker mid-stream) still comes back,
|
||||
/// as `Other`, rather than being dropped.
|
||||
pub fn split_blocks(src: &str) -> Vec<Block> {
|
||||
let mut out: Vec<Block> = Vec::new();
|
||||
let mut depth = 0usize;
|
||||
let mut kind = BlockKind::Other;
|
||||
for (event, range) in Parser::new_ext(src, options()).into_offset_iter() {
|
||||
match event {
|
||||
Event::Start(tag) => {
|
||||
if depth == 0 {
|
||||
kind = kind_of(&tag);
|
||||
}
|
||||
depth += 1;
|
||||
}
|
||||
Event::End(_) => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
push(&mut out, kind, &src[range]);
|
||||
}
|
||||
}
|
||||
// A top-level event that is not part of any block -- a
|
||||
// thematic break, a block of raw HTML. Inside one, it is the
|
||||
// enclosing block's business and this does nothing.
|
||||
_ => {
|
||||
if depth == 0 {
|
||||
push(&mut out, BlockKind::Other, &src[range]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn push(out: &mut Vec<Block>, kind: BlockKind, source: &str) {
|
||||
let source = source.trim_end();
|
||||
if source.is_empty() {
|
||||
return;
|
||||
}
|
||||
out.push(Block {
|
||||
kind,
|
||||
source: source.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
/// How many leading blocks of `old` and `new` are identical -- what a
|
||||
/// caller may keep the laid-out widgets for. See the module doc for why
|
||||
/// this is a comparison rather than an assumption.
|
||||
pub fn common_prefix(old: &[Block], new: &[Block]) -> usize {
|
||||
old.iter().zip(new).take_while(|(a, b)| a == b).count()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn kinds(src: &str) -> Vec<BlockKind> {
|
||||
split_blocks(src).into_iter().map(|b| b.kind).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_message_splits_into_its_top_level_blocks() {
|
||||
let src = "# Title\n\nFirst para.\n\n```rust\nfn main() {}\n```\n\n- a\n- b\n";
|
||||
assert_eq!(
|
||||
kinds(src),
|
||||
vec![
|
||||
BlockKind::Heading,
|
||||
BlockKind::Paragraph,
|
||||
BlockKind::Code,
|
||||
BlockKind::List
|
||||
]
|
||||
);
|
||||
let blocks = split_blocks(src);
|
||||
assert_eq!(blocks[1].source, "First para.");
|
||||
assert_eq!(blocks[2].source, "```rust\nfn main() {}\n```");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_input_has_no_blocks() {
|
||||
assert!(split_blocks("").is_empty());
|
||||
assert!(split_blocks(" \n\n ").is_empty());
|
||||
}
|
||||
|
||||
/// The property the streaming fast path rests on, in its ordinary
|
||||
/// shape: a delta landing in the last paragraph must leave every
|
||||
/// earlier block byte-identical.
|
||||
#[test]
|
||||
fn a_delta_into_the_last_paragraph_leaves_earlier_blocks_untouched() {
|
||||
let before = split_blocks("# Title\n\nFirst para.\n\nSecond par");
|
||||
let after = split_blocks("# Title\n\nFirst para.\n\nSecond paragraph now.");
|
||||
assert_eq!(common_prefix(&before, &after), 2);
|
||||
assert_eq!(before.len(), 3);
|
||||
assert_eq!(after.len(), 3);
|
||||
assert_ne!(before[2], after[2]);
|
||||
}
|
||||
|
||||
/// A delta that starts a *new* block keeps every old block, including
|
||||
/// the one that was last -- so the fast path appends rather than
|
||||
/// replacing.
|
||||
#[test]
|
||||
fn a_delta_that_starts_a_new_block_keeps_every_old_one() {
|
||||
let before = split_blocks("First para.\n\nSecond para.");
|
||||
let after = split_blocks("First para.\n\nSecond para.\n\nThird");
|
||||
assert_eq!(common_prefix(&before, &after), 2);
|
||||
assert_eq!(after.len(), 3);
|
||||
}
|
||||
|
||||
/// A code fence arrives one delta at a time and is unterminated for
|
||||
/// most of its life. It must still be *one* block the whole way, or
|
||||
/// every delta would re-split the message into a different number of
|
||||
/// pieces.
|
||||
#[test]
|
||||
fn an_unterminated_fence_is_one_block_while_it_streams() {
|
||||
for src in [
|
||||
"Here:\n\n```rust\n",
|
||||
"Here:\n\n```rust\nfn main() {\n",
|
||||
"Here:\n\n```rust\nfn main() {\n println!(\"hi\");\n",
|
||||
] {
|
||||
assert_eq!(
|
||||
kinds(src),
|
||||
vec![BlockKind::Paragraph, BlockKind::Code],
|
||||
"{src:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The half the fast path had no reason to touch, and the reason
|
||||
/// `common_prefix` is a comparison rather than an assumption:
|
||||
/// appending can rewrite what came before. `---` under a paragraph
|
||||
/// turns that paragraph into a setext heading, so the block that was
|
||||
/// already laid out is not the block it is now.
|
||||
#[test]
|
||||
fn appending_can_rewrite_an_earlier_block_and_the_prefix_says_so() {
|
||||
let before = split_blocks("Not a heading\n\nsecond");
|
||||
let after = split_blocks("Not a heading\n\nsecond\n---");
|
||||
assert_eq!(before[1].kind, BlockKind::Paragraph);
|
||||
assert_eq!(after[1].kind, BlockKind::Heading);
|
||||
assert_eq!(
|
||||
common_prefix(&before, &after),
|
||||
1,
|
||||
"the rewritten block must not be reported as keepable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thematic_break_is_its_own_block() {
|
||||
assert_eq!(
|
||||
kinds("one\n\n---\n\ntwo"),
|
||||
vec![BlockKind::Paragraph, BlockKind::Other, BlockKind::Paragraph]
|
||||
);
|
||||
}
|
||||
|
||||
/// The shapes a real transcript actually contains, each checked for
|
||||
/// the one property the streaming fast path needs: the *number* of
|
||||
/// blocks and every earlier block's source stay put while the message
|
||||
/// grows. A fence's own blank lines, a `---` inside one, a nested
|
||||
/// list and a table are all places where a naive line-based split
|
||||
/// would break the message into more pieces than there are blocks.
|
||||
#[test]
|
||||
fn the_transcripts_own_block_shapes_survive_a_split() {
|
||||
let fence_with_blanks = "Intro.\n\n```rust\nfn a() {}\n\nfn b() {}\n```\n\nAfter.";
|
||||
assert_eq!(
|
||||
kinds(fence_with_blanks),
|
||||
vec![BlockKind::Paragraph, BlockKind::Code, BlockKind::Paragraph],
|
||||
"a blank line inside a fence is not a block boundary"
|
||||
);
|
||||
assert_eq!(
|
||||
kinds("```\n---\n```"),
|
||||
vec![BlockKind::Code],
|
||||
"a thematic break inside a fence is code, not a break"
|
||||
);
|
||||
assert_eq!(
|
||||
kinds("- a\n - a1\n - a2\n- b"),
|
||||
vec![BlockKind::List],
|
||||
"a nested list is one top-level block"
|
||||
);
|
||||
assert_eq!(
|
||||
kinds("## Heading\n```sh\nls\n```"),
|
||||
vec![BlockKind::Heading, BlockKind::Code],
|
||||
"a fence directly under a heading, with no blank line"
|
||||
);
|
||||
assert_eq!(
|
||||
kinds("| a | b |\n|---|---|\n| 1 | 2 |"),
|
||||
vec![BlockKind::Table]
|
||||
);
|
||||
assert_eq!(
|
||||
kinds("> quoted\n> more\n\nplain"),
|
||||
vec![BlockKind::Quote, BlockKind::Paragraph]
|
||||
);
|
||||
}
|
||||
|
||||
/// `apply_delta`'s precondition, stated as the property rather than
|
||||
/// the arithmetic: for every prefix of a realistic streamed message,
|
||||
/// the blocks before the last one must be exactly the blocks the
|
||||
/// previous prefix had. Where markdown breaks that (the `---` case
|
||||
/// above), `common_prefix` has to *say* so -- which is what the
|
||||
/// `>= len - 1` assertion below checks: the split may rewrite the
|
||||
/// last block, never an earlier one, or `RowBlocks::apply_delta`
|
||||
/// would keep a widget whose text is no longer what it holds.
|
||||
#[test]
|
||||
fn every_prefix_of_a_streamed_message_keeps_all_but_its_last_block() {
|
||||
let full = "# Report\n\nFirst finding, at some length.\n\n```rust\nfn main() {\n\n println!(\"hi\");\n}\n```\n\n- one\n - nested\n- two\n\n| a | b |\n |---|---|\n| 1 | 2 |\n\n> and a closing quote.";
|
||||
// Every character boundary, so a delta landing mid-word and one
|
||||
// landing exactly on a fence's closing backtick are both covered.
|
||||
let mut prev = Vec::new();
|
||||
for end in full.char_indices().map(|(i, _)| i).chain([full.len()]) {
|
||||
let now = split_blocks(&full[..end]);
|
||||
let common = common_prefix(&prev, &now);
|
||||
assert!(
|
||||
prev.is_empty() || common + 1 >= prev.len(),
|
||||
"at {end} bytes the split rewrote block {common} of {}, not just the last one:\n before={prev:#?}\nafter={now:#?}",
|
||||
prev.len()
|
||||
);
|
||||
prev = now;
|
||||
}
|
||||
}
|
||||
|
||||
/// The half a growing message cannot show: a fence that never closes.
|
||||
/// The stream ends there and the block must still be the code block
|
||||
/// it has been all along, not re-split into paragraphs.
|
||||
#[test]
|
||||
fn a_stream_that_ends_inside_a_fence_still_ends_with_one_code_block() {
|
||||
let src = "Here is the patch:\n\n```diff\n- old line\n+ new line";
|
||||
let blocks = split_blocks(src);
|
||||
assert_eq!(
|
||||
blocks.iter().map(|b| b.kind).collect::<Vec<_>>(),
|
||||
vec![BlockKind::Paragraph, BlockKind::Code]
|
||||
);
|
||||
assert_eq!(blocks[1].source, "```diff\n- old line\n+ new line");
|
||||
}
|
||||
|
||||
/// A delta that closes a fence changes the *last* block only, so the
|
||||
/// fast path takes it -- the case the module doc says is the reason
|
||||
/// `common_prefix` is a comparison.
|
||||
#[test]
|
||||
fn the_delta_that_closes_a_fence_changes_only_the_last_block() {
|
||||
let before = split_blocks("Text.\n\n```\ncode\n");
|
||||
let after = split_blocks("Text.\n\n```\ncode\n```");
|
||||
assert_eq!(before.len(), after.len());
|
||||
assert_eq!(common_prefix(&before, &after), 1);
|
||||
assert_ne!(before[1], after[1]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
//! A tool call's input, read rather than dumped -- the port of
|
||||
//! `ToolInput.kt`'s `parseToolInput`, which is what both the collapsed
|
||||
//! card's one-line summary and the expanded card's key/value list are
|
||||
//! derived from.
|
||||
//!
|
||||
//! Every tool's input arrives as JSON, and showing it raw makes the reader
|
||||
//! parse `{"command":"…","timeout":120000}` themselves to find the one
|
||||
//! line they care about. So the fields that carry the meaning are pulled
|
||||
//! out, and anything left over is still shown, because dropping a field
|
||||
//! would be claiming the tool has no other input when it might.
|
||||
//!
|
||||
//! Pure, and here rather than in the widget crate, for the reason the rest
|
||||
//! of this crate exists: the derivation is the same on a phone and on a
|
||||
//! desktop, and it is testable without a renderer.
|
||||
|
||||
use crate::durations::format_millis_text;
|
||||
use crate::highlight::Language;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// A tool call's input, split into the parts a card draws separately.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct ToolInput {
|
||||
/// The thing that will actually be run or read, if this tool has one.
|
||||
pub subject: Option<String>,
|
||||
/// The language [`ToolInput::subject`] is written in, for
|
||||
/// highlighting.
|
||||
pub language: Option<Language>,
|
||||
/// The tool's own one-line summary, when it wrote one.
|
||||
pub description: Option<String>,
|
||||
/// How long the call may take, in the largest units it fits. Shown
|
||||
/// apart because it is a limit on the call rather than part of what
|
||||
/// the call does.
|
||||
pub timeout: Option<String>,
|
||||
/// Everything else, as `name: value` lines. Never dropped.
|
||||
pub rest: Vec<String>,
|
||||
}
|
||||
|
||||
impl ToolInput {
|
||||
/// The one line to show when there is only room for one: what this
|
||||
/// call is for.
|
||||
pub fn title(&self) -> Option<&str> {
|
||||
self.description
|
||||
.as_deref()
|
||||
.or(self.subject.as_deref())
|
||||
// A subject that is only whitespace would draw as an empty
|
||||
// summary line, which reads as a tool with nothing to say
|
||||
// rather than as one whose subject was blank.
|
||||
.filter(|t| !t.trim().is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// Which field of which tool is the subject.
|
||||
///
|
||||
/// A table rather than a chain of `if`s: adding a tool is a row, and the
|
||||
/// shape stops any of them from being the special case that gets its own
|
||||
/// code path. Unknown tools fall through to "no subject, everything is
|
||||
/// rest".
|
||||
const SUBJECTS: &[(&str, &str, Option<Language>)] = &[
|
||||
("Bash", "command", Some(Language::Shell)),
|
||||
("Read", "file_path", None),
|
||||
("Write", "file_path", None),
|
||||
("Edit", "file_path", None),
|
||||
("Glob", "pattern", None),
|
||||
("Grep", "pattern", None),
|
||||
("WebFetch", "url", None),
|
||||
];
|
||||
|
||||
/// Fields that are the tool's own prose about itself rather than input to
|
||||
/// it.
|
||||
const DESCRIPTIONS: &[&str] = &["description", "prompt"];
|
||||
|
||||
/// One JSON value as the Kotlin's `JSONObject.optString`/`get` wrote it: a
|
||||
/// string is its own characters, anything else is its JSON form.
|
||||
///
|
||||
/// One function rather than two, because the same coercion decides both
|
||||
/// what a subject reads as and what a leftover field's value reads as, and
|
||||
/// two copies would eventually disagree about a number.
|
||||
fn as_text(value: &Value) -> String {
|
||||
match value {
|
||||
Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn non_blank(value: Option<&Value>) -> Option<String> {
|
||||
let text = as_text(value?);
|
||||
(!text.trim().is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
/// Split `input` (a tool call's JSON) into the parts a card draws.
|
||||
///
|
||||
/// Input that is not a JSON object -- older transcripts and some tools
|
||||
/// send a bare string -- is still the input, so it is still shown, as the
|
||||
/// whole of `rest`.
|
||||
pub fn parse_tool_input(tool: &str, input: &str) -> ToolInput {
|
||||
let Ok(Value::Object(json)) = serde_json::from_str::<Value>(input) else {
|
||||
return ToolInput {
|
||||
rest: match input.trim().is_empty() {
|
||||
true => Vec::new(),
|
||||
false => vec![input.to_string()],
|
||||
},
|
||||
..ToolInput::default()
|
||||
};
|
||||
};
|
||||
parse_object(tool, &json)
|
||||
}
|
||||
|
||||
fn parse_object(tool: &str, json: &Map<String, Value>) -> ToolInput {
|
||||
let (subject_key, language) = SUBJECTS
|
||||
.iter()
|
||||
.find(|(name, ..)| *name == tool)
|
||||
.map(|(_, key, language)| (Some(*key), *language))
|
||||
.unwrap_or((None, None));
|
||||
let subject = subject_key.and_then(|key| non_blank(json.get(key)));
|
||||
let description = DESCRIPTIONS
|
||||
.iter()
|
||||
.find_map(|key| non_blank(json.get(*key)));
|
||||
let timeout = non_blank(json.get("timeout")).map(|t| format_millis_text(&t));
|
||||
|
||||
// Sorted, so the leftovers are in the same order every time this call
|
||||
// is drawn rather than in whatever order the JSON happened to arrive
|
||||
// in. A field is left out only when it is already drawn somewhere
|
||||
// else on the card.
|
||||
let mut keys: Vec<&String> = json
|
||||
.keys()
|
||||
.filter(|k| Some(k.as_str()) != subject_key || subject.is_none())
|
||||
.filter(|k| !DESCRIPTIONS.contains(&k.as_str()) || description.is_none())
|
||||
.filter(|k| k.as_str() != "timeout" || timeout.is_none())
|
||||
.collect();
|
||||
keys.sort();
|
||||
let rest = keys
|
||||
.into_iter()
|
||||
.map(|key| format!("{key}: {}", as_text(&json[key])))
|
||||
.collect();
|
||||
|
||||
ToolInput {
|
||||
subject,
|
||||
language,
|
||||
description,
|
||||
timeout,
|
||||
rest,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn each_tool_in_the_table_has_its_own_subject() {
|
||||
// One assertion per row of `SUBJECTS`, because the table is the
|
||||
// whole of the rule and a row lost in an edit would otherwise
|
||||
// only show up as a card with no summary line.
|
||||
let cases = [
|
||||
("Bash", r#"{"command":"ls -la"}"#, "ls -la"),
|
||||
("Read", r#"{"file_path":"/tmp/x.rs"}"#, "/tmp/x.rs"),
|
||||
("Write", r#"{"file_path":"/tmp/y.rs"}"#, "/tmp/y.rs"),
|
||||
("Edit", r#"{"file_path":"/tmp/z.rs"}"#, "/tmp/z.rs"),
|
||||
("Glob", r#"{"pattern":"**/*.rs"}"#, "**/*.rs"),
|
||||
("Grep", r#"{"pattern":"fn main"}"#, "fn main"),
|
||||
("WebFetch", r#"{"url":"https://x/y"}"#, "https://x/y"),
|
||||
];
|
||||
for (tool, input, expected) in cases {
|
||||
let parsed = parse_tool_input(tool, input);
|
||||
assert_eq!(parsed.subject.as_deref(), Some(expected), "{tool}");
|
||||
assert_eq!(parsed.title(), Some(expected), "{tool}");
|
||||
assert!(parsed.rest.is_empty(), "{tool}: {:?}", parsed.rest);
|
||||
}
|
||||
assert_eq!(
|
||||
parse_tool_input("Bash", r#"{"command":"ls"}"#).language,
|
||||
Some(Language::Shell),
|
||||
"a Bash command is shell, and is the one row that names a language"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tools_own_description_is_what_the_one_line_says() {
|
||||
// The description wins over the subject: it is the tool's own
|
||||
// prose about what this call is for, which is what a reader
|
||||
// scanning a collapsed run is looking for.
|
||||
let parsed = parse_tool_input(
|
||||
"Bash",
|
||||
r#"{"command":"cargo test -p iris","description":"Run the iris tests"}"#,
|
||||
);
|
||||
assert_eq!(parsed.title(), Some("Run the iris tests"));
|
||||
assert_eq!(parsed.subject.as_deref(), Some("cargo test -p iris"));
|
||||
assert!(parsed.rest.is_empty(), "{:?}", parsed.rest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_timeout_is_read_as_a_span_and_kept_apart_from_the_rest() {
|
||||
let parsed = parse_tool_input("Bash", r#"{"command":"sleep 500","timeout":480000}"#);
|
||||
assert_eq!(parsed.timeout.as_deref(), Some("8m"));
|
||||
assert!(parsed.rest.is_empty(), "{:?}", parsed.rest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_field_not_drawn_elsewhere_is_still_shown() {
|
||||
// The half the "never dropped" promise is about: a tool this
|
||||
// build has never heard of has no subject, so *everything* is
|
||||
// rest -- and a known tool's extra fields are too.
|
||||
let parsed = parse_tool_input(
|
||||
"Edit",
|
||||
r#"{"file_path":"/a.rs","old_string":"x","new_string":"y","replace_all":true}"#,
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.rest,
|
||||
vec![
|
||||
"new_string: y".to_string(),
|
||||
"old_string: x".to_string(),
|
||||
"replace_all: true".to_string(),
|
||||
],
|
||||
"sorted, and a non-string value written as JSON"
|
||||
);
|
||||
let unknown = parse_tool_input("SomeNewTool", r#"{"b":2,"a":"one"}"#);
|
||||
assert_eq!(unknown.subject, None);
|
||||
assert_eq!(unknown.rest, vec!["a: one".to_string(), "b: 2".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_that_is_not_an_object_is_still_the_input() {
|
||||
// Older transcripts and some tools send a bare string; a card
|
||||
// that dropped it would claim the call had no input at all.
|
||||
assert_eq!(
|
||||
parse_tool_input("Bash", "just a string").rest,
|
||||
vec!["just a string".to_string()]
|
||||
);
|
||||
assert_eq!(parse_tool_input("Bash", " ").rest, Vec::<String>::new());
|
||||
assert_eq!(parse_tool_input("Bash", "").title(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blank_subject_is_no_subject_rather_than_an_empty_summary_line() {
|
||||
let parsed = parse_tool_input("Bash", r#"{"command":" ","other":1}"#);
|
||||
assert_eq!(parsed.subject, None);
|
||||
assert_eq!(parsed.title(), None);
|
||||
// Not dropped just because it was blank -- it is still a field
|
||||
// the call carried.
|
||||
assert_eq!(
|
||||
parsed.rest,
|
||||
vec!["command: ".to_string(), "other: 1".to_string()]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -361,6 +361,10 @@ impl SessionCache {
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
debug_assert!(
|
||||
lines.iter().all(|l| !l.contains('\n')),
|
||||
"a stored page's lines must each be one line"
|
||||
);
|
||||
fs::create_dir_all(&this.dir)?;
|
||||
let kind = if rows { "rows" } else { "raw" };
|
||||
let mut content = lines.join("\n");
|
||||
@@ -389,8 +393,16 @@ impl SessionCache {
|
||||
return Ok(());
|
||||
};
|
||||
// Written as it arrived. A newline inside it would split one
|
||||
// event into two unreadable halves, but neither source can
|
||||
// produce one.
|
||||
// event into two unreadable halves. No source here can produce
|
||||
// one -- an SSE `data:` field cannot hold a raw newline, and a
|
||||
// fetched line is one element of a compact JSON array -- but
|
||||
// that is a fact about the *server's* serializer rather than
|
||||
// anything this file controls, so it is checked rather than
|
||||
// trusted.
|
||||
debug_assert!(
|
||||
!line.contains('\n'),
|
||||
"a cached transcript line must be one line: {line}"
|
||||
);
|
||||
use std::io::Write;
|
||||
writer.write_all(line.as_bytes())?;
|
||||
writer.write_all(b"\n")?;
|
||||
|
||||
@@ -78,6 +78,11 @@ pub enum TranscriptItem {
|
||||
input: String,
|
||||
output: String,
|
||||
done: bool,
|
||||
/// Whether the result that arrived said the call failed
|
||||
/// ([`Event::ToolEnd`]'s `is_error`). Meaningless while `done` is
|
||||
/// false, and [`ToolState::of`] is the only thing that reads the
|
||||
/// pair, so the two cannot be combined wrongly at a call site.
|
||||
failed: bool,
|
||||
asks: Vec<QuestionCard>,
|
||||
images: Vec<String>,
|
||||
},
|
||||
@@ -112,6 +117,13 @@ pub enum TranscriptItem {
|
||||
ClearedNote {
|
||||
seq: u64,
|
||||
},
|
||||
/// The account's usage limit stopped the turn; `resets_at` is epoch
|
||||
/// seconds when the dialect said when it lifts (`LimitNote` in
|
||||
/// `TranscriptItems.kt`).
|
||||
LimitNote {
|
||||
seq: u64,
|
||||
resets_at: Option<f64>,
|
||||
},
|
||||
CompactedNote {
|
||||
seq: u64,
|
||||
pre_tokens: Option<u64>,
|
||||
@@ -131,6 +143,7 @@ impl TranscriptItem {
|
||||
| Self::CommandRow { seq, .. }
|
||||
| Self::Note { seq, .. }
|
||||
| Self::ClearedNote { seq }
|
||||
| Self::LimitNote { seq, .. }
|
||||
| Self::CompactedNote { seq, .. } => *seq,
|
||||
Self::QuestionCard(card) => card.seq,
|
||||
}
|
||||
@@ -286,6 +299,201 @@ fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptIte
|
||||
out
|
||||
}
|
||||
|
||||
/// Puts a page of older items in front of the ones already loaded, healing
|
||||
/// whatever the page boundary cut in two. Ported from `TranscriptItems.kt`'s
|
||||
/// `joinPages`.
|
||||
///
|
||||
/// Two things straddle a boundary: a tool call separated from its result,
|
||||
/// and a message separated from the rest of itself. Both were one thing
|
||||
/// before the transcript was cut into pages.
|
||||
///
|
||||
/// A boundary lands wherever it lands, and roughly half the time that is
|
||||
/// between a call and its result. The newer page then holds a `ToolEnd`
|
||||
/// whose start it never saw, which `fold_event` draws as a row of its own
|
||||
/// -- correctly, because a call that renders as nothing is indistinguishable
|
||||
/// from one that never happened. When the older page arrives it brings the
|
||||
/// real `ToolStart`, and concatenating the two lists left *both*: the same
|
||||
/// call twice.
|
||||
///
|
||||
/// Merged by the call's own id rather than by position, because position is
|
||||
/// exactly what a page boundary destroys. The older row wins on what a
|
||||
/// start knows and the newer on what an end knows, which is the only way
|
||||
/// round that loses nothing.
|
||||
///
|
||||
/// The third thing is the *run*, and it is the one the Kotlin original used
|
||||
/// to miss (AGENTS.md's "things that have bitten"): every page ends up
|
||||
/// here, but `adopt_run` must run on *every* join, not only the one where a
|
||||
/// split call was found -- a boundary landing cleanly between two finished
|
||||
/// calls, which is most of them, would otherwise leave the older page's
|
||||
/// calls under the run name they were folded with. On screen: one run of
|
||||
/// tool calls drawn as two groups, with the seam wherever the reader
|
||||
/// happened to have paged.
|
||||
pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<TranscriptItem> {
|
||||
let (older, newer) = heal_split_message(earlier, later);
|
||||
let started_earlier: std::collections::HashSet<&str> = older
|
||||
.iter()
|
||||
.filter_map(TranscriptItem::as_tool_run)
|
||||
.collect();
|
||||
// Owned rather than borrowed from `newer`: `kept` below needs to consume `newer` by
|
||||
// value, and a map borrowing it would keep that alive.
|
||||
let ended_later: std::collections::HashMap<String, TranscriptItem> = newer
|
||||
.iter()
|
||||
.filter_map(|item| item.as_tool_run().map(|id| (id.to_string(), item.clone())))
|
||||
.filter(|(id, _)| started_earlier.contains(id.as_str()))
|
||||
.collect();
|
||||
let healed: Vec<TranscriptItem> = older
|
||||
.into_iter()
|
||||
.map(|row| match row {
|
||||
TranscriptItem::ToolRun {
|
||||
seq,
|
||||
id,
|
||||
run_id,
|
||||
tool,
|
||||
input,
|
||||
asks: row_asks,
|
||||
images: row_images,
|
||||
..
|
||||
} if ended_later.contains_key(id.as_str()) => {
|
||||
let &TranscriptItem::ToolRun {
|
||||
ref output,
|
||||
done,
|
||||
failed,
|
||||
asks: ref half_asks,
|
||||
images: ref half_images,
|
||||
..
|
||||
} = &ended_later[id.as_str()]
|
||||
else {
|
||||
unreachable!("filtered to ToolRun above");
|
||||
};
|
||||
TranscriptItem::ToolRun {
|
||||
seq,
|
||||
id,
|
||||
run_id,
|
||||
tool,
|
||||
input,
|
||||
output: output.clone(),
|
||||
done,
|
||||
failed,
|
||||
// Kept from both halves: a question or an image can be
|
||||
// attached to either, depending on which side of the
|
||||
// boundary its event fell.
|
||||
asks: row_asks.into_iter().chain(half_asks.clone()).collect(),
|
||||
images: row_images.into_iter().chain(half_images.clone()).collect(),
|
||||
}
|
||||
}
|
||||
other => other,
|
||||
})
|
||||
.collect();
|
||||
let kept: Vec<TranscriptItem> = newer
|
||||
.into_iter()
|
||||
.filter(|item| match item.as_tool_run() {
|
||||
Some(id) => !ended_later.contains_key(id),
|
||||
None => true,
|
||||
})
|
||||
.collect();
|
||||
let mut out = adopt_run(&healed, &kept);
|
||||
out.extend(kept);
|
||||
// What this function exists to prevent, checked rather than assumed: the same
|
||||
// call drawn twice, once from the page that saw its start and once from the page
|
||||
// that saw its end. Not a seq-ordering check -- a peer note is stamped with the
|
||||
// seq its turn began at, which can be older than the page it arrived in, so the
|
||||
// two pages' seqs legitimately interleave at the boundary.
|
||||
debug_assert!(
|
||||
{
|
||||
let mut ids: Vec<&str> = out.iter().filter_map(TranscriptItem::as_tool_run).collect();
|
||||
let before = ids.len();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
ids.len() == before
|
||||
},
|
||||
"join_pages left the same tool call in both halves"
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/// Rejoins a message the page boundary cut, and hands back the two pages to
|
||||
/// concatenate. Ported from `TranscriptItems.kt`'s `healSplitMessage`.
|
||||
///
|
||||
/// `fold_event` never leaves two assistant messages next to each other
|
||||
/// inside one page, so two meeting at a join are always the two halves of
|
||||
/// one reply, and leaving them apart drew a single answer as two with a
|
||||
/// paragraph break through the middle of a sentence.
|
||||
///
|
||||
/// The newer half keeps its identity, for the reason `adopt_run`'s doc
|
||||
/// gives. It grows by what the older half brings, which is safe here and
|
||||
/// nowhere else -- the join is at the oldest end of what is loaded, so the
|
||||
/// growth extends off the top of the screen.
|
||||
fn heal_split_message(
|
||||
earlier: &[TranscriptItem],
|
||||
later: &[TranscriptItem],
|
||||
) -> (Vec<TranscriptItem>, Vec<TranscriptItem>) {
|
||||
let (
|
||||
Some(TranscriptItem::AssistantMsg {
|
||||
text: head_text, ..
|
||||
}),
|
||||
Some(TranscriptItem::AssistantMsg {
|
||||
seq: tail_seq,
|
||||
text: tail_text,
|
||||
settled: tail_settled,
|
||||
}),
|
||||
) = (earlier.last(), later.first())
|
||||
else {
|
||||
return (earlier.to_vec(), later.to_vec());
|
||||
};
|
||||
let merged = TranscriptItem::AssistantMsg {
|
||||
seq: *tail_seq,
|
||||
text: format!("{head_text}{tail_text}"),
|
||||
settled: *tail_settled,
|
||||
};
|
||||
let mut newer = vec![merged];
|
||||
newer.extend(later[1..].iter().cloned());
|
||||
(earlier[..earlier.len() - 1].to_vec(), newer)
|
||||
}
|
||||
|
||||
/// Hands the older calls at the join the name of the run they are joining.
|
||||
/// Ported from `TranscriptItems.kt`'s `adoptRun`.
|
||||
///
|
||||
/// The two pages were folded separately, so a run split by the boundary
|
||||
/// came back as two runs with two names. Naming the joined run after the
|
||||
/// *older* half would be the obvious way round and is wrong: the newer half
|
||||
/// is the part already on screen, and renaming it is renaming the row the
|
||||
/// reader is looking at, which is how a list loses its anchor.
|
||||
fn adopt_run(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<TranscriptItem> {
|
||||
let Some(TranscriptItem::ToolRun { run_id, tool, .. }) = later.first() else {
|
||||
return earlier.to_vec();
|
||||
};
|
||||
// A question is in a run of its own on both sides of the join, the same as it would be
|
||||
// had the two pages been folded as one. Without this the heal would merge a group
|
||||
// straight through the row the reader was asked something on.
|
||||
if tool == ASK_USER_QUESTION {
|
||||
return earlier.to_vec();
|
||||
}
|
||||
let joining = run_id.clone();
|
||||
let tail_len = earlier
|
||||
.iter()
|
||||
.rev()
|
||||
.take_while(|item| matches!(item, TranscriptItem::ToolRun { tool, .. } if tool != ASK_USER_QUESTION))
|
||||
.count();
|
||||
if tail_len == 0 {
|
||||
return earlier.to_vec();
|
||||
}
|
||||
let split = earlier.len() - tail_len;
|
||||
let mut out = earlier[..split].to_vec();
|
||||
out.extend(earlier[split..].iter().cloned().map(|mut item| {
|
||||
// `take_while` above already restricted this slice to non-question tool calls;
|
||||
// this just guards the invariant rather than trusting it silently.
|
||||
debug_assert!(
|
||||
matches!(&item, TranscriptItem::ToolRun { tool, .. } if tool != ASK_USER_QUESTION),
|
||||
"adopt_run must never rename a question's own run"
|
||||
);
|
||||
if let TranscriptItem::ToolRun { run_id, .. } = &mut item {
|
||||
*run_id = joining.clone();
|
||||
}
|
||||
item
|
||||
}));
|
||||
out
|
||||
}
|
||||
|
||||
/// Folds one transcript event onto `items`, the way `foldEvent` does in
|
||||
/// `TranscriptItems.kt`. Every wire event has a case; see the module doc
|
||||
/// for the one difference from the Kotlin original (no `Unknown` fallback
|
||||
@@ -361,6 +569,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
||||
input: input.to_string(),
|
||||
output: String::new(),
|
||||
done: false,
|
||||
failed: false,
|
||||
asks: Vec::new(),
|
||||
images: Vec::new(),
|
||||
});
|
||||
@@ -371,15 +580,23 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
||||
*out = output.clone();
|
||||
}
|
||||
}),
|
||||
Event::ToolEnd { id, output } => {
|
||||
Event::ToolEnd {
|
||||
id,
|
||||
output,
|
||||
is_error,
|
||||
} => {
|
||||
if items.iter().any(|i| i.as_tool_run() == Some(id.as_str())) {
|
||||
update_tool(items, id, |item| {
|
||||
if let TranscriptItem::ToolRun {
|
||||
output: out, done, ..
|
||||
output: out,
|
||||
done,
|
||||
failed,
|
||||
..
|
||||
} = item
|
||||
{
|
||||
*out = output.clone();
|
||||
*done = true;
|
||||
*failed = *is_error;
|
||||
}
|
||||
})
|
||||
} else {
|
||||
@@ -393,6 +610,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
||||
input: String::new(),
|
||||
output: output.clone(),
|
||||
done: true,
|
||||
failed: *is_error,
|
||||
asks: Vec::new(),
|
||||
images: Vec::new(),
|
||||
});
|
||||
@@ -449,6 +667,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
||||
input,
|
||||
output,
|
||||
done,
|
||||
failed,
|
||||
images,
|
||||
} if asks.iter().any(|a| &a.id == id) => {
|
||||
for ask in asks.iter_mut() {
|
||||
@@ -464,6 +683,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
||||
input,
|
||||
output,
|
||||
done,
|
||||
failed,
|
||||
asks,
|
||||
images,
|
||||
}
|
||||
@@ -525,6 +745,14 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
||||
items.push(TranscriptItem::ClearedNote { seq });
|
||||
items
|
||||
}
|
||||
Event::LimitReached { resets_at } => {
|
||||
let mut items = items.to_vec();
|
||||
items.push(TranscriptItem::LimitNote {
|
||||
seq,
|
||||
resets_at: *resets_at,
|
||||
});
|
||||
items
|
||||
}
|
||||
Event::Compacted {
|
||||
pre_tokens,
|
||||
post_tokens,
|
||||
@@ -541,6 +769,74 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
||||
}
|
||||
}
|
||||
|
||||
/// What became of one tool call -- every state a card has to be able to
|
||||
/// draw, including the two that are not answers.
|
||||
///
|
||||
/// The pair this enum exists for is [`ToolState::Succeeded`] against
|
||||
/// [`ToolState::NoResult`]. A call that finished having printed nothing
|
||||
/// and a call whose result never arrived both leave an empty `output`,
|
||||
/// and drawing them the same way states a verdict nobody reached: "it
|
||||
/// worked and said nothing" reads as a fact, where the truth is that the
|
||||
/// turn ended before anything came back.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ToolState {
|
||||
/// Started, no result yet, and the session is still working -- the
|
||||
/// ordinary state of a call in flight.
|
||||
Running,
|
||||
/// Stopped on the reader: a permission or question this call carries
|
||||
/// has not been answered, so nothing is happening until somebody
|
||||
/// answers it. Distinct from [`Self::Running`] because whose move it
|
||||
/// is differs, which is the Compose card's "your turn".
|
||||
Deciding,
|
||||
/// A result arrived and the tool did not report a failure.
|
||||
Succeeded,
|
||||
/// A result arrived and the tool reported that the call failed
|
||||
/// (`is_error`).
|
||||
Failed,
|
||||
/// No result ever arrived and the session is not working any more --
|
||||
/// the turn was interrupted, or the process went away. Not a verdict
|
||||
/// on the call: it says only that nobody found out.
|
||||
NoResult,
|
||||
}
|
||||
|
||||
impl ToolState {
|
||||
/// The state of one call. `session_working` is
|
||||
/// [`session_working`]'s answer for the session this call is in --
|
||||
/// the only thing here that is not a property of the call itself, and
|
||||
/// what separates "still running" from "never came back".
|
||||
///
|
||||
/// Written once, over the fields rather than per call site, because
|
||||
/// the five states are decided by four conditions and every place
|
||||
/// that re-derived a subset of them got a different subset.
|
||||
pub fn of(item: &TranscriptItem, session_working: bool) -> Option<Self> {
|
||||
let TranscriptItem::ToolRun {
|
||||
done, failed, asks, ..
|
||||
} = item
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
debug_assert!(
|
||||
!failed || *done,
|
||||
"a call cannot have failed before its result arrived"
|
||||
);
|
||||
Some(if asks.iter().any(|ask| ask.answers.is_empty()) {
|
||||
// Ahead of `done`: a call waiting on permission has not
|
||||
// finished either, and which of the two the reader is being
|
||||
// told about is the one they can act on.
|
||||
Self::Deciding
|
||||
} else if !*done {
|
||||
match session_working {
|
||||
true => Self::Running,
|
||||
false => Self::NoResult,
|
||||
}
|
||||
} else if *failed {
|
||||
Self::Failed
|
||||
} else {
|
||||
Self::Succeeded
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// One row as the transcript draws it: a run of consecutive tool calls, or
|
||||
/// anything else. Ported from `ToolRows.kt`'s `TranscriptRow` and
|
||||
/// `groupToolRuns` -- the Compose card rendering in that file is not part
|
||||
@@ -780,6 +1076,7 @@ mod tests {
|
||||
Event::ToolEnd {
|
||||
id: "x".to_string(),
|
||||
output: "done".to_string(),
|
||||
is_error: false,
|
||||
},
|
||||
)]);
|
||||
assert_eq!(
|
||||
@@ -792,6 +1089,7 @@ mod tests {
|
||||
input: String::new(),
|
||||
output: "done".to_string(),
|
||||
done: true,
|
||||
failed: false,
|
||||
asks: Vec::new(),
|
||||
images: Vec::new(),
|
||||
}]
|
||||
@@ -939,4 +1237,284 @@ mod tests {
|
||||
let err = fold_page(&values).unwrap_err();
|
||||
assert!(err.contains("couldn't parse"));
|
||||
}
|
||||
|
||||
fn tool_start(seq: u64, id: &str, tool: &str) -> SeqEvent {
|
||||
event(
|
||||
seq,
|
||||
Event::ToolStart {
|
||||
id: id.to_string(),
|
||||
tool: tool.to_string(),
|
||||
input: serde_json::json!({}),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn tool_end(seq: u64, id: &str, output: &str) -> SeqEvent {
|
||||
event(
|
||||
seq,
|
||||
Event::ToolEnd {
|
||||
id: id.to_string(),
|
||||
output: output.to_string(),
|
||||
is_error: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// AGENTS.md's "things that have bitten": `joinPages` used to run
|
||||
/// `adoptRun` only on the path where a *split* call was found, so a
|
||||
/// boundary landing cleanly between two already-finished calls -- most
|
||||
/// of them -- left the older page's calls under the run name they were
|
||||
/// folded with, drawing one run of tool calls as two groups. Two
|
||||
/// finished, unrelated calls (no id in common) must still end up under
|
||||
/// one run name after the join.
|
||||
#[test]
|
||||
fn a_clean_boundary_between_two_finished_runs_is_still_healed_into_one_run() {
|
||||
let older = fold_all(&[tool_start(1, "a", "Bash"), tool_end(2, "a", "old output")]);
|
||||
let newer = fold_all(&[tool_start(3, "b", "Bash"), tool_end(4, "b", "new output")]);
|
||||
let joined = join_pages(&older, &newer);
|
||||
let run_ids: Vec<_> = joined
|
||||
.iter()
|
||||
.map(|item| match item {
|
||||
TranscriptItem::ToolRun { run_id, .. } => run_id.as_str(),
|
||||
other => panic!("expected only ToolRun items, got {other:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
run_ids,
|
||||
vec!["b", "b"],
|
||||
"the older call must adopt the newer, already-on-screen run's name"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_call_split_across_the_boundary_merges_into_one_row() {
|
||||
let older = fold_all(&[tool_start(1, "x", "Bash")]);
|
||||
let newer = fold_all(&[tool_end(2, "x", "the result")]);
|
||||
let joined = join_pages(&older, &newer);
|
||||
assert_eq!(
|
||||
joined,
|
||||
vec![TranscriptItem::ToolRun {
|
||||
seq: 1,
|
||||
id: "x".to_string(),
|
||||
run_id: "x".to_string(),
|
||||
tool: "Bash".to_string(),
|
||||
input: "{}".to_string(),
|
||||
output: "the result".to_string(),
|
||||
done: true,
|
||||
failed: false,
|
||||
asks: Vec::new(),
|
||||
images: Vec::new(),
|
||||
}],
|
||||
"the older half's tool/input and the newer half's output/done must both survive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_message_split_across_the_boundary_is_rejoined_with_the_newer_halfs_identity() {
|
||||
let older = vec![TranscriptItem::AssistantMsg {
|
||||
seq: 1,
|
||||
text: "Hel".to_string(),
|
||||
settled: false,
|
||||
}];
|
||||
let newer = vec![
|
||||
TranscriptItem::AssistantMsg {
|
||||
seq: 2,
|
||||
text: "lo".to_string(),
|
||||
settled: true,
|
||||
},
|
||||
TranscriptItem::UserMsg {
|
||||
seq: 3,
|
||||
text: "next".to_string(),
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
];
|
||||
let joined = join_pages(&older, &newer);
|
||||
assert_eq!(
|
||||
joined,
|
||||
vec![
|
||||
TranscriptItem::AssistantMsg {
|
||||
seq: 2,
|
||||
text: "Hello".to_string(),
|
||||
settled: true,
|
||||
},
|
||||
TranscriptItem::UserMsg {
|
||||
seq: 3,
|
||||
text: "next".to_string(),
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// A question is in a run of its own on both sides of a join -- healing
|
||||
/// must never rename the run of calls the reader was asked something
|
||||
/// on, the same rule `splitRun` enforces for a live turn boundary.
|
||||
#[test]
|
||||
fn adopt_run_never_renames_into_a_question_row() {
|
||||
let older = fold_all(&[tool_start(1, "a", "Bash"), tool_end(2, "a", "done")]);
|
||||
let newer = vec![TranscriptItem::ToolRun {
|
||||
seq: 3,
|
||||
id: "q".to_string(),
|
||||
run_id: "q".to_string(),
|
||||
tool: ASK_USER_QUESTION.to_string(),
|
||||
input: "{}".to_string(),
|
||||
output: String::new(),
|
||||
done: false,
|
||||
failed: false,
|
||||
asks: Vec::new(),
|
||||
images: Vec::new(),
|
||||
}];
|
||||
let joined = join_pages(&older, &newer);
|
||||
match &joined[0] {
|
||||
TranscriptItem::ToolRun { run_id, .. } => assert_eq!(run_id, "a"),
|
||||
other => panic!("expected a ToolRun, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ToolState`] is what a card colours itself by, so each of its five
|
||||
/// states is asserted from the events that actually produce it rather than
|
||||
/// from a hand-built item -- a mapping that agreed with a fixture and
|
||||
/// disagreed with the fold would be invisible until it was on screen.
|
||||
#[cfg(test)]
|
||||
mod tool_state_tests {
|
||||
use super::*;
|
||||
|
||||
fn event(seq: u64, e: Event) -> SeqEvent {
|
||||
SeqEvent {
|
||||
seq,
|
||||
ts: 0.0,
|
||||
event: e,
|
||||
}
|
||||
}
|
||||
|
||||
fn fold_all(events: &[SeqEvent]) -> Vec<TranscriptItem> {
|
||||
events
|
||||
.iter()
|
||||
.fold(Vec::new(), |items, e| fold_event(&items, e))
|
||||
}
|
||||
|
||||
fn start(id: &str) -> SeqEvent {
|
||||
event(
|
||||
1,
|
||||
Event::ToolStart {
|
||||
id: id.to_string(),
|
||||
tool: "Bash".to_string(),
|
||||
input: serde_json::json!({"command": "ls"}),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn end(id: &str, output: &str, is_error: bool) -> SeqEvent {
|
||||
event(
|
||||
2,
|
||||
Event::ToolEnd {
|
||||
id: id.to_string(),
|
||||
output: output.to_string(),
|
||||
is_error,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn state_of(events: &[SeqEvent], session_working: bool) -> ToolState {
|
||||
let items = fold_all(events);
|
||||
ToolState::of(&items[0], session_working).expect("the fixture's first item is a tool call")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_result_that_arrived_is_read_from_is_error() {
|
||||
assert_eq!(
|
||||
state_of(&[start("a"), end("a", "ok", false)], false),
|
||||
ToolState::Succeeded
|
||||
);
|
||||
assert_eq!(
|
||||
state_of(&[start("a"), end("a", "No such file", true)], false),
|
||||
ToolState::Failed
|
||||
);
|
||||
}
|
||||
|
||||
/// The pair this enum exists for. Both calls have an empty `output`
|
||||
/// and nothing else distinguishes them, so a card that only looked at
|
||||
/// the text would draw the interrupted one as a call that ran fine and
|
||||
/// printed nothing.
|
||||
#[test]
|
||||
fn a_call_that_printed_nothing_is_not_a_call_that_never_answered() {
|
||||
assert_eq!(
|
||||
state_of(&[start("a"), end("a", "", false)], false),
|
||||
ToolState::Succeeded,
|
||||
"a result arrived; it was empty"
|
||||
);
|
||||
assert_eq!(
|
||||
state_of(&[start("a")], false),
|
||||
ToolState::NoResult,
|
||||
"no result, and the session is not working any more"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same call, mid-turn: still running rather than abandoned. The
|
||||
/// only thing separating the two is the session's own status, which is
|
||||
/// why `of` takes it.
|
||||
#[test]
|
||||
fn no_result_while_the_session_works_is_still_running() {
|
||||
assert_eq!(state_of(&[start("a")], true), ToolState::Running);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unanswered_ask_is_the_readers_move_whatever_else_is_true() {
|
||||
let asking = event(
|
||||
3,
|
||||
Event::Question {
|
||||
id: "q1".to_string(),
|
||||
prompt: "Allow?".to_string(),
|
||||
header: None,
|
||||
options: vec![QuestionOption {
|
||||
label: "Allow".to_string(),
|
||||
description: None,
|
||||
preview: None,
|
||||
}],
|
||||
multi_select: false,
|
||||
about: Some("a".to_string()),
|
||||
},
|
||||
);
|
||||
let answered = event(
|
||||
4,
|
||||
Event::Answered {
|
||||
id: "q1".to_string(),
|
||||
answers: vec!["Allow".to_string()],
|
||||
},
|
||||
);
|
||||
// Ahead of both "still running" and "no result": the reader can
|
||||
// act on this one, and cannot act on either of those.
|
||||
assert_eq!(
|
||||
state_of(&[start("a"), asking.clone()], true),
|
||||
ToolState::Deciding
|
||||
);
|
||||
assert_eq!(
|
||||
state_of(&[start("a"), asking.clone()], false),
|
||||
ToolState::Deciding
|
||||
);
|
||||
assert_eq!(
|
||||
state_of(
|
||||
&[start("a"), asking, answered, end("a", "ok", false)],
|
||||
false
|
||||
),
|
||||
ToolState::Succeeded,
|
||||
"once it is answered the call is an ordinary one again"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_but_a_tool_call_has_a_tool_state() {
|
||||
assert_eq!(
|
||||
ToolState::of(
|
||||
&TranscriptItem::UserMsg {
|
||||
seq: 1,
|
||||
text: "hi".to_string(),
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
true
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
//! Where a session screen gets a transcript from: this phone's copy first,
|
||||
//! the server for the rest. Ported from `app/.../TranscriptSource.kt`; see
|
||||
//! `docs/TRANSCRIPT_CACHE.md` for the design this implements and
|
||||
//! `docs/CLIENT_CORE.md` for how this file corresponds to the Kotlin.
|
||||
//!
|
||||
//! One seam rather than a cache the screen has to remember to consult.
|
||||
//! Everything fetched before is asked of this, and everything the server
|
||||
//! sends is written into the cache on the way past, so a caller never
|
||||
//! learns which side answered. The one rule worth keeping in mind: the
|
||||
//! cache is never load-bearing. Every read here has a network path beside
|
||||
//! it producing the same result.
|
||||
//!
|
||||
//! **Not ported**: `EventStream.kt`'s reconnect-with-backoff loop and the
|
||||
//! ability to close a live stream from another thread. Both are wall-clock
|
||||
//! and thread-lifetime concerns that belong to whatever runtime the caller
|
||||
//! embeds this crate in (a Tokio task, an iris timer, a Kotlin coroutine
|
||||
//! scope) rather than to this pure logic -- `follow` below is the same
|
||||
//! decorator shape `iris/desktop-app/src/app.rs` and
|
||||
//! `iris/android-app/src/transcript_client.rs` already hand-wrote around
|
||||
//! `event_stream::follow_session_events`, just with the cache write built
|
||||
//! in so a future caller does not have to repeat it a third time.
|
||||
|
||||
use event_model::SeqEvent;
|
||||
|
||||
use crate::api::{ApiClient, ApiError, Transport};
|
||||
use crate::event_stream::{self, StreamItem};
|
||||
use crate::transcript_cache::SessionCache;
|
||||
|
||||
/// How many events a session screen opens with, cached or fetched.
|
||||
///
|
||||
/// The server's own default page size, named here because the cached
|
||||
/// opening has to be the same size as the fetched one -- a reader must not
|
||||
/// get a shorter first screen for having been here before (`OPENING_WINDOW`
|
||||
/// in the Kotlin original).
|
||||
pub const OPENING_WINDOW: u32 = 80;
|
||||
|
||||
/// A transcript-line parse failure, told apart from [`ApiError`] so a
|
||||
/// caller can tell "the server is unreachable" from "the server (or this
|
||||
/// phone's own disk) sent something this build cannot read" -- the two
|
||||
/// mean different things to a reader (retry, versus a build that is
|
||||
/// behind).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParseError(pub String);
|
||||
|
||||
impl std::fmt::Display for ParseError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
impl std::error::Error for ParseError {}
|
||||
|
||||
/// Either half of what can go wrong asking for a page: the network, or a
|
||||
/// line neither the cache's nor the server's copy of `parseSeqEvent` could
|
||||
/// read.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PageError {
|
||||
Api(ApiError),
|
||||
Parse(ParseError),
|
||||
}
|
||||
|
||||
impl From<ApiError> for PageError {
|
||||
fn from(e: ApiError) -> Self {
|
||||
Self::Api(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ParseError> for PageError {
|
||||
fn from(e: ParseError) -> Self {
|
||||
Self::Parse(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`TranscriptSource::page`] found, kept as two states rather than
|
||||
/// one possibly-empty list.
|
||||
///
|
||||
/// The difference is the whole of AGENTS.md's `loadOlderPage` incident: an
|
||||
/// empty [`Self::Events`] means "this conversation has no more history",
|
||||
/// which a caller is meant to latch, and [`Self::NothingLoaded`] means the
|
||||
/// question could not be asked yet, which it must not. Collapsing the two
|
||||
/// into an empty `Vec` puts the bug back, because the caller cannot tell
|
||||
/// them apart -- and `unwrap_or_default()` on an `Option` would do the
|
||||
/// same silently.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum OlderPage {
|
||||
/// The events before the cursor, oldest first. Empty means the start
|
||||
/// of the conversation has been reached.
|
||||
Events(Vec<SeqEvent>),
|
||||
/// Nothing is loaded, so there was no cursor to page back from
|
||||
/// (`before == 0`). Not an answer about the conversation at all.
|
||||
NothingLoaded,
|
||||
}
|
||||
|
||||
fn parse_line(line: &str) -> Result<SeqEvent, ParseError> {
|
||||
serde_json::from_str(line).map_err(|e| ParseError(format!("{e}")))
|
||||
}
|
||||
|
||||
/// This phone's copy of one session's transcript, plus the server it
|
||||
/// falls back to. Ported from the Kotlin `TranscriptSource` class.
|
||||
pub struct TranscriptSource<T: Transport> {
|
||||
api: ApiClient<T>,
|
||||
session_id: String,
|
||||
pub cache: SessionCache,
|
||||
}
|
||||
|
||||
impl<T: Transport> TranscriptSource<T> {
|
||||
pub fn new(api: ApiClient<T>, session_id: impl Into<String>, cache: SessionCache) -> Self {
|
||||
Self {
|
||||
api,
|
||||
session_id: session_id.into(),
|
||||
cache,
|
||||
}
|
||||
}
|
||||
|
||||
/// The cached opening window, or `None` when there is nothing usable
|
||||
/// to draw.
|
||||
///
|
||||
/// Meant to be drawn *before* [`Self::probe`] returns, which is the
|
||||
/// whole point of the feature: the rows are on screen while the check
|
||||
/// that they are still the server's rows is in flight, and a failed
|
||||
/// check replaces them exactly as a reset does.
|
||||
pub fn cached_opening(&self, limit: usize) -> Option<Vec<SeqEvent>> {
|
||||
self.cache.tail()?;
|
||||
let lines = self.cache.newest(limit);
|
||||
if lines.is_empty() {
|
||||
return None;
|
||||
}
|
||||
match lines.iter().map(|l| parse_line(l)).collect() {
|
||||
Ok(events) => Some(events),
|
||||
// A line this build cannot read at all, which the cache's own checks cannot
|
||||
// see: it reads a seq off a line, not an event. Nothing to serve, so a cold
|
||||
// open.
|
||||
Err(ParseError(_)) => {
|
||||
self.cache.purge();
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the server's event at the cached cursor is still the cached
|
||||
/// one.
|
||||
///
|
||||
/// A caller must not resume a live stream from a cached seq unless it
|
||||
/// is the same conversation: a transcript is append-only in ordinary
|
||||
/// use, but the file backing it can be replaced or truncated (a
|
||||
/// sandbox re-seeded with the same ids, a backup restored, a session
|
||||
/// re-imported), and the server's catch-up on such a file would hand
|
||||
/// this phone a continuation of a *different* conversation, spliced
|
||||
/// onto the cached one with no seam. Caught with one request of a few
|
||||
/// hundred bytes.
|
||||
///
|
||||
/// `Ok(false)` purges the cache and means "open cold". `Err` is the
|
||||
/// server not being askable, which is neither: the cached rows stay
|
||||
/// on screen and the caller tries again on its own reconnect schedule.
|
||||
///
|
||||
/// What this cannot see is a line changed in the middle of the file
|
||||
/// with the tail intact -- that is what a full reload is for.
|
||||
pub fn probe(&self) -> Result<bool, ApiError> {
|
||||
let Some(tail) = self.cache.tail() else {
|
||||
return Ok(false);
|
||||
};
|
||||
// `before = seq + 1` is the newest event with seq <= the cursor, which is the
|
||||
// event *at* the cursor when the server still has one there.
|
||||
let page = self.api.fetch_transcript_lines(
|
||||
&self.session_id,
|
||||
Some(tail.seq + 1),
|
||||
1,
|
||||
false,
|
||||
None,
|
||||
)?;
|
||||
let matches = page.len() == 1
|
||||
&& parse_line(&tail.line)
|
||||
.map(|cached| cached == page[0].1)
|
||||
.unwrap_or(false);
|
||||
if !matches {
|
||||
self.cache.purge();
|
||||
}
|
||||
Ok(matches)
|
||||
}
|
||||
|
||||
/// Today's opening fetch, kept as the start of the live run. Only
|
||||
/// called when the cache has nothing to open with, or when
|
||||
/// [`Self::probe`] said what it had was not the server's.
|
||||
pub fn fetch_opening(&self) -> Result<Vec<SeqEvent>, ApiError> {
|
||||
let page =
|
||||
self.api
|
||||
.fetch_transcript_lines(&self.session_id, None, OPENING_WINDOW, false, None)?;
|
||||
for (line, event) in &page {
|
||||
self.cache.append(line, event.seq);
|
||||
}
|
||||
self.cache.flush();
|
||||
Ok(page.into_iter().map(|(_, event)| event).collect())
|
||||
}
|
||||
|
||||
/// The page before `before`: from the cache when it holds it,
|
||||
/// otherwise from the server bounded by what the cache already has.
|
||||
///
|
||||
/// The server bound (`after`) is what keeps the cache worth having. A
|
||||
/// coalesced page reaches back as far as its row count takes it -- a
|
||||
/// single reply is hundreds of lines -- so a page fetched after the
|
||||
/// reader has been away could run straight past the cached run and
|
||||
/// overlap it, and an overlapping page cannot be stored. Told where
|
||||
/// this phone's copy starts, the server stops there instead.
|
||||
///
|
||||
/// `before == 0` answers [`OlderPage::NothingLoaded`] without asking
|
||||
/// the cache or the server anything -- see AGENTS.md's "things that
|
||||
/// have bitten": there is no event before the first one, so the
|
||||
/// request is not a harmless no-op, and its empty answer is
|
||||
/// indistinguishable from having reached the start of history.
|
||||
/// Guarded here rather than left to every caller, because it is a fact
|
||||
/// about the question, not about who is asking it.
|
||||
pub fn page(&self, before: u64, limit: u32, coalesce: bool) -> Result<OlderPage, PageError> {
|
||||
if before == 0 {
|
||||
return Ok(OlderPage::NothingLoaded);
|
||||
}
|
||||
if let Some(lines) = self.cache.page(before, limit as usize, coalesce) {
|
||||
let events: Vec<SeqEvent> = lines
|
||||
.iter()
|
||||
.map(|l| parse_line(l).map_err(PageError::from))
|
||||
.collect::<Result<_, _>>()?;
|
||||
return Ok(OlderPage::Events(events));
|
||||
}
|
||||
let after = self.cache.covered_up_to(before).map(|v| v - 1);
|
||||
let page = self.api.fetch_transcript_lines(
|
||||
&self.session_id,
|
||||
Some(before),
|
||||
limit,
|
||||
coalesce,
|
||||
after,
|
||||
)?;
|
||||
if let Some((_, first_event)) = page.first() {
|
||||
// `before` rather than the newest line's seq: a coalesced page covers
|
||||
// everything up to the cursor it was asked with, and nothing in its lines
|
||||
// says so.
|
||||
let lines: Vec<String> = page.iter().map(|(line, _)| line.clone()).collect();
|
||||
self.cache
|
||||
.store_page(&lines, first_event.seq, before, coalesce);
|
||||
}
|
||||
Ok(OlderPage::Events(
|
||||
page.into_iter().map(|(_, event)| event).collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// [`event_stream::follow_session_events`], with every frame written to
|
||||
/// the cache before `on_item` sees it.
|
||||
///
|
||||
/// Before, so that an event held back for a reader who is scrolled
|
||||
/// away is already on disk -- what the cache holds is what the server
|
||||
/// sent, not what a screen has got round to drawing. Flushed on each
|
||||
/// status change, which is a turn's boundary and the granularity a
|
||||
/// crash may as well lose, and once more when the stream ends.
|
||||
pub fn follow(
|
||||
&self,
|
||||
after: u64,
|
||||
mut on_item: impl FnMut(StreamItem) -> bool,
|
||||
) -> Result<(), ApiError> {
|
||||
let cache = &self.cache;
|
||||
let result = event_stream::follow_session_events(
|
||||
self.api.transport(),
|
||||
&self.session_id,
|
||||
after,
|
||||
|item| {
|
||||
if let StreamItem::Event { raw, event } = &item {
|
||||
cache.append(raw, event.seq);
|
||||
if matches!(event.event, event_model::Event::Status { .. }) {
|
||||
cache.flush();
|
||||
}
|
||||
}
|
||||
on_item(item)
|
||||
},
|
||||
);
|
||||
cache.flush();
|
||||
result
|
||||
}
|
||||
|
||||
/// Leaves the cache with everything it was given -- called once a
|
||||
/// caller is done with this source, mirroring the Kotlin `close`'s
|
||||
/// final flush (that method's stream cancellation itself is the
|
||||
/// runtime concern the module doc says is not ported here).
|
||||
pub fn close(&self) {
|
||||
self.cache.flush();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::{Body, RawResponse};
|
||||
use std::collections::VecDeque;
|
||||
use std::io::Read;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// A transport that answers fixed bodies in call order, and records
|
||||
/// every path it was asked for -- so a test can assert *how many*
|
||||
/// requests a method made, which is the point for the `before == 0`
|
||||
/// guard (AGENTS.md's regression: the guard must stop the request
|
||||
/// before it happens, not merely tolerate the empty answer).
|
||||
#[derive(Default)]
|
||||
struct ScriptedTransport {
|
||||
responses: Mutex<VecDeque<(u16, String)>>,
|
||||
calls: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl ScriptedTransport {
|
||||
fn respond(&self, status: u16, body: impl Into<String>) {
|
||||
self.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_back((status, body.into()));
|
||||
}
|
||||
|
||||
fn call_count(&self) -> usize {
|
||||
self.calls.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Transport for ScriptedTransport {
|
||||
fn request(
|
||||
&self,
|
||||
_method: &str,
|
||||
path: &str,
|
||||
_body: Option<Body>,
|
||||
) -> Result<RawResponse, ApiError> {
|
||||
self.calls.lock().unwrap().push(path.to_string());
|
||||
let (status, body) = self
|
||||
.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.unwrap_or_else(|| panic!("ScriptedTransport got an unscripted request: {path}"));
|
||||
Ok(RawResponse {
|
||||
status,
|
||||
body: body.into_bytes(),
|
||||
})
|
||||
}
|
||||
|
||||
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError> {
|
||||
self.calls.lock().unwrap().push(path.to_string());
|
||||
let (_, body) = self
|
||||
.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.unwrap_or_else(|| {
|
||||
panic!("ScriptedTransport got an unscripted stream request: {path}")
|
||||
});
|
||||
Ok(Box::new(std::io::Cursor::new(body.into_bytes())))
|
||||
}
|
||||
}
|
||||
|
||||
fn source(
|
||||
transport: ScriptedTransport,
|
||||
cache_root: &std::path::Path,
|
||||
) -> TranscriptSource<ScriptedTransport> {
|
||||
let api = ApiClient::new(transport);
|
||||
let cache = crate::transcript_cache::TranscriptCache::new(cache_root).session("s1");
|
||||
TranscriptSource::new(api, "s1", cache)
|
||||
}
|
||||
|
||||
fn status_line(seq: u64) -> String {
|
||||
format!(r#"{{"seq":{seq},"ts":1.0,"type":"status","state":"idle"}}"#)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cold_cache_has_no_opening_and_fetches_from_the_server() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(200, format!("[{}]", status_line(1)));
|
||||
let source = source(transport, dir.path());
|
||||
|
||||
assert_eq!(source.cached_opening(80), None);
|
||||
let opening = source.fetch_opening().unwrap();
|
||||
assert_eq!(opening.len(), 1);
|
||||
assert_eq!(opening[0].seq, 1);
|
||||
// The fetch wrote through: reopening the same cache now has something to show.
|
||||
assert!(source.cache.tail().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_matching_the_cached_tail_leaves_the_cache_alone() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(200, format!("[{}]", status_line(1)));
|
||||
let source = source(transport, dir.path());
|
||||
source.fetch_opening().unwrap();
|
||||
|
||||
let transport2 = ScriptedTransport::default();
|
||||
transport2.respond(200, format!("[{}]", status_line(1)));
|
||||
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
|
||||
assert!(source2.probe().unwrap());
|
||||
assert!(source2.cache.tail().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_mismatching_the_cached_tail_purges_the_cache() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(200, format!("[{}]", status_line(1)));
|
||||
let source = source(transport, dir.path());
|
||||
source.fetch_opening().unwrap();
|
||||
|
||||
// The server now answers with a different event at the same seq -- the file
|
||||
// behind this session was replaced.
|
||||
let transport2 = ScriptedTransport::default();
|
||||
let different = r#"{"seq":1,"ts":1.0,"type":"status","state":"running"}"#.to_string();
|
||||
transport2.respond(200, format!("[{different}]"));
|
||||
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
|
||||
assert!(!source2.probe().unwrap());
|
||||
assert!(source2.cache.tail().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_finding_no_server_leaves_the_cache_untouched() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(200, format!("[{}]", status_line(1)));
|
||||
let source = source(transport, dir.path());
|
||||
source.fetch_opening().unwrap();
|
||||
|
||||
let transport2 = ScriptedTransport::default();
|
||||
transport2.respond(500, "server on fire");
|
||||
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
|
||||
assert!(source2.probe().is_err());
|
||||
assert!(
|
||||
source2.cache.tail().is_some(),
|
||||
"an unreachable server must not be treated as a mismatch"
|
||||
);
|
||||
}
|
||||
|
||||
/// The regression this module exists to close: `before == 0` must
|
||||
/// never reach the network or the cache, because an empty answer there
|
||||
/// is indistinguishable from "there is genuinely no more history" --
|
||||
/// AGENTS.md's `loadOlderPage` incident.
|
||||
#[test]
|
||||
fn paging_before_the_first_event_makes_no_request_at_all() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
let source = source(transport, dir.path());
|
||||
assert_eq!(source.page(0, 80, true).unwrap(), OlderPage::NothingLoaded);
|
||||
assert_eq!(source.api.transport().call_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_already_covered_by_the_cache_never_reaches_the_server() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(200, format!("[{},{}]", status_line(1), status_line(2)));
|
||||
let source = source(transport, dir.path());
|
||||
source.fetch_opening().unwrap();
|
||||
|
||||
let calls_before = source.api.transport().call_count();
|
||||
let OlderPage::Events(page) = source.page(2, 10, true).unwrap() else {
|
||||
panic!("a cursor of 2 is a real question about the conversation");
|
||||
};
|
||||
assert_eq!(page.len(), 1);
|
||||
assert_eq!(page[0].seq, 1);
|
||||
assert_eq!(
|
||||
source.api.transport().call_count(),
|
||||
calls_before,
|
||||
"a cache hit must not touch the network"
|
||||
);
|
||||
}
|
||||
|
||||
/// With nothing older cached there is no floor to give the server, so
|
||||
/// the request carries no `after` at all.
|
||||
#[test]
|
||||
fn a_server_page_with_nothing_older_cached_carries_no_bound() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(200, format!("[{}]", status_line(5)));
|
||||
let source = source(transport, dir.path());
|
||||
source.fetch_opening().unwrap();
|
||||
|
||||
let transport2 = ScriptedTransport::default();
|
||||
transport2.respond(200, format!("[{}]", status_line(3)));
|
||||
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
|
||||
source2.page(5, 10, true).unwrap();
|
||||
assert_eq!(
|
||||
source2.api.transport().calls.lock().unwrap()[0],
|
||||
"/sessions/s1/transcript?limit=10&before=5&coalesce=true"
|
||||
);
|
||||
}
|
||||
|
||||
/// The half the test above cannot show: when the cache *does* hold an
|
||||
/// older run, the fetch is floored at its end, or the page would run
|
||||
/// straight past it and overlap -- which `store_page` then refuses,
|
||||
/// silently costing the phone the page it just paid for.
|
||||
#[test]
|
||||
fn a_server_page_is_floored_at_the_end_of_the_cached_run() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||
// A stored page covering [3, 6) and two live events above it, so the run this
|
||||
// phone holds is [3, 8) -- the newest chunk has to be an appended one, or the
|
||||
// cache reads the directory as damaged and discards it.
|
||||
let lines: Vec<String> = (3..6).map(status_line).collect();
|
||||
assert!(cache.store_page(&lines, 3, 6, true));
|
||||
cache.append(&status_line(6), 6);
|
||||
cache.append(&status_line(7), 7);
|
||||
cache.flush();
|
||||
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(200, format!("[{}]", status_line(9)));
|
||||
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
|
||||
source.page(10, 10, true).unwrap();
|
||||
assert_eq!(
|
||||
source.api.transport().calls.lock().unwrap()[0],
|
||||
"/sessions/s1/transcript?limit=10&before=10&coalesce=true&after=7",
|
||||
"the fetch must stop one seq below where this phone's copy ends"
|
||||
);
|
||||
}
|
||||
|
||||
/// A page the server could not answer is an error, never an empty
|
||||
/// page: the caller would read the second as "this conversation has no
|
||||
/// more history" and stop paging for good.
|
||||
#[test]
|
||||
fn a_failing_server_page_is_an_error_rather_than_an_empty_one() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(500, "server on fire");
|
||||
let source = source(transport, dir.path());
|
||||
assert!(matches!(source.page(9, 10, true), Err(PageError::Api(_)),));
|
||||
}
|
||||
|
||||
/// A cached line this build cannot read is told apart from the network
|
||||
/// failing, for the same reason: neither is "no more history".
|
||||
#[test]
|
||||
fn an_unreadable_cached_page_is_a_parse_error_rather_than_an_empty_one() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||
cache.store_page(
|
||||
&[r#"{"seq":3,"but":"not an event"}"#.to_string()],
|
||||
3,
|
||||
4,
|
||||
true,
|
||||
);
|
||||
cache.append(&status_line(4), 4);
|
||||
cache.flush();
|
||||
let transport = ScriptedTransport::default();
|
||||
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
|
||||
assert!(matches!(source.page(4, 10, true), Err(PageError::Parse(_)),));
|
||||
assert_eq!(
|
||||
source.api.transport().call_count(),
|
||||
0,
|
||||
"a cache hit that cannot be read must not fall through to the server unnoticed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_cached_opening_line_purges_rather_than_panicking() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||
cache.append("not json at all", 1);
|
||||
cache.flush();
|
||||
let transport = ScriptedTransport::default();
|
||||
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
|
||||
assert_eq!(source.cached_opening(80), None);
|
||||
assert!(
|
||||
source.cache.tail().is_none(),
|
||||
"a damaged line purges the cache"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_writes_events_to_the_cache_before_the_caller_sees_them() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(200, format!("{}\n\n", sse_frame(&status_line(1))));
|
||||
let source = source(transport, dir.path());
|
||||
let mut seen = Vec::new();
|
||||
source
|
||||
.follow(0, |item| {
|
||||
if let StreamItem::Event { event, .. } = item {
|
||||
seen.push(event.seq);
|
||||
}
|
||||
true
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(seen, vec![1]);
|
||||
assert_eq!(source.cache.tail().unwrap().seq, 1);
|
||||
}
|
||||
|
||||
fn sse_frame(data: &str) -> String {
|
||||
format!("data:{data}")
|
||||
}
|
||||
}
|
||||
@@ -25,16 +25,19 @@ next (a Masonry or iris transcript screen, most likely).
|
||||
| `sse.rs` | `Sse.kt` (the framing half) | Done, new tests (Kotlin had none of its own beyond integration) |
|
||||
| `api.rs` | `Api.kt` | Partial -- see below |
|
||||
| `event_stream.rs` | `EventStream.kt` | Done |
|
||||
| `transcript_fold.rs` | `TranscriptItems.kt`, `ToolRows.kt` | Partial -- see below |
|
||||
| `transcript_fold.rs` | `TranscriptItems.kt`, `ToolRows.kt` | Done -- see below |
|
||||
| `config.rs` | `ServerConfig.kt`'s `handleEnrollment` | New, desktop-only so far -- see below |
|
||||
| *(not started)* | `TranscriptSource.kt` | Not started |
|
||||
| `transcript_source.rs` | `TranscriptSource.kt` | Done -- see below |
|
||||
| *(not ported, and may never be)* | `TranscriptUnits.kt` | Out of scope -- see below |
|
||||
|
||||
Every file above whose Kotlin counterpart had a JVM unit test (`AnsiTest`,
|
||||
`HighlighterTest`, `TranscriptCacheTest`) has had every one of those test
|
||||
cases ported alongside it, plus new tests for the pieces that had none
|
||||
(`sse.rs`, `api.rs`, `event_stream.rs`, `transcript_fold.rs`). Test count by
|
||||
crate as of this writing: **85 in `client-core`**, 0 in `event-model` (its
|
||||
(`sse.rs`, `api.rs`, `event_stream.rs`, `transcript_fold.rs`,
|
||||
`transcript_source.rs` -- the Kotlin `TranscriptSource.kt`/`TranscriptItems.kt`
|
||||
had no JVM unit tests of their own, so these were written fresh against the
|
||||
Kotlin source and AGENTS.md's paging incidents as the spec). Test count by
|
||||
crate as of this writing: **109 in `client-core`**, 0 in `event-model` (its
|
||||
types carry no logic of their own to test -- `server/`'s own tests exercise
|
||||
them via `session::transcript`'s round-trip coverage).
|
||||
|
||||
@@ -88,13 +91,27 @@ the full table to work from when one of these is next.
|
||||
including tool-call/question/image attachment and peer-message placement.
|
||||
`group_tool_runs` groups adjacent calls into `TranscriptRow::Tools`.
|
||||
|
||||
**Not ported:** `TranscriptItems.kt`'s `joinPages` (and its
|
||||
`healSplitMessage`/`adoptRun` helpers) -- the page-boundary healing that
|
||||
merges a tool call split across two fetched pages and re-merges a run a
|
||||
boundary cut through. This matters the moment paging backward through
|
||||
history is exercised; it is deliberately left rather than rushed, since
|
||||
it is exactly the kind of boundary logic this project's own "things that
|
||||
have bitten" section warns reads fine and is wrong at the edges.
|
||||
`join_pages` (with `heal_split_message` and `adopt_run`, both private) is
|
||||
now ported too, 2026-09-06 -- the page-boundary healing that merges a tool
|
||||
call split across two fetched pages, rejoins a message a boundary cut
|
||||
through, and renames a run of tool calls onto whichever name is already on
|
||||
screen. Ported with AGENTS.md's "things that have bitten" incidents as the
|
||||
spec rather than a JVM test file (`TranscriptItems.kt` had none of its
|
||||
own): `a_clean_boundary_between_two_finished_runs_is_still_healed_into_one_run`
|
||||
is the regression test for the bug that shipped -- `adopt_run` must run on
|
||||
*every* join, not only the one where a split call was found, or a boundary
|
||||
landing cleanly between two already-finished calls (most of them) leaves
|
||||
one run drawn as two. `a_call_split_across_the_boundary_merges_into_one_row`,
|
||||
`a_message_split_across_the_boundary_is_rejoined_with_the_newer_halfs_identity`,
|
||||
and `adopt_run_never_renames_into_a_question_row` cover the other three
|
||||
edges the Kotlin doc calls out. `join_pages` ends in a `debug_assert!`
|
||||
that no tool id survives in both halves -- the duplicate row it exists to
|
||||
prevent, checked rather than assumed. What it deliberately does *not*
|
||||
assert is seq ordering across the boundary: a peer note carries the seq
|
||||
its turn began at (`place_peer_note`), which can be older than the page
|
||||
it arrived in, so the two pages' seqs legitimately interleave there. An
|
||||
earlier draft asserted it and would have panicked in debug builds on an
|
||||
ordinary transcript.
|
||||
|
||||
**Known gap, and a decision for whoever closes it:** `event_model::Event`
|
||||
has no `Unknown`/catch-all variant, unlike `Events.kt`'s hand-kept mirror.
|
||||
@@ -119,20 +136,72 @@ caller-specific (the code rules' "ask for the least you need"). Its only
|
||||
caller today is `desktop-app`; a future Android build of this crate would
|
||||
be a second one, not a reason to move the type.
|
||||
|
||||
## What `transcript_source.rs` covers, and what it does not
|
||||
|
||||
`TranscriptSource<T: Transport>` is the seam a session screen asks for a
|
||||
page, ported test-for-test against the Kotlin doc rather than a JVM test
|
||||
file (there wasn't one): `cached_opening`, `probe`, `fetch_opening`,
|
||||
`page` and `follow`, each matching its Kotlin namesake's contract --
|
||||
including `probe`'s three-way outcome (matches / cache purged /
|
||||
unreachable, told apart so a caller never treats "couldn't ask" as "was
|
||||
wrong") and `page`'s cache-vs-server split bounded by `covered_up_to`.
|
||||
|
||||
Two additions beyond a literal port, both load-bearing:
|
||||
|
||||
- **`page(before, ..)` refuses `before == 0` before touching the cache or
|
||||
the network**, answering `OlderPage::NothingLoaded`. This is AGENTS.md's
|
||||
`loadOlderPage` incident (`before = 0` is "no event before the first
|
||||
one," indistinguishable from "reached the start of history" if a caller
|
||||
ever asks it) moved out of the Kotlin screen and into this layer, so
|
||||
every future caller gets the guard rather than having to remember it.
|
||||
**The return type is `OlderPage`, not a `Vec`, and that is the guard.**
|
||||
The Kotlin's two falses are different answers -- `oldestSeq == 0`
|
||||
returns without touching `moreHistory`, an empty page latches it false
|
||||
-- so a port that answered both with an empty list would have moved the
|
||||
bug rather than fixed it, one layer down and out of sight of the screen
|
||||
that used to hold the check. `OlderPage::Events(vec![])` means the start
|
||||
of the conversation; `OlderPage::NothingLoaded` is not an answer about
|
||||
the conversation at all. Reviewed 2026-09-06.
|
||||
`paging_before_the_first_event_makes_no_request_at_all` asserts zero
|
||||
transport calls, not just the variant, since a request that happens to
|
||||
answer empty is exactly what caused the original bug, and
|
||||
`a_failing_server_page_is_an_error_rather_than_an_empty_one` plus
|
||||
`an_unreadable_cached_page_is_a_parse_error_rather_than_an_empty_one`
|
||||
are the same rule for the two ways a page can fail.
|
||||
- **`fetch_transcript_lines`** (new in `api.rs`) hands back each line
|
||||
paired with the exact server bytes it came from, via
|
||||
`serde_json::value::RawValue` rather than re-serializing a parsed
|
||||
`Value` -- the cache and a live SSE frame for the same event have to
|
||||
agree byte-for-byte, which is exactly what the `serde_json`
|
||||
float-rounding bug (AGENTS.md) was about. The existing
|
||||
`fetch_transcript_page` is untouched (other callers under `iris/`
|
||||
depend on its signature); the two share a `transcript_path` helper so
|
||||
the query string is written in one place.
|
||||
|
||||
**Not ported:** `EventStream.kt`'s reconnect-with-backoff loop, and
|
||||
`TranscriptSource.close`'s ability to cancel a live stream from another
|
||||
thread. Both are wall-clock/thread-lifetime policy that belongs to
|
||||
whichever runtime embeds this crate (iris's own timers, a Tokio task, a
|
||||
Kotlin coroutine scope), not to this pure logic -- `follow` is the same
|
||||
"write to the cache, then hand the frame to the caller" decorator
|
||||
`iris/desktop-app/src/app.rs` and `iris/android-app/src/transcript_client.rs`
|
||||
already hand-wrote around `event_stream::follow_session_events` before this
|
||||
existed; the cache write moved into one shared place so a third caller
|
||||
does not repeat it again by hand.
|
||||
|
||||
## What is not started at all
|
||||
|
||||
- **`TranscriptSource.kt`** -- the layer that decides whether a page comes
|
||||
from the transcript cache or the server, and stitches the two. Needs
|
||||
`transcript_cache.rs` and `api.rs`'s transcript-page method, both of
|
||||
which exist now, so this is unblocked whenever picked up.
|
||||
- **The markdown *block* model beyond syntax spans** -- `highlight/markdown.rs`
|
||||
colours a `.md` file or fence for the highlighter, but does not build the
|
||||
block tree (headings, lists, tables, fences as distinct nodes) that a
|
||||
renderer walks to lay out prose versus code versus a table.
|
||||
`CodeFence.kt`'s use of `org.intellij.markdown` for that full CommonMark
|
||||
AST is Compose rendering plumbing, not something to port as-is; a Rust
|
||||
UI layer will want its own block parser or a crate for it, decided
|
||||
alongside the framework choice in RUST.md.
|
||||
- **A full markdown AST.** `markdown_blocks` (2026-09-06) splits a message
|
||||
into its *top-level* blocks -- heading, paragraph, fence, list, table,
|
||||
quote -- with each block's own source, which is what a renderer needs to
|
||||
lay out prose versus code and what lets a streamed delta re-lay out one
|
||||
block instead of the message (docs/RUST.md's Task B). What it
|
||||
deliberately does **not** build is the tree below that: nested list
|
||||
items, table cells, inline spans. Inline styling is still the renderer's
|
||||
own job per block (`iris/transcript-ui/src/markdown.rs`), and nothing
|
||||
has needed the rest yet. `CodeFence.kt`'s use of `org.intellij.markdown`
|
||||
for a full CommonMark AST is Compose rendering plumbing, not something
|
||||
to port as-is.
|
||||
- **`TranscriptUnits.kt`** (see above) -- deliberately out of scope, since
|
||||
it flattens a row into bounded units for a *specific* lazy-list
|
||||
framework's composition cost, which is a fact about that framework
|
||||
@@ -142,5 +211,6 @@ be a second one, not a reason to move the type.
|
||||
|
||||
`./run-tests.sh` from the repo root now runs `event-model`, `client-core`
|
||||
and `server` in that order (each `cargo test`, forwarding arguments the
|
||||
same way it always has). From `client-core/` directly: `cargo test`,
|
||||
`cargo clippy --all-targets`, `cargo fmt` -- all clean as of this writing.
|
||||
same way it always has). From `client-core/` directly: `cargo test`
|
||||
(119 tests), `cargo clippy --all-targets`, `cargo fmt` -- all clean as of
|
||||
this writing (2026-09-06).
|
||||
@@ -5,6 +5,157 @@ they can be judged and reversed later. Detail lives in RUST.md (and IRIS.md
|
||||
for iris API changes); this file is only the summary. Newest first. Items
|
||||
marked **DEFERRED** are ones the agent chose not to decide alone.
|
||||
|
||||
## 2026-09-06 (how a tool call looks, P1b)
|
||||
|
||||
- **A card that never got a result says "no result", in yellow, and it is
|
||||
a state Compose cannot say.** A call that finished having printed
|
||||
nothing and a call whose turn was interrupted before anything came back
|
||||
both leave an empty output. Compose draws both as an ordinary finished
|
||||
call, which reads as a fact somebody established. There are five states
|
||||
now, each with a word and a colour: nothing at all for a call that
|
||||
worked, "running" (grey), "your turn" (peach, Compose's own wording and
|
||||
colour), "failed" (red), "no result" (yellow).
|
||||
|
||||
- **A failed call is drawn as failed, which needed a field on the wire.**
|
||||
`is_error` is on the CLI's `tool_result` and was being dropped; the
|
||||
server now carries it to the phone. Reversible, but the alternative is a
|
||||
card that says a call succeeded because it cannot tell.
|
||||
|
||||
- **A group's cards do not each carry their own surface.** Compose gives
|
||||
each card a fill and squares the corners where it faces a neighbour, so
|
||||
a run reads as one object broken into parts. iris has no per-corner
|
||||
radius, and -- more to the point -- a group built the way Compose builds
|
||||
it hit a framework layout defect that drew every card's text a card
|
||||
below its own box. So a group is one surface with its cards on it,
|
||||
separated by a small gap, and the 4dp inset Compose holds them off the
|
||||
edge by is gone. Worth revisiting once the layout defect is fixed
|
||||
(docs/IRIS_TODO.md).
|
||||
|
||||
- **A long tool output is capped at 80 lines or 4 kB with a "Show all N
|
||||
lines".** Compose draws the whole thing, and gets away with it because
|
||||
its `Text` inside a `LazyColumn` lays out lazily; here the output is one
|
||||
text widget and shaping a hundred kilobytes of it costs what the file
|
||||
editor's 32 kB limit was measured against. If iris's text gets cheaper,
|
||||
this is the number to move.
|
||||
|
||||
- **A card's command is clipped, not pannable, and its summary line is
|
||||
clipped rather than ellipsised.** Both are framework gaps rather than
|
||||
choices (`scrollable_on` on a non-editable text draws nothing; there is
|
||||
no overflow ellipsis), and both are worse than Compose today. Named here
|
||||
because they are visible.
|
||||
|
||||
## 2026-09-06 (how a markdown block looks, P1a)
|
||||
|
||||
- **A table is drawn as padded monospace columns, not as a grid.** Your
|
||||
call to reverse. Compose draws a real grid: cells on a tint, each
|
||||
column with a 136dp floor, scrolling sideways when there are too many.
|
||||
iris has no grid widget, and building one would be a widget per
|
||||
markdown feature -- which is the thing the block model exists to avoid.
|
||||
In a monospace face a character count *is* a pixel width, so padding
|
||||
each cell to its column's width is alignment, the widths are still
|
||||
measured from the cells, and a table that is too wide pans sideways
|
||||
through the same mechanism a code fence already uses. The header is
|
||||
bold with a rule under it, and a long cell wraps inside its column
|
||||
(capped at 28 characters, which is what fits three columns across a
|
||||
phone). **What it trades:** no cell borders, and a table looks like
|
||||
code rather than like a table. If you want the grid, it is a new widget
|
||||
and it is a day's work.
|
||||
- **Three block frames, and only three.** A heading, paragraph and list
|
||||
are plain text with spans; a fence and a table are a rounded panel that
|
||||
does not wrap; a quote is a bar with the text padded past it.
|
||||
Everything else markdown says is expressed in span styles, which cost
|
||||
no widgets and no layout nodes. So a new markdown feature is a span,
|
||||
not a widget.
|
||||
- **A list's marker is part of the text, so a wrapped item's second line
|
||||
returns to the left margin.** Compose keeps it indented by giving the
|
||||
marker its own column. Doing the same here needs per-line indent in
|
||||
iris's text attributes; it is written down rather than done, because
|
||||
the list items in a real reply are usually one line.
|
||||
- **A link opens on a tap and not on the end of a drag.** A press that
|
||||
panned the transcript past a link, or that held long enough to start a
|
||||
selection, does not follow it -- decided by the same gesture machine
|
||||
that decides pan-versus-select, so there is one rule rather than two
|
||||
that can disagree.
|
||||
|
||||
## 2026-09-06 (composer scroll and the streaming block model)
|
||||
|
||||
- **A streamed message becomes a column of per-block widgets.** Decided by
|
||||
the design agent; recorded here because it is the shape of every message
|
||||
on screen. A transcript row is one `TextEdit` today, so a streamed delta
|
||||
re-shapes the entire message through parley on every event -- the stream
|
||||
phase is the one place iris is behind Compose on your phone (p50 18.2ms
|
||||
vs 13.4ms). A row becomes a column of one widget per markdown block
|
||||
(paragraph, heading, fence, list, table) and a delta replaces only the
|
||||
last block, keeping every earlier block's layout. **Rejected:** splitting
|
||||
parley's layout at block boundaries inside one text widget (couples
|
||||
iris's text widget to markdown structure, and parley has no incremental
|
||||
API), and caching shaped runs per paragraph inside `TextEdit` (a second
|
||||
cache with its own invalidation beside the glyph cache). Chosen because
|
||||
P1's markdown block model is needed anyway, so the split happens once, in
|
||||
`client-core`, and iris stays a text renderer. **Status: designed, not
|
||||
built** -- this pass spent its budget on the composer's three layout
|
||||
defects; docs/RUST.md has the design and the pass conditions.
|
||||
- **The composer's overflowing text now scrolls on a finger**, capped at
|
||||
six lines and clipped to the bar. Reverses the "still does not scroll"
|
||||
item below.
|
||||
- **A widget may not report a `dp` length** (see IRIS.md). A rule for
|
||||
widget authors, enforced by a `debug_assert!`; nothing changes for app
|
||||
code.
|
||||
|
||||
## 2026-09-06 (stale-primitives and touch-scroll pass)
|
||||
|
||||
- **A vertical drag inside a focused composer now scrolls rather than
|
||||
selects.** Android's own `EditText` does this -- a vertical drag scrolls
|
||||
the field, and only a long press starts a selection -- so the platform
|
||||
decided it. What it costs: you can no longer drag straight down inside
|
||||
the composer to select several lines of what you typed; use a long press
|
||||
and then drag, or drag sideways. Say if that trade is wrong for you.
|
||||
- **`Scroll` gets a finger pan but no fling.** `List` flings; a scroll area
|
||||
does not, because it has no per-frame tick to animate one and the areas
|
||||
it wraps are at most a screenful (Android does not fling a six-line text
|
||||
box either). Easy to add later if a scroll area ever wraps something long.
|
||||
- **The composer still does not scroll its overflowed text**, though the
|
||||
mechanism it needs is now in place. Wrapping the field in `.scrollable()`
|
||||
was tried and reverted the same day: `Scroll` measures its content and
|
||||
container against the *window*, so inside the `MaxSize` that caps the
|
||||
composer at six lines the two are in different spaces and the field pans
|
||||
itself entirely out of the bar (measured on the emulator with 474
|
||||
characters in it -- the bar collapsed to its padding). Fixing that means
|
||||
`Scroll` measuring against its own offered box, which is a change to a
|
||||
widget the transcript and the bench shell both use, so it is its own
|
||||
piece of work rather than a rider on this one.
|
||||
|
||||
## 2026-09-06 (defect pass)
|
||||
|
||||
- **The keyboard-open diagnostics overlay is gone; the capture only
|
||||
logs now.** It was added when `on_insets_changed` was not firing at all
|
||||
and there was no way to get a report off the phone. It fires reliably
|
||||
since the activity went edge-to-edge -- and what that looks like in
|
||||
use is a full-screen report covering the app **every time the keyboard
|
||||
opens**, with its own Copy/Close buttons sitting underneath the
|
||||
keyboard, so it cannot be dismissed (reproduced on the emulator this
|
||||
pass: two `tap 'CLOSE'` runs left it up). An interruption for something
|
||||
nobody asked for, over the app you are trying to type into. The named
|
||||
`Diagnostics` button still shows the same text on demand, and the new
|
||||
`iris surface:`/`iris insets:` log lines carry the lifecycle a `logcat`
|
||||
pull needs. Reversible: `capture_keyboard_diagnostics` is still the one
|
||||
place this is decided, and `PlatformHandle::show_diagnostics_overlay`
|
||||
is still there.
|
||||
|
||||
- **The bench shell's report pane is sized to its report, not to a share
|
||||
of the window.** It held `.height(rest(1))` beside the transcript's
|
||||
`rest(2)`, so an *empty* `TextEdit` reserved a third of every screen --
|
||||
which is what Iris's "the app does not start with keyboard spacing
|
||||
correct" screenshot was showing, with the composer two thirds down and
|
||||
black below it. It is `.max_height(dp(260))` now and sits above the
|
||||
transcript rather than under the composer, where it was eating the
|
||||
navigation-bar clearance. Cost: a filled report is clipped at 260dp
|
||||
rather than scrolling (a `Scroll` there drew itself off the top of the
|
||||
screen, since `Scroll` pins to the end of its content and reports its
|
||||
content's full length to the parent -- worth fixing in `Scroll`, not
|
||||
worked around here). "Copy report" and `logcat` still have the whole
|
||||
thing.
|
||||
|
||||
## 2026-09-05
|
||||
|
||||
- **iris no longer asks every device for compute-shader limits it never
|
||||
@@ -264,24 +415,30 @@ marked **DEFERRED** are ones the agent chose not to decide alone.
|
||||
| app | build | GPU mode | frames | janky % | p50 | p90 | p99 | worst | cpu p50 | gpu-wait p50 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Compose (in-app report) | debug | host (virgl) | 1268 | 96.4% late | 20.0ms | 28.4ms | 37.7ms | -- | -- | -- |
|
||||
| iris (`FrameReport`) | **release**, `force-gles` | host (virgl) | 62 | 41.94% | 15.0ms | 21.8ms | 37.1ms | 37.1ms | 0.2ms | 12.9ms |
|
||||
| iris (`FrameReport`), **best of three, 2026-09-05** | release, `force-gles` | host (virgl) | 439 | 46.24% | 15.7ms | 23.3ms | 31.2ms | 57.4ms | 1.2ms | 13.2ms |
|
||||
|
||||
Under real GPU rendering iris's median frame is *faster* than
|
||||
Compose's, not the 2-3x-slower shape the software-mode table shows. A
|
||||
new split inside `FrameReport` (redraw-to-submit vs. submit-to-present,
|
||||
commit `e2a1fad`) says why: iris's own CPU work per frame is a median
|
||||
0.2ms -- almost the entire frame is time spent handing the frame to the
|
||||
~1ms -- almost the entire frame is time spent handing the frame to the
|
||||
driver, not in iris's layout/text/primitive code. This is consistent
|
||||
with the earlier software-mode gap being mostly SwiftShader's CPU
|
||||
rasterisation cost rather than an iris-specific slowness, but is not
|
||||
proof of it: a same-mode software `force-gles` run to isolate the
|
||||
backend crashed for an unrelated reason (SwiftShader's GL path reports
|
||||
itself as OpenGL ES 3.0, which has no compute shaders, and iris's device
|
||||
request assumes them unconditionally) — real scope to fix, not done
|
||||
here — and the two apps' frame populations still differ in kind the same
|
||||
way the software-mode caveats describe. A real intermittent touch-
|
||||
scroll dropout was also reproduced this pass (six consecutive swipes
|
||||
produced zero redraws while taps kept working; an identical retry then
|
||||
succeeded) and is not explained. RUST.md's I5 box, "Where iris's frame
|
||||
time goes, 2026-09-05, the `-gpu host` pass," has the full account. The
|
||||
iris-vs-Masonry choice itself is still Iris's to make.
|
||||
rasterisation cost rather than an iris-specific slowness. **Still not
|
||||
proof, and now closed as unanswerable rather than merely untaken**: a
|
||||
same-mode software `force-gles` run to isolate the backend was retried
|
||||
2026-09-05 after fixing the compute-limit crash the first attempt hit,
|
||||
and hit a second, structural wall instead — SwiftShader's ES 3.0 GL
|
||||
path has no storage-buffer capacity at all, and `shader.wgsl` reads
|
||||
`var<storage>` buffers unconditionally, so reaching that path needs a
|
||||
shader rewrite, not a limits fix (RUST.md's I5 box, "The three
|
||||
remaining I5 verifications, closed 2026-09-05," item 2). The
|
||||
intermittent touch-scroll dropout this pass also reproduced is
|
||||
root-caused and fixed as of the same date (a missed `ACTION_DOWN` on a
|
||||
row's padding/header left `DragArbiter` stuck in `Idle`); three clean
|
||||
`iris-scroll.sh` runs post-fix each scrolled all 24/24 swipes, replacing
|
||||
the single-attempt 62-frame reading this table used to carry. RUST.md's
|
||||
I5 box, "Where iris's frame time goes, 2026-09-05, the `-gpu host`
|
||||
pass," and "The three remaining I5 verifications, closed 2026-09-05,"
|
||||
have the full account. The iris-vs-Masonry choice itself is still
|
||||
Iris's to make.
|
||||
@@ -8,6 +8,325 @@ capability that moved. Small and trivial changes do not go here.
|
||||
An entry gives the date, what changed, why, and a short before/after where
|
||||
it helps judge the change without the session that made it. Newest first.
|
||||
|
||||
## 2026-09-06: tool cards, `ToolState`, and a screen that knows whether its session is working
|
||||
|
||||
`transcript_ui::tool` is new: a card per tool call, a group per run
|
||||
(P1b). Three things in the public surface follow from it.
|
||||
|
||||
**`client_core::transcript_fold::ToolState`** is what a card colours
|
||||
itself by -- `Running`, `Deciding`, `Succeeded`, `Failed`, `NoResult` --
|
||||
built by `ToolState::of(&item, session_working)`. The pair it exists for
|
||||
is `Succeeded` against `NoResult`: a call that finished having printed
|
||||
nothing and a call whose result never arrived both leave an empty
|
||||
`output`, and drawing them the same way states a verdict nobody reached.
|
||||
Only the session's own status separates them, which is why `of` takes it.
|
||||
|
||||
**`event_model::Event::ToolEnd` gained `is_error`** (`#[serde(default)]`,
|
||||
so an older transcript still parses), and
|
||||
`client_core::transcript_fold::TranscriptItem::ToolRun` gained `failed`.
|
||||
Without them a result was everything a card knew and a broken call drew
|
||||
exactly as confidently as one that worked -- the missing state, not a
|
||||
wrong one. Every construction site of both had to gain a field; the value
|
||||
comes from the CLI's own `tool_result`, read in one place
|
||||
(`import::tool_result_is_error`) by both the live translator and the
|
||||
import replay.
|
||||
|
||||
**`TranscriptScreen::set_session_working(rsc, bool)`** is new, and is the
|
||||
only thing that writes it. Before: a card with no result was drawn the
|
||||
same whether its turn was still going or had been interrupted. After:
|
||||
only the *newest* row can say "running", because every row behind it
|
||||
belongs to a turn that has ended, and changing the flag redraws that one
|
||||
row rather than the screen. `TranscriptScreen::expand_tail_tools(rsc,
|
||||
bool)` joins it, answering whether there was a tool run to act on -- a
|
||||
group's expanded appearance is otherwise unreachable from anything that
|
||||
cannot press the screen.
|
||||
|
||||
**`transcript_ui::row::build_row` now returns a `TailRow`** rather than an
|
||||
`Option<RowBlocks>`: `Blocks` for a message (a delta costs the last
|
||||
markdown block) or `Tools` for a run (an arriving result costs one card).
|
||||
One mechanism for "what can this row change cheaply", asked of the row
|
||||
rather than decided again at each call site. It also takes the row's own
|
||||
`working` flag.
|
||||
|
||||
Two smaller ones. `client_core::tool_summary::parse_tool_input` is
|
||||
`ToolInput.kt`'s subject/description/timeout/rest split, and
|
||||
`client_core::durations::format_millis` is `Durations.kt`'s -- both pure,
|
||||
both with the Kotlin's own tests ported.
|
||||
|
||||
## 2026-09-06: a tap is its own gesture outcome, and opening a URL is a backend capability
|
||||
|
||||
Three related additions, all for following a markdown link.
|
||||
|
||||
**`iris::platform::OpenUrl`** is a new trait beside `attr::FocusHost`, and
|
||||
has the same shape: declared in `iris`, implemented once per backend (a
|
||||
detached `xdg-open`/`open`/`start` on the desktop, an `ACTION_VIEW` intent
|
||||
on Android, deferred to the next view callback exactly the way
|
||||
`pending_show_keyboard` is). A widget asks for the capability by bound --
|
||||
`Rsc::State: FocusHost + OpenUrl` -- instead of a caller threading a
|
||||
callback down through every builder. One method, not a general "run an
|
||||
intent": a narrower capability is a narrower thing to get wrong. Nothing
|
||||
is returned; the platform either shows a browser or does not, and both
|
||||
are outside the process.
|
||||
|
||||
**`GestureOutcome::Tapped`** is new. `Released(None)` used to mean both
|
||||
"the press ended having selected something" and "the press ended having
|
||||
done nothing at all", and only the second is a tap. Any caller that acts
|
||||
on a tap -- following a link -- must not also act when the finger was
|
||||
panning the list past that link, so the distinction is made once, in the
|
||||
gesture machine every widget already shares, rather than timed again per
|
||||
widget. `DragArbiter::is_undecided()` is what answers it.
|
||||
`Selection::drag` returns the outcome now instead of `()`.
|
||||
|
||||
**`DragArbiter`/`DragGesture` take an axis** (`::on(Axis)`; `::new()` is
|
||||
still vertical). A code fence pans across its own long lines exactly the
|
||||
way a transcript pans down its rows, and the two were the same state
|
||||
machine with `dx` and `dy` swapped. `WidgetLike::scrollable_on(axis)`
|
||||
joins `scrollable()` for the same reason. Before this, a horizontal
|
||||
`Scroll` existed but could not be dragged by a finger at all -- its
|
||||
arbiter only ever committed on the vertical axis.
|
||||
|
||||
Two smaller ones in the same pass. **`TextEditCtx::byte_at(pos, size)`**
|
||||
answers which byte of the text a tap landed on, doing the same
|
||||
region-relative transform `select` does, without handing out the parley
|
||||
layout a caller could shape against stale text. And **`Rect::radius` now
|
||||
takes a `Len`**, so a corner can be written in `dp` and come out the same
|
||||
physical size on every display; a bare number still means physical pixels.
|
||||
|
||||
**One behaviour change worth knowing about**: `Rect::is_size_independent()`
|
||||
answers `false` now. It answered `true`, and a `Rect` fills whatever
|
||||
region it is given -- so `draw_inner`'s fast path, which rewrites a
|
||||
widget's primitives in place instead of redrawing it, could not reproduce
|
||||
what `draw` would have done. A `.background(rect(..))` behind
|
||||
variable-height content kept the size of the provisional pass its parent
|
||||
`Span` had drawn it at, which on the transcript screen meant one code
|
||||
block's panel covering every block below it. Costs one primitive's redraw
|
||||
when a rect is resized.
|
||||
|
||||
## 2026-09-06: a transcript row is a column of blocks, and a block is the selection unit
|
||||
|
||||
`transcript-ui`'s row builder used to make **one** `TextEdit` per message.
|
||||
It makes one per top-level markdown block now -- heading, paragraph,
|
||||
fenced code, list, table -- in a `Span::down`, because a streamed delta
|
||||
into a single buffer re-shaped the whole message through parley on every
|
||||
event. `client_core::markdown_blocks::split_blocks` does the splitting;
|
||||
`row::RowBlocks::apply_delta` updates the block a delta lands in and
|
||||
leaves the rest of the message's layout alone.
|
||||
|
||||
**The change to judge, since it is what a reader feels**:
|
||||
`Selection` is keyed by `SelKey = (RowKey, u32)` -- a row and a block --
|
||||
so **a block, not a row, is the unit a selection steps in**. A drag still
|
||||
runs from a reply into the tool output beneath it and copies as one
|
||||
thing; what changed is that the row under the finger is filled in block by
|
||||
block rather than all at once, which is if anything closer to what the
|
||||
old shortcut in `Selection`'s module doc was apologising for. `register`
|
||||
takes a `SelKey`; `unregister` still takes a `RowKey` and now drops every
|
||||
block of it (dropping only the first is how a freed widget gets left in
|
||||
the map -- the shape docs/REVIEW-2026-09-06.md's finding 1 called out).
|
||||
|
||||
`Selection::locate(ui, render, pos_window)` is new: which block is under a
|
||||
window position, with that block's own local position and size. The
|
||||
list-level handler uses it for the pointer-captured half of a drag,
|
||||
instead of computing a row-local position from `List::extent`.
|
||||
|
||||
`row::build_row` returns `(RowKey, StrongWidget, Option<RowBlocks>)` --
|
||||
the third is the per-block state a caller keeps only for the row a reply
|
||||
is streaming into, and is `None` for a tool run, which never streams.
|
||||
|
||||
## 2026-09-06: a reported `Size` may not carry `dp`; `Len::fold_dp`
|
||||
|
||||
**New: `Len::fold_dp(density) -> Len`** -- the same fold `apply_rest` does
|
||||
(`dp` becomes physical pixels), but staying a `Len` so `rest` survives.
|
||||
|
||||
**New rule, and it is a rule about every widget, not about the two that
|
||||
broke it**: a `Len` a widget *reports* from `draw` must not carry an
|
||||
unresolved `dp`. `dp` is an input unit -- a number the widget author wrote
|
||||
-- and the containers that consume a reported length read `abs`, `rel` and
|
||||
`rest` straight off it (`Span`'s placement arithmetic, `Pad`'s addition),
|
||||
so a reported `dp` is silently worth **zero**. `MaxSize` and `Sized` both
|
||||
returned the caller's declared `Len` as written; a `.max_height(dp(168))`
|
||||
therefore gave its child a slot of nothing the moment the cap actually
|
||||
applied, which is what made the composer's bar collapse. Both put their
|
||||
declared lengths through `fold_dp` now, and
|
||||
`UiRenderState::draw_inner` `debug_assert!`s the invariant after every
|
||||
`Widget::draw`, so a widget that gets this wrong says so at the mistake
|
||||
rather than laying out at zero somewhere else.
|
||||
|
||||
Nothing changes for a caller: `.max_height(dp(48))` is written the same
|
||||
way. It is only widget *authors* who now have a rule to follow, and a
|
||||
debug build that enforces it.
|
||||
|
||||
## 2026-09-06: `Painter::set_mask` reuses one slot; `ActiveData` gains two fields
|
||||
|
||||
**`Painter::set_mask(region)` allocates its widget's mask slot once and
|
||||
rewrites it in place** on every later draw, instead of pushing a new one
|
||||
each time. It has to: `draw_inner`'s unchanged-region fast path does not
|
||||
revisit a descendant whose own region did not change, so those descendants
|
||||
go on referencing whichever slot they were first drawn under. Pushing a
|
||||
fresh slot per draw left the composer's field clipped to a box the bar had
|
||||
long since moved away from -- four live mask entries, none of them the
|
||||
`Masked`'s current region -- and it drew nothing at all. Same call, same
|
||||
signature; only the lifetime changed.
|
||||
|
||||
**`ActiveData` gains `own_mask` and `move_applied`** (both public, since
|
||||
`ActiveData` is). `own_mask` is the slot above, `MaskIdx::NONE` for a
|
||||
widget that sets no mask. `move_applied` is how much of a widget's own
|
||||
move-slot delta its `region` already accounts for: `mov` shifts both,
|
||||
`Painter::reposition` shifts only the slot, and `resolved_region` -- and so
|
||||
every hit test -- has to subtract it. Without that a widget that had been
|
||||
panned had its *own* hit box at twice the pan while its descendants were
|
||||
correct, which made the composer's field untappable after a finger drag.
|
||||
|
||||
## 2026-09-06: `Scroll` pans on a finger drag, and a vertical drag in a focused text field no longer selects
|
||||
|
||||
Three related public changes, all in aid of IRIS_TODO.md's "the composer
|
||||
has no touch-drag scroll".
|
||||
|
||||
**`Scroll::drag(render, id, sense, pos_window, now)` is new**, and
|
||||
`WidgetLike::scrollable()` now registers it alongside the wheel handler it
|
||||
already registered -- so anything built with `.scrollable()` pans on a
|
||||
finger drag with no extra wiring at the call site. It goes through the same
|
||||
`sense::DragGesture` that `transcript-ui::Selection::drag` drives `List`
|
||||
with (arbitration, `DRAG_SLOP`, velocity, pointer capture), rather than a
|
||||
second copy of that widget's wiring: `DragGesture` owns the mechanics and
|
||||
each caller decides only what a committed pan *means*. `Scroll::amt()` is
|
||||
new too, the read-only pan position a test or a scroll indicator needs.
|
||||
|
||||
There is deliberately **no fling** on `Scroll`. Unlike `List` it has no
|
||||
per-frame tick to animate one with (`List::set_redraw_handle`/`tick_fling`),
|
||||
and the areas it wraps today are at most a screenful, where Android does not
|
||||
fling either. The released velocity is dropped rather than approximated.
|
||||
|
||||
**A vertical drag inside an already-focused `TextEdit` no longer extends a
|
||||
selection.** `iris::attr`'s `on_press` used to treat a focused field as the
|
||||
plain `click_or_drag` case -- every `Pressing` frame updated the selection.
|
||||
It now applies the same `DRAG_SLOP` rule the *unfocused* branch already
|
||||
applied: a press that moves past the slop vertically abandons its pending
|
||||
selection for the rest of the gesture, so the scroll area around the field
|
||||
gets the drag instead. Horizontal drag-to-select is unchanged, and a long
|
||||
press still starts a selection. This is Android's own `EditText` behaviour
|
||||
(a vertical drag scrolls; only a long press selects), and it is what makes
|
||||
"swipe up over the composer to scroll the transcript" work without dragging
|
||||
a highlight through the message you were typing.
|
||||
|
||||
**`UiRenderState::orphaned_primitives()` is new**, and `update` now
|
||||
`debug_assert!`s (debug builds only) that nothing is orphaned. An orphan is
|
||||
a primitive still bound for the GPU that no live `ActiveData` names -- a
|
||||
copy nothing can move, clip or free. That was the doubled `Compacted:` row
|
||||
on the phone; see the same date's commit `76b1f99` and docs/RUST.md. The
|
||||
per-frame guard is a count comparison (O(active widgets)); the walk that
|
||||
names the offenders only runs when the counts disagree, because the walk is
|
||||
O(primitives) and made a debug build on a phone too slow to finish a
|
||||
benchmark run.
|
||||
|
||||
## 2026-09-06: a tap on a text field always leaves a caret
|
||||
|
||||
`TextEditCtx::select` used to compare the tap position against the
|
||||
*laid-out text's* own box and set `selection = None` for anything outside
|
||||
it. A press only reaches `select` after being hit-tested to the widget, so
|
||||
that "outside" meant the field's own padding -- or, for an **empty** field,
|
||||
everything, since an empty layout is a zero-width box. So tapping an empty
|
||||
composer focused it and opened the keyboard while leaving no caret, and
|
||||
`TextEditCtx::insert`/`insert_str` return early with no caret: every
|
||||
keystroke was dropped in silence, and no glyph ever appeared. Parley's
|
||||
`from_point`/`extend_to_point` already clamp a point outside the layout to
|
||||
the nearest cursor position, which is also what a tap in a field's padding
|
||||
should do.
|
||||
|
||||
Behaviour change a caller would notice, in one line: **`select` with a
|
||||
non-drag position now always produces a selection; it no longer clears
|
||||
one.** Clearing is `TextEditCtx::deselect`, which is what the backends'
|
||||
focus handling already calls. A drag is unchanged -- with no previous
|
||||
selection there is still nothing to extend, so it produces none.
|
||||
|
||||
`insert_str` also gained a `debug_assert!` for the no-caret case, so an
|
||||
insert routed to an unfocused field fails at the mistake in a debug build
|
||||
instead of silently swallowing input.
|
||||
|
||||
## 2026-09-06: `List::anchor_position_display`## 2026-09-06: `List::anchor_position_display`, `FrameReport::mark_phase`/`phase_stats`/`late_at_hz` (RUST.md's "Benchmark v2")
|
||||
|
||||
`List` gained `anchor_position_display(&self) -> String`, reporting the
|
||||
anchor's own row index and pixel offset (`idx=N/off=Mpx`, or
|
||||
`idx=more-before`/`idx=more-after`/`idx=none`) -- what a scripted
|
||||
benchmark reads to report fling travel. Note the anchor does not
|
||||
necessarily change *slot* over a long scroll (this widget's own documented
|
||||
design: the anchor is a stable identity, not re-derived from what's on
|
||||
screen each frame), so this is not the same measurement as a Compose
|
||||
`LazyListState.firstVisibleItemIndex`, which does track the true topmost
|
||||
visible row -- the `off` half is what actually reflects how far a fling
|
||||
travelled.
|
||||
|
||||
`iris_core::render::frame_report::FrameReport` gained three methods for
|
||||
per-phase benchmark reporting: `mark_phase(name)` records a named phase
|
||||
boundary at the current frame/instant; `phase_stats(now, refresh_hz)`
|
||||
returns one `PhaseStats` (frames, wall duration, late count/percent,
|
||||
p50/p90/p99, worst) per marked phase, sliced from the existing ring by a
|
||||
new parallel `index_ring`; `late_at_hz(refresh_hz)` gives the whole run's
|
||||
late count/percent judged against an arbitrary refresh rate rather than
|
||||
the fixed 60Hz `JANK_THRESHOLD` every existing caller still uses (a
|
||||
separate method, not a parameter on `report()`, so nothing else changes
|
||||
behaviour). `RING_CAPACITY` grew 4096->16384 to hold a full multi-phase
|
||||
run without evicting earlier phases' samples.
|
||||
|
||||
## 2026-09-06: `List::fling`, `VelocityTracker`, `FlingCalculator` (IRIS_TODO.md's "swiping has no momentum")
|
||||
|
||||
`iris::widget::List` gained a real fling: `fling(velocity_px_per_s)` starts
|
||||
one (cancelled by the next touch-down via `cancel_fling`, or automatically
|
||||
once it settles or reaches loaded content's start/end), `is_scrolling()`
|
||||
reports whether one is running, and `tick_fling(now: Instant) -> bool`
|
||||
advances it and returns whether it is still going -- a caller that owns a
|
||||
`RequestRedraw` handle can hand it to the list once via the new
|
||||
`set_redraw_handle`, after which `List` re-arms its own next frame while
|
||||
flinging with no further polling needed; a caller driving a scripted
|
||||
benchmark instead calls `tick_fling` itself in a loop, same as it already
|
||||
drives `scroll`.
|
||||
|
||||
The physics is `iris::sense::FlingCalculator` + `VelocityTracker`
|
||||
(`sense.rs`, beside `DragArbiter`): a port of AOSP `SplineOverScroller`'s
|
||||
deceleration curve (the same one Compose's own `ScrollableDefaults.
|
||||
flingBehavior()` uses), cited at the definition, so a fling here travels
|
||||
the same distance a Compose `LazyColumn` would for the same initial
|
||||
velocity. `VelocityTracker` estimates that velocity from the drag's last
|
||||
~100ms of samples rather than one frame's last delta. Unit-tested:
|
||||
velocity from known samples, fling distance/duration against the closed-
|
||||
form spline result (within 1%), cancel-on-touch, and the start/end clamp
|
||||
(a fling stops rather than scrolling into content that was never loaded).
|
||||
|
||||
Before: a touch-drag panned exactly as far as the finger moved and stopped
|
||||
dead on release. After: releasing mid-drag continues scrolling and
|
||||
decelerates, matching the muscle memory every other Android scroll view
|
||||
already trained. `transcript_ui::selection::Selection::drag` wires this in
|
||||
-- a release only flings if the gesture had committed to panning
|
||||
(`DragArbiter::is_panning`, new), never a selection or an undecided tap.
|
||||
|
||||
## 2026-09-06: `UiRenderNode::new` returns `Result`, not `Self` (RUST.md's P0 box, phone-crash fix)
|
||||
|
||||
`iris_core::UiRenderNode::new(device, queue, config)` now returns
|
||||
`Result<Self, String>` instead of `Self`. Why: it used to let a bind-group-
|
||||
layout validation failure reach wgpu's default error handler, which panics
|
||||
with no way for a caller to intervene -- exactly what aborted the P0 bench
|
||||
APK on Iris's phone with the crash report truncated to "wgpu error:
|
||||
Validation Error" and nothing else recoverable. It now runs its creation
|
||||
calls inside wgpu error scopes and returns the full error text (wgpu's own
|
||||
"Caused by" chain) as `Err` instead.
|
||||
|
||||
Both callers changed to match: `android::render::AndroidRenderer::new`
|
||||
itself now returns `Result<Self, String>` too, building a fuller report
|
||||
(adapter identity, the limits/downlevel flags a layout validates against,
|
||||
then wgpu's text) on failure -- its caller,
|
||||
`android::view::IrisViewPeer::surface_changed`, logs that report as one
|
||||
logcat line and shows it on screen (a new `IrisView.showRendererError`,
|
||||
called via an ordinary JNI method call rather than a new `native fn`)
|
||||
instead of letting the process abort. `default::render::UiRenderer::new`
|
||||
(the winit/desktop backend) still panics on failure -- there is no
|
||||
on-screen fallback there -- but the panic message is now the same full
|
||||
text rather than whatever wgpu's own handler would have printed.
|
||||
|
||||
No change for an app that never constructs a `UiRenderNode` directly (every
|
||||
current one goes through `AndroidRenderer`/`UiRenderer`), but anyone who
|
||||
does needs an `?`/`.expect()`/`match` at the call site now. Full audit and
|
||||
the named hypothesis for what actually failed on the phone are in
|
||||
RUST.md's P0 box, "iris bench crash on the phone, 2026-09-06."
|
||||
|
||||
## 2026-09-05: `AndroidAppState::platform_ready` (RUST.md's P0 box, iris half)
|
||||
|
||||
Added a second, optional lifecycle method to `iris::android::AndroidAppState`
|
||||
@@ -437,3 +756,188 @@ with a number instead of a guess (RUST.md's I5 box).
|
||||
blocked handing the frame to the driver," not a confirmed GPU-completion
|
||||
time. Enough to separate "iris is slow building the frame" from "iris is
|
||||
slow handing it off," not enough to claim an exact GPU budget.
|
||||
|
||||
## 2026-09-05: `List::replace_back`/`List::clear`, and `TranscriptScreen::apply`
|
||||
|
||||
Fixes the "every client refolds and rebuilds the whole widget tree per
|
||||
streamed event" cost RUST.md's P0 box measured (20 events/second against a
|
||||
~3,200-row transcript). Two small additions to `iris::widget::List`
|
||||
(`iris/src/widget/list.rs`), plus one new method on `transcript-ui`'s
|
||||
`TranscriptScreen`.
|
||||
|
||||
- **`List::replace_back(row: ListRow) -> Option<ListRow>`**: swaps the
|
||||
*last* row's widget for a new one without moving it — same slot index,
|
||||
so an anchor already pinned there (in particular a list flush with its
|
||||
own end) stays pinned, and a `List` scrolled elsewhere is untouched.
|
||||
`None` if the list is empty. `RowKey` may differ between the old and new
|
||||
row; only `heights`/`extents` care, and both are invalidated for the
|
||||
evicted key the same way `pop_back` already does.
|
||||
- **`List::clear()`**: drops every loaded row and resets to `List::new`'s
|
||||
state (`more_before`/`more_after` untouched — a caller that wants those
|
||||
cleared too calls `set_more_before(None)`/`set_more_after(None)` itself).
|
||||
The fallback path for a change that touches more than the tail.
|
||||
- **`transcript_ui::TranscriptScreen::apply(&self, rsc, old: &[TranscriptItem], new: &[TranscriptItem])`**:
|
||||
the incremental alternative to rebuilding the whole screen from
|
||||
`transcript_ui::build_tree` on every folded event. Diffs the two
|
||||
`group_tool_runs` outputs and picks the cheapest update: nothing changed
|
||||
(no-op), a pure append (`push_row`, unchanged cost), or — the common
|
||||
streaming case, a delta into a still-open assistant message — a rebuild
|
||||
of just the one changed row via `List::replace_back`, with any further
|
||||
new rows appended after it. A row changing *before* the tail (only
|
||||
`group_tool_runs` retroactively grouping tool calls into a run does
|
||||
this) falls back to `List::clear` plus a full rebuild, counted in
|
||||
`TranscriptScreen::take_rebuilds()`. **A caller that keeps its own
|
||||
row-keyed side table alongside `List` (`Selection`'s `rows:
|
||||
BTreeMap<RowKey, WeakWidget<TextEdit>>` is the one this crate has) must
|
||||
clear it in step with `List::clear()`** — the fallback drops every row
|
||||
`List` was holding, so any side table not cleared the same way is left
|
||||
pointing at widgets the clear just freed (docs/REVIEW-2026-09-06.md
|
||||
finding 1, fixed 2026-09-06 by `Selection::clear()`, called from
|
||||
`apply`'s `Rebuild` arm right before `List::clear()`). `bench_client.rs`, `transcript_client.rs`
|
||||
and `desktop-app/app.rs` all call this now instead of rebuilding on every
|
||||
event; only the opening page (and `apply`'s own fallback) still calls
|
||||
`build_tree`.
|
||||
- **`TextEditCtx::set_with_spans(text, spans)`**: `set()` plus a fresh
|
||||
`Vec<SpanStyle>` in one call, needed because a streamed row's markdown
|
||||
re-renders to both a new string and a new span list on every delta and
|
||||
the two have to land together — a stale span list drawn against new
|
||||
text can point past its end. `set()` itself is unchanged (still clears
|
||||
spans to none, as before).
|
||||
|
||||
Measured on this checkout's emulator (`iris/android-app/run-bench.sh`,
|
||||
release, x86_64, `force-gles`): worst-frame and p99 during the streaming
|
||||
phase dropped from 369.3ms/284.5ms (full rebuild per event, prior pass) to
|
||||
~101–130ms/~76–103ms across three runs (this fix) — see RUST.md's P0 box
|
||||
for the full numbers and the comparison's caveats (different AVD
|
||||
instances, not a controlled A/B on identical hardware state).
|
||||
|
||||
## 2026-09-06: bundled fonts, `content_scale`, `AndroidAppState::on_insets_changed`
|
||||
|
||||
From RUST.md's P0 box, working Iris's first real-phone report (font/scale/
|
||||
inset bugs the emulator never showed).
|
||||
|
||||
- **`TextData` now bundles Noto Sans + Noto Sans Mono** (regular/bold/
|
||||
italic/bold-italic static faces, OFL) and registers them ahead of the
|
||||
platform's own fonts in the `SansSerif`/`Monospace` generic-family
|
||||
lists, rather than relying on the platform's font enumeration alone.
|
||||
`TextData::font_diagnostics() -> FontDiagnostics` reports what was found
|
||||
and what each style axis resolved to — logged once at startup and shown
|
||||
on a screen's Diagnostics page if it has one. Adds ~3.6 MB uncompressed
|
||||
to any binary linking `iris-core`; `build-apk.sh`'s own output says the
|
||||
delivered (compressed) number.
|
||||
- **`UiRenderNode::new`/`resize` now take the window size explicitly**
|
||||
(`window_size: impl Into<Vec2>`) instead of deriving it from the
|
||||
surface's physical `SurfaceConfiguration`. Existing callers pass a
|
||||
*logical* size (physical ÷ density/scale-factor) now; this is what makes
|
||||
a `font_size: 16.0` 16 dp instead of 16 raw device pixels on a
|
||||
high-density phone. Before this, `scale_factor` did not exist anywhere
|
||||
in the crate, on either platform.
|
||||
- **`AndroidUiState::content_scale: f32`** (`DisplayMetrics.density`, read
|
||||
once in `new_peer`) and the desktop equivalent (`window.scale_factor()`)
|
||||
now divide every physical-pixel number before it reaches layout or
|
||||
touch handling — see `content_scale`'s own field doc for the full list
|
||||
of what depends on it.
|
||||
- **New: `AndroidAppState::on_insets_changed(&mut self, rsc, LogicalInsets)`**,
|
||||
a default-no-op hook called from `render()` exactly when
|
||||
`AndroidUiState::insets()` changes. Nothing previously consumed
|
||||
`insets().top` at all; a screen with chrome under the status bar
|
||||
implements this to pad it, in the same logical units `content_scale`
|
||||
converts everything else to.
|
||||
- **New: `iris_core::WgpuErrorLog`**, installed via `Device::
|
||||
on_uncaptured_error` on the Android device (wgpu's default handler is an
|
||||
unconditional panic outside `UiRenderNode::new`'s own error scopes).
|
||||
Explicit `Arc`-backed value passed to the callback and kept on
|
||||
`AndroidRenderer`, not a global — a caller wanting one on desktop builds
|
||||
its own the same way.
|
||||
|
||||
## 2026-09-06: `Len::dp`, physical pixels throughout, the keyboard glyph wipe
|
||||
|
||||
Iris's phone report on build a9232ac (screenshots): text now the right
|
||||
size but blurry; the keyboard still wipes every glyph; the header buttons
|
||||
have nothing behind them. All three are fixed; this entry is the public
|
||||
API side. docs/LAYOUT.md has the layout-side writeup, docs/RUST.md's P0
|
||||
box has the full investigation and the phone verification still to do.
|
||||
|
||||
- **The keyboard wipe was `surface_changed` rebuilding the whole renderer
|
||||
on every resize**, including an IME-driven one — a fresh, empty glyph
|
||||
atlas while the CPU-side glyph cache kept UV coordinates from the old
|
||||
one. `surface_changed` now calls `AndroidRenderer::resize` (reconfigures
|
||||
the surface and window uniform only) when a renderer is already live,
|
||||
and only builds a new one when there genuinely isn't one yet.
|
||||
- **`Len` has a third field, `dp`** (Android's dp / CSS's reference pixel,
|
||||
1/160in), beside the existing `abs` (now explicitly *physical* pixels)
|
||||
and `rel`/`rest`. `len_fns::dp`/`Len::dp` construct one, used exactly
|
||||
like `abs`/`rel`/`rest` — `dp(16)` instead of a bare `16` wherever a
|
||||
size should look the same physical size on any density. This is the
|
||||
unit IRIS_TODO.md's "density-independent length unit" item asked for;
|
||||
it replaces the previous stopgap (the whole rendered scene divided by
|
||||
`content_scale` then implicitly stretched back up), which is also what
|
||||
made text blurry — a glyph rasterised at the small, pre-stretch size and
|
||||
then upscaled onto the real framebuffer.
|
||||
- **`UiRenderState`/`Painter` gained `density()`/`set_density()`** (physical
|
||||
pixels per dp). Every place a length resolves (`Len::apply_rest`,
|
||||
`Size::to_uivec2`) now takes it; `Span::gap` and `Padding`'s four sides
|
||||
moved from a bare `f32` to `Len` so they take `dp(...)` too. A bare
|
||||
number anywhere is unaffected — still `abs`, physical pixels.
|
||||
- **Text is rasterised at physical resolution now.** `TextBuffer::shape`
|
||||
takes `density` and multiplies `font_size`/`line_height` (and any span
|
||||
override) by it before handing them to parley, so the atlas holds a
|
||||
bitmap at the size it is actually shown at rather than a low-resolution
|
||||
one stretched afterward.
|
||||
- **Everything at the Android boundary is physical pixels now** — window
|
||||
size, touch coordinates, insets (`LogicalInsets` renamed
|
||||
`WindowInsets`). The previous "logical" division by `content_scale` is
|
||||
gone; `content_scale` now feeds `set_density` instead.
|
||||
- Not yet verified on Iris's actual phone (this pass had no device) —
|
||||
built and checked on this checkout's emulator only. RUST.md's P0 box
|
||||
says what she should check for: crisp text at two densities, the
|
||||
keyboard no longer wiping, and the header's background.
|
||||
|
||||
## 2026-09-06: composing text, focus-on-tap, and atlas invalidation on a new renderer
|
||||
|
||||
Three small but public API changes, from the same phone-report pass as the
|
||||
entry above (RUST.md's P0 box has the full account, including a real bug
|
||||
still not root-caused).
|
||||
|
||||
- **`FocusHost` gained `is_focused(&self, id) -> bool`** (both platform
|
||||
impls). `attr.rs`'s `Selector`/`Selectable` used to grant focus (and so
|
||||
request the IME) on the very first frame of *any* press, before it was
|
||||
known whether the gesture was a tap or a drag — a swipe over a text
|
||||
field wrongly summoned the keyboard. They now wait for a completed tap
|
||||
(press and release with no frame crossing `sense::DRAG_SLOP`) unless the
|
||||
field is already focused, in which case dragging inside it to select
|
||||
text is unchanged. `TextEdit` gained one new `pub(crate)` field
|
||||
(`press_origin`) to track this; no public surface change there.
|
||||
- **`android::ime`'s `InputConnection` now calls `InputMethodManager::
|
||||
updateSelection` after every edit** (`IrisViewPeer::update_ime_selection`,
|
||||
called from `after_input`). Gboard was holding keystrokes back because
|
||||
nothing ever told it where the app's own selection/composing region had
|
||||
moved to — this is what android-view's own demo does in its `render()`
|
||||
and this bridge never did.
|
||||
- **`GlyphAtlas::clear()` and `Textures::reset()`** (`iris_core`). Called
|
||||
together, once, from `android::view`'s `surface_changed` exactly when a
|
||||
*genuinely new* `AndroidRenderer` is built (backgrounding and returning,
|
||||
not a keyboard-triggered resize, which already reuses the renderer) —
|
||||
both CPU-side caches otherwise kept pointing at the old, now-destroyed
|
||||
device's textures, which is why text used to vanish again after leaving
|
||||
and returning to the app.
|
||||
|
||||
## 2026-09-06: `take_counters` counts text layouts too
|
||||
|
||||
One public API change, from the verification pass over the composer-scroll
|
||||
and per-block-row work (RUST.md's "Verification pass over Tasks A and B").
|
||||
|
||||
- **`UiRenderState::take_counters` returns four numbers, not three**:
|
||||
`(draws, region rewrites, move writes, **text shapes**)`. The new one is
|
||||
bumped in `Painter::render_text`, which `TextView::render` only reaches
|
||||
on a cache miss, so it counts layouts actually computed rather than
|
||||
layouts asked for. Callers destructuring the tuple need one more `_`.
|
||||
|
||||
It exists because a draw counter cannot answer the question the
|
||||
per-block transcript row was built for. A widget can be redrawn without
|
||||
re-shaping (the layout is memoized by width) and re-shaped without any
|
||||
extra draw, and re-shaping is the expensive half — so "a streamed delta
|
||||
costs one block" was, until now, argued from the code rather than
|
||||
measured. With the counter it is a test: one delta into a 100-paragraph
|
||||
reply shapes exactly **1** text layout, the same as into a
|
||||
one-paragraph one.
|
||||
@@ -117,6 +117,291 @@ order and what "done" looks like. Tick and date them in place.
|
||||
screen wants the same thing (P1's own transcript rows already read
|
||||
their content from a `TextEdit` for the same reason).
|
||||
|
||||
## From the phone, 2026-09-06
|
||||
|
||||
Found on Iris's own phone while working RUST.md's P0 box's phone-report
|
||||
follow-ups. Recorded here rather than fixed in that pass, so a follow-up
|
||||
agent takes them without colliding with that pass's `bench_client.rs`/
|
||||
`android/view.rs`/`android/sense.rs` changes.
|
||||
|
||||
- [x] **Swiping has no momentum, fixed 2026-09-06.** `List::fling`/
|
||||
`VelocityTracker`/`FlingCalculator` (`iris/src/widget/list.rs`,
|
||||
`iris/src/sense.rs`) -- IRIS.md's 2026-09-06 entry has the full account.
|
||||
Wired through `Selection::drag`'s release path, cancelled by the next
|
||||
touch-down, clamped at the loaded content's start/end. Verified by unit
|
||||
test (fling distance against the closed-form spline result, cancel-on-
|
||||
touch, the clamp), not yet by an on-device or emulator feel-check --
|
||||
that is still open.
|
||||
- [x] **Scrolling down sometimes jitters the text, fixed 2026-09-06.**
|
||||
Root-caused by reading `DragArbiter::update`'s `Undecided`-to-`Panning`
|
||||
transition rather than by an on-device trace (no emulator was used this
|
||||
pass): it was the first named suspect, not the second. `self.last` stays
|
||||
at the press origin for every `Undecided` frame (nothing pans while the
|
||||
gesture might still be a selection), so the frame that finally crosses
|
||||
`DRAG_SLOP` returned `Pan(dy)` with `dy` measured from `press_start` --
|
||||
the *whole* pre-threshold drag, applied to the list in one step, however
|
||||
many frames it had taken to get there. Fixed by applying only the
|
||||
excess past `DRAG_SLOP` on that one frame (`dy - DRAG_SLOP.copysign
|
||||
(dy)`), the same "consume the slop, don't replay it" rule Android's own
|
||||
touch handling follows. New regression test,
|
||||
`crossing_the_slop_by_a_little_pans_by_a_little` (`iris/src/sense.rs`).
|
||||
**Not yet done**: an emulator trace of the real per-frame offset
|
||||
confirming this was the whole story on real touch input rather than
|
||||
only the arbiter's own unit tests -- worth a follow-up pass before
|
||||
calling it fully closed.
|
||||
- [x] **Composing text held back until a space, caret not moving, fixed
|
||||
2026-09-06.** `InputMethodManager.updateSelection` was never called --
|
||||
see IRIS.md's 2026-09-06 entry and RUST.md's P0 box, item 1, for the
|
||||
full account and the emulator evidence.
|
||||
- [x] **Swipe over the composer summons the keyboard, fixed 2026-09-06.**
|
||||
`Selector`/`Selectable` now wait for a completed tap -- see IRIS.md's
|
||||
2026-09-06 entry and RUST.md's P0 box, item 5. Verified via `dumpsys
|
||||
input_method`'s `mInputShown` on the emulator, not yet on the phone.
|
||||
- [x] **Text disappears again after leaving and returning to the app,
|
||||
fixed 2026-09-06.** `GlyphAtlas::clear`/`Textures::reset` on a
|
||||
genuinely new renderer -- see IRIS.md's 2026-09-06 entry and RUST.md's
|
||||
P0 box, item 4. Verified on the emulator (home, reopen, screenshot);
|
||||
not yet on the phone.
|
||||
- [x] **Composed/typed text never becomes visible at all -- root-caused
|
||||
and fixed 2026-09-06.** Not the renderer at all: **the composer's buffer
|
||||
was empty the whole time.** `TextEditCtx::select` (`iris/src/widget/
|
||||
text/edit.rs`) compared the tap against the *laid-out text's* box and
|
||||
set `selection = None` for anything outside it -- and an empty field's
|
||||
layout is a zero-width box, so tapping an empty composer granted focus
|
||||
and opened the keyboard while leaving no caret; `insert_str` returns
|
||||
early with no caret, so every keystroke after that was dropped in
|
||||
silence. Gboard's suggestion strip is its own composing state, not a
|
||||
read of our buffer, which is what made the earlier pass conclude the
|
||||
buffer held the text. Fixed by letting parley clamp a tap outside the
|
||||
layout to the nearest cursor position (a press that reaches `select`
|
||||
has already been hit-tested to the widget, so there is no "outside"),
|
||||
plus a `debug_assert!` in `insert_str` so an insert with no caret fails
|
||||
at the mistake instead of dropping input -- it immediately caught
|
||||
`layout_tests::composing_text_after_a_keyboard_resize_...` typing into
|
||||
an unfocused field. Three new tests in `edit.rs`
|
||||
(`tapping_an_empty_field_places_a_caret_so_typing_lands`,
|
||||
`tapping_past_the_end_of_the_text_clamps_to_the_end`,
|
||||
`dragging_without_a_previous_selection_selects_nothing`); the first
|
||||
fails on the pre-fix code. Emulator evidence: `adb shell input text`
|
||||
after `tap 'Message'` now shows the text in the bar
|
||||
(`/tmp/final-typing.png`) and logs `iris text render: chars=5 ...
|
||||
glyphs=5`, against `glyphs=0` on every keystroke before.
|
||||
|
||||
**The old, superseded diagnosis, kept because it was wrong in an
|
||||
instructive way:** The composer bar stays empty even once the
|
||||
buffer genuinely holds the typed text (confirmed indirectly: Gboard's
|
||||
own suggestion strip reacts correctly to each keystroke). A new unit
|
||||
test proves the widget tree's own layout math resolves the field's
|
||||
region correctly across a keyboard resize, so the bug is downstream of
|
||||
that -- most likely `UiRenderState::redraw`'s single-widget redraw path,
|
||||
or specific to this emulator's forced `force-gles` backend (untested on
|
||||
Vulkan or the real phone). RUST.md's P0 box, item 2, has the full
|
||||
writeup, what was ruled out, and where to look next. **Also unverified
|
||||
because of this**: item 3's composer rebuild (one `Stack`-based widget,
|
||||
a capped/scrollable height, bottom padding tied to the IME/nav-bar
|
||||
inset) -- structurally in place and unit-tested, but its own visual
|
||||
correctness cannot be screenshotted until text actually renders.
|
||||
- [x] **The composer has no touch-drag scroll for overflowing text.**
|
||||
**Done 2026-09-06.** `field.scrollable().masked()` in
|
||||
`transcript-ui/src/composer.rs`: a finger drag inside the bar pans the
|
||||
message, the bar stays capped at six lines, and a vertical drag in the
|
||||
focused field no longer extends a selection (Android `EditText`'s own
|
||||
behaviour). Verified on this checkout's emulator with the
|
||||
`transcript-screen bench force-gles` debug build -- six repetitions of a
|
||||
13-word sentence typed in, then
|
||||
`ui-trace record --do "swipe 540 1200 540 1460 300"`: the field's
|
||||
`Message` box moved `31,1041..1048,1509` -> `31,1131..1048,1651` (the
|
||||
content panned down with the finger) with its **height unchanged at
|
||||
468px** (the bar did not grow), and the two screenshots either side show
|
||||
different text in the same band.
|
||||
Three real defects had to be fixed first, each with a headless
|
||||
regression test in `iris/src/layout_tests.rs` and each confirmed to fail
|
||||
without its fix (docs/RUST.md's plan box has the measurements):
|
||||
a `MaxSize` reporting its cap as an unresolved `dp` (`Len::fold_dp`), a
|
||||
`Masked` allocating a fresh mask slot per draw (`ActiveData::own_mask`),
|
||||
and a panned widget's own hit box moving twice (`move_applied`).
|
||||
`Scroll` itself turned out to measure the right number by a misleading
|
||||
route -- it is written against `painter.px_size()` now, and the claim
|
||||
below that it "measures against the window" was wrong.
|
||||
**The grey background was not missing** -- that note (written here on
|
||||
2026-09-06 and repeated as still open) is withdrawn. Re-measured the
|
||||
same day on the same AVD by decoding the screencap rather than reading
|
||||
it: the bar is `rgb(41,40,49)`, the declared `UiColor::new(40, 40, 46)`
|
||||
after sRGB rounding, **full width and y2245..y2365** on 1080x2424, with
|
||||
the field at `31,2277..1048,2329` and the 63px nav strip below it. It
|
||||
is dark by design and sits on black, which is very likely what the
|
||||
earlier reading was: at a glance the band and the background are hard
|
||||
to tell apart. If it should read as a bar rather than as a slightly
|
||||
different black, the colour is the thing to change, not the tree.
|
||||
|
||||
## From the phone, 2026-09-06, 11:39 (build delivered 02:07, commit 543f6d9)
|
||||
|
||||
Iris's report on the build with the composing-text, tap-vs-swipe and
|
||||
atlas-reset fixes, with a screenshot, verbatim. Each is open until an
|
||||
agent ticks it here with the evidence.
|
||||
|
||||
- [x] **"The app definitely does not start with keyboard spacing
|
||||
correct. This is how it looks without me doing anything initially."**
|
||||
**Not an inset bug at all -- fixed 2026-09-06.** The black third is the
|
||||
bench shell's own empty *benchmark report* pane: `bench_client.rs`'s
|
||||
root tree gave it `.height(rest(1))` beside `content.height(rest(2))`,
|
||||
so an empty `TextEdit` reserved a third of the window at every launch
|
||||
and pushed the composer up by exactly that. Measured on this checkout's
|
||||
emulator at the phone's own size (1080x2424, density 420, gesture nav),
|
||||
which reproduced Iris's screenshot exactly: new `iris insets:` log line
|
||||
reported `bottom=63 ime_bottom=0` at launch (a nav bar, no keyboard --
|
||||
so the inset the composer was fed was never large), while `ui-trace
|
||||
show -m Message --field box` put the field at `31,1488..1048,1540` on a
|
||||
2282px-tall surface, 789px clear of the bottom -- that pane's third.
|
||||
**Unit mixing checked explicitly and cleared**: `set_bottom_inset` takes
|
||||
physical px and stores `Len::abs`, `MainActivity.java`'s `1`/`0`
|
||||
`ime_bottom` only ever reaches `insets.bottom.max(ime_bottom)` and
|
||||
`> 0.0`, and every `dp` in the composer resolves at layout time. Fix:
|
||||
the report pane is sized to its content (`.max_height(dp(260))
|
||||
.scrollable()`), and moved above the transcript so it cannot eat the
|
||||
composer's nav-bar clearance. After: field box `31,2277..1048,2329`,
|
||||
grey bar ending at device y2361 with the 63px nav strip below it
|
||||
(`/tmp/fix1.png` this pass).
|
||||
The screenshot shows the composer bar (the grey band) sitting about
|
||||
two thirds of the way down a 704x1568 screen, with black below it to
|
||||
the bottom, and the transcript ending at "Claude / Results" just above
|
||||
it -- at launch, no keyboard. So the composer's bottom padding, which
|
||||
the 2026-09-06 rebuild tied to the IME/nav-bar inset, is being fed a
|
||||
large value at start on the phone. Suspects, in order: the initial
|
||||
inset delivery on the phone (GrapheneOS, gesture navigation) versus
|
||||
the emulator; `ime_bottom` now carrying a `1`/`0` boolean through a
|
||||
field the composer may still read as pixels or dp; a stale value from
|
||||
before the first `on_insets_changed`. Reproduce with the phone's
|
||||
screen size and density on the emulator before guessing.
|
||||
- [~] **"Swiping still gets caught by the grey bar but keeps working
|
||||
after I go past it."** Improved 2026-09-06 by the focused-field rule
|
||||
below, still needs her phone to close. `attr.rs`'s `on_press` treated an
|
||||
already-focused composer as the plain drag-to-select case, so a swipe
|
||||
starting inside it dragged a highlight through the typed text for the
|
||||
whole gesture; it now abandons that the moment the press passes
|
||||
`DRAG_SLOP` vertically (Android `EditText`'s own rule), which removes one
|
||||
of the two things that made the bar feel like it caught the swipe. The
|
||||
residual `DRAG_SLOP` measured from the boundary crossing, described
|
||||
below, is unchanged. Original note follows.
|
||||
Not closeable from the emulator, annotated
|
||||
2026-09-06 after the `DragGesture` merge. `attr.rs`'s `on_press` never
|
||||
calls `capture_pointer` and never consumes a `Pressing` frame past
|
||||
`DRAG_SLOP` (it just stops watching), so once the finger's *current*
|
||||
position leaves the composer's box and enters the list's, `List`
|
||||
starts receiving ordinary hit-tested `Pressing` frames there --
|
||||
`DragArbiter::is_idle()`'s 2026-09-05 recovery (a missed `PressStart`)
|
||||
picks it up rather than leaving it stuck. What this does **not** do is
|
||||
what "wherever it began" implies literally: `DragArbiter::press_start`
|
||||
restarts from the *boundary-crossing* position, not from the original
|
||||
touch-down inside the composer, so the pan still needs a fresh
|
||||
`DRAG_SLOP` of travel measured from the boundary rather than from the
|
||||
start of the gesture -- composer and list are adjacent, non-overlapping
|
||||
widgets (`lib.rs`'s `(list, composer_bar).span(Dir::DOWN)`), and only
|
||||
the composer forwarding its own drag to the list would remove that
|
||||
residual slop entirely, which is more than this pass's merge changes.
|
||||
RUST.md's merge-pass box has the reasoning in full and an emulator
|
||||
swipe confirming the composer's own box never moves/resizes during it;
|
||||
whether the residual slop is still perceptible as "caught" needs Iris's
|
||||
phone, since the emulator's per-widget boundary is a few dp wide and
|
||||
easy to cross without noticing on a real screen too.
|
||||
- [ ] **"Flinging still does not work."** No longer expected to reproduce
|
||||
after the `DragGesture` merge (`e12c708`, pointer capture +
|
||||
`CursorSense::Drop`), 2026-09-06. Emulator evidence (RUST.md's
|
||||
merge-pass box, check (b)): a real `ui-trace` finger swipe followed by
|
||||
screenshot-hash sampling caught a post-release frame distinct from the
|
||||
drag's own last frame in one run, and every run showed 28-32
|
||||
`render()` frames per gesture against an idle baseline of 0 and ~8
|
||||
expected from the drag alone -- redraw kept being requested well past
|
||||
the finger lifting, which only happens while a fling is still
|
||||
animating. Left unticked in spirit until Iris's phone confirms it,
|
||||
since only she can say whether it *feels* like a fling now; the
|
||||
emulator's screenshot timing could not always catch the tail of a
|
||||
fast-settling one visually (same caveat noted in RUST.md).
|
||||
- [~] **"Text still disappears if I leave and come back to the app."**
|
||||
**Instrumented 2026-09-06 so the phone can answer it**, since no
|
||||
emulator here has a Vulkan adapter. `iris/src/android/view.rs` now logs
|
||||
one `log::info!` line per surface event with the glyph/atlas counts:
|
||||
`iris surface: surface_destroyed, tearing the renderer down
|
||||
(glyphs_cached=387 atlas_pages=1)`, `iris surface: surface_changed
|
||||
1080x2424 already_live=false glyphs_cached=387 atlas_pages=1`, `iris
|
||||
surface: new renderer built (Gl), clearing glyph atlas: glyphs=387
|
||||
pages=1`, plus `iris insets: ... window=(1080, 2424)` on every insets
|
||||
change. That is the emulator's own healthy app-switch cycle, verified
|
||||
this pass (home, reopen, screenshot: all text intact,
|
||||
`/tmp/appswitch.png`). **The one line to look for on the phone is
|
||||
`already_live=`**: `true` on the return from backgrounding would mean
|
||||
the surface came back *without* a `surface_destroyed`, so
|
||||
`surface_changed` reconfigured a renderer whose Vulkan swapchain and
|
||||
atlas textures belong to a window that is gone -- the reuse branch
|
||||
never clears the atlas, by design. `false` with no `new renderer built`
|
||||
line after it would mean the renderer failed to rebuild. Either answer
|
||||
names the fix; guessing between them from here does not.
|
||||
The `GlyphAtlas::clear`/`Textures::reset` fix was verified on the
|
||||
emulator under `force-gles` only; the phone runs Vulkan. So either the
|
||||
reset is not reached on the phone's path (a different surface-
|
||||
lifecycle sequence -- `surface_destroyed`/`surface_created` ordering,
|
||||
or the renderer not being rebuilt but its textures lost), or the CPU
|
||||
glyph cache and the GPU atlas still disagree after it. Needs logging
|
||||
of the renderer lifecycle on the phone build, readable from `adb
|
||||
logcat` when Iris next runs it, since no emulator here has a Vulkan
|
||||
adapter under host GPU.
|
||||
|
||||
## From the phone, 2026-09-06, 22:16 (build from 20303e0, delivered via ai-app-bench 95e25fe)
|
||||
|
||||
Iris's report, verbatim, with a screenshot. Phone: Mali-G715 (Vulkan),
|
||||
`content_scale: 2.55`, 120Hz. Open until ticked with phone-side evidence.
|
||||
|
||||
- [ ] **"Fling still doesn't work."** Second report; the emulator's
|
||||
`ui-trace` swipe flings (verified 2026-09-06 with `render()` counts),
|
||||
a finger on the phone does not. What differs: a real flick at 120Hz is
|
||||
batched by Android into few `MotionEvent`s with *historical* samples
|
||||
(`getHistoricalX/Y/EventTime`), and can be DOWN, one or two MOVEs, UP
|
||||
inside `DRAG_SLOP`'s worth of frames; a `ui-trace` swipe is many
|
||||
evenly-spaced MOVEs. Suspects, in order: `android/sense.rs` reading
|
||||
only each event's final position (the velocity tracker sees two
|
||||
samples, or one); the release path starting a fling only from a
|
||||
gesture already in `Panning`, so a flick that crosses the slop on its
|
||||
last sample is treated as a tap; `ACTION_CANCEL`/pointer-capture
|
||||
delivering no `Drop`. Log the release decision (samples, span,
|
||||
velocity, outcome) at `info` so the next logcat settles it.
|
||||
- [ ] **"I can't reopen keyboard by tapping on message box after it
|
||||
already happened once."** The field stays focused after the keyboard
|
||||
is dismissed (back gesture, or the IME's own hide), so `on_press`'s
|
||||
already-focused branch never requests the IME again. Android's
|
||||
`EditText` shows the IME on every tap of a focused field; do the same
|
||||
(`FocusHost`: a tap on a focused field requests the IME, idempotent
|
||||
when it is already shown).
|
||||
- [ ] **"Message box does not push up the scroll area."** Since
|
||||
`MainActivity` went edge-to-edge (`e12c708`), `adjustResize` no
|
||||
longer resizes the window, so the app owns the IME inset -- but
|
||||
`ime_bottom` is passed through JNI as the boolean `1`/`0` (the
|
||||
2026-09-06 "(b)" fix), so nothing has the inset's *height* to pad the
|
||||
transcript and composer with. Pass both: `isVisible(ime())` and
|
||||
`getInsets(ime()).bottom` in px; the list's bottom padding and the
|
||||
composer's position follow the height, the visibility drives the
|
||||
boolean the `imePadding` rule in AGENTS.md's "Things that have bitten"
|
||||
describes.
|
||||
- [ ] **"Picture is what happens if I leave the app and come back,
|
||||
which completely removes text, and then I tap on the debug info. The
|
||||
textures are definitely getting cooked for some reason after leaving
|
||||
the app and resuming."** Screenshot: every glyph drawn *before* the
|
||||
resume is fragments; the diagnostics text drawn *after* is perfect;
|
||||
the report says `atlas format: Rgba8Unorm, views live: 0`. Reading:
|
||||
`Textures::reset`/`GlyphAtlas::clear` on the new renderer emptied the
|
||||
GPU atlas, but the per-widget cached text primitives (`TextView`'s
|
||||
render cache -- the one `c3cfc67`'s shape counter is keyed on) still
|
||||
carry the old atlas coordinates and are re-submitted as-is; only
|
||||
widgets drawn fresh after the resume shape and upload again. Fix: a
|
||||
renderer rebuild invalidates every cached text render (one
|
||||
generation counter on the atlas, checked at `TextView::render`, or
|
||||
a full-tree redraw with caches dropped), with a `debug_assert!` that
|
||||
no submitted glyph quad references an atlas generation older than the
|
||||
live one. Reproducible on the emulator by forcing a renderer rebuild
|
||||
(home + return, or `surface_destroyed`/`surface_created`) on a screen
|
||||
with text already drawn -- the earlier "verified" home/reopen check
|
||||
screenshotted the emulator's GLES path, where a resume may not
|
||||
destroy the surface at all.
|
||||
|
||||
## Build
|
||||
|
||||
- [x] **Benchmarks**, not unit tests, run on demand (2026-09-05; a
|
||||
@@ -306,22 +591,31 @@ order and what "done" looks like. Tick and date them in place.
|
||||
`row.rs`'s `build_text_row` is where one would go, keyed to something
|
||||
stable per row (its sender + a short excerpt, matching what a screen
|
||||
reader announcing a chat message would say).
|
||||
- [ ] **A tappable link and a background chip behind inline code.**
|
||||
Both need per-range glyph geometry that `TextEditCtx` does not expose
|
||||
outside `iris::widget::text` (`edit.rs`'s `layout()` helper is
|
||||
private) — see `markdown.rs`'s module doc for the exact shape the fix
|
||||
would take (the same primitive `TextEdit::draw`'s own selection
|
||||
highlight already uses internally,
|
||||
`iris/src/widget/text/edit.rs:99`).
|
||||
- [x] **A tappable link** — done 2026-09-06 (P1a). `TextEditCtx::
|
||||
byte_at(pos, size)` answers which byte a tap landed on without
|
||||
handing out the parley layout, `GestureOutcome::Tapped` says the
|
||||
press committed to neither a pan nor a selection, and
|
||||
`iris::platform::OpenUrl` is the capability each backend implements
|
||||
(`xdg-open`/`open`/`start`; an `ACTION_VIEW` intent on Android,
|
||||
deferred to `after_input` the way `pending_show_keyboard` is).
|
||||
- [ ] **A background chip behind inline code.** Still needs per-range
|
||||
glyph *geometry* — a run's boxes, not one offset — which
|
||||
`TextEditCtx` does not expose outside `iris::widget::text`
|
||||
(`edit.rs`'s `layout()` helper is private). The same primitive
|
||||
`TextEdit::draw`'s own selection highlight uses internally,
|
||||
`iris/src/widget/text/edit.rs:99`. `byte_at` above deliberately did
|
||||
not open that up: a tap needs one offset and a chip needs the run.
|
||||
- [ ] **`Selection`'s anchor-row shortcut.** The row a drag started in
|
||||
is selected in full (`select_all`) the moment the drag leaves it,
|
||||
rather than "from the click point to whichever edge points away from
|
||||
the drag" — needs the same private `layout()` access as the item
|
||||
above. `selection.rs`'s module doc has the exact reasoning.
|
||||
- [ ] **No syntax highlighting inside a fenced code block.**
|
||||
`client_core::highlight` exists (built for the file explorer) and
|
||||
could feed per-token `SpanStyle`s into a code block's span; wiring it
|
||||
in was not attempted this pass.
|
||||
- [x] **Syntax highlighting inside a fenced code block** — done
|
||||
2026-09-06 (P1a). `client_core::highlight::spans_of` by language,
|
||||
converted from its char indices to `SpanStyle`'s byte offsets, in
|
||||
the same Catppuccin palette `Theme.kt` uses. A language the scanner
|
||||
has no rules for stays plain rather than being coloured by the
|
||||
nearest one's.
|
||||
|
||||
- [ ] **Masks defined relative to each other.** Wanted: mask A multiplies
|
||||
by something *and also* applies mask B — a mask can reference a parent
|
||||
@@ -341,6 +635,109 @@ order and what "done" looks like. Tick and date them in place.
|
||||
everything, the same way input is**. Whatever the mechanism, a widget
|
||||
that does not animate must pay nothing and import nothing for it.
|
||||
|
||||
## Found by P1a (2026-09-06)
|
||||
|
||||
- [x] **`Rect` claimed to be size-independent, and it is not.** A `Rect`
|
||||
fills whatever region it is handed, so `draw_inner`'s size-independent
|
||||
fast path -- which rewrites primitives with
|
||||
`r.outside(&from).within(®ion)` rather than redrawing -- could not
|
||||
reproduce its `draw`, and a `.background(rect(..))` kept the size of
|
||||
the *provisional* full-region pass `Span` does in phase 1. One fenced
|
||||
code block's panel covered every block below it and every row below
|
||||
that. Fixed in `iris/src/widget/rect.rs`; the reason is written at the
|
||||
definition. Suspect the same cause for anything else tinted with a
|
||||
background rect.
|
||||
- [x] **A wrapped transcript row tripped `reposition`'s debug assert.**
|
||||
Settled 2026-09-06 by giving the move slot one owner instead of two.
|
||||
`mov` accumulates a delta on it, `reposition` overwrote it, and both
|
||||
legitimately land on one widget in one frame: `List::place`'s
|
||||
Bottom-known branch offers a row a same-size box that has *moved*
|
||||
(`mov`), then corrects the placement inside it when the row's cached
|
||||
height no longer matches what the row reports (`reposition`). The
|
||||
slot now always means `move_applied + repositioned`
|
||||
(`ActiveData::repositioned`, `iris/core/src/ui/render_state.rs`), so
|
||||
`reposition` adds the move rather than dropping it -- the assert is
|
||||
gone and the arithmetic is right. Test:
|
||||
`a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement`
|
||||
in `layout_tests.rs`, which lands the child at the *offered* position
|
||||
(-100px) instead of the placement (100px) without the fix, and a
|
||||
`debug_assert_eq!` in `reposition` that nothing but those two ever
|
||||
writes the slot. Verified with the `.wrap(true)` repro (draws, no
|
||||
panic) and an emulator bench run with assertions live.
|
||||
- [ ] **Desktop colours are washed out: the winit surface is sRGB and
|
||||
the shader writes the palette's bytes as linear.** Mocha Crust
|
||||
(17,17,27) is drawn as (73,73,91), measured off
|
||||
`run-headless.sh --shot`. Android is correct, so this is the surface
|
||||
format rather than the palette -- but it makes the desktop build
|
||||
useless as a colour reference, which is exactly what P1a needed it for
|
||||
when the emulator could not draw glyphs.
|
||||
- [x] **Every glyph was a solid box on the GLES backend -- iris's bug,
|
||||
not the emulator's.** Fixed 2026-09-06. The atlas is one
|
||||
`texture_2d_array` and `GpuTextures::new` created it with **one
|
||||
layer**; wgpu-hal picks the GL target from the descriptor
|
||||
(`(false, 1) => TEXTURE_2D`), so under GLES that array was a
|
||||
`GL_TEXTURE_2D` bound to the shader's `sampler2DArray`, the unit was
|
||||
incomplete, every `textureSample` returned (0,0,0,1), and
|
||||
`draw_glyph`'s `color.a *= texel.a` filled the quad. `MIN_ARRAY_LAYERS
|
||||
= 2` in `iris/core/src/render/texture.rs`, with a `debug_assert!` at
|
||||
`create_array_texture`. Vulkan (the phone, the desktop's default
|
||||
backend) was never affected. Reproduce the class in seconds without an
|
||||
emulator: `iris`'s `force-gles` feature now switches the **desktop**
|
||||
backend too -- `./run-headless.sh transcript --shot /tmp/x.png -- -p
|
||||
transcript-ui --features iris/force-gles`.
|
||||
|
||||
- [ ] **The bench report pane draws over the transcript rows instead of
|
||||
replacing them.** Visible on the emulator for the first time now that
|
||||
glyphs render there (`/tmp/emu-final.png`, 2026-09-06): after a bench
|
||||
run the report's lines and the transcript's occupy the same rows in the
|
||||
top third of the screen, both legible, neither on top. Pre-existing --
|
||||
the same overlap is in a screenshot taken before the move-slot fix -- so
|
||||
it is its own item, most likely the report pane not masking or not
|
||||
claiming its region.
|
||||
|
||||
## Found by P1b (2026-09-06), all with a headless repro
|
||||
|
||||
Each was found by looking at `iris/run-headless.sh transcript -- -p
|
||||
transcript-ui` rather than at a diff, and each is worked around in
|
||||
`transcript-ui/src/tool.rs` rather than fixed here. docs/RUST.md's P1b box
|
||||
has the fuller account.
|
||||
|
||||
- [ ] **A `Span` of `Pad`ded children inside another `Span` places those
|
||||
children a slot out of step.** Each child drew its content one sibling's
|
||||
height below its own box. Repro: `IRIS_TOOLS_EXPANDED=1
|
||||
iris/run-headless.sh transcript --shot /tmp/x.png -- -p transcript-ui`
|
||||
with `tool.rs`'s group built as `Span(DOWN)[header, Pad(Span(DOWN)
|
||||
[cards]), bar]` instead of the single `Span` it uses now. Bisected:
|
||||
removing the inner `Span` fixes it, and so does removing the children's
|
||||
own `Pad`; the background `Stack`, the `Sized` wrappers and the
|
||||
`WidgetPtr` per child make no difference. **Not** the `mov`-vs-
|
||||
`reposition` fault f5b8893 fixed -- it survives that commit. The
|
||||
workaround costs the group the 4dp inset its Compose counterpart holds
|
||||
its cards off the edge by, so this is worth fixing.
|
||||
- [ ] **`scrollable_on(Axis::X)` on a non-editable `Text` draws nothing.**
|
||||
The panel is drawn and the text inside it is not. A markdown fence does
|
||||
the same to a `TextEdit` and is fine, so it is the widget kind rather
|
||||
than the chain. `tool.rs`'s `raw_block` is `masked()` only until this is
|
||||
fixed, which means a long command is clipped rather than pannable.
|
||||
- [ ] **No overflow ellipsis.** `TextAttrs` can wrap or not wrap; there is
|
||||
no "one line, ellipsised" the way `maxLines = 1` + `TextOverflow.
|
||||
Ellipsis` gives Compose. A tool card's summary is clipped instead, so
|
||||
nothing on screen says it was cut. Whichever end is cut has to be a
|
||||
choice when this lands: a path is identified by its tail, a command by
|
||||
its head.
|
||||
- [ ] **A drawn chevron.** `Chevron.kt` draws its own strokes precisely
|
||||
because a chevron from a font is a glyph a system font may not have --
|
||||
and the bundled `NotoSans-Regular.ttf` indeed has no U+25B8/25BE/25B4,
|
||||
while `NotoSansMono-Regular.ttf` does. `tool.rs` sets the mark in the
|
||||
monospace face as a result. A real fix needs a line/path primitive;
|
||||
iris has rects, text and textures only.
|
||||
- [ ] **A tool card's text is not selectable.** `Selection` is keyed
|
||||
`(RowKey, block index)` and a card has no markdown blocks, so nothing in
|
||||
a card registers. Compose's `SelectionContainer` covers tool output,
|
||||
which is the text people most want to copy. Needs a key for "the nth
|
||||
text of this row" that a card can mint without colliding with a
|
||||
message's blocks.
|
||||
|
||||
## Build (for the port)
|
||||
|
||||
Widgets `RUST.md`'s "The port, in order (decided 2026-09-05)" needs and
|
||||
@@ -385,3 +782,68 @@ do not duplicate it there.
|
||||
redundant. Decide after the layout change lands, by writing a button
|
||||
both ways and keeping the one that is shorter to explain; delete the
|
||||
other rather than keeping two ways.
|
||||
|
||||
## Build (asked for by Iris, 2026-09-06): a density-independent length unit
|
||||
|
||||
- [x] **A third length kind beside relative and pixels, so display scales
|
||||
"just work".** Done 2026-09-06 — `Len::dp`/`len_fns::dp`, resolved
|
||||
against `UiRenderState`/`Painter::density()` at `apply_rest` time; text
|
||||
additionally rasterises at the resolved (physical) size instead of
|
||||
scaling a low-resolution bitmap afterward, which was making text blurry.
|
||||
`Span::gap`/`Padding` moved from `f32` to `Len` so they take `dp(...)`
|
||||
too; transcript-ui's row/composer padding and one example migrated.
|
||||
`em` was not added — nothing in this pass needed a text-relative unit,
|
||||
and `dp`'s own doc says why it and physical pixels are kept as separate
|
||||
fields rather than one the caller pre-multiplies. Not yet verified on
|
||||
Iris's own phone at two densities (this pass had no device) — see
|
||||
docs/RUST.md's P0 box and docs/IRIS.md's 2026-09-06 entry for what to
|
||||
check. Iris's words: "another length type similar to absolute &
|
||||
relative, so instead there would be relative, pixels, and another unit
|
||||
like em or whatever is standard. That way different display scales
|
||||
should just work." Today a length is either a fraction of the parent
|
||||
(`rest`/relative) or physical pixels, and the phone drew 16 px text at
|
||||
roughly a third of its intended size until the P0 fixes applied the
|
||||
display's scale factor globally. That global scale is a stopgap for the
|
||||
benchmark; the real shape is a unit resolved against the display's
|
||||
density at layout time — Android's `dp` / CSS's reference pixel is the
|
||||
standard (1 unit = 1/160 in), with `em` as the text-relative option —
|
||||
so a widget author writes `16.dp()` once and never sees the scale.
|
||||
Done when: `Length` (or whatever the enum is called) has the third
|
||||
variant; every place that resolves a length takes the density; the
|
||||
examples and `transcript-ui` use the new unit for text sizes, padding
|
||||
and control sizes; the emulator at two densities and the phone draw the
|
||||
same layout at the same physical size. After the bench setup is
|
||||
finished, before P1 draws any new screen.
|
||||
|
||||
## From the phone, bench v2 (2026-09-06): streaming re-lays out the whole message
|
||||
|
||||
- [x] **Streaming a delta into a long message costs a full text layout of
|
||||
that message.** **Done 2026-09-06** -- a row is a column of one
|
||||
`TextEdit` per markdown block (`client_core::markdown_blocks`,
|
||||
`row::RowBlocks::apply_delta`), so a delta re-shapes the last block and
|
||||
keeps every earlier block's layout. A block is the selection unit now
|
||||
(`Selection`'s `SelKey`); selection across blocks and rows still works,
|
||||
checked on the emulator with a real long-press drag. Pass condition met
|
||||
in `a_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one`:
|
||||
a delta into a 100-paragraph reply redraws the same widget count as one
|
||||
into a one-paragraph reply (30 either way). Emulator stream phase, same
|
||||
AVD before and after: **p50 61.5 -> 54.5ms, p90 211.7 -> 113.1ms, p99
|
||||
342.6 -> 137.4ms, worst 403.6 -> 143.0ms**, 202 -> 293 frames in the same
|
||||
21 seconds. docs/RUST.md's Task B box has the detail and the two dead
|
||||
ends. **The phone is the measurement that decides it** -- these are
|
||||
emulator numbers and only the ratio transfers.
|
||||
|
||||
The original entry, for the record: Iris's phone report (`docs/bench/iris-phone-v2-2026-09-06.md`):
|
||||
the stream phase is the one place iris is behind Compose (p50 18.2 ms vs
|
||||
13.4 ms; p99 level at ~43 ms). `TranscriptScreen::apply` replaces only
|
||||
the last row, but that row is the growing message, and replacing it
|
||||
re-renders its markdown and re-shapes the entire paragraph run through
|
||||
parley on every event. Compose pays a reparse (8.6 ms mean) for the
|
||||
same event. What "done" looks like: a streamed delta re-lays out only
|
||||
the block it lands in (the last paragraph or code block), with earlier
|
||||
blocks' layouts kept -- which needs a row to be a column of per-block
|
||||
`Text`s rather than one `TextEdit` for the whole message, or parley's
|
||||
layout to be split at block boundaries; measured by the stream phase's
|
||||
p50 dropping below Compose's on the phone. Do this after the four bench
|
||||
v2 defects (stale primitives, finger fling, decay curve, IME show) are
|
||||
closed, since they are what make the run unrepresentative today.
|
||||
@@ -861,6 +861,58 @@ unspecified rather than getting them wrong:
|
||||
conditions, so the remaining slack was accepted rather than chased
|
||||
further.
|
||||
|
||||
## Density: `Len::dp`, resolved at `apply_rest` time (2026-09-06)
|
||||
|
||||
Iris asked for a third length kind beside `abs` (physical pixels) and
|
||||
`rel`/`rest` (a fraction of the parent) — IRIS_TODO.md's "density-
|
||||
independent length unit" — after the P0 phone pass found 16px text
|
||||
drawing at roughly a third size on a real phone. The fix that shipped
|
||||
first (RUST.md's P0 box) was a global stopgap: divide the whole window
|
||||
into a "logical" coordinate space (physical ÷ `content_scale`) and let
|
||||
the shader's NDC mapping stretch it back up onto the real framebuffer.
|
||||
That fixed the *size* but not the *sharpness* — a glyph rasterised at the
|
||||
small, pre-stretch size and then stretched onto more physical pixels than
|
||||
it has texels for is blurry, which is exactly what Iris's next report
|
||||
said.
|
||||
|
||||
**The fix**: `Len` gained a `dp` field, resolved against a `density: f32`
|
||||
(physical pixels per dp) at the one place a `Len` becomes a `UiScalar`
|
||||
(`Len::apply_rest`) — `abs + dp * density`. `density` lives on
|
||||
`UiRenderState` (`set_density`/`density()`) and `Painter` (`density()`),
|
||||
set once from `DisplayMetrics.density` in `android::view::new_peer`; the
|
||||
desktop backend has no per-monitor density wired up yet and stays at
|
||||
`1.0`. Every layout call site that used to call `.apply_rest()`/
|
||||
`.to_uivec2()` now passes `painter.density()` (nine call sites — `Span`,
|
||||
`Sized`, `MaxSize`, `Aligned`, `Scroll`, `List::place`, and
|
||||
`UiRenderState::reposition` itself). This also meant the Android
|
||||
boundary's global logical-space stopgap could come out entirely: window
|
||||
size, touch coordinates and insets are physical pixels again, matching
|
||||
`AndroidRenderer`'s own swapchain resolution, with `dp` doing the
|
||||
per-length work the global divide used to do for everything at once.
|
||||
|
||||
**Text is the case that needed more than the `Len` plumbing.** A widget's
|
||||
`font_size`/`line_height` are plain `f32`, not routed through `Len` at
|
||||
all (there is no sensible `rel`/`rest` for a font size). `TextBuffer::
|
||||
shape` now takes `density` directly and multiplies `font_size`/
|
||||
`line_height` (and any span override) by it before handing them to
|
||||
parley — so the size that reaches both the line-breaker and the
|
||||
rasteriser (`TextData::place`, which reads back whatever `shape` set) is
|
||||
the display's *physical* size, and the glyph atlas holds a bitmap at the
|
||||
resolution it is actually shown at. `GlyphKey.size` already keys on the
|
||||
resolved size, so a cache entry is naturally per-physical-size with no
|
||||
further change. The one caller with no `Painter` to read density from
|
||||
(`TextEditCtx::layout`, cursor movement and hit-testing) reads a second
|
||||
copy kept directly on `TextData` (`TextData::density`) instead — an
|
||||
accepted duplication rather than threading a `Painter` into every input
|
||||
handler for one field, the same tradeoff `AndroidRenderer::content_scale`
|
||||
already makes for the Diagnostics page.
|
||||
|
||||
**What did not change**: `rel`/`rest` are unaffected (already
|
||||
resolution-independent, a fraction of the parent). `Span::gap` and
|
||||
`Padding`'s four sides moved from bare `f32` to `Len` so `dp(...)` works
|
||||
on them the same as any other size; a bare number is still `abs`,
|
||||
physical pixels, unchanged.
|
||||
|
||||
## For IRIS.md
|
||||
|
||||
When this lands, copy this entry into `IRIS.md` (newest first):
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
# Review: iris changes since 0e46293
|
||||
|
||||
Scope: `git diff 0e46293..HEAD -- iris/ client-core/` (58 files, +5224/-226).
|
||||
Read-only review; no source changed. Ordered likely-bug, then invariant
|
||||
guards, then rules, then tests/docs.
|
||||
|
||||
## Likely bugs
|
||||
|
||||
1. **`iris/transcript-ui/src/lib.rs:152-160` (`RowDiff::Rebuild` arm of
|
||||
`TranscriptScreen::apply`) never unregisters the rows it drops from
|
||||
`Selection`, so a stale `WeakWidget<TextEdit>` outlives the widget it
|
||||
points to and the next touch on *any* row panics.**
|
||||
`Selection::rows: BTreeMap<RowKey, WeakWidget<TextEdit>>` documents its
|
||||
own contract at `selection.rs:69-71`: "every addition here needs its
|
||||
removal ... called when `List` evicts the row." The `ReplaceLast` arm
|
||||
above it honours this (`lib.rs:143-145`, `self.selection.borrow_mut()
|
||||
.unregister(old_key)` when the key changes). The `Rebuild` arm calls
|
||||
`(self.list)(rsc).clear()` and rebuilds every row from `new_rows`, but
|
||||
never touches `self.selection` — any key present in `old_rows` and
|
||||
*absent* from `new_rows` (exactly what `group_tool_runs` regrouping two
|
||||
separate tool-call rows into one produces — see `diff_tests::
|
||||
a_tool_run_closing_and_joining_an_earlier_call_is_a_regroup_fallback`,
|
||||
which tests the diff decision but not `apply` itself) is left in
|
||||
`self.rows` pointing at a widget `List::clear()` just freed.
|
||||
`TextEditable::edit` (`iris/src/widget/text/edit.rs:582-587`) resolves
|
||||
that handle with `ui.widgets.get_mut(self).unwrap()` — an unconditional
|
||||
panic on the freed slot. `Selection::begin` (`selection.rs:88-101`)
|
||||
iterates *every* registered row (`w.edit(ui).deselect()`) on an
|
||||
ordinary fresh press, so the crash fires on the next tap anywhere in
|
||||
the transcript after a regroup, not only on a tap targeting the
|
||||
orphaned row.
|
||||
Fix: give `Selection` a way to reconcile against the row set that
|
||||
survived a rebuild (e.g. `Selection::retain(&self, keys: &BTreeSet<RowKey>)`
|
||||
removing everything else, called from the `Rebuild` arm before
|
||||
rebuilding), or simplest — call `self.selection.borrow_mut()` cleared
|
||||
the same way `List::clear()` clears the list, then let the rebuild's
|
||||
`push_row` calls re-`register` everything as they already do.
|
||||
|
||||
## Guarded invariants missing
|
||||
|
||||
2. **`iris/src/widget/list.rs:751` (`List::place`) indexes/expects on
|
||||
`slot` with no assertion that it exists.** `slot_widget` (`:563-575`)
|
||||
panics via `.expect(...)` for a sentinel with no widget set, and does
|
||||
an unchecked `&self.items[s as usize]` for a real index — a bare
|
||||
"index out of bounds" with no context if `place` is ever reached with a
|
||||
stale slot. Every current caller happens to derive `slot` from
|
||||
`repair_anchor`/`prev_slot`/`next_slot`, which already check existence,
|
||||
but that invariant is enforced by convention across three call sites,
|
||||
not by the function that depends on it. Add
|
||||
`debug_assert!(self.slot_exists(slot), "place() called with a slot that doesn't exist: {slot:?}");`
|
||||
at the top of `place`.
|
||||
3. **`iris/src/widget/list.rs:426` (`List::fling`) and `sense.rs`'s
|
||||
`FlingCalculator::distance`/`duration`/`position_at` never check that
|
||||
the incoming velocity is finite.** A `NaN`/`inf` velocity (a
|
||||
`VelocityTracker::velocity()` divide-by-near-zero span, or a caller
|
||||
passing a raw device value straight through) propagates through
|
||||
`deceleration_for`'s `.ln()` silently — the fling either never settles
|
||||
(`settled_on_schedule` compares against a `NaN` `duration()`, which is
|
||||
always `false`) or jumps to `NaN` positions with nothing on screen
|
||||
saying why. Add `debug_assert!(velocity_px_per_s.is_finite())` in
|
||||
`List::fling` and `FlingCalculator::new`/`distance`.
|
||||
4. **`iris/src/sense.rs:592-604` (`VelocityTracker::velocity`) has no
|
||||
assertion that samples are chronological.** `add_sample` trusts its
|
||||
caller's `Instant` ordering; a caller that samples out of order (a
|
||||
restored/replayed gesture, a test) would silently produce a negative
|
||||
`span` handled only by the `span <= 0.0 => 0.0` catch-all, masking the
|
||||
bug that produced it rather than surfacing it. Add
|
||||
`debug_assert!(self.samples.back().is_none_or(|&(last, _)| at >= last))`
|
||||
in `add_sample`.
|
||||
5. **`iris/core/src/render/frame_report.rs:247-252` (`mark_phase`) has no
|
||||
assertion that phases are pushed in non-decreasing `start_index`
|
||||
order.** `phase_stats`'s slicing (`:274`, `idx >= phase.start_index &&
|
||||
idx < end_index`) silently produces an empty or nonsensical slice for
|
||||
an out-of-order phase rather than surfacing the misuse — cheap to add
|
||||
given `self.phases.last()` is already in scope:
|
||||
`debug_assert!(self.phases.last().is_none_or(|p| self.total_frames >= p.start_index));`
|
||||
|
||||
## Rules
|
||||
|
||||
6. **Two mechanisms answer "what row selection points at, still valid?"**
|
||||
`Selection` relies on callers remembering to `unregister` (finding 1);
|
||||
`List` relies on callers deriving slots only from already-checked
|
||||
sources (finding 2). Both are the same class of problem — a derived
|
||||
handle that silently outlives what it points to — solved ad hoc twice
|
||||
rather than once. Not asking for a shared abstraction here, but the two
|
||||
should at minimum cross-reference each other's doc comment so the next
|
||||
caller who adds a third handle-into-`List`-rows type (the code rules'
|
||||
"a rule that governs a set belongs to the set") finds both existing
|
||||
examples.
|
||||
7. **`iris/android-app/src/bench_client.rs:224-225` (`battery_line`)
|
||||
calls `.min().unwrap()`/`.max().unwrap()` on `samples` guarded three
|
||||
lines above by `if samples.is_empty()`, which is fine — but the guard
|
||||
and the two unwraps are two statements apart with a `let mean = ...`
|
||||
in between reading the same slice; a future edit reordering those
|
||||
lines loses the guard's protection silently.** Low severity (this is
|
||||
the bench tool, not the app), but worth a one-line comment tying the
|
||||
unwraps back to the guard, or restructuring as
|
||||
`let (Some(min), Some(max)) = (samples.iter().min(), samples.iter().max())`
|
||||
pattern so the empty case can't be separated from the check by a future
|
||||
edit.
|
||||
|
||||
## Tests
|
||||
|
||||
8. **No test exercises `TranscriptScreen::apply`'s `Rebuild` arm through
|
||||
`Selection`.** `lib.rs`'s `diff_tests` module (`:284-379`) tests only
|
||||
the pure `diff_rows` decision function, never `apply` itself wired to a
|
||||
real `Selection`; `selection.rs`'s own tests (`a_missed_press_start_
|
||||
recovers_on_the_next_pressing_frame`, `unregister_forgets_the_row_and_
|
||||
clears_a_matching_anchor`) never go through `apply`/`List::clear`
|
||||
either. This is exactly the gap that let finding 1 through: the two
|
||||
pieces (`apply`'s fallback, `Selection`'s registration contract) are
|
||||
each tested in isolation and never together. Add: build a
|
||||
`TranscriptScreen`, force a `RowDiff::Rebuild` (two adjacent tool-call
|
||||
rows regrouping, per the existing `diff_tests` case), then call
|
||||
`selected_text`/simulate a fresh press on a surviving row and assert no
|
||||
panic.
|
||||
9. **`iris/src/widget/list.rs`'s fling tests check total distance and the
|
||||
start/end clamp but not the speed profile in between.**
|
||||
`fling_moves_the_list_and_then_settles`/`fling_distance_is_positive_
|
||||
toward_the_end` only assert the fling started, moved in the right
|
||||
direction, and eventually stopped — none checks that
|
||||
`tick_fling`'s per-tick delta is *monotonically decreasing* once past
|
||||
the fling's peak (the property `fling_calculator_tests::position_at_
|
||||
is_monotonic_and_clamped_past_the_end` already checks one level down,
|
||||
for `FlingCalculator` alone, but never through `List::tick_fling`'s own
|
||||
`scroll`/`anchor.offset` accumulation). A regression that made
|
||||
`tick_fling` apply the *total* distance every tick instead of the
|
||||
incremental one, for instance, would still pass both existing tests
|
||||
(final position and direction are unaffected by how the interior ticks
|
||||
split it up) while being wildly wrong every intermediate frame.
|
||||
10. **`iris/src/widget/list.rs::replacing_the_last_row_stays_pinned_to_
|
||||
the_bottom` and its sibling test `replace_back`'s effect on the
|
||||
displayed row, never that the row it evicted is actually gone from
|
||||
`heights`/`extents`.** Both tests assert the *new* row's position;
|
||||
neither asserts `old.key` is absent from `list_ref.heights`/`extents`
|
||||
after the replace (the "stale primitive" class finding 1 is a
|
||||
production instance of). A cheap addition: assert
|
||||
`!list_ref.heights.contains_key(&old.key)` after `replace_back` in the
|
||||
existing test, since `old.key` is already returned to the test as
|
||||
`evicted`... (`lib.rs` calls it that way; the `list.rs` test would need
|
||||
to capture the key from `old` similarly.)
|
||||
|
||||
## Docs
|
||||
|
||||
No missing `IRIS.md` entry found for a *public* API change in this diff —
|
||||
`List::fling`/`VelocityTracker`/`FlingCalculator`, `List::
|
||||
anchor_position_display`, `FrameReport::mark_phase`/`phase_stats`/
|
||||
`late_at_hz`, `UiRenderNode::new`'s `Result` change, `Len::dp`, and
|
||||
`List::replace_back`/`clear`/`TranscriptScreen::apply` all have entries.
|
||||
The `List::replace_back`/`clear`/`TranscriptScreen::apply` entry
|
||||
(`docs/IRIS.md:526`) predates this review's finding 1 and does not mention
|
||||
`Selection`'s registration contract at all — once finding 1 is fixed,
|
||||
that entry should gain a line noting what the fix requires of a caller
|
||||
that keeps its own row-keyed side table (the same shape `Selection` is),
|
||||
so the next such table doesn't reproduce the same gap.
|
||||
|
||||
## Fixed, 2026-09-06
|
||||
|
||||
All ten findings addressed after the `DragGesture` merge (`selection.rs`
|
||||
was rewritten by that merge, but finding 1's shape and location were
|
||||
unchanged — `TranscriptScreen::apply`'s `Rebuild` arm, `iris/transcript-ui/
|
||||
src/lib.rs`).
|
||||
|
||||
1. **Fixed.** `Selection::clear()` (`selection.rs`) drops `rows` and
|
||||
`anchor`, called from `apply`'s `Rebuild` arm right before
|
||||
`List::clear()` — `push_row` re-`register`s whatever survives as it
|
||||
rebuilds each row, the "simplest" fix option the finding named.
|
||||
2. **Fixed.** `debug_assert!(self.slot_exists(slot), ...)` at the top of
|
||||
`List::place` (`iris/src/widget/list.rs`).
|
||||
3. **Fixed.** `debug_assert!(velocity_px_per_s.is_finite())` in
|
||||
`List::fling`, and `debug_assert!(velocity.is_finite())` in
|
||||
`FlingCalculator::distance`/`duration` (`iris/src/sense.rs`).
|
||||
`position_at` calls both, so it inherits the guard rather than needing
|
||||
its own.
|
||||
4. **Fixed.** `debug_assert!` on chronological sample order in
|
||||
`VelocityTracker::add_sample` (`iris/src/sense.rs`).
|
||||
5. **Fixed.** `debug_assert!` on non-decreasing `start_index` in
|
||||
`FrameReport::mark_phase` (`iris/core/src/render/frame_report.rs`).
|
||||
6. **Fixed (doc cross-reference only, as asked).** `Selection::register`'s
|
||||
doc now points at `List::place`'s `slot_exists` assertion and vice
|
||||
versa isn't needed since finding 2's fix already cites this file in
|
||||
its own comment; both are grep-able on "docs/REVIEW-2026-09-06.md" and
|
||||
on each other's type names.
|
||||
7. **Fixed.** `bench_client.rs::battery_line` restructured to
|
||||
`let (Some(min), Some(max)) = (samples.iter().min(), samples.iter().max())`,
|
||||
so the empty-guard and the two lookups can no longer be separated by a
|
||||
future edit.
|
||||
8. **Fixed.** `transcript-ui`'s new `apply_tests::
|
||||
a_row_dropped_by_a_regroup_does_not_outlive_itself_in_selection`
|
||||
(`lib.rs`) builds a real `TranscriptScreen`, forces the same regroup
|
||||
shape `diff_tests` already covers at the pure-diff level, calls `apply`,
|
||||
and then `Selection::begin` on a surviving row — which panicked before
|
||||
fix 1, resolving a `WeakWidget` `List::clear()` had just freed.
|
||||
9. **Fixed.** `list.rs`'s new `tick_fling_applies_shrinking_incremental_
|
||||
deltas` flings toward the end from `jump_to_start` and asserts each
|
||||
tick's `extents[&0]` delta is no larger than the previous one — would
|
||||
fail against a `tick_fling` that applied the total spline distance
|
||||
every tick instead of the incremental slice, which the two pre-existing
|
||||
fling tests cannot catch.
|
||||
10. **Fixed.** `list.rs`'s new `replace_back_forgets_the_evicted_keys_own_
|
||||
height` replaces row 4 with a row keyed `100` (the two existing
|
||||
`replace_back` tests always reuse the same key, so neither actually
|
||||
exercises the removal) and asserts `heights` no longer contains the
|
||||
evicted key.
|
||||
|
||||
Docs: `docs/IRIS.md`'s 2026-09-05 `List::replace_back`/`clear`/
|
||||
`TranscriptScreen::apply` entry now has a line on what the fix requires of
|
||||
a caller with its own row-keyed side table, naming `Selection` as the
|
||||
example and dating the fix.
|
||||
|
||||
Verification run alongside the rest of this pass's checks: `cargo fmt
|
||||
--all`, `cargo clippy --workspace --all-targets`, `cargo test --workspace`
|
||||
from `iris/` — see docs/RUST.md's plan box for the pass/fail and any
|
||||
caveats from this same session.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Compose bench report from Iris's phone, 2026-09-06
|
||||
|
||||
The Compose half of P0 (RUST.md), run by Iris on her own phone and pasted
|
||||
back verbatim. The iris half's report goes beside it in this directory
|
||||
when it exists. Her caveat, worth keeping with the numbers: "I don't think
|
||||
this is entirely fair because the UI for iris is more minimal" -- the
|
||||
Compose screen also draws the usage bar, the status row and tool cards,
|
||||
which the iris bench screen does not yet. Her impression of the iris build
|
||||
before its first-touch bug: "it already feels very smooth so far".
|
||||
|
||||
What to read first: the phone runs at 120 Hz, so the budget is 8.3 ms;
|
||||
`late` is measured against that. Compose's tail is the streaming phase --
|
||||
`markdown reparsed while streaming: 396, 8.5ms mean, 25.8ms worst` and
|
||||
`record: one block: 398, 6.3ms mean, 19.9ms worst` -- which is exactly the
|
||||
path iris's `TranscriptScreen::apply` (replace the last row only) is meant
|
||||
to beat. Process CPU over the run is 20.9 s of a 38.5 s run; peak RSS
|
||||
587 MB; battery current mean 419 mA.
|
||||
|
||||
```
|
||||
ai-app render report
|
||||
device: Pixel 9 Pro XL (Google), Android 17
|
||||
build: release
|
||||
|
||||
transcript:
|
||||
43 events, 41 rows, 93 units loaded
|
||||
viewport 1333px, 2 units visible
|
||||
on screen: the list's own 0px, AssistantMsg 24520px
|
||||
0 tool calls and 0 groups open
|
||||
|
||||
frames:
|
||||
1613 frames over 38.5s at 120Hz (8.3ms budget)
|
||||
late: 742 (46.0%)
|
||||
total p50 7.7ms p90 29.2ms p99 41.1ms
|
||||
waited p50 0.5ms p90 12.9ms p99 27.2ms
|
||||
input p50 0.0ms p90 0.0ms p99 0.0ms
|
||||
anim p50 1.1ms p90 5.8ms p99 9.5ms
|
||||
layout p50 0.0ms p90 0.1ms p99 0.2ms
|
||||
draw p50 0.4ms p90 15.9ms p99 27.9ms
|
||||
sync p50 0.1ms p90 0.5ms p99 1.0ms
|
||||
issue p50 1.1ms p90 1.7ms p99 3.0ms
|
||||
swap p50 0.4ms p90 0.5ms p99 0.7ms
|
||||
gpu p50 1.8ms p90 2.1ms p99 6.6ms
|
||||
|
||||
where the draw phase went:
|
||||
draw phase 3.83ms per frame, of which:
|
||||
the transcript: 0.25ms (measure 0.15, place 0.10, record 0.00)
|
||||
everything else: 3.58ms (93%)
|
||||
|
||||
work since this was last copied:
|
||||
draw: the whole transcript: 12, 0.2ms total, 0.0ms mean, 0.0ms worst
|
||||
grouped tool runs: 398, 10.3ms total, 0.0ms mean, 0.1ms worst
|
||||
markdown cut into pieces: 1, 0.0ms total, 0.0ms mean, 0.0ms worst
|
||||
markdown parsed while composing: 7, 3.0ms total, 0.4ms mean, 0.5ms worst
|
||||
markdown ready: 46
|
||||
markdown reparsed while streaming: 396, 3384.6ms total, 8.5ms mean, 25.8ms worst
|
||||
markdown warmed: 1, 1.4ms total, 1.4ms mean, 1.4ms worst
|
||||
measure: the whole transcript: 957, 248.7ms total, 0.3ms mean, 15.7ms worst
|
||||
message composed: 403
|
||||
message cut into parts: 1, 0.1ms total, 0.1ms mean, 0.1ms worst
|
||||
place: the whole transcript: 1319, 162.4ms total, 0.1ms mean, 1.3ms worst
|
||||
record: one block: 398, 2498.7ms total, 6.3ms mean, 19.9ms worst
|
||||
session screen recomposed: 413
|
||||
status row recomposed: 1
|
||||
unit composed: 538
|
||||
units flattened: 399, 44.3ms total, 0.1ms mean, 0.5ms worst
|
||||
usage bar recomposed: 413
|
||||
|
||||
bench:
|
||||
scroll: 6 cycles (24 swipes), streamed 400/400 fixture events
|
||||
process CPU time over this run: 20907ms
|
||||
peak RSS: 587356kB
|
||||
battery current: mean -418509µA over 39 samples (min -1988281, max -107812)
|
||||
```
|
||||
@@ -0,0 +1,93 @@
|
||||
# Compose bench v2 report from Iris's phone, 2026-09-06
|
||||
|
||||
Bench v2 (fling / stream / type / keyboard, RUST.md's P0 box) on the
|
||||
Compose `bench` build, run by Iris on her Pixel 9 Pro XL, verbatim. Note
|
||||
the display was at **60 Hz** for this run (16.7 ms budget) where the v1
|
||||
run was at 120 Hz -- the phone's adaptive refresh rate decides, and
|
||||
`late` is judged against whichever it was, so compare a run with a run at
|
||||
the same rate. The iris v2 report goes beside this when it exists.
|
||||
|
||||
What it says: fling, type and keyboard are all essentially clean on
|
||||
Compose (0.1%, 0.9% and 0% late; fling p50 5.5 ms, p99 11.6 ms). The
|
||||
whole tail is the streaming phase again -- 41.9% late, p99 42.5 ms,
|
||||
driven by `markdown reparsed while streaming` (8.6 ms mean, 30.3 ms
|
||||
worst) and `record: one block` (6.3 ms mean, 25.5 ms worst). Process CPU
|
||||
69.6 s over the 125.5 s run; peak RSS 577 MB; battery current mean
|
||||
571 mA over 126 samples.
|
||||
|
||||
```
|
||||
ai-app render report
|
||||
device: Pixel 9 Pro XL (Google), Android 17
|
||||
build: release
|
||||
|
||||
transcript:
|
||||
108 events, 26 rows, 58 units loaded
|
||||
viewport 1531px, 2 units visible
|
||||
on screen: the list's own 0px, AssistantMsg 24520px
|
||||
0 tool calls and 0 groups open
|
||||
|
||||
per phase:
|
||||
fling: 3278 frames over 32.7s
|
||||
late: 4 (0.1%)
|
||||
total p50 5.5ms p90 8.7ms p99 11.6ms
|
||||
worst 49.0ms
|
||||
stream: 1041 frames over 21.3s
|
||||
late: 436 (41.9%)
|
||||
total p50 13.4ms p90 31.7ms p99 42.5ms
|
||||
worst 52.5ms
|
||||
type: 2446 frames over 61.5s
|
||||
late: 23 (0.9%)
|
||||
total p50 7.3ms p90 13.2ms p99 16.5ms
|
||||
worst 38.9ms
|
||||
keyboard: 358 frames over 10.0s
|
||||
late: 0 (0.0%)
|
||||
total p50 6.3ms p90 8.6ms p99 11.1ms
|
||||
worst 12.0ms
|
||||
|
||||
frames:
|
||||
7122 frames over 125.5s at 60Hz (16.7ms budget)
|
||||
late: 463 (6.5%)
|
||||
total p50 6.0ms p90 13.8ms p99 34.0ms
|
||||
waited p50 0.5ms p90 1.1ms p99 19.9ms
|
||||
input p50 0.0ms p90 0.0ms p99 0.0ms
|
||||
anim p50 0.7ms p90 4.5ms p99 7.6ms
|
||||
layout p50 0.1ms p90 0.1ms p99 0.2ms
|
||||
draw p50 0.7ms p90 2.9ms p99 21.9ms
|
||||
sync p50 0.1ms p90 0.2ms p99 0.6ms
|
||||
issue p50 1.4ms p90 2.4ms p99 3.2ms
|
||||
swap p50 0.4ms p90 0.8ms p99 1.2ms
|
||||
gpu p50 1.5ms p90 2.1ms p99 6.6ms
|
||||
|
||||
where the draw phase went:
|
||||
draw phase 1.74ms per frame, of which:
|
||||
the transcript: 0.24ms (measure 0.10, place 0.14, record 0.00)
|
||||
everything else: 1.51ms (86%)
|
||||
|
||||
work since this was last copied:
|
||||
draw: the whole transcript: 280, 1.9ms total, 0.0ms mean, 0.0ms worst
|
||||
grouped tool runs: 407, 18.6ms total, 0.0ms mean, 0.2ms worst
|
||||
markdown cut into pieces: 40, 0.4ms total, 0.0ms mean, 0.0ms worst
|
||||
markdown parsed while composing: 2, 1.6ms total, 0.8ms mean, 1.1ms worst
|
||||
markdown ready: 323
|
||||
markdown reparsed while streaming: 395, 3406.9ms total, 8.6ms mean, 30.3ms worst
|
||||
markdown warmed: 40, 38.2ms total, 1.0ms mean, 4.3ms worst
|
||||
measure: the whole transcript: 1978, 683.9ms total, 0.3ms mean, 15.9ms worst
|
||||
message composed: 397
|
||||
message cut into parts: 40, 2.9ms total, 0.1ms mean, 0.2ms worst
|
||||
place: the whole transcript: 4308, 996.0ms total, 0.2ms mean, 2.4ms worst
|
||||
record: one block: 394, 2472.7ms total, 6.3ms mean, 25.5ms worst
|
||||
session screen recomposed: 1630
|
||||
status row recomposed: 1
|
||||
transcript page from server: 10
|
||||
unit composed: 927
|
||||
units flattened: 408, 85.5ms total, 0.2ms mean, 1.8ms worst
|
||||
|
||||
bench:
|
||||
fling: 8 flings out + 8 back at 12000px/s, travel start=idx=0/off=0px outward=idx=188/off=182px end=idx=0/off=0px
|
||||
scroll: 6 cycles (24 swipes, legacy tween), streamed 400/400 fixture events
|
||||
type: 600 characters inserted then deleted, one per 50ms
|
||||
keyboard: shown 5/5, hidden 5/5 (confirmed via isImeVisible)
|
||||
process CPU time over this run: 69564ms
|
||||
peak RSS: 577452kB
|
||||
battery current: mean -571483µA over 126 samples (min -2361718, max -99218)
|
||||
```
|
||||
@@ -0,0 +1,31 @@
|
||||
# iris bench report from Iris's phone, 2026-09-06, before the phone fixes
|
||||
|
||||
Build 46246ea (Vulkan, bench v1: 24-swipe scroll loop then 400 streamed
|
||||
events), run by Iris on her Pixel 9 Pro XL before the first-touch wipe,
|
||||
the missing bold faces, the density scale and the status-bar inset were
|
||||
fixed -- so the rows were drawn at roughly a third of their intended size
|
||||
and the run may have included frames after the wipe. Preliminary, kept
|
||||
because it is the first iris number from real hardware. Compare with
|
||||
`compose-phone-2026-09-06.md`, taken on the same phone with the same
|
||||
fixture and gesture loop.
|
||||
|
||||
Reading it: the phone is 120 Hz (8.3 ms budget). `janky%` here counts
|
||||
frames over 16.7 ms, so it is not Compose's `late` (over 8.3 ms). Like for
|
||||
like: iris p50 6.2 ms vs Compose 7.7 ms; p90 32.0 vs 29.2; p99 42.1 vs
|
||||
41.1. `cpu_p50=4.7ms` is iris's own per-frame CPU work on the phone,
|
||||
against 0.2-0.4 ms on the emulator's x86 cores. Process CPU 15.6 s vs
|
||||
20.9 s, but over a shorter run (692 frames vs 1613 -- iris only renders on
|
||||
change and had no fling settle time), so per-second CPU is not directly
|
||||
comparable; peak RSS 365 MB vs 587 MB. Battery current mean 563 mA vs
|
||||
419 mA is the one figure that reads worse, and it is the least
|
||||
comparable: 22 samples vs 39, over runs of different length and different
|
||||
idle share. Bench v2's per-phase accounting is what makes these comparable.
|
||||
|
||||
```
|
||||
iris bench report
|
||||
frames=692 janky%=32.37 p50=6.2ms p90=32.0ms p99=42.1ms worst=52.6ms (measures redraw-start to after present() is called, not GPU/compositor completion) cpu_p50=4.7ms gpu_wait_p50=1.3ms (redraw-start-to-submit vs. submit-to-after-present)
|
||||
scroll: 6 cycles (24 swipes), streamed 400/400 fixture events
|
||||
process CPU time over this run: 15554ms
|
||||
peak RSS: 365328kB
|
||||
battery current: mean -563493µA over 22 samples (min -1807812, max -132812)
|
||||
```
|
||||
@@ -0,0 +1,66 @@
|
||||
# iris bench v2 report from Iris's phone, 2026-09-06
|
||||
|
||||
Build 2e3f4ad (bench v2, fling physics, keyboard-wipe fix, dp unit), run
|
||||
by Iris on her Pixel 9 Pro XL, verbatim. The display was at **120 Hz**
|
||||
(8.3 ms budget) where `compose-phone-v2-2026-09-06.md` ran at 60 Hz, so
|
||||
compare the millisecond percentiles, not `late`.
|
||||
|
||||
Side by side (Compose 60 Hz / iris 120 Hz, p50 / p90 / p99 ms): fling
|
||||
5.5/8.7/11.6 vs 3.8/6.9/12.6; stream 13.4/31.7/42.5 vs 18.2/35.8/43.1;
|
||||
type 7.3/13.2/16.5 vs 7.2/9.2/11.2; keyboard: iris could not show the IME
|
||||
(phase invalid). Process CPU 69.6 s over 125 s vs 40.6 s over 150 s; peak
|
||||
RSS 577 MB vs 379 MB; battery current mean 571 mA vs 452 mA.
|
||||
|
||||
Iris's observations on the same run: "the scrolling is not similar at
|
||||
all. It does not fling for me yet [with a finger], and the test also seems
|
||||
to give it a constant velocity and abruptly stop it at some point. Also
|
||||
unsure what's going on in that image with the compaction" -- her
|
||||
screenshot shows the `Compacted: 180000 -> 20000 tokens.` row drawn twice
|
||||
overlapping, and once more below the composer bar: primitives of a
|
||||
replaced/removed row surviving in the GPU buffers, the same shape as the
|
||||
header drawn twice after a keyboard resize.
|
||||
|
||||
**Root-caused and fixed 2026-09-06** (commit `76b1f99`): the diagnosis in
|
||||
that sentence was right and the location was not -- `UiRenderState::
|
||||
draw_inner` read the `needs_redraw` mark without consuming it and skipped
|
||||
the branch that frees a redrawn widget's old primitives. docs/RUST.md's
|
||||
"Stale primitives, the phone's half" box has the full account, the guard
|
||||
(`orphaned_primitives`, `debug_assert`ed every frame) and the emulator run
|
||||
that exercises it.
|
||||
|
||||
```
|
||||
iris bench report
|
||||
per phase:
|
||||
fling: 1783 frames over 53.2s
|
||||
late: 104 (5.8%)
|
||||
total p50 3.8ms p90 6.9ms p99 12.6ms
|
||||
worst 29.1ms
|
||||
stream: 401 frames over 21.3s
|
||||
late: 306 (76.3%)
|
||||
total p50 18.2ms p90 35.8ms p99 43.1ms
|
||||
worst 43.8ms
|
||||
type: 1202 frames over 65.7s
|
||||
late: 309 (25.7%)
|
||||
total p50 7.2ms p90 9.2ms p99 11.2ms
|
||||
worst 15.3ms
|
||||
keyboard: 9 frames over 9.7s
|
||||
late: 9 (100.0%)
|
||||
total p50 12.0ms p90 12.9ms p99 12.9ms
|
||||
worst 12.9ms
|
||||
|
||||
frames:
|
||||
3395 frames over 149.9s at 120Hz (8.3ms budget)
|
||||
late: 728 (21.4%)
|
||||
total p50 5.0ms p90 10.9ms p99 36.6ms
|
||||
worst 43.8ms
|
||||
cpu_p50 2.0ms gpu_wait_p50 2.6ms
|
||||
|
||||
bench:
|
||||
fling: 8 flings out + 8 back at 12000px/s, travel start=idx=651/off=1217px outward=idx=651/off=101536px end=idx=651/off=1022px
|
||||
scroll: 6 cycles (24 swipes, legacy tween), streamed 400/400 fixture events
|
||||
type: 600 characters inserted then deleted, one per 50ms
|
||||
keyboard: could not be shown (5 attempts, 0 confirmed visible)
|
||||
process CPU time over this run: 40603ms
|
||||
peak RSS: 379156kB
|
||||
battery current: mean -452353µA over 149 samples (min -1753125, max -204687)
|
||||
```
|
||||
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 114 KiB |
@@ -150,6 +150,19 @@ pub enum Event {
|
||||
ToolEnd {
|
||||
id: String,
|
||||
output: String,
|
||||
/// Whether the tool reported that the call *failed*, from the
|
||||
/// CLI's own `is_error` on the `tool_result`.
|
||||
///
|
||||
/// Added 2026-09-06 with the tool-call cards (RUST.md's P1b),
|
||||
/// because without it a result is the only thing a card has and a
|
||||
/// failed call is drawn as confidently as a successful one -- the
|
||||
/// missing state, not a wrong one. `#[serde(default)]` so a
|
||||
/// transcript written before this field, or a peer on an older
|
||||
/// build, reads back as "not reported to have failed" rather than
|
||||
/// failing to parse; that is the same claim the field's absence
|
||||
/// used to make implicitly.
|
||||
#[serde(default)]
|
||||
is_error: bool,
|
||||
},
|
||||
/// An image the session produced or was sent, saved under the session
|
||||
/// dir and referenced by id; the phone fetches it by URL.
|
||||
@@ -305,6 +318,25 @@ pub enum Event {
|
||||
/// it, which is why this is written down rather than left to be inferred
|
||||
/// from a second example that does not exist.
|
||||
Cleared,
|
||||
/// The account behind this session has no quota left, so the turn stopped
|
||||
/// without finishing.
|
||||
///
|
||||
/// Its own event rather than an [`Event::Error`] carrying the dialect's
|
||||
/// sentence, because two things act on it that cannot read English: the
|
||||
/// transcript draws it as a state the session is in rather than as a
|
||||
/// failure of something it did, and `crate::resume` schedules the message
|
||||
/// that picks the work back up. Recognising it belongs to the driver, which
|
||||
/// is the only layer that knows its dialect's wording -- above here nothing
|
||||
/// matches on strings.
|
||||
///
|
||||
/// `resets_at` is epoch seconds, and `None` is a real state: the dialect
|
||||
/// said the limit was hit without saying when it lifts. Nothing here
|
||||
/// invents one -- what the wait is actually decided against is the usage
|
||||
/// endpoint, and this is the hint that starts the waiting.
|
||||
LimitReached {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
resets_at: Option<f64>,
|
||||
},
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
|
||||
@@ -721,6 +721,7 @@ name = "client-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"event-model",
|
||||
"pulldown-cmark",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"ureq",
|
||||
@@ -1757,6 +1758,7 @@ dependencies = [
|
||||
"fxhash",
|
||||
"image",
|
||||
"parley",
|
||||
"pollster",
|
||||
"swash",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
@@ -15,6 +15,11 @@ wgpu = { workspace = true }
|
||||
image = { workspace = true }
|
||||
accesskit = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] }
|
||||
# For diagnostics visible through android_logger (or whatever logger the
|
||||
# app crate installs) -- this crate never installs one itself. Not in the
|
||||
# android-only block below any more: the lines that matter most are in
|
||||
# shared widget code, which the host backend compiles too.
|
||||
log = "0.4.28"
|
||||
|
||||
# winit everywhere except Android; android-view (below) is what stands in
|
||||
# for it there. Both backends live in this crate (see `src/android/mod.rs`'s
|
||||
@@ -53,9 +58,6 @@ accesskit_android = "0.8.0"
|
||||
# for `android/insets.rs`'s own id -> state map -- the same reason
|
||||
# android-view's own `PEER_MAP` carries one.
|
||||
send_wrapper = "0.6.0"
|
||||
# For diagnostics visible through android_logger, wherever the app crate
|
||||
# installs it -- this crate never installs a logger itself.
|
||||
log = "0.4.28"
|
||||
|
||||
[features]
|
||||
# RUST.md's I5 "Where iris's frame time goes" diagnosis: forces the Android
|
||||
@@ -64,7 +66,11 @@ log = "0.4.28"
|
||||
# default) or virgl's GLES path, without a second env-var plumbing path that
|
||||
# nothing on this machine can hand to an already-launched Android process
|
||||
# (there is no `am start` environment and no system-property reader here to
|
||||
# add one). Android-only; `android/render.rs` is the only reader.
|
||||
# add one). Read by `android/render.rs` and, so the GLES path can be
|
||||
# reproduced on a machine with a real GPU rather than only in the emulator,
|
||||
# by `default/render.rs`:
|
||||
# ./run-headless.sh transcript --shot /tmp/x.png -- -p transcript-ui \
|
||||
# --features iris/force-gles
|
||||
force-gles = []
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -745,6 +745,7 @@ name = "client-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"event-model",
|
||||
"pulldown-cmark",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"ureq",
|
||||
@@ -1785,6 +1786,7 @@ dependencies = [
|
||||
"fxhash",
|
||||
"image",
|
||||
"parley",
|
||||
"pollster",
|
||||
"swash",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
package dev.iris.android.demo;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.ScrollView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.linebender.android.rustview.RustView;
|
||||
|
||||
@@ -33,4 +44,112 @@ public final class IrisView extends RustView {
|
||||
unregisterInsetsNative(mViewPeer);
|
||||
super.onDetachedFromWindow();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the Rust side (iris/src/android/view.rs's
|
||||
* `show_renderer_error`) when `AndroidRenderer::new` fails instead of
|
||||
* drawing -- an ordinary instance method rather than a `native` one,
|
||||
* since this call is Rust reaching into Java rather than the other
|
||||
* direction. Replaces the whole activity content with plain,
|
||||
* selectable, scrollable text rather than leaving the last frame (or a
|
||||
* blank surface) on screen with no way to report what happened:
|
||||
* UI_RULES.md's "a failure is reported where it happened, and says
|
||||
* what to do next." No dialog and no styling beyond what is needed to
|
||||
* read and copy the text -- this path exists for exactly the crash it
|
||||
* replaces, so it must not depend on anything that could itself fail
|
||||
* to render.
|
||||
*/
|
||||
void showRendererError(String report) {
|
||||
Context context = getContext();
|
||||
if (!(context instanceof Activity)) {
|
||||
return;
|
||||
}
|
||||
Activity activity = (Activity) context;
|
||||
TextView text = new TextView(activity);
|
||||
text.setText(report);
|
||||
text.setTextIsSelectable(true);
|
||||
text.setGravity(Gravity.TOP | Gravity.START);
|
||||
int pad = (int) (16 * activity.getResources().getDisplayMetrics().density);
|
||||
text.setPadding(pad, pad, pad, pad);
|
||||
ScrollView scroll = new ScrollView(activity);
|
||||
scroll.addView(text);
|
||||
activity.setContentView(scroll);
|
||||
}
|
||||
|
||||
private static final String DIAGNOSTICS_OVERLAY_TAG = "iris-diagnostics-overlay";
|
||||
|
||||
/**
|
||||
* The bench build's keyboard diagnostics capture
|
||||
* (`bench_client.rs`'s `on_insets_changed` /
|
||||
* `capture_keyboard_diagnostics`, via `bench_jni.rs`'s
|
||||
* `PlatformHandle::show_diagnostics_overlay`): unlike
|
||||
* `showRendererError` above, this adds a panel *over* this view
|
||||
* (`MainActivity`'s `FrameLayout` still holds `IrisView` underneath,
|
||||
* running) rather than replacing the activity's content, and gives it
|
||||
* a Copy button and a Close that removes the panel -- so it draws
|
||||
* (and can be read) whether or not iris itself is still putting
|
||||
* anything on screen, without abandoning the session that produced
|
||||
* it. Runs on the UI thread regardless of which thread calls it,
|
||||
* since the call comes from a background task (a delayed capture
|
||||
* after the keyboard opens), and touching the view tree off the UI
|
||||
* thread is undefined.
|
||||
*/
|
||||
void showDiagnosticsOverlay(String report) {
|
||||
Context context = getContext();
|
||||
if (!(context instanceof Activity)) {
|
||||
return;
|
||||
}
|
||||
Activity activity = (Activity) context;
|
||||
activity.runOnUiThread(() -> {
|
||||
ViewGroup parent = (ViewGroup) getParent();
|
||||
if (parent == null) {
|
||||
return;
|
||||
}
|
||||
View existing = parent.findViewWithTag(DIAGNOSTICS_OVERLAY_TAG);
|
||||
if (existing != null) {
|
||||
parent.removeView(existing);
|
||||
}
|
||||
|
||||
float density = activity.getResources().getDisplayMetrics().density;
|
||||
int pad = (int) (16 * density);
|
||||
|
||||
LinearLayout overlay = new LinearLayout(activity);
|
||||
overlay.setTag(DIAGNOSTICS_OVERLAY_TAG);
|
||||
overlay.setOrientation(LinearLayout.VERTICAL);
|
||||
overlay.setBackgroundColor(0xEE000000);
|
||||
overlay.setPadding(pad, pad, pad, pad);
|
||||
|
||||
TextView text = new TextView(activity);
|
||||
text.setText(report);
|
||||
text.setTextIsSelectable(true);
|
||||
text.setTextColor(0xFFFFFFFF);
|
||||
ScrollView scroll = new ScrollView(activity);
|
||||
scroll.addView(text);
|
||||
overlay.addView(scroll, new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f));
|
||||
|
||||
LinearLayout buttonRow = new LinearLayout(activity);
|
||||
buttonRow.setOrientation(LinearLayout.HORIZONTAL);
|
||||
buttonRow.setPadding(0, pad, 0, 0);
|
||||
|
||||
Button copy = new Button(activity);
|
||||
copy.setText("Copy");
|
||||
copy.setOnClickListener(v -> {
|
||||
ClipboardManager clipboard =
|
||||
(ClipboardManager) activity.getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
if (clipboard != null) {
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("iris diagnostics", report));
|
||||
}
|
||||
});
|
||||
Button close = new Button(activity);
|
||||
close.setText("Close");
|
||||
close.setOnClickListener(v -> parent.removeView(overlay));
|
||||
buttonRow.addView(copy);
|
||||
buttonRow.addView(close);
|
||||
overlay.addView(buttonRow);
|
||||
|
||||
parent.addView(overlay, new FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -31,14 +31,52 @@ public final class MainActivity extends Activity {
|
||||
setContentView(layout);
|
||||
view.requestFocus();
|
||||
|
||||
// RUST.md's P0 box, defect 4 ("keyboard: could not be shown"):
|
||||
// `logcat` showed the platform's own IME open/resize happening
|
||||
// while `setOnApplyWindowInsetsListener` fired only once, at
|
||||
// attach, and never again for a pure keyboard toggle -- a plain
|
||||
// (non-edge-to-edge) window is only guaranteed that one initial
|
||||
// dispatch; `adjustResize` handling the IME entirely by resizing
|
||||
// the window is not itself a trigger for a fresh one. Opting into
|
||||
// edge-to-edge (a platform call, API 30+, no new dependency) is
|
||||
// what makes the system redeliver insets on every change,
|
||||
// including the ones this activity actually cares about --
|
||||
// `getSystemWindowInset*` below is unaffected by this (it has
|
||||
// always reported the raw system-bar/IME overlap regardless of
|
||||
// who consumes it), so the on-screen bars and the padding Rust
|
||||
// already derives from those four numbers are unchanged; only the
|
||||
// callback's firing became reliable.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
getWindow().setDecorFitsSystemWindows(false);
|
||||
}
|
||||
|
||||
view.setOnApplyWindowInsetsListener((v, insets) -> {
|
||||
int left = insets.getSystemWindowInsetLeft();
|
||||
int top = insets.getSystemWindowInsetTop();
|
||||
int right = insets.getSystemWindowInsetRight();
|
||||
int bottom = insets.getSystemWindowInsetBottom();
|
||||
// The manifest declares adjustResize (AGENTS.md: without it the
|
||||
// keyboard pans the whole window instead of resizing it), and
|
||||
// under adjustResize the window itself shrinks to make room for
|
||||
// the keyboard -- which is exactly the condition under which
|
||||
// WindowInsets.Type.ime()'s own *inset amount* reports zero: it
|
||||
// measures how much of the window the keyboard overlaps, and
|
||||
// resize already made that overlap zero by construction. That
|
||||
// numeric inset is not a usable "is the keyboard open" signal
|
||||
// here (found while root-causing why bench_client.rs's keyboard
|
||||
// phase and auto-diagnostics never fired on the emulator despite
|
||||
// the keyboard visibly opening -- RUST.md's P0 box). What does
|
||||
// survive adjustResize is the boolean isVisible() answer, set
|
||||
// from the platform's own start/end of the transition over a
|
||||
// different path than the inset amount -- the same fact
|
||||
// AGENTS.md's "Things that have bitten" already names for the
|
||||
// Compose side's identical trap. Passed through as a 0/1 stand-
|
||||
// in for the ime_bottom pixel amount, since nothing on the Rust
|
||||
// side reads it as a real pixel value -- only `> 0.0`.
|
||||
int imeBottom = 0;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
|
||||
&& insets.isVisible(WindowInsets.Type.ime())) {
|
||||
imeBottom = 1;
|
||||
}
|
||||
((IrisView) v).applyWindowInsets(left, top, right, bottom, imeBottom);
|
||||
return insets;
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/bin/sh
|
||||
# Builds iris-android-app end to end: the cdylib (cargo ndk, straight into
|
||||
# app/src/main/jniLibs/) then the APK (Gradle). Written to stop re-typing
|
||||
# the same incantation by hand every time (ANDROID_HOME/NDK exports, the
|
||||
# cargo ndk invocation, the keystore env for a release build, apksigner/
|
||||
# aapt2 verification) -- see docs/RUST.md's P0 box. Same shape as `app/
|
||||
# build-apk.sh` (the Compose app's own build script) and `app/
|
||||
# iris-scroll.sh` (no coordinates, set -eu, exit 0 on success).
|
||||
#
|
||||
# Usage: ./build-apk.sh [debug|release] [--abi arm64-v8a|x86_64] [--features "a b c"]
|
||||
# debug/release default to debug (matches this-machine-android's "the
|
||||
# emulator stays on debug" rule -- pass `release` explicitly for a phone
|
||||
# build). --abi defaults to arm64-v8a (a phone/real device); pass
|
||||
# x86_64 for this checkout's own AVD. --features defaults to
|
||||
# "transcript-screen bench" -- deliberately *without* `force-gles`, unlike
|
||||
# an earlier version of this default. `force-gles` (`iris/Cargo.toml`'s
|
||||
# own doc) exists only to force the emulator off its default software
|
||||
# Vulkan and onto GLES for one specific measurement (RUST.md's I5, "Where
|
||||
# iris's frame time goes") -- it was never meant to reach a real device,
|
||||
# but this script's old default put it in every arm64 build regardless,
|
||||
# so the P0 bench APK delivered to Iris's phone forced GLES there too.
|
||||
# That is the named hypothesis in RUST.md's P0 box ("iris bench crash on
|
||||
# the phone, 2026-09-06"): a real Vulkan driver is what a phone should
|
||||
# run, and GLES is the backend the same box's own SwiftShader finding
|
||||
# already flagged as the fragile one for this shader's storage buffers.
|
||||
# Pass `--features "transcript-screen force-gles bench"` explicitly for
|
||||
# an emulator backend-isolation run; never for a build meant for a phone.
|
||||
set -eu
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
BUILD_TYPE="debug"
|
||||
ABI="arm64-v8a"
|
||||
FEATURES="transcript-screen bench"
|
||||
case "${1:-}" in
|
||||
debug|release) BUILD_TYPE="$1"; shift ;;
|
||||
esac
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--abi) ABI="$2"; shift 2 ;;
|
||||
--features) FEATURES="$2"; shift 2 ;;
|
||||
*) echo "build-apk.sh: unknown argument: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
SDK_ROOT="$HOME/Android/Sdk"
|
||||
export ANDROID_HOME="$SDK_ROOT"
|
||||
export ANDROID_SDK_ROOT="$SDK_ROOT"
|
||||
NDK_DIR=$(ls -d "$SDK_ROOT"/ndk/*/ 2>/dev/null | sort -V | tail -1)
|
||||
if [ -z "$NDK_DIR" ]; then
|
||||
echo "build-apk.sh: no NDK found under $SDK_ROOT/ndk" >&2
|
||||
exit 1
|
||||
fi
|
||||
export ANDROID_NDK_HOME="$NDK_DIR"
|
||||
|
||||
# Only the ABI asked for goes into the APK. cargo ndk adds its output beside
|
||||
# whatever earlier builds left here, and Gradle packages every directory it
|
||||
# finds -- a debug x86_64 emulator build left behind made an arm64 "release"
|
||||
# 339 MB on 2026-09-06.
|
||||
rm -rf app/src/main/jniLibs
|
||||
echo "build-apk.sh: cargo ndk -t $ABI build ${BUILD_TYPE:+(${BUILD_TYPE})} --features \"$FEATURES\""
|
||||
if [ "$BUILD_TYPE" = "release" ]; then
|
||||
cargo ndk -t "$ABI" -P 26 -o app/src/main/jniLibs/ build --release --features "$FEATURES"
|
||||
else
|
||||
cargo ndk -t "$ABI" -P 26 -o app/src/main/jniLibs/ build --features "$FEATURES"
|
||||
fi
|
||||
|
||||
GRADLE_TASK="assembleDebug"
|
||||
APK_DIR="app/build/outputs/apk/debug"
|
||||
APK_NAME="app-debug.apk"
|
||||
if [ "$BUILD_TYPE" = "release" ]; then
|
||||
GRADLE_TASK="assembleRelease"
|
||||
APK_DIR="app/build/outputs/apk/release"
|
||||
APK_NAME="app-release.apk"
|
||||
# Same key `app/build-apk.sh` (the Compose app) generates once under
|
||||
# ~/.config/ai-app/release.jks -- see AGENTS.md's "Checking your work".
|
||||
export AI_APP_KEYSTORE="$HOME/.config/ai-app/release.jks"
|
||||
if [ ! -f "$AI_APP_KEYSTORE" ]; then
|
||||
echo "build-apk.sh: no release key at $AI_APP_KEYSTORE -- run app/build-apk.sh once first" >&2
|
||||
exit 1
|
||||
fi
|
||||
export AI_APP_KEYSTORE_PASSWORD
|
||||
AI_APP_KEYSTORE_PASSWORD=$(cat "$AI_APP_KEYSTORE.password")
|
||||
fi
|
||||
|
||||
gradle ":app:$GRADLE_TASK" --console=plain
|
||||
|
||||
APK_PATH="$(pwd)/$APK_DIR/$APK_NAME"
|
||||
BUILD_TOOLS=$(ls -d "$SDK_ROOT"/build-tools/*/ | sort -V | tail -1)
|
||||
echo "--- aapt2 dump badging ---"
|
||||
"${BUILD_TOOLS}aapt2" dump badging "$APK_PATH" | head -5
|
||||
if [ "$BUILD_TYPE" = "release" ]; then
|
||||
echo "--- apksigner verify ---"
|
||||
"${BUILD_TOOLS}apksigner" verify --print-certs "$APK_PATH"
|
||||
fi
|
||||
echo "$APK_PATH"
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/bin/sh
|
||||
# Installs and runs the iris `bench` build on this checkout's own emulator
|
||||
# (per this-machine-android's per-checkout-AVD rule; `emu serial` picks it)
|
||||
# and prints the report -- the iris half of `app/transcript-bench.sh`'s
|
||||
# job. No coordinates: the button is found by its accessibility label
|
||||
# through `ui-trace`, per AGENTS.md's "Driving the UI".
|
||||
#
|
||||
# Usage: ./run-bench.sh [--apk PATH]
|
||||
# Defaults to this checkout's own release APK
|
||||
# (app/build/outputs/apk/release/app-release.apk) if it exists, else the
|
||||
# debug one -- build one first with ./build-apk.sh.
|
||||
set -eu
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
APK=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--apk) APK="$2"; shift 2 ;;
|
||||
*) echo "run-bench.sh: unknown argument: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
if [ -z "$APK" ]; then
|
||||
if [ -f app/build/outputs/apk/release/app-release.apk ]; then
|
||||
APK=app/build/outputs/apk/release/app-release.apk
|
||||
else
|
||||
APK=app/build/outputs/apk/debug/app-debug.apk
|
||||
fi
|
||||
fi
|
||||
if [ ! -f "$APK" ]; then
|
||||
echo "run-bench.sh: no APK at $APK -- run ./build-apk.sh first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERIAL=$(emu serial)
|
||||
PKG=$(aapt2 dump badging "$APK" 2>/dev/null | sed -n "s/^package: name='\\([^']*\\)'.*/\\1/p")
|
||||
if [ -z "$PKG" ]; then
|
||||
BUILD_TOOLS=$(ls -d "$HOME"/Android/Sdk/build-tools/*/ | sort -V | tail -1)
|
||||
PKG=$("${BUILD_TOOLS}aapt2" dump badging "$APK" | sed -n "s/^package: name='\\([^']*\\)'.*/\\1/p")
|
||||
fi
|
||||
|
||||
echo "run-bench.sh: installing $APK ($PKG) on $SERIAL"
|
||||
adb -s "$SERIAL" install -r "$APK" >/dev/null
|
||||
adb -s "$SERIAL" shell am force-stop "$PKG"
|
||||
adb -s "$SERIAL" logcat -c
|
||||
adb -s "$SERIAL" shell am start -n "$PKG/dev.iris.android.demo.MainActivity" >/dev/null
|
||||
|
||||
ui-trace record -s "$SERIAL" -d 3000 --do "tap 'Run benchmark'" -o /tmp/run-bench-tap.txt >/dev/null
|
||||
|
||||
# Poll for the report line rather than a fixed sleep -- the run itself is
|
||||
# a fixed script (RUST.md's "Benchmark v2": 16 flings, a 20s streaming
|
||||
# phase, ~61s of typing, 10s of keyboard toggles, roughly 2.5 minutes end
|
||||
# to end) but device speed varies. 260s cap rather than v1's 90s -- v2 is
|
||||
# a longer script than v1's swipe-loop-only run.
|
||||
# The report's own first line, not the bare "iris bench report:" prefix:
|
||||
# `copy_report` logs that prefix too ("nothing to copy -- run the benchmark
|
||||
# first", which the app emits at startup), so polling for the prefix
|
||||
# returned instantly and the script printed a report that was never run.
|
||||
REPORT_LINE="iris bench report: iris bench report"
|
||||
i=0
|
||||
while [ "$i" -lt 260 ]; do
|
||||
LINE=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null | grep "$REPORT_LINE" || true)
|
||||
if [ -n "$LINE" ]; then
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 1
|
||||
done
|
||||
if [ -z "$LINE" ]; then
|
||||
echo "run-bench.sh: no report after 260s -- check logcat by hand" >&2
|
||||
exit 1
|
||||
fi
|
||||
# -A 60 rather than v1's -A 6 -- v2's report has a per-phase block (four
|
||||
# phases, four lines each) on top of the frames/bench sections v1 had.
|
||||
adb -s "$SERIAL" logcat -d -s iris-android-app:I | grep -A 60 "$REPORT_LINE"
|
||||
@@ -4,17 +4,21 @@
|
||||
//! `transcript-ui`'s real screen with no server -- a frame-time comparison
|
||||
//! that measures the renderer rather than the data or the network.
|
||||
//!
|
||||
//! **Reuses `transcript_client.rs`'s shape** (folded items, a full
|
||||
//! `transcript_ui::build_tree` rebuild per event) with the network half
|
||||
//! replaced by the checked-in fixture, embedded with `include_str!` --
|
||||
//! `app/bench-fixture/assets/transcript.jsonl`, 1,915,760 bytes, generated
|
||||
//! by `app/bench-fixture/generate.py` and never a real transcript (that
|
||||
//! file's own README). The first 3,200 lines are the opening backlog,
|
||||
//! folded once through `client_core::transcript_fold::fold_page` exactly
|
||||
//! as a real `/transcript` page would be; the remaining ~400 are the
|
||||
//! streaming tail, replayed one at a time through `fold_event` -- the same
|
||||
//! fold path a live SSE reply arrives on -- by the "Run benchmark"
|
||||
//! control below.
|
||||
//! **Reuses `transcript_client.rs`'s shape** (folded items, the same
|
||||
//! `TranscriptScreen::apply` incremental update on every event) with the
|
||||
//! network half replaced by the checked-in fixture, embedded with
|
||||
//! `include_str!` -- `app/bench-fixture/assets/transcript.jsonl`,
|
||||
//! 1,915,760 bytes, generated by `app/bench-fixture/generate.py` and never
|
||||
//! a real transcript (that file's own README). The first 3,200 lines are
|
||||
//! the opening backlog, folded once through
|
||||
//! `client_core::transcript_fold::fold_page` exactly as a real
|
||||
//! `/transcript` page would be (then a full `transcript_ui::build_tree`,
|
||||
//! same as any first load); the remaining ~400 are the streaming tail,
|
||||
//! replayed one at a time through `fold_event` -- the same fold path a
|
||||
//! live SSE reply arrives on -- by the "Run benchmark" control below.
|
||||
//! Streaming through `apply` rather than a full rebuild per event is what
|
||||
//! this file exists to measure -- see docs/RUST.md's P0 box for the
|
||||
//! before/after report.
|
||||
|
||||
use crate::bench_jni::PlatformHandle;
|
||||
use android_view::jni::{JavaVM, objects::GlobalRef};
|
||||
@@ -22,9 +26,9 @@ use client_core::transcript_fold::{TranscriptItem, fold_event, fold_page, group_
|
||||
use event_model::SeqEvent;
|
||||
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
|
||||
use iris::prelude::*;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// bench-fixture/README.md: the first `BACKLOG_COUNT` non-blank lines are
|
||||
/// the opening window; the rest are the streaming tail. Kept in sync with
|
||||
@@ -33,27 +37,77 @@ use std::time::Duration;
|
||||
/// builds open a different split of it, not a wrong-vs-right answer.
|
||||
const BACKLOG_COUNT: usize = 3200;
|
||||
|
||||
/// `BenchRun.kt`'s own constants -- kept identical so the two apps' bench
|
||||
/// runs are the same gesture and the same load, which is the entire point
|
||||
/// of a shared fixture and a shared scripted loop (P0's pass condition).
|
||||
const CYCLES: usize = 6;
|
||||
const SWIPE_PX: f32 = 900.0;
|
||||
const SWIPE_MS: u64 = 200;
|
||||
const SWIPE_PAUSE_MS: u64 = 500;
|
||||
/// RUST.md's "Benchmark v2" spec, written once so both apps' bench clients
|
||||
/// implement the identical four phases -- see that box before changing any
|
||||
/// constant here, since a mismatch would make the two reports stop
|
||||
/// measuring the same thing while still looking like they do.
|
||||
const STREAM_EVENTS_PER_SEC: u64 = 20;
|
||||
const STREAM_SECONDS: u64 = 20;
|
||||
|
||||
/// Kept only so this phase's own label text still reads "scroll: 6 cycles
|
||||
/// (24 swipes, legacy tween)" the way `BenchRun.kt`'s v2 report does --
|
||||
/// `docs/bench/compose-phone-v2-2026-09-06.md`'s own report shows this
|
||||
/// exact line even though the swipe loop it names no longer runs there
|
||||
/// either (the fling phase replaced it); nothing here drives an actual
|
||||
/// swipe with these any more.
|
||||
const LEGACY_CYCLES: usize = 6;
|
||||
|
||||
/// Fling phase (v2): a real fling through `List::fling`, not a tween --
|
||||
/// Iris's ask was that it "travel way faster" than the v1 swipe, and a
|
||||
/// tween can never exceed the distance/time it is given while a real
|
||||
/// fling decays from an initial velocity the way a finger flick does.
|
||||
/// 12,000 px/s matches `BenchRun.kt`'s own constant exactly.
|
||||
const FLING_VELOCITY_PX_S: f32 = 12_000.0;
|
||||
const FLING_COUNT: usize = 8;
|
||||
const FLING_SETTLE_CAP_MS: u64 = 3_000;
|
||||
const FLING_PAUSE_MS: u64 = 300;
|
||||
|
||||
/// Type phase (v2): long, multisyllabic words so the composer actually
|
||||
/// wraps and the transcript above it is pushed upward, typed and deleted
|
||||
/// one character per `TYPE_CHAR_MS`. Exactly `BenchRun.TYPE_TEXT` --
|
||||
/// verified 600 characters by `type_text_is_exactly_600_characters` below.
|
||||
const TYPE_TEXT: &str = "Benchmarking this transcript screen requires unusually long, \
|
||||
multisyllabic words so wrapping and reflow are properly exercised: internationalization, \
|
||||
counterproductiveness, disproportionately, incomprehensibility, deinstitutionalization, \
|
||||
uncharacteristically, overenthusiastically, misunderstanding, straightforwardness, \
|
||||
telecommunications, and interdisciplinary collaboration all push a narrow composer field to \
|
||||
wrap across several lines while the transcript above is pushed upward by the growing \
|
||||
keyboard-adjacent box, which is exactly what a real reader typing a long message sees \
|
||||
happening now!!!";
|
||||
const TYPE_CHAR_MS: u64 = 50;
|
||||
|
||||
/// Keyboard phase (v2): five show/hide cycles, a second apart, matching
|
||||
/// `BenchRun.kt`'s `KEYBOARD_CYCLES`/`KEYBOARD_SHOW_WAIT_MS`/
|
||||
/// `KEYBOARD_HIDE_WAIT_MS`.
|
||||
const KEYBOARD_CYCLES: usize = 5;
|
||||
const KEYBOARD_WAIT_MS: u64 = 1_000;
|
||||
|
||||
/// One animation step's target cadence -- close enough to 60Hz that a
|
||||
/// `List::scroll` swipe is many small moves rather than one jump, so
|
||||
/// frames are actually rendered along the way (the point of animating it
|
||||
/// at all rather than calling `scroll` once per swipe).
|
||||
/// fling/scroll is many small moves rather than one jump, so frames are
|
||||
/// actually rendered along the way, and close enough that a `ctx.update`
|
||||
/// closure's effect (only applied once the next frame callback drains the
|
||||
/// task channel -- `IrisViewPeer::drain_tasks`) is visible again quickly
|
||||
/// when a later step in the same phase needs to read state back.
|
||||
const ANIM_STEP_MS: u64 = 16;
|
||||
|
||||
const FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl");
|
||||
|
||||
/// How much of the screen a *filled* benchmark report may take before it
|
||||
/// scrolls instead of growing -- roughly a third of a phone screen, the
|
||||
/// share the pane used to reserve unconditionally. An empty report takes
|
||||
/// nothing at all; see `new`'s comment at the tree it is used in.
|
||||
const REPORT_MAX_HEIGHT_DP: f32 = 260.0;
|
||||
|
||||
pub struct BenchClient {
|
||||
ui_state: AndroidUiState,
|
||||
content: WeakWidget<WidgetPtr>,
|
||||
report_display: WeakWidget<TextEdit>,
|
||||
/// The top button row, in a `WidgetPtr` slot rather than added
|
||||
/// directly (like `content`) so `on_insets_changed` can swap in a
|
||||
/// version padded for the status bar once insets are known -- RUST.md's
|
||||
/// P0 box, "the status-bar inset is not applied," found the row sitting
|
||||
/// directly under it because nothing here read `insets().top` at all.
|
||||
top_bar: WeakWidget<WidgetPtr>,
|
||||
screen: Option<transcript_ui::TranscriptScreen>,
|
||||
items: Vec<TranscriptItem>,
|
||||
/// The events not yet streamed -- consumed by `start_benchmark`'s own
|
||||
@@ -64,6 +118,35 @@ pub struct BenchClient {
|
||||
platform: Option<Arc<PlatformHandle>>,
|
||||
last_report: Option<String>,
|
||||
running: bool,
|
||||
/// The keyboard phase's own confirmation channel -- updated from
|
||||
/// `on_insets_changed` (the platform's own answer for whether the IME
|
||||
/// is actually visible, per `WindowInsets::ime_bottom`), read from the
|
||||
/// benchmark's spawned task via the shared `Arc<Mutex<_>>` rather than
|
||||
/// `ctx.update`, since neither side needs the widget tree for this.
|
||||
ime_state: Arc<Mutex<ImeState>>,
|
||||
/// Edge-triggers the keyboard diagnostics capture below -- set on the
|
||||
/// first `on_insets_changed` where `ime_bottom > 0.0`, cleared on the
|
||||
/// first where it is not, so opening the keyboard fires this once
|
||||
/// rather than on every insets update while it stays open (a rotation
|
||||
/// or a status-bar change with the keyboard already up would otherwise
|
||||
/// re-fire it).
|
||||
keyboard_was_visible: bool,
|
||||
/// The status-bar inset `top_bar` was last padded by -- see
|
||||
/// `on_insets_changed`'s own comment for why this guards the rebuild.
|
||||
last_top_pad: f32,
|
||||
}
|
||||
|
||||
/// See `BenchClient::ime_state`'s doc. `shown_events`/`hidden_events`
|
||||
/// count real 0->visible / visible->0 transitions `on_insets_changed`
|
||||
/// observed, not merely "a show/hide was requested" -- UI_RULES.md: never
|
||||
/// present an inferred value as a measured one. `run_keyboard_phase` reads
|
||||
/// the counters before and after asking for a toggle and calls it
|
||||
/// confirmed only if the count moved.
|
||||
#[derive(Default)]
|
||||
struct ImeState {
|
||||
visible: bool,
|
||||
shown_events: u32,
|
||||
hidden_events: u32,
|
||||
}
|
||||
|
||||
impl HasAndroidUiState for BenchClient {
|
||||
@@ -144,8 +227,15 @@ fn battery_line(samples: &[i32]) -> String {
|
||||
return " battery current: unavailable on this device".to_string();
|
||||
}
|
||||
let mean = samples.iter().map(|&v| v as i64).sum::<i64>() / samples.len() as i64;
|
||||
let min = samples.iter().min().unwrap();
|
||||
let max = samples.iter().max().unwrap();
|
||||
// `min`/`max` are guarded by the `is_empty` check above, three lines
|
||||
// up -- pairing the `Option` unwraps with the emptiness check right
|
||||
// here (rather than two statements apart, with `mean` in between
|
||||
// reading the same slice) is what keeps a future reorder from
|
||||
// separating the guard from what it protects (docs/
|
||||
// REVIEW-2026-09-06.md finding 7).
|
||||
let (Some(min), Some(max)) = (samples.iter().min(), samples.iter().max()) else {
|
||||
unreachable!("samples is non-empty, checked above");
|
||||
};
|
||||
format!(
|
||||
" battery current: mean {mean}\u{b5}A over {} samples (min {min}, max {max})",
|
||||
samples.len()
|
||||
@@ -168,27 +258,68 @@ impl AndroidAppState for BenchClient {
|
||||
.label("Benchmark report")
|
||||
.add(rsc);
|
||||
|
||||
let controls = bench_controls(rsc);
|
||||
let top_bar = WidgetPtr::new().add(rsc);
|
||||
let controls = bench_controls(rsc, 0.0);
|
||||
top_bar(rsc).set(controls);
|
||||
// The report pane is sized to whatever report it is holding, not
|
||||
// to a share of the window: `rest(1)` here reserved a third of
|
||||
// the screen for an *empty* `TextEdit` at every launch, which is
|
||||
// what Iris's 2026-09-06 11:39 phone report described as "the app
|
||||
// does not start with keyboard spacing correct" -- the composer
|
||||
// two thirds down with black below it, nothing to do with the IME
|
||||
// inset (measured: `iris insets:` reports bottom=63 ime_bottom=0
|
||||
// at launch, while the `Message` field's own box sat 789px above
|
||||
// the bottom of a 2282px surface -- exactly this pane's third).
|
||||
// Capped and scrollable so a long report cannot take the screen
|
||||
// back over, the same idiom `composer.rs` uses for the field.
|
||||
// Above the transcript, not below it: the report is what the
|
||||
// header's own "Run benchmark" button produces (UI_RULES.md --
|
||||
// results appear where the action was started), and a pane under
|
||||
// the composer would eat the navigation-bar clearance
|
||||
// `set_bottom_inset` gives it.
|
||||
let tree = (
|
||||
controls,
|
||||
content.height(rest(2)),
|
||||
report_display.height(rest(1)).pad(8),
|
||||
top_bar,
|
||||
report_display
|
||||
.pad(dp(8))
|
||||
.max_height(dp(REPORT_MAX_HEIGHT_DP)),
|
||||
content.height(rest(1)),
|
||||
)
|
||||
.span(Dir::DOWN)
|
||||
.add_strong(rsc)
|
||||
.any();
|
||||
ui_state.set_root(tree);
|
||||
|
||||
// Startup log line (RUST.md's P0 box, "log once at startup ... the
|
||||
// number of font families found, the default family resolved"):
|
||||
// what font discovery actually found on this device, before
|
||||
// anything is drawn.
|
||||
let font = rsc.ui.text.font_diagnostics();
|
||||
log::info!(
|
||||
"iris fonts: {} families found, default={:?} mono={:?}, resolved regular={:?} \
|
||||
bold={:?} italic={:?} mono={:?}",
|
||||
font.families_found,
|
||||
font.default_family,
|
||||
font.default_mono_family,
|
||||
font.regular_resolved,
|
||||
font.bold_resolved,
|
||||
font.italic_resolved,
|
||||
font.mono_resolved,
|
||||
);
|
||||
|
||||
let mut client = Self {
|
||||
ui_state,
|
||||
content,
|
||||
report_display,
|
||||
top_bar,
|
||||
screen: None,
|
||||
items: Vec::new(),
|
||||
stream_tail: Vec::new(),
|
||||
platform: None,
|
||||
last_report: None,
|
||||
running: false,
|
||||
ime_state: Arc::new(Mutex::new(ImeState::default())),
|
||||
keyboard_was_visible: false,
|
||||
last_top_pad: 0.0,
|
||||
};
|
||||
|
||||
let (backlog, stream_tail) = parse_fixture();
|
||||
@@ -212,11 +343,138 @@ impl AndroidAppState for BenchClient {
|
||||
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>, _render: &mut UiRenderState) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Pads the top button row by the status-bar inset -- see `top_bar`'s
|
||||
/// field comment. Rebuilds the row rather than mutating a stored
|
||||
/// `Padding` in place, since nothing here holds a handle to one --
|
||||
/// but **only when `insets.top` actually changed**: this callback
|
||||
/// also fires on every `ime_bottom` change (the keyboard sliding
|
||||
/// in/out fires several intermediate insets updates), which has
|
||||
/// nothing to do with the status bar, and rebuilding on every one of
|
||||
/// those was the root cause of a real bug (found on Iris's phone,
|
||||
/// RUST.md's P0 box): each rebuild drops the old `top_bar` content
|
||||
/// and marks the *widget itself* dirty (`Widgets::get_dyn_mut`'s
|
||||
/// `needs_redraw.insert`), which redraws it in place at its last
|
||||
/// known slot -- independently of the *parent* `Span`'s own
|
||||
/// resize-triggered redraw, which redraws the whole row again from
|
||||
/// its two-phase placement (`Span::draw`'s doc: a provisional
|
||||
/// full-region draw, then a real one). A `.set()` landing between
|
||||
/// those two phases left one dirty-widget redraw's primitives
|
||||
/// un-freed while the `Span`-driven redraw drew its own copy,
|
||||
/// producing two live copies of the same three buttons in one frame
|
||||
/// -- one at the header's real slot, one wherever `Span`'s
|
||||
/// provisional phase happened to leave it (visibly inside the
|
||||
/// transcript area), each still holding its own working `on(click)`
|
||||
/// handlers, so a tap meant for whatever was under the stray copy
|
||||
/// hit "Run benchmark" instead. Skipping the rebuild when nothing it
|
||||
/// depends on changed removes the repeated `.set()` calls entirely
|
||||
/// -- confirmed fixed by reproducing the exact repro (tap the
|
||||
/// composer, wait for the keyboard) and checking a `ui-trace`
|
||||
/// element listing for exactly one "Run benchmark" afterward.
|
||||
///
|
||||
/// Also two things downstream of the same `ime_bottom` transition:
|
||||
/// **the keyboard phase's own confirmation signal** (`ime_state`'s
|
||||
/// doc -- the platform's own answer for whether the IME actually
|
||||
/// opened or closed, rather than assumed from having called
|
||||
/// `show_ime`/`hide_ime`), and **the trigger for the keyboard
|
||||
/// diagnostics capture** (RUST.md's P0 box): the IME resizing the
|
||||
/// surface is exactly the case a previous commit found wiped text,
|
||||
/// and Iris needs a way to get a report off the phone even if that
|
||||
/// (or some other keyboard-triggered regression) is still happening
|
||||
/// on the build she is holding -- `capture_keyboard_diagnostics`
|
||||
/// below fires ~500ms after the keyboard becomes visible, once per
|
||||
/// keyboard opening, and shows its report in a plain overlay view
|
||||
/// that draws independently of whatever iris itself is doing.
|
||||
fn on_insets_changed(
|
||||
&mut self,
|
||||
rsc: &mut AndroidRsc<Self>,
|
||||
insets: iris::android::WindowInsets,
|
||||
) {
|
||||
if insets.top != self.last_top_pad {
|
||||
self.last_top_pad = insets.top;
|
||||
let controls = bench_controls(rsc, insets.top);
|
||||
(self.top_bar)(rsc).set(controls);
|
||||
}
|
||||
|
||||
// The composer bar sits directly on whichever of the IME or the
|
||||
// navigation bar is currently the bottom of usable space -- see
|
||||
// `transcript_ui::composer::Composer::set_bottom_inset`'s doc.
|
||||
// `ime_bottom` already exceeds the plain nav-bar inset whenever the
|
||||
// keyboard covers it, so the larger of the two is always the right
|
||||
// answer without needing to know which is currently showing.
|
||||
if let Some(screen) = &self.screen {
|
||||
screen
|
||||
.composer
|
||||
.set_bottom_inset(rsc, insets.bottom.max(insets.ime_bottom));
|
||||
}
|
||||
|
||||
let ime_visible = insets.ime_bottom > 0.0;
|
||||
|
||||
let mut ime = self.ime_state.lock().unwrap();
|
||||
if ime_visible && !ime.visible {
|
||||
ime.shown_events += 1;
|
||||
}
|
||||
if !ime_visible && ime.visible {
|
||||
ime.hidden_events += 1;
|
||||
}
|
||||
ime.visible = ime_visible;
|
||||
drop(ime);
|
||||
|
||||
if ime_visible && !self.keyboard_was_visible {
|
||||
self.keyboard_was_visible = true;
|
||||
let redraw = rsc.tasks.redraw_handle();
|
||||
rsc.spawn_task(async move |mut ctx| {
|
||||
tokio::time::sleep(Duration::from_millis(KEYBOARD_DIAGNOSTICS_DELAY_MS)).await;
|
||||
ctx.update(|state: &mut BenchClient, rsc| {
|
||||
state.capture_keyboard_diagnostics(rsc);
|
||||
});
|
||||
redraw.request_redraw();
|
||||
});
|
||||
} else if !ime_visible {
|
||||
self.keyboard_was_visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How long to wait after the keyboard becomes visible before capturing
|
||||
/// diagnostics -- long enough that the resize, the reported wipe (if it is
|
||||
/// still happening) and a couple of frames have all had time to land, per
|
||||
/// AGENTS.md's "so that operations that finish in milliseconds have states
|
||||
/// on the way that nothing can observe" reasoning applied the other way:
|
||||
/// this wants to observe the state *after* the transition settles, not
|
||||
/// mid-flight.
|
||||
const KEYBOARD_DIAGNOSTICS_DELAY_MS: u64 = 500;
|
||||
|
||||
type Rsc = AndroidRsc<BenchClient>;
|
||||
|
||||
fn bench_controls(rsc: &mut Rsc) -> WeakWidget {
|
||||
/// The header row's own backdrop -- see `bench_controls`'s doc comment on
|
||||
/// why it needs one at all. A dark neutral rather than pure black
|
||||
/// (`android::render::CLEAR_COLOR`) so the row reads as a distinct panel
|
||||
/// instead of a hole in the background the buttons happen to float in.
|
||||
const HEADER_SURFACE: UiColor = UiColor::new(28, 28, 34, 255);
|
||||
|
||||
/// `top_pad` is the status-bar inset in physical pixels (0.0 until
|
||||
/// `on_insets_changed` has run once) -- folded in here, rather than
|
||||
/// exposing the unadded builder for a caller to `.pad()` itself, because
|
||||
/// naming that builder's type at each call site is more machinery than a
|
||||
/// top-of-screen padding number is worth.
|
||||
///
|
||||
/// **Backed by an opaque rect the full size of the row, not just the three
|
||||
/// buttons.** Iris's phone report (docs/RUST.md's P0 box, screenshots on
|
||||
/// build a9232ac): "the header buttons have nothing behind them and
|
||||
/// overlap the transcript text" -- before this, only each button's own
|
||||
/// `rect(...)` painted anything, so the gaps between and around them (and
|
||||
/// the status-bar strip above them) showed whatever was one layer back
|
||||
/// (`CLEAR_COLOR`, black), and the row's true height was three
|
||||
/// physical-pixel-sized (`abs`, not `dp`) button boxes rather than the
|
||||
/// density-correct size the transcript below was already using post-P0 --
|
||||
/// exactly what reads as "overlap" once the two disagree. Fixed two ways
|
||||
/// together: a `HEADER_SURFACE` rect stacked behind the whole row (this
|
||||
/// function), and every size below moved from a bare number (physical
|
||||
/// pixels) to `dp(...)` (IRIS_TODO.md's density-independent length unit),
|
||||
/// so the row's reserved height in the outer `Span::DOWN`
|
||||
/// (`AndroidAppState::new`) matches what is actually painted.
|
||||
fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
|
||||
let run_rect = rect(Color::rgb(40, 70, 40))
|
||||
.on(
|
||||
CursorSense::click(),
|
||||
@@ -230,7 +488,7 @@ fn bench_controls(rsc: &mut Rsc) -> WeakWidget {
|
||||
wtext("Run benchmark").size(18).text_align(Align::CENTER),
|
||||
)
|
||||
.stack()
|
||||
.pad(8)
|
||||
.pad(dp(8))
|
||||
.add(rsc);
|
||||
|
||||
let copy_rect = rect(Color::rgb(50, 50, 60))
|
||||
@@ -246,10 +504,33 @@ fn bench_controls(rsc: &mut Rsc) -> WeakWidget {
|
||||
wtext("Copy report").size(18).text_align(Align::CENTER),
|
||||
)
|
||||
.stack()
|
||||
.pad(8)
|
||||
.pad(dp(8))
|
||||
.add(rsc);
|
||||
|
||||
(run, copy).span(Dir::RIGHT).height(56).add(rsc)
|
||||
let diag_rect = rect(Color::rgb(60, 45, 70))
|
||||
.on(
|
||||
CursorSense::click(),
|
||||
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
|
||||
ctx.state.show_diagnostics(rsc);
|
||||
},
|
||||
)
|
||||
.label("Diagnostics");
|
||||
let diagnostics = (
|
||||
diag_rect,
|
||||
wtext("Diagnostics").size(18).text_align(Align::CENTER),
|
||||
)
|
||||
.stack()
|
||||
.pad(dp(8))
|
||||
.add(rsc);
|
||||
|
||||
let buttons = (run, copy, diagnostics).span(Dir::RIGHT).add(rsc);
|
||||
|
||||
(rect(HEADER_SURFACE), buttons)
|
||||
.stack()
|
||||
.height(dp(56))
|
||||
.pad(Padding::top(top_pad))
|
||||
.add_strong(rsc)
|
||||
.any()
|
||||
}
|
||||
|
||||
impl BenchClient {
|
||||
@@ -266,6 +547,52 @@ impl BenchClient {
|
||||
self.screen = Some(screen);
|
||||
}
|
||||
|
||||
/// RUST.md's P0 box: "a named `Diagnostics` control ... with 'copy this
|
||||
/// and send it to Iris'." Fills `report_display` (the same TextEdit the
|
||||
/// benchmark report uses) rather than a separate widget, so the
|
||||
/// existing "Copy report" button and clipboard path work on whichever
|
||||
/// text is currently shown -- `last_report` is what `copy_report` reads,
|
||||
/// so it's set here too rather than adding a second copy path.
|
||||
fn show_diagnostics(&mut self, rsc: &mut Rsc) {
|
||||
let report = self.diagnostics_text(rsc);
|
||||
self.report_display.edit(rsc).set(&report);
|
||||
self.last_report = Some(report);
|
||||
}
|
||||
|
||||
/// The diagnostics report as text, with no side effect on what is on
|
||||
/// screen -- shared by the `Diagnostics` button (which shows it) and
|
||||
/// the keyboard-open capture (which only logs it), so the two can
|
||||
/// never drift into reporting different things.
|
||||
fn diagnostics_text(&self, rsc: &mut Rsc) -> String {
|
||||
let font = rsc.ui.text.font_diagnostics();
|
||||
let frame_report = match self.android_state().frame_report.report() {
|
||||
Some(stats) => format!("{stats}"),
|
||||
None => "no frames recorded yet".to_string(),
|
||||
};
|
||||
match &self.android_state().renderer {
|
||||
Some(renderer) => renderer.diagnostics_report(&font, &frame_report),
|
||||
None => "iris diagnostics: no renderer yet (no surface)".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The keyboard's own diagnostics capture -- see `on_insets_changed`'s
|
||||
/// doc comment. **Logged only.** It used to also copy the report to
|
||||
/// the clipboard unprompted and put it in the shell's overlay view,
|
||||
/// from when the keyboard-inset callback was not firing at all and a
|
||||
/// report could not be got off the phone any other way. Both are gone
|
||||
/// as of 2026-09-06: the callback fires reliably now (edge-to-edge,
|
||||
/// `MainActivity.java`), and the overlay covered the whole screen on
|
||||
/// *every* keyboard open with its own Copy/Close buttons underneath
|
||||
/// the keyboard, so it could not be dismissed -- an interruption for
|
||||
/// something nobody asked for, over an app you are trying to type
|
||||
/// into (UI_RULES.md). The named `Diagnostics` button still shows the
|
||||
/// same text on demand, and `iris surface:`/`iris insets:` (view.rs)
|
||||
/// carry the lifecycle a `logcat` pull actually needs.
|
||||
fn capture_keyboard_diagnostics(&mut self, rsc: &mut Rsc) {
|
||||
let report = self.diagnostics_text(rsc);
|
||||
log::info!("iris keyboard diagnostics:\n{report}");
|
||||
}
|
||||
|
||||
fn copy_report(&mut self) {
|
||||
let Some(report) = &self.last_report else {
|
||||
log::info!("iris bench report: nothing to copy -- run the benchmark first");
|
||||
@@ -282,10 +609,10 @@ impl BenchClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// P0's scripted run: `BenchRun.kt`'s scroll loop, then its streaming
|
||||
/// phase, then the report -- run in-process for the same reason that
|
||||
/// file's own doc gives (no usable system tracing on a real phone, no
|
||||
/// agent that can drive one).
|
||||
/// RUST.md's "Benchmark v2": fling, then stream (unchanged from v1),
|
||||
/// then type, then keyboard, then the report -- run in-process for the
|
||||
/// same reason `BenchRun.kt`'s own doc gives (no usable system tracing
|
||||
/// on a real phone, no agent that can drive one).
|
||||
fn start_benchmark(&mut self, rsc: &mut Rsc) {
|
||||
if self.running {
|
||||
log::info!("iris bench report: already running");
|
||||
@@ -298,35 +625,21 @@ impl BenchClient {
|
||||
let redraw = rsc.tasks.redraw_handle();
|
||||
let platform = self.platform.clone();
|
||||
let stream_tail = self.stream_tail.clone();
|
||||
let ime_state = self.ime_state.clone();
|
||||
let refresh_hz = platform
|
||||
.as_ref()
|
||||
.and_then(|p| p.refresh_rate_hz())
|
||||
.unwrap_or(60.0);
|
||||
let cpu_start = process_cpu_ms();
|
||||
let run_started_at = Instant::now();
|
||||
|
||||
rsc.spawn_task(async move |mut ctx| {
|
||||
// The swipe loop: two drags toward newer content, two back --
|
||||
// a cycle returns to where it started, so the whole loop
|
||||
// measures steady-state scrolling. `BenchRun.kt`'s own
|
||||
// comment on this shape.
|
||||
for _ in 0..CYCLES {
|
||||
for delta in [SWIPE_PX, SWIPE_PX, -SWIPE_PX, -SWIPE_PX] {
|
||||
animate_scroll(&mut ctx, &redraw, delta, SWIPE_MS).await;
|
||||
tokio::time::sleep(Duration::from_millis(SWIPE_PAUSE_MS)).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Pinned to the newest end before streaming starts, matching
|
||||
// `stream-bench.sh`'s "Jump to latest" tap.
|
||||
ctx.update(|state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
(screen.list)(rsc).jump_to_end();
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
|
||||
// The battery sampler runs concurrently with the streaming
|
||||
// phase, once a second, the same cadence `BatterySampler` uses
|
||||
// on the Compose side -- via its own JNI-attached thread, not
|
||||
// `ctx.update`, since a sample needs no widget-tree access.
|
||||
// The battery sampler runs for the whole run, once a second,
|
||||
// the same cadence `BatterySampler` uses on the Compose side
|
||||
// -- via its own JNI-attached thread, not `ctx.update`, since
|
||||
// a sample needs no widget-tree access.
|
||||
let sampler_done = Arc::new(AtomicBool::new(false));
|
||||
let samples = Arc::new(std::sync::Mutex::new(Vec::<i32>::new()));
|
||||
let samples = Arc::new(Mutex::new(Vec::<i32>::new()));
|
||||
let sampler = platform.clone().map(|platform| {
|
||||
let done = sampler_done.clone();
|
||||
let samples = samples.clone();
|
||||
@@ -340,20 +653,10 @@ impl BenchClient {
|
||||
})
|
||||
});
|
||||
|
||||
let total = (STREAM_EVENTS_PER_SEC * STREAM_SECONDS) as usize;
|
||||
let mut sent = 0usize;
|
||||
for event in stream_tail.into_iter().take(total) {
|
||||
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||
state.items = fold_event(&state.items, &event);
|
||||
state.rebuild_transcript(rsc);
|
||||
});
|
||||
redraw.request_redraw();
|
||||
sent += 1;
|
||||
tokio::time::sleep(Duration::from_millis(1000 / STREAM_EVENTS_PER_SEC)).await;
|
||||
}
|
||||
// Lets the last few deltas land and draw before the report is
|
||||
// read -- `BenchRun.kt`'s own closing delay.
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
let travel = run_fling_phase(&mut ctx, &redraw).await;
|
||||
let (sent, total) = run_stream_phase(&mut ctx, &redraw, stream_tail).await;
|
||||
run_type_phase(&mut ctx, &redraw, &platform).await;
|
||||
let keyboard = run_keyboard_phase(&mut ctx, &platform, &ime_state).await;
|
||||
|
||||
sampler_done.store(true, Ordering::Relaxed);
|
||||
if let Some(sampler) = sampler {
|
||||
@@ -362,7 +665,10 @@ impl BenchClient {
|
||||
let battery = battery_line(&samples.lock().unwrap());
|
||||
let cpu_line = match (cpu_start, process_cpu_ms()) {
|
||||
(Some(start), Some(end)) => {
|
||||
format!(" process CPU time over this run: {}ms", end.saturating_sub(start))
|
||||
format!(
|
||||
" process CPU time over this run: {}ms",
|
||||
end.saturating_sub(start)
|
||||
)
|
||||
}
|
||||
_ => " process CPU time over this run: unavailable".to_string(),
|
||||
};
|
||||
@@ -370,19 +676,61 @@ impl BenchClient {
|
||||
Some(kb) => format!(" peak RSS: {kb}kB"),
|
||||
None => " peak RSS: unavailable (/proc/self/status unreadable)".to_string(),
|
||||
};
|
||||
let total_seconds = run_started_at.elapsed().as_secs_f64();
|
||||
|
||||
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||
state.running = false;
|
||||
let scroll_line = format!(
|
||||
" scroll: {CYCLES} cycles ({} swipes), streamed {sent}/{total} fixture events",
|
||||
CYCLES * 4
|
||||
);
|
||||
let frames_line = match state.android_state().frame_report.report() {
|
||||
Some(stats) => format!("{stats}"),
|
||||
None => "no frames recorded".to_string(),
|
||||
let now = Instant::now();
|
||||
let phase_lines: String = state
|
||||
.android_state()
|
||||
.frame_report
|
||||
.phase_stats(now, refresh_hz)
|
||||
.iter()
|
||||
.map(|p| format!("{p}\n"))
|
||||
.collect();
|
||||
let per_phase = if phase_lines.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("per phase:\n{phase_lines}\n")
|
||||
};
|
||||
let frames_block = match state.android_state().frame_report.report() {
|
||||
Some(stats) => {
|
||||
let (late, late_pct) =
|
||||
state.android_state().frame_report.late_at_hz(refresh_hz);
|
||||
format!(
|
||||
"frames:\n {} frames over {:.1}s at {:.0}Hz ({:.1}ms budget)\n \
|
||||
late: {late} ({late_pct:.1}%)\n total p50 {:.1}ms p90 {:.1}ms \
|
||||
p99 {:.1}ms\n worst {:.1}ms\n cpu_p50 {:.1}ms gpu_wait_p50 {:.1}ms",
|
||||
stats.total_frames,
|
||||
total_seconds,
|
||||
refresh_hz,
|
||||
1000.0 / refresh_hz as f64,
|
||||
stats.p50.as_secs_f64() * 1000.0,
|
||||
stats.p90.as_secs_f64() * 1000.0,
|
||||
stats.p99.as_secs_f64() * 1000.0,
|
||||
stats.worst.as_secs_f64() * 1000.0,
|
||||
stats.cpu_p50.as_secs_f64() * 1000.0,
|
||||
stats.gpu_wait_p50.as_secs_f64() * 1000.0,
|
||||
)
|
||||
}
|
||||
None => "frames:\n no frames recorded".to_string(),
|
||||
};
|
||||
let scroll_line = format!(
|
||||
" scroll: {LEGACY_CYCLES} cycles ({} swipes, legacy tween), streamed \
|
||||
{sent}/{total} fixture events",
|
||||
LEGACY_CYCLES * 4
|
||||
);
|
||||
let fling_line = format!(
|
||||
" fling: {FLING_COUNT} flings out + {FLING_COUNT} back at \
|
||||
{FLING_VELOCITY_PX_S}px/s, travel {travel}"
|
||||
);
|
||||
let type_line = format!(
|
||||
" type: {} characters inserted then deleted, one per {TYPE_CHAR_MS}ms",
|
||||
TYPE_TEXT.chars().count()
|
||||
);
|
||||
let report = format!(
|
||||
"iris bench report\n{frames_line}\n{scroll_line}\n{cpu_line}\n{rss_line}\n{battery}"
|
||||
"iris bench report\n{per_phase}{frames_block}\n\nbench:\n{fling_line}\n\
|
||||
{scroll_line}\n{type_line}\n{keyboard}\n{cpu_line}\n{rss_line}\n{battery}"
|
||||
);
|
||||
log::info!("iris bench report: {report}");
|
||||
state.report_display.edit(rsc).set(&report);
|
||||
@@ -393,26 +741,287 @@ impl BenchClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves `List::scroll` by `total_px` over `duration_ms`, in ~60Hz steps,
|
||||
/// so the swipe is many rendered frames rather than one jump -- the same
|
||||
/// shape `animateScrollBy(SWIPE_PX, tween(SWIPE_MS))` gives on the Compose
|
||||
/// side, in the one place the two backends have to differ (iris's `List`
|
||||
/// has no built-in tween, so this drives it by hand).
|
||||
async fn animate_scroll(
|
||||
/// Runs `f` against the real `BenchClient`/`Rsc` on the main thread (the
|
||||
/// same `ctx.update` every other mutation here goes through) and returns
|
||||
/// its result to the caller's async task -- `ctx.update` alone has no way
|
||||
/// to hand a value back, since the closure only actually runs once the
|
||||
/// next frame callback drains `IrisViewPeer`'s task channel
|
||||
/// (`drain_tasks`). **Must call `redraw.request_redraw()` itself, right
|
||||
/// after enqueueing** -- `ctx.update` only ever pushes onto a channel;
|
||||
/// nothing drains it until something schedules the frame callback that
|
||||
/// calls `drain_tasks`, and a caller relying on some *earlier*,
|
||||
/// already-in-flight `request_redraw()` to cover a *later* `ctx.update`
|
||||
/// deadlocks the moment that earlier callback has already fired and
|
||||
/// drained everything queued before this call existed. Cost a real hang
|
||||
/// in this file's first version of the fling phase: every loop iteration
|
||||
/// after the first sat forever with nothing scheduled to drain it.
|
||||
/// Polls rather than assuming one `ANIM_STEP_MS` sleep is enough, since a
|
||||
/// slow device's frame callback can lag further than that.
|
||||
async fn read_from_state<T, F>(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
redraw: &Arc<dyn iris::task::RequestRedraw>,
|
||||
total_px: f32,
|
||||
duration_ms: u64,
|
||||
) {
|
||||
let steps = (duration_ms / ANIM_STEP_MS).max(1);
|
||||
let step_px = total_px / steps as f32;
|
||||
for _ in 0..steps {
|
||||
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
(screen.list)(rsc).scroll(step_px);
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
redraw: &Arc<dyn RequestRedraw>,
|
||||
f: F,
|
||||
) -> T
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: FnOnce(&mut BenchClient, &mut Rsc) -> T + Send + 'static,
|
||||
{
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||
let _ = tx.send(f(state, rsc));
|
||||
});
|
||||
redraw.request_redraw();
|
||||
loop {
|
||||
if let Ok(value) = rx.try_recv() {
|
||||
return value;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 1: starting pinned at the newest end, `FLING_COUNT` flings away
|
||||
/// from it (toward older messages) through `List::fling`, then
|
||||
/// `FLING_COUNT` back. Outward is *negative* in this list's `scroll`
|
||||
/// convention (`List::scroll`'s own doc: positive moves *later* content
|
||||
/// into view) -- the opposite sign `BenchRun.kt`'s `runFlingPhase` uses,
|
||||
/// since `TranscriptList`'s `LazyColumn` and this list define "positive"
|
||||
/// the other way around; the two apps' *travel* is still directly
|
||||
/// comparable because both report it as a row index + pixel offset, not a
|
||||
/// signed distance.
|
||||
async fn run_fling_phase(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
redraw: &Arc<dyn RequestRedraw>,
|
||||
) -> String {
|
||||
ctx.update(|state: &mut BenchClient, _rsc| {
|
||||
state.android_state_mut().frame_report.mark_phase("fling");
|
||||
});
|
||||
ctx.update(|state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
(screen.list)(rsc).jump_to_end();
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
// Lets the next frame's `repair_anchor` resolve `jump_to_end`'s
|
||||
// `anchor = None` into a real slot before `start` is read.
|
||||
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS * 2)).await;
|
||||
let start = read_anchor_position(ctx, redraw).await;
|
||||
|
||||
for _ in 0..FLING_COUNT {
|
||||
ctx.update(|state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
(screen.list)(rsc).fling(-FLING_VELOCITY_PX_S);
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
wait_for_fling_settle(ctx, redraw).await;
|
||||
tokio::time::sleep(Duration::from_millis(FLING_PAUSE_MS)).await;
|
||||
}
|
||||
let outward = read_anchor_position(ctx, redraw).await;
|
||||
|
||||
for _ in 0..FLING_COUNT {
|
||||
ctx.update(|state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
(screen.list)(rsc).fling(FLING_VELOCITY_PX_S);
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
wait_for_fling_settle(ctx, redraw).await;
|
||||
tokio::time::sleep(Duration::from_millis(FLING_PAUSE_MS)).await;
|
||||
}
|
||||
let end = read_anchor_position(ctx, redraw).await;
|
||||
|
||||
format!("start={start} outward={outward} end={end}")
|
||||
}
|
||||
|
||||
async fn read_anchor_position(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
redraw: &Arc<dyn RequestRedraw>,
|
||||
) -> String {
|
||||
read_from_state(ctx, redraw, |state, rsc| match &state.screen {
|
||||
Some(screen) => (screen.list)(rsc).anchor_position_display(),
|
||||
None => "idx=none".to_string(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Ticks the fling forward in ~60Hz steps (the same shape
|
||||
/// `run_stream_phase`'s per-event loop and the old `animate_scroll` used)
|
||||
/// until it settles or `FLING_SETTLE_CAP_MS` passes -- belt-and-suspenders
|
||||
/// the same way `BenchRun.kt`'s own `waitForSettle` is, since a fling's
|
||||
/// own spline-decided `duration()` already caps how long it can run.
|
||||
async fn wait_for_fling_settle(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
redraw: &Arc<dyn RequestRedraw>,
|
||||
) {
|
||||
let cap = Duration::from_millis(FLING_SETTLE_CAP_MS);
|
||||
let started = Instant::now();
|
||||
while started.elapsed() < cap {
|
||||
let still_scrolling = read_from_state(ctx, redraw, |state, rsc| match &state.screen {
|
||||
Some(screen) => (screen.list)(rsc).tick_fling(Instant::now()),
|
||||
None => false,
|
||||
})
|
||||
.await;
|
||||
if !still_scrolling {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 2, unchanged from v1: pinned to the newest end before streaming
|
||||
/// starts (matching `stream-bench.sh`'s "Jump to latest" tap), then
|
||||
/// `STREAM_EVENTS_PER_SEC * STREAM_SECONDS` fixture events replayed
|
||||
/// through the real `fold_event`/`TranscriptScreen::apply` path. Returns
|
||||
/// `(sent, total)`.
|
||||
async fn run_stream_phase(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
redraw: &Arc<dyn RequestRedraw>,
|
||||
stream_tail: Vec<SeqEvent>,
|
||||
) -> (usize, usize) {
|
||||
ctx.update(|state: &mut BenchClient, _rsc| {
|
||||
state.android_state_mut().frame_report.mark_phase("stream");
|
||||
});
|
||||
ctx.update(|state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
(screen.list)(rsc).jump_to_end();
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
|
||||
let total = (STREAM_EVENTS_PER_SEC * STREAM_SECONDS) as usize;
|
||||
let mut sent = 0usize;
|
||||
for event in stream_tail.into_iter().take(total) {
|
||||
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||
let old_items = state.items.clone();
|
||||
state.items = fold_event(&state.items, &event);
|
||||
match &state.screen {
|
||||
Some(screen) => screen.apply(rsc, &old_items, &state.items),
|
||||
None => state.rebuild_transcript(rsc),
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
sent += 1;
|
||||
tokio::time::sleep(Duration::from_millis(1000 / STREAM_EVENTS_PER_SEC)).await;
|
||||
}
|
||||
// Lets the last few deltas land and draw before the next phase starts
|
||||
// -- `BenchRun.kt`'s own closing delay.
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
(sent, total)
|
||||
}
|
||||
|
||||
/// Phase 3: focuses the real composer, shows the keyboard, then types
|
||||
/// `TYPE_TEXT` one character at a time through the composer `TextEdit`'s
|
||||
/// real edit path (`set`, the same call a real keystroke's `onValueChange`
|
||||
/// makes -- `Composer::build_composer`'s `field`), and deletes it the same
|
||||
/// way.
|
||||
async fn run_type_phase(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
redraw: &Arc<dyn RequestRedraw>,
|
||||
platform: &Option<Arc<PlatformHandle>>,
|
||||
) {
|
||||
ctx.update(|state: &mut BenchClient, _rsc| {
|
||||
state.android_state_mut().frame_report.mark_phase("type");
|
||||
});
|
||||
ctx.update(|state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
(screen.list)(rsc).jump_to_end();
|
||||
state.set_focus(Some(screen.composer.field));
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
if let Some(p) = platform {
|
||||
p.show_ime();
|
||||
}
|
||||
// Lets focus and the keyboard's opening animation land before typing
|
||||
// starts, so the frames this phase records are the wrap/reflow it is
|
||||
// measuring, not the keyboard opening -- `BenchRun.kt`'s own delay.
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
|
||||
let mut typed = String::new();
|
||||
for ch in TYPE_TEXT.chars() {
|
||||
typed.push(ch);
|
||||
let text = typed.clone();
|
||||
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
screen.composer.field.edit(rsc).set(&text);
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
tokio::time::sleep(Duration::from_millis(TYPE_CHAR_MS)).await;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
while !typed.is_empty() {
|
||||
typed.pop();
|
||||
let text = typed.clone();
|
||||
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
screen.composer.field.edit(rsc).set(&text);
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
tokio::time::sleep(Duration::from_millis(TYPE_CHAR_MS)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 4: `KEYBOARD_CYCLES` show/hide cycles through the shell's own
|
||||
/// `InputMethodManager` (`bench_jni.rs`'s `show_ime`/`hide_ime`), each
|
||||
/// confirmed by `on_insets_changed`'s real `ime_bottom` transition rather
|
||||
/// than assumed from the JNI call having returned -- `ImeState`'s doc.
|
||||
/// "keyboard: could not be shown" if the platform never confirms it even
|
||||
/// once, per UI_RULES.md ("design the unknown/failed state before the
|
||||
/// answer's").
|
||||
async fn run_keyboard_phase(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
platform: &Option<Arc<PlatformHandle>>,
|
||||
ime_state: &Arc<Mutex<ImeState>>,
|
||||
) -> String {
|
||||
ctx.update(|state: &mut BenchClient, _rsc| {
|
||||
state
|
||||
.android_state_mut()
|
||||
.frame_report
|
||||
.mark_phase("keyboard");
|
||||
});
|
||||
let mut shown = 0;
|
||||
let mut hidden = 0;
|
||||
for _ in 0..KEYBOARD_CYCLES {
|
||||
let before_shown = ime_state.lock().unwrap().shown_events;
|
||||
if let Some(p) = platform {
|
||||
p.show_ime();
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(KEYBOARD_WAIT_MS)).await;
|
||||
if ime_state.lock().unwrap().shown_events > before_shown {
|
||||
shown += 1;
|
||||
}
|
||||
|
||||
let before_hidden = ime_state.lock().unwrap().hidden_events;
|
||||
if let Some(p) = platform {
|
||||
p.hide_ime();
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(KEYBOARD_WAIT_MS)).await;
|
||||
if ime_state.lock().unwrap().hidden_events > before_hidden {
|
||||
hidden += 1;
|
||||
}
|
||||
}
|
||||
if shown == 0 {
|
||||
format!(" keyboard: could not be shown ({KEYBOARD_CYCLES} attempts, 0 confirmed visible)")
|
||||
} else {
|
||||
format!(
|
||||
" keyboard: shown {shown}/{KEYBOARD_CYCLES}, hidden {hidden}/{KEYBOARD_CYCLES} \
|
||||
(confirmed via on_insets_changed)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::TYPE_TEXT;
|
||||
|
||||
/// `BenchRun.kt`'s own `TYPE_TEXT` is verified `.length == 600`; this
|
||||
/// is the same string, so it has to match exactly or the two apps'
|
||||
/// type phases stop typing the same content -- RUST.md's "Benchmark
|
||||
/// v2" spec is one shared string for both.
|
||||
#[test]
|
||||
fn type_text_is_exactly_600_characters() {
|
||||
assert_eq!(TYPE_TEXT.chars().count(), 600);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
//! JNI calls the `bench` feature needs that go through the shell's own
|
||||
//! Java side rather than anything `iris`/`android-view` already wraps:
|
||||
//! `BatteryManager.getIntProperty(BATTERY_PROPERTY_CURRENT_NOW)` for the
|
||||
//! per-second battery sample, and `ClipboardManager.setPrimaryClip` for
|
||||
//! the "Copy report" control (P0's iris half, docs/RUST.md). Neither is
|
||||
//! part of `android_view::context`'s own `Context`/`Resources` wrappers
|
||||
//! (that file's own `// TODO: more methods?`), so this calls them
|
||||
//! directly rather than growing that crate's wrapper for two one-off
|
||||
//! per-second battery sample, `ClipboardManager.setPrimaryClip` for the
|
||||
//! "Copy report" control (P0's iris half, docs/RUST.md), and -- added for
|
||||
//! RUST.md's "Benchmark v2" -- `Display.getRefreshRate()` for the phase
|
||||
//! report's real late-frame budget and `InputMethodManager.
|
||||
//! showSoftInput`/`hideSoftInputFromWindow` for the keyboard phase. None
|
||||
//! of these are part of `android_view::context`'s own `Context`/
|
||||
//! `Resources` wrappers (that file's own `// TODO: more methods?`), so
|
||||
//! this calls them directly rather than growing that crate's wrapper for
|
||||
//! calls this crate alone needs.
|
||||
//!
|
||||
//! Holds its own `JavaVM` + `GlobalRef` to the view (handed in through
|
||||
@@ -131,4 +134,119 @@ impl PlatformHandle {
|
||||
.ok()?;
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// The display's own refresh rate in Hz (`View::getDisplay()` ->
|
||||
/// `Display::getRefreshRate()`), for RUST.md's "Benchmark v2": late
|
||||
/// frames are judged against *this* device's real budget, not an
|
||||
/// assumed 60Hz -- a 90Hz or 120Hz phone would otherwise call frames
|
||||
/// "late" that met their own faster deadline. `None` if the view is
|
||||
/// not yet attached to a window (`getDisplay` returns `null`) or the
|
||||
/// platform reports a non-positive rate, which is not a real answer
|
||||
/// either.
|
||||
pub fn refresh_rate_hz(&self) -> Option<f32> {
|
||||
let mut guard = self.vm.attach_current_thread().ok()?;
|
||||
let env: &mut JNIEnv = &mut guard;
|
||||
let display = env
|
||||
.call_method(
|
||||
self.view.as_obj(),
|
||||
"getDisplay",
|
||||
"()Landroid/view/Display;",
|
||||
&[],
|
||||
)
|
||||
.ok()?
|
||||
.l()
|
||||
.ok()?;
|
||||
if display.is_null() {
|
||||
return None;
|
||||
}
|
||||
let rate = env
|
||||
.call_method(&display, "getRefreshRate", "()F", &[])
|
||||
.ok()?
|
||||
.f()
|
||||
.ok()?;
|
||||
if rate > 0.0 { Some(rate) } else { None }
|
||||
}
|
||||
|
||||
/// `InputMethodManager.showSoftInput(view, 0)` -- the keyboard phase's
|
||||
/// own show, called directly rather than through the focus-driven
|
||||
/// `pending_show_keyboard` path `android/view.rs` uses for a real tap,
|
||||
/// since RUST.md's "Benchmark v2" spec asks for this "through the
|
||||
/// shell's InputMethodManager" independent of focus state. `true` only
|
||||
/// if the platform itself reports the request succeeded -- whether the
|
||||
/// IME actually became visible is confirmed separately, from
|
||||
/// `on_insets_changed`, per UI_RULES.md ("never present an inferred
|
||||
/// value as a measured one").
|
||||
pub fn show_ime(&self) -> bool {
|
||||
self.try_toggle_ime(true).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// `InputMethodManager.hideSoftInputFromWindow(windowToken, 0)`.
|
||||
pub fn hide_ime(&self) -> bool {
|
||||
self.try_toggle_ime(false).unwrap_or(false)
|
||||
}
|
||||
|
||||
fn try_toggle_ime(&self, show: bool) -> Option<bool> {
|
||||
let mut guard = self.vm.attach_current_thread().ok()?;
|
||||
let env: &mut JNIEnv = &mut guard;
|
||||
let context = self.context(env)?;
|
||||
let imm = self.system_service(env, &context, "input_method")?;
|
||||
if show {
|
||||
env.call_method(
|
||||
&imm,
|
||||
"showSoftInput",
|
||||
"(Landroid/view/View;I)Z",
|
||||
&[JValue::Object(self.view.as_obj()), JValue::Int(0)],
|
||||
)
|
||||
.ok()?
|
||||
.z()
|
||||
.ok()
|
||||
} else {
|
||||
let token = env
|
||||
.call_method(
|
||||
self.view.as_obj(),
|
||||
"getWindowToken",
|
||||
"()Landroid/os/IBinder;",
|
||||
&[],
|
||||
)
|
||||
.ok()?
|
||||
.l()
|
||||
.ok()?;
|
||||
env.call_method(
|
||||
&imm,
|
||||
"hideSoftInputFromWindow",
|
||||
"(Landroid/os/IBinder;I)Z",
|
||||
&[JValue::Object(&token), JValue::Int(0)],
|
||||
)
|
||||
.ok()?
|
||||
.z()
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows `report` in the shell's plain-view diagnostics overlay
|
||||
/// (`IrisView.showDiagnosticsOverlay`) -- a real `TextView` plus Copy
|
||||
/// and Close controls, added over whatever iris itself is drawing
|
||||
/// rather than replacing it (unlike `android::view::show_renderer_error`,
|
||||
/// which exists for the case the renderer can never recover from and
|
||||
/// intentionally never returns). Called from a background task after
|
||||
/// the keyboard-open delay (`bench_client.rs`'s `on_insets_changed`),
|
||||
/// so the Java side hops onto the UI thread itself before touching the
|
||||
/// view tree -- see that method's own comment.
|
||||
pub fn show_diagnostics_overlay(&self, report: &str) -> bool {
|
||||
self.try_show_diagnostics_overlay(report).is_some()
|
||||
}
|
||||
|
||||
fn try_show_diagnostics_overlay(&self, report: &str) -> Option<()> {
|
||||
let mut guard = self.vm.attach_current_thread().ok()?;
|
||||
let env: &mut JNIEnv = &mut guard;
|
||||
let jreport = env.new_string(report).ok()?;
|
||||
env.call_method(
|
||||
self.view.as_obj(),
|
||||
"showDiagnosticsOverlay",
|
||||
"(Ljava/lang/String;)V",
|
||||
&[JValue::Object(jreport.as_ref())],
|
||||
)
|
||||
.ok()?;
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
@@ -20,14 +20,22 @@
|
||||
//! **Reuses `iris/desktop-app`'s `app.rs` shape almost exactly** --
|
||||
//! `fold_event`/`group_tool_runs`/`fold_page`/`raw_seq` from
|
||||
//! `client_core::transcript_fold`, a `generation` counter guarding against
|
||||
//! a stale background response, and a full rebuild of the widget tree on
|
||||
//! every event (same tradeoff, same reason: `push_row` cannot update a row
|
||||
//! already on screen, and this rig's conversations are small). What
|
||||
//! differs is only the redraw mechanism: android-view has no
|
||||
//! `winit::EventLoopProxy`, so this uses `iris::task::Tasks::redraw_handle`
|
||||
//! (new, added alongside this box) to request a frame after each
|
||||
//! `TaskCtx::update` instead of relying on `Tasks::spawn`'s single
|
||||
//! end-of-future redraw -- see that method's own doc for why.
|
||||
//! a stale background response. What differs is only the redraw
|
||||
//! mechanism: android-view has no `winit::EventLoopProxy`, so this uses
|
||||
//! `iris::task::Tasks::redraw_handle` (new, added alongside this box) to
|
||||
//! request a frame after each `TaskCtx::update` instead of relying on
|
||||
//! `Tasks::spawn`'s single end-of-future redraw -- see that method's own
|
||||
//! doc for why.
|
||||
//!
|
||||
//! **Streaming no longer costs a full rebuild** (fixed after the P0 gate
|
||||
//! showed why it mattered -- 20 events/second means 20 rebuilds/second of
|
||||
//! a ~3,200-row transcript otherwise): `apply_event` calls
|
||||
//! `transcript_ui::TranscriptScreen::apply` with the item list before and
|
||||
//! after `fold_event`, which updates only the row(s) that actually
|
||||
//! changed (almost always just the one open assistant message) instead of
|
||||
//! refolding and rebuilding every row. `rebuild_transcript` still runs
|
||||
//! the whole widget tree once, for the opening page and for `apply`'s own
|
||||
//! rare regroup fallback.
|
||||
|
||||
use client_core::api::{ApiClient, UreqTransport};
|
||||
use client_core::event_stream::{StreamItem, follow_session_events};
|
||||
@@ -360,8 +368,17 @@ impl TranscriptClient {
|
||||
}
|
||||
|
||||
fn apply_event(&mut self, rsc: &mut AndroidRsc<Self>, event: &SeqEvent) {
|
||||
let old_items = self.items.clone();
|
||||
self.items = fold_event(&self.items, event);
|
||||
self.rebuild_transcript(rsc);
|
||||
match &self.screen {
|
||||
// The common path: update only the row(s) that actually
|
||||
// changed instead of refolding and rebuilding all ~3,200 of
|
||||
// them per event (RUST.md's P0 streaming-phase fix).
|
||||
Some(screen) => screen.apply(rsc, &old_items, &self.items),
|
||||
// No screen yet (the opening page hasn't landed) -- build one
|
||||
// the ordinary way once it has.
|
||||
None => self.rebuild_transcript(rsc),
|
||||
}
|
||||
}
|
||||
|
||||
fn send_message(&mut self, session_id: String, text: String) {
|
||||
|
||||
@@ -142,7 +142,7 @@ fn bench_first_frame(n: usize) {
|
||||
let start = Instant::now();
|
||||
render.update(&root, &mut rsc);
|
||||
let elapsed = start.elapsed();
|
||||
let (draws, rewrites, moves) = render.take_counters();
|
||||
let (draws, rewrites, moves, _shapes) = render.take_counters();
|
||||
report(
|
||||
&format!("(a) first frame, N={n}"),
|
||||
elapsed,
|
||||
@@ -177,7 +177,7 @@ fn bench_scroll(n: usize, ticks: usize) {
|
||||
let start = Instant::now();
|
||||
render.update(&root, &mut rsc);
|
||||
total += start.elapsed();
|
||||
let (draws, rewrites, moves) = render.take_counters();
|
||||
let (draws, rewrites, moves, _shapes) = render.take_counters();
|
||||
total_draws += draws;
|
||||
total_rewrites += rewrites;
|
||||
total_moves += moves;
|
||||
@@ -245,7 +245,7 @@ fn bench_input_grows(n: usize, lines: usize) {
|
||||
let start = Instant::now();
|
||||
render.update(&root, &mut rsc);
|
||||
total += start.elapsed();
|
||||
let (draws, rewrites, moves) = render.take_counters();
|
||||
let (draws, rewrites, moves, _shapes) = render.take_counters();
|
||||
total_draws += draws;
|
||||
total_rewrites += rewrites;
|
||||
total_moves += moves;
|
||||
@@ -302,7 +302,7 @@ fn bench_insert_above_anchor(n: usize, inserts: usize) {
|
||||
let start = Instant::now();
|
||||
render.update(&root, &mut rsc);
|
||||
total += start.elapsed();
|
||||
let (draws, rewrites, moves) = render.take_counters();
|
||||
let (draws, rewrites, moves, _shapes) = render.take_counters();
|
||||
total_draws += draws;
|
||||
total_rewrites += rewrites;
|
||||
total_moves += moves;
|
||||
@@ -384,7 +384,7 @@ fn bench_expand_holds_edge(n: usize, growths: usize) {
|
||||
let start = Instant::now();
|
||||
render.update(&root, &mut rsc);
|
||||
total += start.elapsed();
|
||||
let (draws, rewrites, moves) = render.take_counters();
|
||||
let (draws, rewrites, moves, _shapes) = render.take_counters();
|
||||
total_draws += draws;
|
||||
total_rewrites += rewrites;
|
||||
total_moves += moves;
|
||||
|
||||
@@ -5,6 +5,12 @@ edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
wgpu = { workspace = true }
|
||||
# Only for `UiRenderNode::new`'s `push_error_scope`/`pop_error_scope` pair
|
||||
# (renderer-creation error reporting, RUST.md's P0 phone-crash box) --
|
||||
# `block_on` turns that one async pop into the same synchronous call shape
|
||||
# `device_limits()`'s two callers already use for `request_adapter`/
|
||||
# `request_device`, rather than making this crate's one entry point async.
|
||||
pollster = { workspace = true }
|
||||
bytemuck ={ workspace = true }
|
||||
image = { workspace = true }
|
||||
parley = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -9,7 +9,31 @@ pub struct Size {
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Len {
|
||||
/// Physical pixels -- a raw device pixel, unaffected by the display's
|
||||
/// density. Rare to want directly (a hairline border is the usual
|
||||
/// case); most sizes should be `dp` instead. See `dp`'s own doc for why
|
||||
/// the two are kept separate rather than one field a caller has to
|
||||
/// remember to pre-multiply.
|
||||
pub abs: f32,
|
||||
/// Density-independent pixels -- Android's `dp` / CSS's reference pixel
|
||||
/// (1 unit = 1/160in), resolved against the display's density at
|
||||
/// layout time (`apply_rest`'s `density` parameter) rather than at the
|
||||
/// point a widget is built, since density is a property of the device
|
||||
/// this ends up running on, not of the widget tree. This is the unit
|
||||
/// IRIS_TODO.md's "a density-independent length unit" item asked for,
|
||||
/// 2026-09-06: before it existed, every size in the tree was `abs`
|
||||
/// (physical pixels), and the only way to make a 16px design draw at
|
||||
/// the right *size* on a denser display was a single global multiply
|
||||
/// applied to the whole rendered scene after layout -- which is also
|
||||
/// what made text blurry (RUST.md's P0 box, "blurry ... glyphs drawn
|
||||
/// at logical size and stretched by the scale"): a glyph rasterised at
|
||||
/// 16 physical px and then stretched 3x by that global multiply is a
|
||||
/// 48px area sampled from a 16px bitmap. Resolving `dp` per-length at
|
||||
/// layout time instead means the font size handed to the text shaper
|
||||
/// is already the physical size (`16.0.dp() * 3.0`), so the glyph
|
||||
/// atlas rasterises at the display's real resolution and nothing
|
||||
/// downstream needs to stretch anything.
|
||||
pub dp: f32,
|
||||
pub rel: f32,
|
||||
pub rest: f32,
|
||||
}
|
||||
@@ -67,10 +91,10 @@ impl Size {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_uivec2(self) -> UiVec2 {
|
||||
pub fn to_uivec2(self, density: f32) -> UiVec2 {
|
||||
UiVec2 {
|
||||
x: self.x.apply_rest(),
|
||||
y: self.y.apply_rest(),
|
||||
x: self.x.apply_rest(density),
|
||||
y: self.y.apply_rest(density),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,26 +122,66 @@ impl Size {
|
||||
impl Len {
|
||||
pub const ZERO: Self = Self {
|
||||
abs: 0.0,
|
||||
dp: 0.0,
|
||||
rel: 0.0,
|
||||
rest: 0.0,
|
||||
};
|
||||
|
||||
pub const REST: Self = Self {
|
||||
abs: 0.0,
|
||||
dp: 0.0,
|
||||
rel: 0.0,
|
||||
rest: 1.0,
|
||||
};
|
||||
|
||||
pub fn apply_rest(&self) -> UiScalar {
|
||||
/// Resolves to a `UiScalar`, folding `dp` into `abs` pixels against
|
||||
/// `density` (physical pixels per dp -- 1.0 on a desktop or an
|
||||
/// unscaled display, `content_scale` on Android; see `dp`'s field
|
||||
/// doc). Every other component of `Len` is already resolution-
|
||||
/// independent (`rel` is a fraction of the parent; `rest` becomes a
|
||||
/// fraction too, below), so `density` only ever touches this one term.
|
||||
pub fn apply_rest(&self, density: f32) -> UiScalar {
|
||||
UiScalar {
|
||||
rel: self.rel + if self.rest > 0.0 { 1.0 } else { 0.0 },
|
||||
abs: self.abs,
|
||||
abs: self.abs + self.dp * density,
|
||||
}
|
||||
}
|
||||
|
||||
/// The same fold as [`Self::apply_rest`] but staying a `Len`, so
|
||||
/// `rest` survives: `dp` becomes physical pixels and every other
|
||||
/// component is left alone.
|
||||
///
|
||||
/// **A `Len` a widget *reports* must have been through this.** `dp` is
|
||||
/// an input unit -- a number the widget author wrote -- and the
|
||||
/// containers that consume a reported length read `abs`/`rel`/`rest`
|
||||
/// directly (`Span::draw`'s placement arithmetic, `Pad`'s addition),
|
||||
/// so a reported `dp` is silently worth zero. That is what made the
|
||||
/// composer's bar collapse to nothing the moment its content grew past
|
||||
/// `MaxSize`'s cap: the cap was `dp(168)` and was returned unresolved,
|
||||
/// so the bar was given a slot of 0 and the field inside it was panned
|
||||
/// out of a container measured at -63px. `UiRenderState::draw_inner`
|
||||
/// debug-asserts the invariant after every `Widget::draw`.
|
||||
pub fn fold_dp(&self, density: f32) -> Self {
|
||||
Self {
|
||||
abs: self.abs + self.dp * density,
|
||||
dp: 0.0,
|
||||
rel: self.rel,
|
||||
rest: self.rest,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn abs(abs: impl UiNum) -> Self {
|
||||
Self {
|
||||
abs: abs.to_f32(),
|
||||
dp: 0.0,
|
||||
rel: 0.0,
|
||||
rest: 0.0,
|
||||
}
|
||||
}
|
||||
pub fn dp(dp: impl UiNum) -> Self {
|
||||
Self {
|
||||
abs: 0.0,
|
||||
dp: dp.to_f32(),
|
||||
rel: 0.0,
|
||||
rest: 0.0,
|
||||
}
|
||||
@@ -125,6 +189,7 @@ impl Len {
|
||||
pub fn rel(rel: impl UiNum) -> Self {
|
||||
Self {
|
||||
abs: 0.0,
|
||||
dp: 0.0,
|
||||
rel: rel.to_f32(),
|
||||
rest: 0.0,
|
||||
}
|
||||
@@ -132,6 +197,7 @@ impl Len {
|
||||
pub fn rest(ratio: impl UiNum) -> Self {
|
||||
Self {
|
||||
abs: 0.0,
|
||||
dp: 0.0,
|
||||
rel: 0.0,
|
||||
rest: ratio.to_f32(),
|
||||
}
|
||||
@@ -144,6 +210,15 @@ pub mod len_fns {
|
||||
pub fn abs(abs: impl UiNum) -> Len {
|
||||
Len {
|
||||
abs: abs.to_f32(),
|
||||
dp: 0.0,
|
||||
rel: 0.0,
|
||||
rest: 0.0,
|
||||
}
|
||||
}
|
||||
pub fn dp(dp: impl UiNum) -> Len {
|
||||
Len {
|
||||
abs: 0.0,
|
||||
dp: dp.to_f32(),
|
||||
rel: 0.0,
|
||||
rest: 0.0,
|
||||
}
|
||||
@@ -151,6 +226,7 @@ pub mod len_fns {
|
||||
pub fn rel(rel: impl UiNum) -> Len {
|
||||
Len {
|
||||
abs: 0.0,
|
||||
dp: 0.0,
|
||||
rel: rel.to_f32(),
|
||||
rest: 0.0,
|
||||
}
|
||||
@@ -158,14 +234,15 @@ pub mod len_fns {
|
||||
pub fn rest(ratio: impl UiNum) -> Len {
|
||||
Len {
|
||||
abs: 0.0,
|
||||
dp: 0.0,
|
||||
rel: 0.0,
|
||||
rest: ratio.to_f32(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl_op!(Len Add add; abs rel rest);
|
||||
impl_op!(Len Sub sub; abs rel rest);
|
||||
impl_op!(Len Add add; abs dp rel rest);
|
||||
impl_op!(Len Sub sub; abs dp rel rest);
|
||||
|
||||
impl_op!(Size Add add; x y);
|
||||
impl_op!(Size Sub sub; x y);
|
||||
@@ -187,6 +264,9 @@ impl std::fmt::Display for Len {
|
||||
if self.abs != 0.0 {
|
||||
write!(f, "{} abs;", self.abs)?;
|
||||
}
|
||||
if self.dp != 0.0 {
|
||||
write!(f, "{} dp;", self.dp)?;
|
||||
}
|
||||
if self.rel != 0.0 {
|
||||
write!(f, "{} rel;", self.rel)?;
|
||||
}
|
||||
|
||||
@@ -2,14 +2,63 @@ use crate::{Align, GlyphAtlas, GlyphKey, PlacedGlyph, RegionAlign, Textures, UiC
|
||||
use parley::{
|
||||
Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, FontStyle, FontWeight,
|
||||
GenericFamily, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty,
|
||||
fontique::{Blob, FamilyId},
|
||||
};
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
use swash::{
|
||||
FontRef,
|
||||
scale::{Render, ScaleContext, Source, StrikeWith},
|
||||
zeno::{Format, Vector},
|
||||
};
|
||||
|
||||
/// Bundled fonts, registered over the system collection rather than relied
|
||||
/// on alone -- see `TextData::register_bundled_fonts`'s doc comment for
|
||||
/// why. Static weight/style cuts, not a variable font: parley/fontique
|
||||
/// resolve a variable font's weight axis by picking normalized coordinates
|
||||
/// on whatever single face registers for the family, and a phone whose
|
||||
/// system "Roboto" is actually the variable "Roboto Flex" is exactly the
|
||||
/// device class this sidesteps, rather than depends on working correctly.
|
||||
/// Noto Sans, OFL-licensed (`assets/fonts/OFL.txt`), chosen for coverage
|
||||
/// breadth (a transcript's content is not known in advance) over a
|
||||
/// smaller-footprint alternative -- see the doc comment for the size this
|
||||
/// added.
|
||||
const NOTO_SANS_REGULAR: &[u8] = include_bytes!("../../assets/fonts/NotoSans-Regular.ttf");
|
||||
const NOTO_SANS_BOLD: &[u8] = include_bytes!("../../assets/fonts/NotoSans-Bold.ttf");
|
||||
const NOTO_SANS_ITALIC: &[u8] = include_bytes!("../../assets/fonts/NotoSans-Italic.ttf");
|
||||
const NOTO_SANS_BOLD_ITALIC: &[u8] = include_bytes!("../../assets/fonts/NotoSans-BoldItalic.ttf");
|
||||
const NOTO_SANS_MONO_REGULAR: &[u8] = include_bytes!("../../assets/fonts/NotoSansMono-Regular.ttf");
|
||||
const NOTO_SANS_MONO_BOLD: &[u8] = include_bytes!("../../assets/fonts/NotoSansMono-Bold.ttf");
|
||||
|
||||
/// What starting up found about text rendering, for the on-screen
|
||||
/// Diagnostics page and the one startup log line (RUST.md's P0 box, "log
|
||||
/// once at startup ... the number of font families found, the default
|
||||
/// family resolved"). Built once by `TextData::font_diagnostics` --
|
||||
/// `Default::default` still exists for callers (tests, examples) that
|
||||
/// don't need the report.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FontDiagnostics {
|
||||
/// `Collection::family_names().count()` after registering the bundled
|
||||
/// fonts -- system families plus the two bundled ones.
|
||||
pub families_found: usize,
|
||||
/// The family `GenericFamily::SansSerif` resolves to first -- the
|
||||
/// bundled "Noto Sans" unless registration itself failed.
|
||||
pub default_family: Option<String>,
|
||||
/// The family `GenericFamily::Monospace` resolves to first.
|
||||
pub default_mono_family: Option<String>,
|
||||
/// One resolved family name per style axis this crate actually uses
|
||||
/// (`SpanStyle::bold`/`italic`), so a report can say plainly whether a
|
||||
/// bold/italic request is landing on a real face rather than being
|
||||
/// silently absorbed by whatever the sans-serif default resolves to
|
||||
/// for every weight (RUST.md's P0 box, "bold words render as blank
|
||||
/// gaps" -- a family that resolves but has no distinct bold face is
|
||||
/// exactly what produced that).
|
||||
pub regular_resolved: Option<String>,
|
||||
pub bold_resolved: Option<String>,
|
||||
pub italic_resolved: Option<String>,
|
||||
pub mono_resolved: Option<String>,
|
||||
}
|
||||
|
||||
/// Everything text needs that outlives one string: the font collection, the
|
||||
/// layout scratch space, the glyph rasteriser and the atlas they fill.
|
||||
pub struct TextData {
|
||||
@@ -17,15 +66,182 @@ pub struct TextData {
|
||||
pub layout_cx: LayoutContext<UiColor>,
|
||||
scale_cx: ScaleContext,
|
||||
pub atlas: GlyphAtlas,
|
||||
/// Physical pixels per dp -- a second copy of
|
||||
/// `UiRenderState::density`, kept here too because `TextEditCtx::layout`
|
||||
/// (cursor movement and hit-testing, `widget/text/edit.rs`) shapes text
|
||||
/// from an event callback that has a `TextData` but no `Painter`, so it
|
||||
/// has nowhere else to read the display's density from. Both copies are
|
||||
/// set together, from the one place either backend learns the real
|
||||
/// value (`android::view::new_peer`); this is the same accepted
|
||||
/// duplication as `AndroidRenderer::content_scale`; a single source of
|
||||
/// truth would mean carrying a `Painter` (or output size) into every
|
||||
/// input handler for the sake of one field.
|
||||
pub density: f32,
|
||||
}
|
||||
|
||||
impl Default for TextData {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
let mut data = Self {
|
||||
font_cx: FontContext::new(),
|
||||
layout_cx: LayoutContext::new(),
|
||||
scale_cx: ScaleContext::new(),
|
||||
atlas: GlyphAtlas::default(),
|
||||
density: 1.0,
|
||||
};
|
||||
data.register_bundled_fonts();
|
||||
data
|
||||
}
|
||||
}
|
||||
|
||||
impl TextData {
|
||||
/// Registers Noto Sans (regular/bold/italic/bold-italic) and Noto Sans
|
||||
/// Mono (regular/bold) as static faces, and puts them **first** in the
|
||||
/// `SansSerif`/`Monospace` generic-family fallback lists -- ahead of,
|
||||
/// not instead of, whatever the platform already found, so a script
|
||||
/// Noto Sans lacks (CJK, emoji, ...) still falls through to the system
|
||||
/// font the same as before this existed.
|
||||
///
|
||||
/// Exists because text rendering must not depend on the platform's own
|
||||
/// font enumeration succeeding or resolving weight/style the way this
|
||||
/// crate assumes: RUST.md's P0 box found bold spans on a real phone
|
||||
/// rendering as blank gaps of the correct advance width (the glyph
|
||||
/// simply wasn't rasterised -- `TextData::place`'s `None` arm), while
|
||||
/// the emulator's system fonts happened to resolve every style. A
|
||||
/// bundled, static-per-style family removes fontique's Android font
|
||||
/// scan (`fontique::backend::android::SystemFonts::new`, which parses
|
||||
/// `/system/fonts` and `/system/etc/fonts.xml`) from the path a glyph
|
||||
/// has to survive to reach the screen at all.
|
||||
///
|
||||
/// Cost: six static `.ttf`s, ~3.6 MB uncompressed
|
||||
/// (`iris/core/assets/fonts/`), landing in the APK compressed --
|
||||
/// `build-apk.sh`'s own output is what says the delivered number, not
|
||||
/// this comment.
|
||||
fn register_bundled_fonts(&mut self) {
|
||||
fn register(cx: &mut FontContext, bytes: &'static [u8]) -> Option<FamilyId> {
|
||||
let blob = Blob::new(Arc::new(bytes));
|
||||
cx.collection
|
||||
.register_fonts(blob, None)
|
||||
.into_iter()
|
||||
.map(|(id, _)| id)
|
||||
.next()
|
||||
}
|
||||
let sans_id = register(&mut self.font_cx, NOTO_SANS_REGULAR);
|
||||
register(&mut self.font_cx, NOTO_SANS_BOLD);
|
||||
register(&mut self.font_cx, NOTO_SANS_ITALIC);
|
||||
register(&mut self.font_cx, NOTO_SANS_BOLD_ITALIC);
|
||||
let mono_id = register(&mut self.font_cx, NOTO_SANS_MONO_REGULAR);
|
||||
register(&mut self.font_cx, NOTO_SANS_MONO_BOLD);
|
||||
|
||||
if let Some(sans_id) = sans_id {
|
||||
let existing: Vec<_> = self
|
||||
.font_cx
|
||||
.collection
|
||||
.generic_families(GenericFamily::SansSerif)
|
||||
.collect();
|
||||
self.font_cx.collection.set_generic_families(
|
||||
GenericFamily::SansSerif,
|
||||
std::iter::once(sans_id).chain(existing),
|
||||
);
|
||||
let existing: Vec<_> = self
|
||||
.font_cx
|
||||
.collection
|
||||
.generic_families(GenericFamily::SystemUi)
|
||||
.collect();
|
||||
self.font_cx.collection.set_generic_families(
|
||||
GenericFamily::SystemUi,
|
||||
std::iter::once(sans_id).chain(existing),
|
||||
);
|
||||
}
|
||||
if let Some(mono_id) = mono_id {
|
||||
let existing: Vec<_> = self
|
||||
.font_cx
|
||||
.collection
|
||||
.generic_families(GenericFamily::Monospace)
|
||||
.collect();
|
||||
self.font_cx.collection.set_generic_families(
|
||||
GenericFamily::Monospace,
|
||||
std::iter::once(mono_id).chain(existing),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the startup report -- see `FontDiagnostics`. Queries the
|
||||
/// collection directly (`fontique::Query`) rather than shaping a real
|
||||
/// string, since all that's needed is which family each axis lands on.
|
||||
pub fn font_diagnostics(&mut self) -> FontDiagnostics {
|
||||
use parley::fontique::{Attributes, FontWidth, QueryStatus};
|
||||
let families_found = self.font_cx.collection.family_names().count();
|
||||
let default_family_id = self
|
||||
.font_cx
|
||||
.collection
|
||||
.generic_families(GenericFamily::SansSerif)
|
||||
.next();
|
||||
let default_family = default_family_id
|
||||
.and_then(|id| self.font_cx.collection.family_name(id).map(str::to_string));
|
||||
let default_mono_family_id = self
|
||||
.font_cx
|
||||
.collection
|
||||
.generic_families(GenericFamily::Monospace)
|
||||
.next();
|
||||
let default_mono_family = default_mono_family_id
|
||||
.and_then(|id| self.font_cx.collection.family_name(id).map(str::to_string));
|
||||
|
||||
// Resolves the family a (generic family, weight, style) query lands
|
||||
// on, without holding the `Query`'s borrow of `collection` across
|
||||
// the `family_name` lookup that needs it back -- the `FamilyId` is
|
||||
// captured out of the closure first, then looked up once `query`
|
||||
// (and its borrow) has been dropped.
|
||||
let mut resolve_family =
|
||||
|generic: GenericFamily, weight: FontWeight, style: FontStyle| -> Option<String> {
|
||||
let mut family_id = None;
|
||||
{
|
||||
let mut query = self
|
||||
.font_cx
|
||||
.collection
|
||||
.query(&mut self.font_cx.source_cache);
|
||||
query.set_families([generic]);
|
||||
query.set_attributes(Attributes {
|
||||
width: FontWidth::NORMAL,
|
||||
style,
|
||||
weight,
|
||||
});
|
||||
query.matches_with(|font| {
|
||||
family_id = Some(font.family.0);
|
||||
QueryStatus::Stop
|
||||
});
|
||||
}
|
||||
family_id.and_then(|id| self.font_cx.collection.family_name(id).map(str::to_string))
|
||||
};
|
||||
|
||||
let regular_resolved = resolve_family(
|
||||
GenericFamily::SansSerif,
|
||||
FontWeight::NORMAL,
|
||||
FontStyle::Normal,
|
||||
);
|
||||
let bold_resolved = resolve_family(
|
||||
GenericFamily::SansSerif,
|
||||
FontWeight::BOLD,
|
||||
FontStyle::Normal,
|
||||
);
|
||||
let italic_resolved = resolve_family(
|
||||
GenericFamily::SansSerif,
|
||||
FontWeight::NORMAL,
|
||||
FontStyle::Italic,
|
||||
);
|
||||
let mono_resolved = resolve_family(
|
||||
GenericFamily::Monospace,
|
||||
FontWeight::NORMAL,
|
||||
FontStyle::Normal,
|
||||
);
|
||||
|
||||
FontDiagnostics {
|
||||
families_found,
|
||||
default_family,
|
||||
default_mono_family,
|
||||
regular_resolved,
|
||||
bold_resolved,
|
||||
italic_resolved,
|
||||
mono_resolved,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,7 +375,7 @@ pub struct TextBuffer {
|
||||
/// `set_spans` forces `shaped` to `None` directly, the same way `edit`
|
||||
/// does, since spans change far less often than a naive equality check
|
||||
/// on the whole `Vec` would cost to compute every frame.
|
||||
shaped: Option<(TextAttrs, Option<f32>)>,
|
||||
shaped: Option<(TextAttrs, Option<f32>, f32)>,
|
||||
}
|
||||
|
||||
impl TextBuffer {
|
||||
@@ -215,19 +431,42 @@ impl TextBuffer {
|
||||
Vec2::new(self.layout.width(), self.layout.height())
|
||||
}
|
||||
|
||||
/// Lay the text out, unless it is already laid out for these attributes and
|
||||
/// this width.
|
||||
pub fn shape(&mut self, data: &mut TextData, attrs: &TextAttrs, width: Option<f32>) {
|
||||
if self.shaped.as_ref() == Some(&(attrs.clone(), width)) {
|
||||
/// Lay the text out, unless it is already laid out for these
|
||||
/// attributes, this width and this density.
|
||||
///
|
||||
/// **`attrs.font_size`/`line_height` and every span's own `font_size`
|
||||
/// are density-independent (dp) units, multiplied by `density` here --
|
||||
/// the one place text crosses from the widget tree's dp sizes into the
|
||||
/// physical pixels the shaper and rasteriser (`TextData::place`) both
|
||||
/// then work in.** This is what makes glyphs sharp on a dense display:
|
||||
/// before this existed, `font_size` was already a physical-pixel value
|
||||
/// (RUST.md's P0 box's global-scale stopgap resolved density by
|
||||
/// stretching the whole rendered frame afterward instead), so a glyph
|
||||
/// was rasterised small and then upscaled by whatever the display's
|
||||
/// scale factor was -- exactly the blur Iris's report described.
|
||||
/// Multiplying here instead means the font size hitting `ScaleContext`
|
||||
/// in `place` below is already the display's real physical size, so
|
||||
/// the atlas holds a bitmap at the resolution it is actually shown at.
|
||||
/// `GlyphKey.size` already keys on that resolved `font_size`
|
||||
/// (`(font_size * 16.0).round()`), so a cache entry is naturally per
|
||||
/// physical size with no change needed there.
|
||||
pub fn shape(
|
||||
&mut self,
|
||||
data: &mut TextData,
|
||||
attrs: &TextAttrs,
|
||||
width: Option<f32>,
|
||||
density: f32,
|
||||
) {
|
||||
if self.shaped.as_ref() == Some(&(attrs.clone(), width, density)) {
|
||||
return;
|
||||
}
|
||||
let mut builder = data
|
||||
.layout_cx
|
||||
.ranged_builder(&mut data.font_cx, &self.text, 1.0, true);
|
||||
builder.push_default(StyleProperty::FontFamily(attrs.family.family()));
|
||||
builder.push_default(StyleProperty::FontSize(attrs.font_size));
|
||||
builder.push_default(StyleProperty::FontSize(attrs.font_size * density));
|
||||
builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute(
|
||||
attrs.line_height,
|
||||
attrs.line_height * density,
|
||||
)));
|
||||
builder.push_default(StyleProperty::Brush(attrs.color));
|
||||
for span in &self.spans {
|
||||
@@ -239,7 +478,7 @@ impl TextBuffer {
|
||||
builder.push(StyleProperty::FontFamily(family.family()), range.clone());
|
||||
}
|
||||
if let Some(size) = span.font_size {
|
||||
builder.push(StyleProperty::FontSize(size), range.clone());
|
||||
builder.push(StyleProperty::FontSize(size * density), range.clone());
|
||||
}
|
||||
if span.bold {
|
||||
builder.push(StyleProperty::FontWeight(FontWeight::BOLD), range.clone());
|
||||
@@ -255,7 +494,7 @@ impl TextBuffer {
|
||||
self.layout.break_all_lines(width);
|
||||
self.layout
|
||||
.align(Alignment::Start, AlignmentOptions::default());
|
||||
self.shaped = Some((attrs.clone(), width));
|
||||
self.shaped = Some((attrs.clone(), width, density));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,6 +601,11 @@ pub struct RenderedText {
|
||||
pub glyphs: std::sync::Arc<Vec<PlacedGlyph>>,
|
||||
pub size: Vec2,
|
||||
pub color: UiColor,
|
||||
/// The [`GlyphAtlas::generation`] the glyphs above were placed against.
|
||||
/// A holder must re-render rather than re-emit these quads once the
|
||||
/// atlas has moved on (`GlyphAtlas::clear`'s doc says what happens
|
||||
/// otherwise); `Painter::glyphs` debug-asserts it.
|
||||
pub generation: u64,
|
||||
}
|
||||
|
||||
impl TextData {
|
||||
@@ -372,13 +616,15 @@ impl TextData {
|
||||
attrs: &TextAttrs,
|
||||
width: Option<f32>,
|
||||
textures: &mut Textures,
|
||||
density: f32,
|
||||
) -> RenderedText {
|
||||
buffer.shape(self, attrs, width);
|
||||
buffer.shape(self, attrs, width, density);
|
||||
let glyphs = self.place(buffer, textures);
|
||||
RenderedText {
|
||||
glyphs: std::sync::Arc::new(glyphs),
|
||||
size: buffer.size(),
|
||||
color: attrs.color,
|
||||
generation: self.atlas.generation(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,27 @@ impl Textures {
|
||||
self.updates.push(Update::Patch(handle.slot, rect));
|
||||
}
|
||||
|
||||
/// Forget every image, page and pending update -- what a genuinely new
|
||||
/// GPU device needs alongside [`crate::render::atlas::GlyphAtlas::
|
||||
/// clear`], which this module's own doc references: every slot number
|
||||
/// and every queued [`Update`] here describes the *old* device's
|
||||
/// textures (an `Update::Push`/`Update::Patch` already drained into a
|
||||
/// renderer that no longer exists is gone for good, and a fresh
|
||||
/// `UiRenderNode`'s own texture manager starts with none of them
|
||||
/// applied), so nothing is lost by starting this bookkeeping over too.
|
||||
/// Any `TextureHandle` a caller still holds across the reset (none in
|
||||
/// the transcript screen this reset is wired up for today -- confirmed
|
||||
/// by grep, the only standalone (non-atlas) image anywhere in this
|
||||
/// workspace is `iris/widget/image.rs`'s `Image`, used by the separate
|
||||
/// `tabs-ui` example) is left pointing at a slot this instance no
|
||||
/// longer recognises and needs reinserting via `add`/`add_page` again
|
||||
/// -- the same pre-existing gap a renderer restart already left for
|
||||
/// such a handle before this method existed, just named rather than
|
||||
/// silent now.
|
||||
pub fn reset(&mut self) {
|
||||
*self = Self::new();
|
||||
}
|
||||
|
||||
pub fn free(&mut self) {
|
||||
for (kind, idx) in self.recv.try_iter() {
|
||||
self.images[idx as usize] = None;
|
||||
|
||||
@@ -71,6 +71,10 @@ struct Page {
|
||||
#[derive(Default)]
|
||||
pub struct GlyphAtlas {
|
||||
pages: Vec<Page>,
|
||||
/// Bumped by [`GlyphAtlas::clear`], so anything holding placed glyphs
|
||||
/// from an earlier atlas can tell that its coordinates are stale --
|
||||
/// see that method's doc for what goes wrong without it.
|
||||
generation: u64,
|
||||
/// `None` for a glyph that rasterised to nothing -- a space, say. Cached
|
||||
/// too, so it is not re-rasterised on every layout.
|
||||
entries: HashMap<GlyphKey, Option<GlyphEntry>>,
|
||||
@@ -166,6 +170,13 @@ impl GlyphAtlas {
|
||||
self.entries.insert(key, None);
|
||||
}
|
||||
|
||||
/// Which atlas the entries handed out right now belong to. A
|
||||
/// [`crate::RenderedText`] records this when it is built and is only
|
||||
/// reusable while it still matches.
|
||||
pub fn generation(&self) -> u64 {
|
||||
self.generation
|
||||
}
|
||||
|
||||
pub fn page_count(&self) -> usize {
|
||||
self.pages.len()
|
||||
}
|
||||
@@ -173,6 +184,36 @@ impl GlyphAtlas {
|
||||
pub fn glyph_count(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// Forget every page and every rasterised entry -- what a genuinely new
|
||||
/// GPU device needs (`android::view::IrisViewPeer::surface_changed`'s
|
||||
/// "not already live" branch, e.g. after backgrounding): the pages this
|
||||
/// atlas remembers are `TextureHandle`s into the *old* device's
|
||||
/// textures, which no longer exist, and every `GlyphEntry`'s `uv_min`/
|
||||
/// `uv_max`/`layer` point into them. Without this, a glyph already
|
||||
/// cached here is treated as "already placed" and never re-inserted
|
||||
/// into the fresh (empty) atlas the new renderer actually has --
|
||||
/// exactly the "rectangles stay, glyphs disappear" bug the resize path
|
||||
/// (`AndroidRenderer::resize`) was built to avoid for the reuse case;
|
||||
/// this is its counterpart for the case where the renderer really is
|
||||
/// new. Dropping `pages` also drops its `TextureHandle`s, which send a
|
||||
/// free message back through their `Textures`; see `Textures::reset`'s
|
||||
/// doc for why that is harmless here.
|
||||
/// Bumping `generation` here is the other half of the same
|
||||
/// invalidation: emptying this atlas does nothing about the
|
||||
/// `RenderedText`s widgets are *already holding*
|
||||
/// (`iris::widget::TextView`'s `tex` cache), whose `PlacedGlyph`s carry
|
||||
/// `uv_min`/`uv_max`/`layer` into the atlas that has just been thrown
|
||||
/// away. Those redraw perfectly happily and sample whatever now sits at
|
||||
/// those coordinates -- the fragments-of-other-glyphs Iris photographed
|
||||
/// after resuming the app on 2026-09-06. One counter, checked where the
|
||||
/// cache is read, is what makes a cached render un-reusable across a
|
||||
/// renderer rebuild.
|
||||
pub fn clear(&mut self) {
|
||||
self.pages.clear();
|
||||
self.entries.clear();
|
||||
self.generation += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn fits(page: &Page, need_w: u32, need_h: u32) -> bool {
|
||||
|
||||
@@ -1,15 +1,87 @@
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The frame budget `dumpsys gfxinfo` also uses to call a frame "janky": the
|
||||
/// 60Hz vsync period. Kept as the same threshold so a percentage from this
|
||||
/// report and a percentage from `gfxinfo` mean the same thing.
|
||||
/// report and a percentage from `gfxinfo` mean the same thing. Only a
|
||||
/// fallback now that a caller can read the display's real refresh rate
|
||||
/// (`report_at_hz`/`mark_phase`'s callers) -- most devices are 60Hz, but a
|
||||
/// 90Hz or 120Hz phone judged against this constant would call every frame
|
||||
/// "late" that merely met its own, faster budget.
|
||||
pub const JANK_THRESHOLD: Duration = Duration::from_nanos(16_666_667);
|
||||
|
||||
/// Enough frames for several minutes of scrolling before the oldest ones
|
||||
/// start being overwritten -- the same "diagnostic, not a log" sizing
|
||||
/// `FrameStats.kt`'s `CAP` uses on the Compose side, chosen independently
|
||||
/// here since a `Duration` is smaller than the six `Long` arrays it keeps.
|
||||
const RING_CAPACITY: usize = 4096;
|
||||
/// Bumped from 4096 for RUST.md's "Benchmark v2": a fling+stream+type+
|
||||
/// keyboard run is ~6,500+ frames on the Compose side, comfortably under
|
||||
/// this so `phase_stats` never has to report a phase as partially evicted.
|
||||
const RING_CAPACITY: usize = 16384;
|
||||
|
||||
/// One `mark_phase` call: the wall-clock instant and the (0-based,
|
||||
/// never-reset-by-`reset`-except-at-`reset`-time) absolute frame index at
|
||||
/// which a phase began -- `phase_stats` slices `index_ring` against this to
|
||||
/// find which recorded samples belong to which phase, since the ring
|
||||
/// itself only keeps the most recent `RING_CAPACITY` samples' *values*,
|
||||
/// not which phase they were in.
|
||||
struct PhaseMark {
|
||||
name: String,
|
||||
start_index: u64,
|
||||
start_at: Instant,
|
||||
}
|
||||
|
||||
/// One phase's own slice of a report -- RUST.md's "Benchmark v2" spec's
|
||||
/// "per-phase blocks in `FrameReport`... frames, late count/percent...
|
||||
/// p50/p90/p99, worst, duration". `Display` matches the shape
|
||||
/// `docs/bench/compose-phone-v2-2026-09-06.md`'s report already uses, so
|
||||
/// the two apps' reports read the same way side by side.
|
||||
pub struct PhaseStats {
|
||||
pub name: String,
|
||||
/// How many frames were recorded during this phase in total -- may
|
||||
/// exceed `late + (samples counted)` if some of this phase's frames
|
||||
/// have since been evicted from the ring by a very long run; that
|
||||
/// case is named in the `Display` rather than silently under-counted.
|
||||
pub frames: u64,
|
||||
pub duration: Duration,
|
||||
pub late: u64,
|
||||
pub late_percent: f64,
|
||||
pub p50: Duration,
|
||||
pub p90: Duration,
|
||||
pub p99: Duration,
|
||||
pub worst: Duration,
|
||||
/// `false` if this phase's frame count exceeds how many samples of it
|
||||
/// are still in the ring -- the percentiles above are then computed
|
||||
/// over whatever survived, not the whole phase. UI_RULES.md: this is
|
||||
/// the "we don't fully know" state, named rather than folded silently
|
||||
/// into a number that looks exact.
|
||||
pub complete: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PhaseStats {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
writeln!(
|
||||
f,
|
||||
" {}: {} frames over {:.1}s{}",
|
||||
self.name,
|
||||
self.frames,
|
||||
self.duration.as_secs_f64(),
|
||||
if self.complete {
|
||||
""
|
||||
} else {
|
||||
" (ring evicted some of this phase)"
|
||||
},
|
||||
)?;
|
||||
writeln!(f, " late: {} ({:.1}%)", self.late, self.late_percent)?;
|
||||
writeln!(
|
||||
f,
|
||||
" total p50 {:.1}ms p90 {:.1}ms p99 {:.1}ms",
|
||||
self.p50.as_secs_f64() * 1000.0,
|
||||
self.p90.as_secs_f64() * 1000.0,
|
||||
self.p99.as_secs_f64() * 1000.0,
|
||||
)?;
|
||||
write!(f, " worst {:.1}ms", self.worst.as_secs_f64() * 1000.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A per-frame wall-time report iris keeps of itself, because `dumpsys
|
||||
/// gfxinfo` cannot see a `SurfaceView`'s own GPU-drawn frames at all
|
||||
@@ -41,6 +113,11 @@ pub struct FrameReport {
|
||||
/// "Where iris's frame time goes" CPU/GPU split, added 2026-09-05).
|
||||
/// `ring[i] - submit_ring[i]` is that frame's `redraw_to_submit` half.
|
||||
submit_ring: Box<[Duration; RING_CAPACITY]>,
|
||||
/// The absolute (0-based, since the last `reset`) frame index each
|
||||
/// `ring`/`submit_ring` slot's sample belongs to -- what `phase_stats`
|
||||
/// slices against `PhaseMark::start_index` to tell which recorded
|
||||
/// frames fall in which phase.
|
||||
index_ring: Box<[u64; RING_CAPACITY]>,
|
||||
/// How many of `ring`'s slots hold a real sample -- saturates at
|
||||
/// `RING_CAPACITY`, unlike `total_frames` below which keeps counting.
|
||||
len: usize,
|
||||
@@ -50,6 +127,12 @@ pub struct FrameReport {
|
||||
/// correct even once the ring itself only holds the most recent frames.
|
||||
total_frames: u64,
|
||||
janky_frames: u64,
|
||||
/// `mark_phase` calls since the last `reset`, oldest first -- see
|
||||
/// `phase_stats`. Empty on an ordinary run that never calls
|
||||
/// `mark_phase`, so `phase_stats` returns an empty `Vec` and a caller
|
||||
/// prints no "per phase:" section at all, matching RUST.md's "empty/
|
||||
/// absent on an ordinary 'Copy' press, which never marks a phase."
|
||||
phases: Vec<PhaseMark>,
|
||||
}
|
||||
|
||||
/// One resolved reading. `Display` is the log line both the "Frame report"
|
||||
@@ -107,10 +190,12 @@ impl FrameReport {
|
||||
Self {
|
||||
ring: Box::new([Duration::ZERO; RING_CAPACITY]),
|
||||
submit_ring: Box::new([Duration::ZERO; RING_CAPACITY]),
|
||||
index_ring: Box::new([0; RING_CAPACITY]),
|
||||
len: 0,
|
||||
pos: 0,
|
||||
total_frames: 0,
|
||||
janky_frames: 0,
|
||||
phases: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +216,7 @@ impl FrameReport {
|
||||
pub fn record_split(&mut self, total: Duration, submit_to_present: Duration) {
|
||||
self.ring[self.pos] = total;
|
||||
self.submit_ring[self.pos] = submit_to_present;
|
||||
self.index_ring[self.pos] = self.total_frames;
|
||||
self.pos = (self.pos + 1) % RING_CAPACITY;
|
||||
self.len = (self.len + 1).min(RING_CAPACITY);
|
||||
self.total_frames += 1;
|
||||
@@ -142,12 +228,98 @@ impl FrameReport {
|
||||
/// Clears every counter and every sample -- what the "Reset frame
|
||||
/// report" control calls, so a report covers only what was scrolled
|
||||
/// after the button was pressed (the same reason `FrameStats.kt`'s
|
||||
/// `reset()` exists on the Compose side).
|
||||
/// `reset()` exists on the Compose side). Also clears every phase
|
||||
/// mark, so a fresh run starts with no "per phase:" section until it
|
||||
/// marks one of its own.
|
||||
pub fn reset(&mut self) {
|
||||
self.len = 0;
|
||||
self.pos = 0;
|
||||
self.total_frames = 0;
|
||||
self.janky_frames = 0;
|
||||
self.phases.clear();
|
||||
}
|
||||
|
||||
/// Marks the start of a named phase at the current moment -- every
|
||||
/// frame recorded from here until the next `mark_phase` (or `reset`)
|
||||
/// belongs to it. RUST.md's "Benchmark v2": a scripted bench run calls
|
||||
/// this once per phase (fling/stream/type/keyboard) so `phase_stats`
|
||||
/// can slice one whole run's frames by what was happening during each.
|
||||
pub fn mark_phase(&mut self, name: &str) {
|
||||
// `phase_stats`'s slicing (`idx >= phase.start_index && idx <
|
||||
// end_index`) silently produces an empty or nonsensical slice for
|
||||
// a phase pushed out of order rather than surfacing the misuse
|
||||
// (docs/REVIEW-2026-09-06.md finding 5).
|
||||
debug_assert!(
|
||||
self.phases
|
||||
.last()
|
||||
.is_none_or(|p| self.total_frames >= p.start_index)
|
||||
);
|
||||
self.phases.push(PhaseMark {
|
||||
name: name.to_string(),
|
||||
start_index: self.total_frames,
|
||||
start_at: Instant::now(),
|
||||
});
|
||||
}
|
||||
|
||||
/// One [`PhaseStats`] per `mark_phase` call since the last `reset`,
|
||||
/// oldest first. `now` closes the last phase's wall-clock span (there
|
||||
/// is no "next phase" instant to use for it); `refresh_hz` is what
|
||||
/// each phase's own `late`/`late_percent` is judged against, read from
|
||||
/// the display rather than assumed -- RUST.md's "Benchmark v2": "late
|
||||
/// count/% against the display's refresh rate."
|
||||
pub fn phase_stats(&self, now: Instant, refresh_hz: f32) -> Vec<PhaseStats> {
|
||||
if self.phases.is_empty() || refresh_hz <= 0.0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let budget = Duration::from_secs_f64(1.0 / refresh_hz as f64);
|
||||
self.phases
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, phase)| {
|
||||
let (end_index, end_at) = match self.phases.get(i + 1) {
|
||||
Some(next) => (next.start_index, next.start_at),
|
||||
None => (self.total_frames, now),
|
||||
};
|
||||
let frames = end_index.saturating_sub(phase.start_index);
|
||||
let mut samples: Vec<Duration> = (0..self.len)
|
||||
.filter(|&j| {
|
||||
let idx = self.index_ring[j];
|
||||
idx >= phase.start_index && idx < end_index
|
||||
})
|
||||
.map(|j| self.ring[j])
|
||||
.collect();
|
||||
let complete = samples.len() as u64 >= frames;
|
||||
if samples.is_empty() {
|
||||
return PhaseStats {
|
||||
name: phase.name.clone(),
|
||||
frames,
|
||||
duration: end_at.saturating_duration_since(phase.start_at),
|
||||
late: 0,
|
||||
late_percent: 0.0,
|
||||
p50: Duration::ZERO,
|
||||
p90: Duration::ZERO,
|
||||
p99: Duration::ZERO,
|
||||
worst: Duration::ZERO,
|
||||
complete,
|
||||
};
|
||||
}
|
||||
samples.sort_unstable();
|
||||
let pct = |p: usize| samples[(samples.len() * p / 100).min(samples.len() - 1)];
|
||||
let late = samples.iter().filter(|&&d| d > budget).count() as u64;
|
||||
PhaseStats {
|
||||
name: phase.name.clone(),
|
||||
frames,
|
||||
duration: end_at.saturating_duration_since(phase.start_at),
|
||||
late,
|
||||
late_percent: 100.0 * late as f64 / samples.len() as f64,
|
||||
p50: pct(50),
|
||||
p90: pct(90),
|
||||
p99: pct(99),
|
||||
worst: *samples.last().expect("checked not empty above"),
|
||||
complete,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `None` if nothing has been recorded since the last reset -- the
|
||||
@@ -186,6 +358,28 @@ impl FrameReport {
|
||||
gpu_wait_p50: median(submit_samples),
|
||||
})
|
||||
}
|
||||
|
||||
/// `(late count, late percent)` over every sample still in the ring,
|
||||
/// judged against `refresh_hz`'s own frame budget rather than the
|
||||
/// fixed 60Hz `JANK_THRESHOLD` -- RUST.md's "Benchmark v2": "late
|
||||
/// count/% against the display's refresh rate... print 'at N Hz (X ms
|
||||
/// budget)' like Compose does." A separate method from `report()`
|
||||
/// rather than a parameter on it, so `report()`'s own `janky_percent`
|
||||
/// (and the exact-boundary test pinned to `JANK_THRESHOLD`) is
|
||||
/// unaffected for every existing caller that never measured a real
|
||||
/// refresh rate. `(0, 0.0)` with nothing recorded or a non-positive
|
||||
/// `refresh_hz`.
|
||||
pub fn late_at_hz(&self, refresh_hz: f32) -> (u64, f64) {
|
||||
if self.len == 0 || refresh_hz <= 0.0 {
|
||||
return (0, 0.0);
|
||||
}
|
||||
let budget = Duration::from_secs_f64(1.0 / refresh_hz as f64);
|
||||
let late = self.ring[..self.len]
|
||||
.iter()
|
||||
.filter(|&&d| d > budget)
|
||||
.count() as u64;
|
||||
(late, 100.0 * late as f64 / self.len as f64)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FrameReport {
|
||||
@@ -296,4 +490,68 @@ mod tests {
|
||||
// same pattern here.
|
||||
assert!(stats.worst <= Duration::from_millis(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_marks_means_no_phases() {
|
||||
let mut r = FrameReport::new();
|
||||
r.record(Duration::from_millis(5));
|
||||
assert!(r.phase_stats(Instant::now(), 60.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phases_slice_frames_by_when_they_were_marked() {
|
||||
let mut r = FrameReport::new();
|
||||
r.mark_phase("a");
|
||||
for _ in 0..5 {
|
||||
r.record(Duration::from_millis(10)); // 10ms: late at 60Hz (16.7ms budget)... no, 10<16.7, not late
|
||||
}
|
||||
r.mark_phase("b");
|
||||
for _ in 0..3 {
|
||||
r.record(Duration::from_millis(20)); // 20ms: late at 60Hz
|
||||
}
|
||||
let now = Instant::now();
|
||||
let phases = r.phase_stats(now, 60.0);
|
||||
assert_eq!(phases.len(), 2);
|
||||
assert_eq!(phases[0].name, "a");
|
||||
assert_eq!(phases[0].frames, 5);
|
||||
assert_eq!(phases[0].late, 0);
|
||||
assert_eq!(phases[0].worst, Duration::from_millis(10));
|
||||
assert_eq!(phases[1].name, "b");
|
||||
assert_eq!(phases[1].frames, 3);
|
||||
assert_eq!(phases[1].late, 3);
|
||||
assert_eq!(phases[1].late_percent, 100.0);
|
||||
assert_eq!(phases[1].worst, Duration::from_millis(20));
|
||||
assert!(phases[0].complete);
|
||||
assert!(phases[1].complete);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_phase_runs_until_now() {
|
||||
let mut r = FrameReport::new();
|
||||
r.mark_phase("only");
|
||||
r.record(Duration::from_millis(1));
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
let now = Instant::now();
|
||||
let phases = r.phase_stats(now, 60.0);
|
||||
assert_eq!(phases.len(), 1);
|
||||
assert!(phases[0].duration >= Duration::from_millis(20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_phase_marks() {
|
||||
let mut r = FrameReport::new();
|
||||
r.mark_phase("a");
|
||||
r.record(Duration::from_millis(1));
|
||||
r.reset();
|
||||
assert!(r.phase_stats(Instant::now(), 60.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_at_hz_uses_the_given_refresh_rate_not_the_fixed_60hz_constant() {
|
||||
let mut r = FrameReport::new();
|
||||
// 10ms is under 60Hz's 16.7ms budget but over 120Hz's 8.3ms one.
|
||||
r.record(Duration::from_millis(10));
|
||||
assert_eq!(r.late_at_hz(60.0), (0, 0.0));
|
||||
assert_eq!(r.late_at_hz(120.0), (1, 100.0));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use crate::{
|
||||
util::{HashMap, Vec2},
|
||||
};
|
||||
use data::WindowUniform;
|
||||
use pollster::FutureExt;
|
||||
use wgpu::{
|
||||
util::{BufferInitDescriptor, DeviceExt},
|
||||
*,
|
||||
@@ -65,6 +66,57 @@ pub fn device_limits() -> Limits {
|
||||
}
|
||||
}
|
||||
|
||||
/// A capped log of wgpu's *uncaptured* errors -- everything that reaches
|
||||
/// `Device::on_uncaptured_error` rather than one of `UiRenderNode::new`'s
|
||||
/// own error scopes, i.e. every wgpu error raised outside device/pipeline
|
||||
/// creation: a validation failure during an ordinary frame's `update`/
|
||||
/// `draw`, for instance. wgpu's default handler for these is `panic!` with
|
||||
/// no caller able to intervene -- exactly what aborted the P0 bench APK
|
||||
/// once already (this file's `UiRenderNode::new` doc comment) -- so both
|
||||
/// platform backends install a handler here instead of leaving the default
|
||||
/// in place, per RUST.md's P0 box ("every wgpu uncaptured error ... it
|
||||
/// must never panic in release").
|
||||
///
|
||||
/// Cheap to `Clone` (an `Arc` around the real storage) rather than a
|
||||
/// process-wide static, so a caller builds one alongside its `Device`,
|
||||
/// hands one clone to `on_uncaptured_error`'s closure and keeps the other
|
||||
/// for the Diagnostics page to read -- context passed explicitly, per
|
||||
/// AGENTS.md/CODE_RULES.md's "no globals" rather than reached for through a
|
||||
/// `OnceLock`.
|
||||
#[derive(Clone)]
|
||||
pub struct WgpuErrorLog {
|
||||
errors: std::sync::Arc<std::sync::Mutex<std::collections::VecDeque<String>>>,
|
||||
}
|
||||
|
||||
/// How many uncaptured errors the log keeps -- old ones drop off the front
|
||||
/// rather than being trimmed on read, so a build spraying errors every
|
||||
/// frame doesn't grow this without bound.
|
||||
const WGPU_ERROR_LOG_CAP: usize = 20;
|
||||
|
||||
impl Default for WgpuErrorLog {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
errors: std::sync::Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WgpuErrorLog {
|
||||
pub fn record(&self, error: impl std::fmt::Display) {
|
||||
let mut errors = self.errors.lock().unwrap();
|
||||
if errors.len() >= WGPU_ERROR_LOG_CAP {
|
||||
errors.pop_front();
|
||||
}
|
||||
errors.push_back(error.to_string());
|
||||
}
|
||||
|
||||
/// A snapshot for the Diagnostics page -- cloned rather than held,
|
||||
/// since the lock must not outlive one call.
|
||||
pub fn snapshot(&self) -> Vec<String> {
|
||||
self.errors.lock().unwrap().iter().cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct UiRenderNode {
|
||||
uniform_group: BindGroup,
|
||||
primitive_layout: BindGroupLayout,
|
||||
@@ -152,7 +204,7 @@ impl UiRenderNode {
|
||||
queue: &Queue,
|
||||
ui: &mut UiData,
|
||||
ui_render: &mut UiRenderState,
|
||||
) {
|
||||
) -> FrameUpdateStats {
|
||||
self.active.clear();
|
||||
for (i, primitives) in ui_render.layers.iter_mut() {
|
||||
self.active.push(i);
|
||||
@@ -236,6 +288,10 @@ impl UiRenderNode {
|
||||
if rebuild_main {
|
||||
self.rsc_group = Self::rsc_group(device, &self.rsc_layout, &self.textures);
|
||||
}
|
||||
FrameUpdateStats {
|
||||
masks_resized,
|
||||
moves_resized,
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes a size rather than a window type: this is the only thing the
|
||||
@@ -251,26 +307,69 @@ impl UiRenderNode {
|
||||
queue.write_buffer(&self.window_buffer, 0, bytemuck::cast_slice(slice));
|
||||
}
|
||||
|
||||
pub fn new(device: &Device, queue: &Queue, config: &SurfaceConfiguration) -> Self {
|
||||
/// Builds every bind group layout, the pipeline, and the two storage
|
||||
/// buffers this needs -- fallibly, since this is exactly the call that
|
||||
/// aborted the process on Iris's phone in a release build with no
|
||||
/// message beyond "wgpu error: Validation Error" (RUST.md's P0 box,
|
||||
/// "iris bench crash on the phone, 2026-09-06"). wgpu's own default
|
||||
/// behaviour for an uncaptured error is `panic!` with no caller able to
|
||||
/// intervene, so every `create_bind_group_layout`/`create_render_pipeline`
|
||||
/// call below runs inside three nested error scopes (one per
|
||||
/// `ErrorFilter`) instead: whichever scope catches something, its
|
||||
/// `wgpu::Error`'s `Display` is wgpu-core's own `format_error` output
|
||||
/// (`"Validation Error\n\nCaused by:\n ..."`, the same text the panic
|
||||
/// would have printed before Android's crash reporter truncated it) and
|
||||
/// becomes this function's `Err`. Both callers
|
||||
/// (`android::render::AndroidRenderer::new`, `default::render::
|
||||
/// UiRenderer::new`) already call `Device`-creation with
|
||||
/// `pollster::block_on`, so returning a plain `Result` here rather than
|
||||
/// making this `async fn` keeps that same synchronous shape.
|
||||
pub fn new(
|
||||
device: &Device,
|
||||
queue: &Queue,
|
||||
config: &SurfaceConfiguration,
|
||||
window_size: impl Into<Vec2>,
|
||||
) -> Result<Self, String> {
|
||||
// Popped in reverse of this order, once every creation call below
|
||||
// has run -- `Device::push_error_scope`'s own contract.
|
||||
let oom_scope = device.push_error_scope(ErrorFilter::OutOfMemory);
|
||||
let validation_scope = device.push_error_scope(ErrorFilter::Validation);
|
||||
let internal_scope = device.push_error_scope(ErrorFilter::Internal);
|
||||
|
||||
let shader = device.create_shader_module(ShaderModuleDescriptor {
|
||||
label: Some("UI Shape Shader"),
|
||||
source: ShaderSource::Wgsl(SHAPE_SHADER.into()),
|
||||
});
|
||||
|
||||
// Seeded from the surface's own size, not `WindowUniform::default()`
|
||||
// (0, 0): the vertex shader divides by `window.dim` to reach clip
|
||||
// space, so a window this buffer disagrees with means every
|
||||
// primitive's position is NaN/Inf and is dropped before
|
||||
// rasterization -- the clear colour still reaches the screen (the
|
||||
// pass runs regardless) while nothing drawn on top of it ever does.
|
||||
// winit's backend gets away with the old default because winit
|
||||
// fires an initial `WindowEvent::Resized` that calls `resize()`
|
||||
// before the first frame; android-view has no such automatic
|
||||
// event, so `AndroidRenderer::new` built a node whose window buffer
|
||||
// was never corrected -- this is I2's "nothing draws" bug (RUST.md).
|
||||
let window_uniform = WindowUniform {
|
||||
width: config.width as f32,
|
||||
height: config.height as f32,
|
||||
// Seeded from the caller's own reported size, not
|
||||
// `WindowUniform::default()` (0, 0): the vertex shader divides by
|
||||
// `window.dim` to reach clip space, so a window this buffer
|
||||
// disagrees with means every primitive's position is NaN/Inf and is
|
||||
// dropped before rasterization -- the clear colour still reaches
|
||||
// the screen (the pass runs regardless) while nothing drawn on top
|
||||
// of it ever does. winit's backend gets away with the old default
|
||||
// because winit fires an initial `WindowEvent::Resized` that calls
|
||||
// `resize()` before the first frame; android-view has no such
|
||||
// automatic event, so `AndroidRenderer::new` built a node whose
|
||||
// window buffer was never corrected -- this is I2's "nothing draws"
|
||||
// bug (RUST.md).
|
||||
//
|
||||
// **Deliberately not `config.width`/`config.height`**: those are
|
||||
// the surface's *physical* pixel size, which the swapchain needs,
|
||||
// but everything downstream of this uniform (layout, hit-testing,
|
||||
// glyph/rect positions) works in the caller's own units -- on
|
||||
// Android that's *logical* (physical / density) since RUST.md's P0
|
||||
// box ("text is far too small"), on desktop it's whatever
|
||||
// `default::render::UiRenderer::new` already divides by
|
||||
// `window.scale_factor()`. Passing it in explicitly, rather than
|
||||
// deriving it from `config` here, is what keeps this crate from
|
||||
// needing to know either platform's notion of density at all.
|
||||
let window_uniform = {
|
||||
let size = window_size.into();
|
||||
WindowUniform {
|
||||
width: size.x,
|
||||
height: size.y,
|
||||
}
|
||||
};
|
||||
let window_buffer = device.create_buffer_init(&BufferInitDescriptor {
|
||||
label: Some("window"),
|
||||
@@ -373,7 +472,18 @@ impl UiRenderNode {
|
||||
cache: None,
|
||||
});
|
||||
|
||||
Self {
|
||||
// Reverse of the push order above. Only one of these should ever be
|
||||
// `Some` in practice -- three separate scopes exist to name *which*
|
||||
// kind of error it was, not because more than one is expected at
|
||||
// once.
|
||||
let internal_err = internal_scope.pop().block_on();
|
||||
let validation_err = validation_scope.pop().block_on();
|
||||
let oom_err = oom_scope.pop().block_on();
|
||||
if let Some(err) = validation_err.or(oom_err).or(internal_err) {
|
||||
return Err(err.to_string());
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
uniform_group,
|
||||
primitive_layout,
|
||||
rsc_layout,
|
||||
@@ -387,7 +497,7 @@ impl UiRenderNode {
|
||||
move_offsets,
|
||||
masks_layout,
|
||||
masks_group,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn bind_group_0(
|
||||
@@ -554,4 +664,26 @@ impl UiRenderNode {
|
||||
pub fn take_image_bind_group_creates(&mut self) -> u64 {
|
||||
self.textures.take_bind_group_creates()
|
||||
}
|
||||
|
||||
/// Atlas-array `grow_array` calls since the last call -- same calling
|
||||
/// convention as `take_image_bind_group_creates` (call once per frame,
|
||||
/// before `update()`, to read exactly the previous frame's tally). Part
|
||||
/// of the Diagnostics page's per-frame report (RUST.md's P0 box, "the
|
||||
/// first input frame" investigation): if a report ever shows a grow
|
||||
/// landing on the same frame the glyphs vanished, that is the
|
||||
/// coincidence to chase first.
|
||||
pub fn take_atlas_pages_grown(&mut self) -> u64 {
|
||||
self.textures.take_pages_grown()
|
||||
}
|
||||
}
|
||||
|
||||
/// What `UiRenderNode::update` changed this frame that a caller building a
|
||||
/// per-frame diagnostic report cares about -- see `take_image_bind_group_creates`/
|
||||
/// `take_atlas_pages_grown` for the two counters this doesn't carry (they
|
||||
/// use the existing "call before update()" convention instead, so as not
|
||||
/// to disturb `bench_images`' documented counts).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct FrameUpdateStats {
|
||||
pub masks_resized: bool,
|
||||
pub moves_resized: bool,
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use crate::{
|
||||
ArrBuf,
|
||||
data::{MaskIdx, MoveIdx, PrimitiveInstance},
|
||||
},
|
||||
util::HashSet,
|
||||
};
|
||||
use bytemuck::Pod;
|
||||
use wgpu::*;
|
||||
@@ -277,6 +278,31 @@ impl Primitives {
|
||||
}
|
||||
}
|
||||
|
||||
/// How many instances are still bound for the GPU -- the O(1) half of
|
||||
/// the orphan check, so the O(primitives) walk below only runs on a
|
||||
/// frame that already looks wrong. See
|
||||
/// [`crate::UiRenderState::orphaned_primitives`].
|
||||
pub fn live_count(&self) -> usize {
|
||||
(self.instances.len() - self.free.len()) + (self.images.len() - self.image_free.len())
|
||||
}
|
||||
|
||||
/// Every instance that is still bound for the GPU, as `(inst_idx,
|
||||
/// owner, is_image)` -- everything except the slots already handed to
|
||||
/// [`Self::free`] and waiting for [`Self::apply_free`] to compact them
|
||||
/// away. Only [`crate::UiRenderState::orphaned_primitives`] uses this,
|
||||
/// to check that every drawn primitive still belongs to a live widget.
|
||||
pub fn live_instances(&self) -> impl Iterator<Item = (usize, WidgetId, bool)> + '_ {
|
||||
let free: HashSet<usize> = self.free.iter().copied().collect();
|
||||
let image_free: HashSet<usize> = self.image_free.iter().copied().collect();
|
||||
let rects = (0..self.instances.len())
|
||||
.filter(move |i| !free.contains(i))
|
||||
.map(|i| (i, self.assoc[i], false));
|
||||
let images = (0..self.images.len())
|
||||
.filter(move |i| !image_free.contains(i))
|
||||
.map(|i| (i, self.image_assoc[i], true));
|
||||
rects.chain(images)
|
||||
}
|
||||
|
||||
pub fn data(&self) -> &PrimitiveData {
|
||||
&self.data
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ use crate::{PatchRect, TextureKind, TextureUpdate, Textures};
|
||||
|
||||
use super::atlas::PAGE;
|
||||
|
||||
/// The fewest layers the glyph atlas array is ever created with. Two, not
|
||||
/// one, for the GLES reason written on `create_array_texture`.
|
||||
const MIN_ARRAY_LAYERS: u32 = 2;
|
||||
|
||||
/// What one texture slot is, GPU-side. Parallel to `Textures`' own slot
|
||||
/// numbering (`TextureKind`'s `Image`/`Page`), so a slot's index means the
|
||||
/// same thing on both sides without a second map to keep in sync.
|
||||
@@ -67,6 +71,12 @@ pub struct GpuTextures {
|
||||
/// unchanging image list is zero, the same way `UiRenderState`'s
|
||||
/// `draw_count`/`region_mut_count` prove the layout side.
|
||||
bind_group_creates: u64,
|
||||
/// `grow_array` calls since the last `take_pages_grown` -- the
|
||||
/// Diagnostics page's per-frame report (RUST.md's P0 box, "the first
|
||||
/// input frame" investigation) reads this alongside `bind_group_creates`
|
||||
/// to say whether *this* frame's glyph disappearance, if any, coincided
|
||||
/// with the atlas array being recreated.
|
||||
pages_grown: u64,
|
||||
}
|
||||
|
||||
impl GpuTextures {
|
||||
@@ -226,6 +236,7 @@ impl GpuTextures {
|
||||
/// array's view, which invalidates every bind group that referenced it,
|
||||
/// so this also rebuilds all of them before returning.
|
||||
fn grow_array(&mut self, rsc_layout: &BindGroupLayout) {
|
||||
self.pages_grown += 1;
|
||||
let new_capacity = self.array_capacity * 2;
|
||||
let new_texture = Self::create_array_texture(&self.device, new_capacity);
|
||||
if self.page_count > 0 {
|
||||
@@ -353,7 +364,26 @@ impl GpuTextures {
|
||||
})
|
||||
}
|
||||
|
||||
/// The atlas is sampled as a `texture_2d_array`, and **a one-layer
|
||||
/// array is not one on the GLES backend**: wgpu-hal picks the GL
|
||||
/// texture target from the descriptor alone
|
||||
/// (`gles::Texture::get_info_from_desc`, `(false, 1) => TEXTURE_2D`),
|
||||
/// so a capacity of 1 creates a `GL_TEXTURE_2D` and binds it to the
|
||||
/// shader's `sampler2DArray`. GL then treats that unit as incomplete
|
||||
/// and every `textureSample` returns (0, 0, 0, 1) -- which, through
|
||||
/// `draw_glyph`'s `color.a *= texel.a`, draws every glyph as a solid
|
||||
/// filled box. That was iris's appearance on the emulator's GLES for
|
||||
/// two days (RUST.md, "the emulator cannot draw iris's glyphs"), and
|
||||
/// it is a real defect on any device whose adapter is GL rather than
|
||||
/// Vulkan, not an emulator artifact. So the array never has fewer than
|
||||
/// `MIN_ARRAY_LAYERS` layers; the second layer costs one page of
|
||||
/// texture memory and is used by the next atlas page anyway.
|
||||
fn create_array_texture(device: &Device, capacity: u32) -> Texture {
|
||||
debug_assert!(
|
||||
capacity >= MIN_ARRAY_LAYERS,
|
||||
"glyph atlas array asked for {capacity} layers; fewer than {MIN_ARRAY_LAYERS} is a \
|
||||
GL_TEXTURE_2D on the GLES backend and draws every glyph as a box"
|
||||
);
|
||||
device.create_texture(&TextureDescriptor {
|
||||
label: Some("glyph atlas array"),
|
||||
size: Extent3d {
|
||||
@@ -375,7 +405,7 @@ impl GpuTextures {
|
||||
pub fn new(device: &Device, queue: &Queue) -> Self {
|
||||
let sampler = default_sampler(device);
|
||||
let null_view = null_texture_view(device);
|
||||
let array_capacity = 1;
|
||||
let array_capacity = MIN_ARRAY_LAYERS;
|
||||
let array_texture = Self::create_array_texture(device, array_capacity);
|
||||
let array_view = array_texture.create_view(&TextureViewDescriptor {
|
||||
dimension: Some(TextureViewDimension::D2Array),
|
||||
@@ -392,6 +422,7 @@ impl GpuTextures {
|
||||
sampler,
|
||||
null_view,
|
||||
bind_group_creates: 0,
|
||||
pages_grown: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,6 +433,12 @@ impl GpuTextures {
|
||||
std::mem::take(&mut self.bind_group_creates)
|
||||
}
|
||||
|
||||
/// Reads and zeroes the atlas-array-grow counter -- see `pages_grown`'s
|
||||
/// field comment.
|
||||
pub fn take_pages_grown(&mut self) -> u64 {
|
||||
std::mem::take(&mut self.pages_grown)
|
||||
}
|
||||
|
||||
pub fn array_view(&self) -> &TextureView {
|
||||
&self.array_view
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use crate::{LayerId, MaskIdx, MoveIdx, PrimitiveHandle, Size, TextureHandle, UiRegion, WidgetId};
|
||||
use crate::{
|
||||
LayerId, MaskIdx, MoveIdx, PrimitiveHandle, Size, TextureHandle, UiRegion, WidgetId, util::Vec2,
|
||||
};
|
||||
|
||||
/// important non rendering data for retained drawing
|
||||
#[derive(Debug)]
|
||||
@@ -9,7 +11,22 @@ pub struct ActiveData {
|
||||
pub textures: Vec<TextureHandle>,
|
||||
pub primitives: Vec<PrimitiveHandle>,
|
||||
pub children: Vec<WidgetId>,
|
||||
/// The mask this widget was drawn **under** (its parent's), not the
|
||||
/// one it set for itself -- see `own_mask` for that.
|
||||
pub mask: MaskIdx,
|
||||
/// The mask slot this widget allocated for *itself* with
|
||||
/// `Painter::set_mask`, or `MaskIdx::NONE`. Kept across redraws and
|
||||
/// rewritten in place, the way `move_slot` is: a `Masked` that pushed
|
||||
/// a fresh slot each draw left every already-drawn descendant --
|
||||
/// which `draw_inner`'s unchanged-region fast path does not revisit --
|
||||
/// clipping to the *old* slot's region, so a composer whose bar had
|
||||
/// since been placed at the bottom of the screen was still being
|
||||
/// clipped to a box at the top of it and drew nothing (measured
|
||||
/// 2026-09-06: four mask entries live, none of them the widget's
|
||||
/// current region). Its path out is the `undraw` branch of
|
||||
/// `UiRenderState::remove`, which drops the self-ownership ref taken
|
||||
/// when the slot was allocated.
|
||||
pub own_mask: MaskIdx,
|
||||
pub layer: LayerId,
|
||||
/// What `Widget::draw` returned the last time this widget was actually
|
||||
/// drawn -- read by a parent placing this widget again without
|
||||
@@ -21,4 +38,32 @@ pub struct ActiveData {
|
||||
/// so a retained child's `parent` link never goes stale). See
|
||||
/// LAYOUT.md section 2.
|
||||
pub move_slot: MoveIdx,
|
||||
/// How much of this widget's own `move_slot` delta is already folded
|
||||
/// into `region` above, in window pixels. The two mechanisms that
|
||||
/// write that slot disagree about this and cannot be told apart from
|
||||
/// the slot alone: `UiRenderState::mov` shifts `region` and the delta
|
||||
/// together (the *offered* region genuinely moved), while
|
||||
/// `Painter::reposition` writes only the delta (`region` stays the
|
||||
/// offered box and the delta says where inside it the content was
|
||||
/// placed). So anything that wants the widget's real position --
|
||||
/// `resolved_region`, and through it every hit test -- must subtract
|
||||
/// this from the chain sum. Without it a panned widget's own hit box
|
||||
/// sits at twice the pan while its descendants' are correct, which is
|
||||
/// how it went unnoticed: the composer's field became untappable
|
||||
/// after a finger pan (2026-09-06). Reset to zero whenever the widget
|
||||
/// is really redrawn, since `draw_inner` zeroes the slot then too.
|
||||
pub move_applied: Vec2,
|
||||
/// The offset the last `Painter::reposition` placed this widget's
|
||||
/// content at *within* `region`, in window pixels. The move slot has
|
||||
/// exactly one owner and one meaning:
|
||||
/// `move_offsets[move_slot] == move_applied + repositioned`. `mov`
|
||||
/// adds to the first, `reposition` overwrites the second (it
|
||||
/// recomputes `from` afresh every call, so repeating it must land on
|
||||
/// the same answer rather than drifting), and both then rewrite the
|
||||
/// slot from the sum -- which is what lets a parent both move a child
|
||||
/// with its own layout and place it inside that moved region in one
|
||||
/// frame. `List::place`'s Bottom-known branch does exactly that once a
|
||||
/// row's blocks wrap. Reset to zero on a real redraw, with
|
||||
/// `move_applied` and the slot itself.
|
||||
pub repositioned: Vec2,
|
||||
}
|
||||
@@ -13,6 +13,10 @@ pub struct Painter<'a> {
|
||||
pub(super) region: UiRegion,
|
||||
pub(super) mask: MaskIdx,
|
||||
pub(super) move_slot: MoveIdx,
|
||||
/// This widget's own mask slot, reused across redraws -- see
|
||||
/// `ActiveData::own_mask`. `MaskIdx::NONE` until `set_mask` is called
|
||||
/// for the first time in this widget's life.
|
||||
pub(super) own_mask: MaskIdx,
|
||||
pub(super) textures: Vec<TextureHandle>,
|
||||
pub(super) primitives: Vec<PrimitiveHandle>,
|
||||
pub(super) children: Vec<WidgetId>,
|
||||
@@ -48,12 +52,32 @@ impl<'a> Painter<'a> {
|
||||
self.primitive_at(primitive, region.within(&self.region));
|
||||
}
|
||||
|
||||
/// Clip everything this widget draws, itself and its descendants, to
|
||||
/// `region`. One per widget: a second call would need the two to be
|
||||
/// intersected, which nothing here does.
|
||||
///
|
||||
/// The slot is allocated once and **rewritten in place** on every
|
||||
/// later draw rather than pushed again, because a descendant whose own
|
||||
/// region did not change is not redrawn (`draw_inner`'s fast path) and
|
||||
/// so keeps pointing at whichever slot it was drawn under. See
|
||||
/// `ActiveData::own_mask` for what pushing a fresh one cost.
|
||||
pub fn set_mask(&mut self, region: UiRegion) {
|
||||
assert!(self.mask == MaskIdx::NONE);
|
||||
self.mask = self.rsc.ui_mut().masks.push(Mask {
|
||||
let mask = Mask {
|
||||
region,
|
||||
move_idx: self.move_slot,
|
||||
});
|
||||
};
|
||||
if self.own_mask == MaskIdx::NONE {
|
||||
let slot = self.rsc.ui_mut().masks.push(mask);
|
||||
// The one ref this widget holds on its own slot, so the slot
|
||||
// outlives any single frame's primitives; released in
|
||||
// `UiRenderState::remove`'s `undraw` branch.
|
||||
self.rsc.ui_mut().masks.push_ref(slot);
|
||||
self.own_mask = slot;
|
||||
} else {
|
||||
*self.rsc.ui_mut().masks.get_mut(self.own_mask) = mask;
|
||||
}
|
||||
self.mask = self.own_mask;
|
||||
}
|
||||
|
||||
/// Draws a widget within this widget's region, returning the size it
|
||||
@@ -86,6 +110,7 @@ impl<'a> Painter<'a> {
|
||||
self.mask,
|
||||
None,
|
||||
None,
|
||||
crate::render::MaskIdx::NONE,
|
||||
self.rsc,
|
||||
);
|
||||
self.state
|
||||
@@ -165,8 +190,21 @@ impl<'a> Painter<'a> {
|
||||
attrs: &TextAttrs,
|
||||
width: Option<f32>,
|
||||
) -> RenderedText {
|
||||
let density = self.state.density;
|
||||
// Counted here rather than in `TextView::render`, which returns
|
||||
// its memoized layout without reaching this -- so this counts
|
||||
// shapes, not requests. `UiRenderState::take_counters`.
|
||||
self.state.shape_count += 1;
|
||||
let ui = self.rsc.ui_mut();
|
||||
ui.text.render(buffer, attrs, width, &mut ui.textures)
|
||||
ui.text
|
||||
.render(buffer, attrs, width, &mut ui.textures, density)
|
||||
}
|
||||
|
||||
/// Which glyph atlas the glyphs handed out right now belong to --
|
||||
/// what a widget caching a [`RenderedText`] across frames has to
|
||||
/// compare against before re-emitting it (`GlyphAtlas::clear`).
|
||||
pub fn atlas_generation(&mut self) -> u64 {
|
||||
self.rsc.ui_mut().text.atlas.generation()
|
||||
}
|
||||
|
||||
/// Draw a laid-out string: one quad per glyph, all sampling the atlas.
|
||||
@@ -175,6 +213,18 @@ impl<'a> Painter<'a> {
|
||||
/// absolute pixel offset from it, so re-drawing after a resize is this loop
|
||||
/// and nothing else.
|
||||
pub fn glyphs(&mut self, text: &RenderedText, origin: UiRegion) {
|
||||
// A caller re-emitting quads placed against an atlas that has since
|
||||
// been cleared draws every glyph from coordinates now holding
|
||||
// something else. Caught at the submission rather than on screen,
|
||||
// where it reads as fragments of unrelated letters.
|
||||
debug_assert_eq!(
|
||||
text.generation,
|
||||
self.atlas_generation(),
|
||||
"glyphs placed against atlas generation {} submitted against {}: the holder did not \
|
||||
re-render after the atlas was cleared",
|
||||
text.generation,
|
||||
self.atlas_generation(),
|
||||
);
|
||||
let flags_for = |is_color| {
|
||||
if is_color {
|
||||
GlyphPrimitive::IS_COLOR
|
||||
@@ -210,6 +260,12 @@ impl<'a> Painter<'a> {
|
||||
self.state.output_size
|
||||
}
|
||||
|
||||
/// Physical pixels per `dp` -- see `UiRenderState::density`'s field
|
||||
/// doc. What `Len::dp`'s `apply_rest` call resolves against.
|
||||
pub fn density(&self) -> f32 {
|
||||
self.state.density
|
||||
}
|
||||
|
||||
pub fn px_size(&mut self) -> Vec2 {
|
||||
self.region.size().to_abs(self.state.output_size)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::{
|
||||
ActiveData, IdLike, MaskIdx, MoveIdx, Painter, PixelRegion, PrimitiveLayers, RegionAlign,
|
||||
StrongWidget, UiRegion, UiRsc, UiVec2, WidgetId, Widgets,
|
||||
render::MoveOffset,
|
||||
render::{IMAGE_BINDING, MoveOffset},
|
||||
util::{HashMap, HashSet, Id, Vec2},
|
||||
};
|
||||
|
||||
@@ -9,11 +9,44 @@ pub struct UiRenderState {
|
||||
pub active: HashMap<WidgetId, ActiveData>,
|
||||
pub layers: PrimitiveLayers,
|
||||
pub(super) output_size: Vec2,
|
||||
/// Physical pixels per `dp` -- see `Len::dp`'s field doc. `1.0` (an
|
||||
/// unscaled display) until a backend that knows its own density calls
|
||||
/// `set_density` (Android's `content_scale`, read at `surface_changed`
|
||||
/// time); the winit backend has no analogous per-monitor value wired up
|
||||
/// yet and stays at the default.
|
||||
pub(super) density: f32,
|
||||
|
||||
old_root: Option<WidgetId>,
|
||||
resized: bool,
|
||||
/// The widgets whose `Widget::draw` is on the stack right now -- so
|
||||
/// [`Self::redraw`] can tell "this widget needs drawing again" from
|
||||
/// "an ancestor is drawing it at this very moment", where a second
|
||||
/// draw would leave the first one's primitives behind with nothing
|
||||
/// owning them. An id is inserted immediately before `draw` is called
|
||||
/// and removed the moment it returns (both in `draw_inner`), so this
|
||||
/// is empty between frames -- asserted at the end of `update`.
|
||||
///
|
||||
/// It used to only ever be inserted into, and `redraw` removed the id
|
||||
/// *before* testing for it, which made the test constant `false`: the
|
||||
/// guard could never fire and the set grew by one entry per widget
|
||||
/// ever drawn and was never emptied.
|
||||
draw_started: HashSet<WidgetId>,
|
||||
|
||||
/// The widget currently holding exclusive pointer input, if any --
|
||||
/// `iris::sense::SensorUi::run_sensors` reads and clears this every
|
||||
/// call. Interior mutability (a `Mutex`, not a bare `Cell`, since a
|
||||
/// `CursorData` reaching this through an async `task_on` handler needs
|
||||
/// `Send`/`Sync`) because `run_sensors` takes `&self` (widgets are
|
||||
/// dispatched to, not owned, at that layer) and this render state is
|
||||
/// the one structure both backends (winit, android-view) already hold
|
||||
/// across frames, the same way `old_root`/`resized` are -- see
|
||||
/// `iris::sense`'s pointer-capture doc for why a drag needs this: once
|
||||
/// a gesture has committed to panning or selecting, every later sample
|
||||
/// of it must reach the same widget even if the finger has moved off
|
||||
/// whatever hit region first noticed the press. Never held across an
|
||||
/// await or another lock -- every access here is a single get/set.
|
||||
captured: std::sync::Mutex<Option<WidgetId>>,
|
||||
|
||||
/// `Widget::draw` calls and `Primitives::region_mut` rewrites since the
|
||||
/// last `take_counters`. LAYOUT.md section 8's pass conditions are
|
||||
/// stated in terms of these two: an unchanged frame must cost 0 of
|
||||
@@ -22,6 +55,9 @@ pub struct UiRenderState {
|
||||
draw_count: u64,
|
||||
region_mut_count: u64,
|
||||
mov_count: u64,
|
||||
/// Text layouts actually computed -- bumped by `Painter::render_text`,
|
||||
/// which `TextView::render` only reaches on a cache miss.
|
||||
pub(super) shape_count: u64,
|
||||
}
|
||||
|
||||
/// A move chain more than this deep would mean something else is wrong
|
||||
@@ -35,23 +71,33 @@ impl UiRenderState {
|
||||
active: Default::default(),
|
||||
layers: Default::default(),
|
||||
output_size: Vec2::ZERO,
|
||||
density: 1.0,
|
||||
old_root: None,
|
||||
resized: false,
|
||||
draw_started: Default::default(),
|
||||
captured: Default::default(),
|
||||
draw_count: 0,
|
||||
region_mut_count: 0,
|
||||
mov_count: 0,
|
||||
shape_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads and zeroes the (draws, region_mut rewrites, move_offsets
|
||||
/// writes) counters -- call once per frame before `update()` to
|
||||
/// measure exactly that frame, per LAYOUT.md section 8.
|
||||
pub fn take_counters(&mut self) -> (u64, u64, u64) {
|
||||
/// writes, text shapes) counters -- call once per frame before
|
||||
/// `update()` to measure exactly that frame, per LAYOUT.md section 8.
|
||||
///
|
||||
/// The fourth is the one a draw count cannot stand in for: a widget
|
||||
/// can be redrawn without re-shaping (`TextView::render` memoizes by
|
||||
/// width) and re-shaped without any extra draw, and it is re-shaping
|
||||
/// that the per-block transcript row exists to avoid -- see
|
||||
/// `transcript_ui`'s `a_delta_into_a_long_reply_shapes_one_block`.
|
||||
pub fn take_counters(&mut self) -> (u64, u64, u64, u64) {
|
||||
(
|
||||
std::mem::take(&mut self.draw_count),
|
||||
std::mem::take(&mut self.region_mut_count),
|
||||
std::mem::take(&mut self.mov_count),
|
||||
std::mem::take(&mut self.shape_count),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,6 +106,20 @@ impl UiRenderState {
|
||||
self.resized = true;
|
||||
}
|
||||
|
||||
/// Sets the physical-pixels-per-dp ratio every `Len::dp` in the tree
|
||||
/// resolves against from the next layout pass on -- see `density`'s
|
||||
/// field doc. Not folded into `resize` because the two change on
|
||||
/// different triggers (a surface resize on every rotation or keyboard
|
||||
/// open; a density change only if the app follows the display to a
|
||||
/// different screen, which Android surfaces separately).
|
||||
pub fn set_density(&mut self, density: f32) {
|
||||
self.density = density;
|
||||
}
|
||||
|
||||
pub fn density(&self) -> f32 {
|
||||
self.density
|
||||
}
|
||||
|
||||
pub fn update<'a>(&mut self, root: impl Into<Option<&'a StrongWidget>>, rsc: &mut dyn UiRsc) {
|
||||
// safety mechanism for memory leaks; might wanna return a result instead so user can
|
||||
// decide whether to panic or not
|
||||
@@ -78,6 +138,11 @@ impl UiRenderState {
|
||||
);
|
||||
}
|
||||
let root = root.into();
|
||||
debug_assert!(
|
||||
self.draw_started.is_empty(),
|
||||
"a previous frame left {} widget(s) marked as mid-draw",
|
||||
self.draw_started.len(),
|
||||
);
|
||||
if self.needs_redraw_all(root) {
|
||||
self.redraw_all(root, rsc);
|
||||
self.old_root = root.map(|r| r.id());
|
||||
@@ -85,6 +150,8 @@ impl UiRenderState {
|
||||
} else if rsc.widgets().has_updates() {
|
||||
self.redraw_updates(rsc);
|
||||
}
|
||||
#[cfg(debug_assertions)]
|
||||
debug_assert!(self.primitive_counts_agree(), "{}", self.orphan_report(rsc),);
|
||||
}
|
||||
|
||||
fn redraw_all(&mut self, root: Option<&StrongWidget>, rsc: &mut dyn UiRsc) {
|
||||
@@ -100,6 +167,7 @@ impl UiRenderState {
|
||||
MaskIdx::NONE,
|
||||
None,
|
||||
None,
|
||||
MaskIdx::NONE,
|
||||
rsc,
|
||||
);
|
||||
}
|
||||
@@ -134,12 +202,27 @@ impl UiRenderState {
|
||||
mask: MaskIdx,
|
||||
old_children: Option<Vec<WidgetId>>,
|
||||
old_move_slot: Option<MoveIdx>,
|
||||
old_own_mask: MaskIdx,
|
||||
rsc: &mut dyn UiRsc,
|
||||
) {
|
||||
let mut old_children = old_children.unwrap_or_default();
|
||||
let mut old_move_slot = old_move_slot;
|
||||
let mut own_mask = old_own_mask;
|
||||
// Consumed here, not merely read: this call *is* the redraw the mark
|
||||
// asked for, and leaving the mark set is what stranded a widget's
|
||||
// primitives. `Painter::draw_twice` calls this twice for the same id
|
||||
// in one frame (`List::place`'s measurement pass), and on the second
|
||||
// call the still-set mark took the whole `if let` below -- including
|
||||
// the `remove` that frees the first draw's primitives -- out of play,
|
||||
// so `active.insert` at the end overwrote the only handles that could
|
||||
// ever have freed them. The result is a full second copy of the row,
|
||||
// drawn every frame from then on at the oversized measurement region
|
||||
// and, with `List` setting no mask, outside the list's own bounds:
|
||||
// the doubled `Compacted:` row in docs/bench/iris-phone-v2-2026-09-06.md.
|
||||
// The same shape reaches any dirty widget an ancestor redraws first.
|
||||
let dirty = rsc.widgets_mut().needs_redraw.remove(&id);
|
||||
if let Some(active) = self.active.get_mut(&id)
|
||||
&& !rsc.widgets().needs_redraw.contains(&id)
|
||||
&& !dirty
|
||||
{
|
||||
// check to see if we can skip drawing first
|
||||
if active.region == region {
|
||||
@@ -166,6 +249,15 @@ impl UiRenderState {
|
||||
*r = r.outside(&from).within(®ion);
|
||||
self.region_mut_count += 1;
|
||||
}
|
||||
// `move_applied` is deliberately **not** touched here,
|
||||
// unlike in `mov`: it counts the part of this widget's own
|
||||
// move-slot delta that `region` has already absorbed, and
|
||||
// this branch writes no delta at all -- the primitives were
|
||||
// moved directly. Counting one would make
|
||||
// `resolved_region` subtract a distance the chain never
|
||||
// held, putting the hit box short of the drawing by
|
||||
// exactly this step. See `ActiveData::move_applied`, and
|
||||
// `a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at`.
|
||||
active.region = region;
|
||||
return;
|
||||
}
|
||||
@@ -173,10 +265,25 @@ impl UiRenderState {
|
||||
let active = self.remove(id, false, rsc).unwrap();
|
||||
old_children = active.children;
|
||||
old_move_slot = Some(active.move_slot);
|
||||
own_mask = active.own_mask;
|
||||
} else if dirty && self.active.contains_key(&id) {
|
||||
// Dirty and already drawn: none of the fast paths above may be
|
||||
// taken (the widget's own content changed, so its old primitives
|
||||
// say nothing about its new ones), but they are also the only
|
||||
// thing that frees them. Same two lines, reached the other way.
|
||||
let active = self.remove(id, false, rsc).unwrap();
|
||||
old_children = active.children;
|
||||
old_move_slot = Some(active.move_slot);
|
||||
own_mask = active.own_mask;
|
||||
}
|
||||
|
||||
// draw widget
|
||||
self.draw_started.insert(id);
|
||||
let reentrant = !self.draw_started.insert(id);
|
||||
debug_assert!(
|
||||
!reentrant,
|
||||
"widget {id:?} is being drawn while its own draw is already on the stack; \
|
||||
the second draw's primitives would orphan the first's"
|
||||
);
|
||||
|
||||
let move_slot = match old_move_slot {
|
||||
// Reused across a real redraw of the same id: the fresh
|
||||
@@ -205,11 +312,22 @@ impl UiRenderState {
|
||||
}
|
||||
};
|
||||
|
||||
// The mask this widget was drawn *under*, kept aside because
|
||||
// `Painter::set_mask` overwrites `painter.mask` with the widget's
|
||||
// own new one -- and `ActiveData::mask`'s only consumer is
|
||||
// `redraw`, which feeds it back in as the *inherited* mask. Storing
|
||||
// the set one instead handed a `Masked` its own mask on every
|
||||
// targeted redraw, tripping `set_mask`'s nested-mask assert:
|
||||
// `assertion failed: self.mask == MaskIdx::NONE`, an abort the
|
||||
// first time the composer's scroll area was redrawn on the
|
||||
// emulator.
|
||||
let inherited_mask = mask;
|
||||
let mut painter = Painter {
|
||||
state: self,
|
||||
region,
|
||||
mask,
|
||||
move_slot,
|
||||
own_mask,
|
||||
layer,
|
||||
id,
|
||||
textures: Vec::new(),
|
||||
@@ -221,14 +339,26 @@ impl UiRenderState {
|
||||
let mut widget = painter.rsc.widgets().get_dyn_dynamic(id);
|
||||
painter.state.draw_count += 1;
|
||||
let size = widget.draw(&mut painter);
|
||||
// A reported length is consumed by containers that read `abs`,
|
||||
// `rel` and `rest` straight off it (`Span`'s placement, `Pad`'s
|
||||
// addition), so an unresolved `dp` in one is silently worth zero
|
||||
// -- see `Len::fold_dp`, which is what a widget reporting a
|
||||
// caller-declared size has to put it through.
|
||||
debug_assert!(
|
||||
size.x.dp == 0.0 && size.y.dp == 0.0,
|
||||
"widget {id:?} reported an unresolved `dp` size ({size:?}); \
|
||||
report `Len::fold_dp(painter.density())` instead"
|
||||
);
|
||||
drop(widget);
|
||||
painter.state.draw_started.remove(&id);
|
||||
|
||||
let Painter {
|
||||
state: _,
|
||||
rsc: _,
|
||||
region,
|
||||
mask,
|
||||
mask: _,
|
||||
move_slot,
|
||||
own_mask,
|
||||
textures,
|
||||
primitives,
|
||||
children,
|
||||
@@ -244,10 +374,13 @@ impl UiRenderState {
|
||||
textures,
|
||||
primitives,
|
||||
children,
|
||||
mask,
|
||||
mask: inherited_mask,
|
||||
layer,
|
||||
size,
|
||||
move_slot,
|
||||
own_mask,
|
||||
move_applied: Vec2::ZERO,
|
||||
repositioned: Vec2::ZERO,
|
||||
};
|
||||
|
||||
// remove old children that weren't kept
|
||||
@@ -275,6 +408,7 @@ impl UiRenderState {
|
||||
let from_px = from.top_left().to_abs(self.output_size);
|
||||
let to_px = to.top_left().to_abs(self.output_size);
|
||||
let delta = to_px - from_px;
|
||||
active.move_applied += delta;
|
||||
let entry = rsc.ui_mut().move_offsets.get_mut(slot);
|
||||
entry.delta[0] += delta.x;
|
||||
entry.delta[1] += delta.y;
|
||||
@@ -309,17 +443,38 @@ impl UiRenderState {
|
||||
let Some(active) = self.active.get(&id) else {
|
||||
return;
|
||||
};
|
||||
let move_applied = active.move_applied;
|
||||
let repositioned = active.repositioned;
|
||||
let from = active
|
||||
.size
|
||||
.to_uivec2()
|
||||
.to_uivec2(self.density)
|
||||
.align(RegionAlign::TOP_LEFT)
|
||||
.within(&active.region);
|
||||
let slot = active.move_slot;
|
||||
let from_px = from.top_left().to_abs(self.output_size);
|
||||
let to_px = to.top_left().to_abs(self.output_size);
|
||||
let delta = to_px - from_px;
|
||||
// Not `delta` alone: a parent may have `mov`ed this widget to a
|
||||
// region that itself moved earlier in the same frame, and that
|
||||
// part of the slot is `move_applied`'s, not this call's. Writing
|
||||
// `delta` on its own dropped it and put the content back at the
|
||||
// pre-move position. `from` is computed against `active.region`,
|
||||
// which `mov` already updated, so `delta` is purely the placement
|
||||
// inside the region and the two summands never overlap.
|
||||
let entry = rsc.ui_mut().move_offsets.get_mut(slot);
|
||||
entry.delta = [delta.x, delta.y];
|
||||
debug_assert_eq!(
|
||||
entry.delta,
|
||||
[
|
||||
move_applied.x + repositioned.x,
|
||||
move_applied.y + repositioned.y
|
||||
],
|
||||
"widget {id:?}'s move slot was written by something other than `mov`/`reposition`; \
|
||||
the slot is theirs and means `move_applied + repositioned` -- see `ActiveData`"
|
||||
);
|
||||
entry.delta = [move_applied.x + delta.x, move_applied.y + delta.y];
|
||||
if let Some(active) = self.active.get_mut(&id) {
|
||||
active.repositioned = delta;
|
||||
}
|
||||
self.mov_count += 1;
|
||||
}
|
||||
|
||||
@@ -336,6 +491,13 @@ impl UiRenderState {
|
||||
active.textures.clear();
|
||||
rsc.ui_mut().textures.free();
|
||||
if undraw {
|
||||
// A captured widget that goes away mid-gesture (List's
|
||||
// virtualisation retiring a row, a rebuild) must not leave
|
||||
// the pointer permanently captured by an id nothing will
|
||||
// ever draw again -- `captured`'s own path out.
|
||||
if *self.captured.lock().unwrap() == Some(id) {
|
||||
*self.captured.lock().unwrap() = None;
|
||||
}
|
||||
// Permanent removal: retire this widget's own move slot
|
||||
// (the self-ownership ref taken when it was allocated) and
|
||||
// the up-link ref it held on its parent's slot -- read from
|
||||
@@ -343,6 +505,11 @@ impl UiRenderState {
|
||||
// the parent's own `ActiveData` may already be gone by the
|
||||
// time a deep descendant is retired (see LAYOUT.md
|
||||
// section 2's lifecycle note).
|
||||
if active.own_mask != MaskIdx::NONE {
|
||||
// The self-ownership ref `Painter::set_mask` took when
|
||||
// it allocated this widget's own mask slot.
|
||||
rsc.ui_mut().masks.remove(active.own_mask);
|
||||
}
|
||||
let parent_slot = rsc.ui_mut().move_offsets[active.move_slot.idx()].parent;
|
||||
rsc.ui_mut().move_offsets.remove(active.move_slot);
|
||||
if parent_slot != MoveOffset::NONE_PARENT {
|
||||
@@ -408,6 +575,100 @@ impl UiRenderState {
|
||||
self.active.len()
|
||||
}
|
||||
|
||||
/// Primitive instances still bound for the GPU whose owner is no
|
||||
/// longer in `active`, or whose owner's `ActiveData` no longer names
|
||||
/// them: a copy nothing can move, clip, resize or free, redrawn every
|
||||
/// frame at whatever position it last had. `(layer, inst_idx, owner)`
|
||||
/// each.
|
||||
///
|
||||
/// Asserted empty at the end of every [`Self::update`], because this
|
||||
/// is exactly the shape of the duplicated transcript row on Iris's
|
||||
/// phone (`docs/bench/iris-phone-v2-2026-09-06.md`): counting
|
||||
/// `active` alone cannot see it, since the orphan's owner is very
|
||||
/// much alive -- it is the *earlier* set of primitives that got
|
||||
/// stranded when the widget was drawn a second time without the first
|
||||
/// draw being freed. O(primitives), debug builds only.
|
||||
pub fn orphaned_primitives(&self) -> Vec<(usize, usize, WidgetId)> {
|
||||
let mut orphans = Vec::new();
|
||||
for (layer, primitives) in self.layers.iter() {
|
||||
for (inst_idx, owner, is_image) in primitives.live_instances() {
|
||||
let owned = self.active.get(&owner).is_some_and(|a| {
|
||||
a.primitives.iter().any(|h| {
|
||||
h.layer == layer
|
||||
&& h.inst_idx == inst_idx
|
||||
&& (h.binding == IMAGE_BINDING) == is_image
|
||||
})
|
||||
});
|
||||
if !owned {
|
||||
orphans.push((layer, inst_idx, owner));
|
||||
}
|
||||
}
|
||||
}
|
||||
orphans
|
||||
}
|
||||
|
||||
/// Whether every primitive still bound for the GPU is owned by a live
|
||||
/// widget, decided by counting rather than by walking: an orphan is a
|
||||
/// live instance no `ActiveData` names, so it can only ever make the
|
||||
/// live count exceed the owned one. O(active widgets) -- a few dozen --
|
||||
/// against [`Self::orphaned_primitives`]'s O(primitives), which on a
|
||||
/// transcript is tens of thousands and made a debug build on a phone
|
||||
/// too slow to finish a benchmark run.
|
||||
fn primitive_counts_agree(&self) -> bool {
|
||||
let live: usize = self.layers.iter().map(|(_, p)| p.live_count()).sum();
|
||||
let owned: usize = self.active.values().map(|a| a.primitives.len()).sum();
|
||||
live == owned
|
||||
}
|
||||
|
||||
/// The message [`Self::update`]'s orphan assert prints -- built here
|
||||
/// rather than inline so the (allocating, O(primitives)) work only
|
||||
/// happens on the failing path.
|
||||
#[cfg(debug_assertions)]
|
||||
fn orphan_report(&self, rsc: &dyn UiRsc) -> String {
|
||||
let orphans = self.orphaned_primitives();
|
||||
let mut lines: Vec<String> = orphans
|
||||
.iter()
|
||||
.take(8)
|
||||
.map(|(layer, idx, owner)| {
|
||||
let alive = self.active.contains_key(owner);
|
||||
format!(
|
||||
" layer {layer} instance {idx}: owner '{}' ({owner:?}), owner still active: {alive}",
|
||||
rsc.widgets().label(*owner),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if orphans.len() > lines.len() {
|
||||
lines.push(format!(" ... and {} more", orphans.len() - lines.len()));
|
||||
}
|
||||
format!(
|
||||
"{} primitive(s) are drawn but owned by nobody -- a stale copy \
|
||||
nothing will ever move or free:\n{}",
|
||||
orphans.len(),
|
||||
lines.join("\n"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Give `id` exclusive pointer input from the next `run_sensors` call
|
||||
/// on -- see `captured`'s field doc. Overwrites any previous capture
|
||||
/// (a gesture that starts a new one has already decided the old one
|
||||
/// is over).
|
||||
pub fn capture_pointer(&self, id: WidgetId) {
|
||||
*self.captured.lock().unwrap() = Some(id);
|
||||
}
|
||||
|
||||
/// Release exclusive pointer input, if any is held -- called once
|
||||
/// `run_sensors` has delivered the terminal `Drop` to the capturing
|
||||
/// widget, or by that widget itself if it decides the gesture is over
|
||||
/// some other way.
|
||||
pub fn release_pointer(&self) {
|
||||
*self.captured.lock().unwrap() = None;
|
||||
}
|
||||
|
||||
/// The widget currently holding exclusive pointer input, if any.
|
||||
pub fn captured_pointer(&self) -> Option<WidgetId> {
|
||||
*self.captured.lock().unwrap()
|
||||
}
|
||||
|
||||
pub fn debug(&self, widgets: &Widgets, label: &str) -> impl Iterator<Item = &ActiveData> {
|
||||
self.active.iter().filter_map(move |(&id, inst)| {
|
||||
let l = widgets.label(id);
|
||||
@@ -434,7 +695,12 @@ impl UiRenderState {
|
||||
/// section 2b.
|
||||
pub fn resolved_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option<UiRegion> {
|
||||
let active = self.active.get(&id.id())?;
|
||||
let delta = self.resolve_move_chain(active.move_slot, rsc);
|
||||
// The chain sum is what the shader adds to this widget's
|
||||
// *primitives*, which were written before any of those moves.
|
||||
// `region`, unlike them, has already been shifted by whatever
|
||||
// part of this widget's own slot `mov` put there -- see
|
||||
// `ActiveData::move_applied`, which is exactly that part.
|
||||
let delta = self.resolve_move_chain(active.move_slot, rsc) - active.move_applied;
|
||||
Some(active.region.offset(UiVec2::abs(delta)))
|
||||
}
|
||||
|
||||
@@ -470,7 +736,10 @@ impl UiRenderState {
|
||||
/// redraws a widget that's currently active (drawn)
|
||||
pub fn redraw(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) {
|
||||
rsc.widgets_mut().needs_redraw.remove(&id);
|
||||
self.draw_started.remove(&id);
|
||||
// An ancestor is drawing this widget right now, and that draw is
|
||||
// about to write fresh primitives for it. Drawing it a second time
|
||||
// here would leave one of the two copies on screen with nothing
|
||||
// owning it -- see `draw_started`'s own doc.
|
||||
if self.draw_started.contains(&id) {
|
||||
return;
|
||||
}
|
||||
@@ -494,9 +763,9 @@ impl UiRenderState {
|
||||
active.mask,
|
||||
Some(active.children),
|
||||
Some(active.move_slot),
|
||||
active.own_mask,
|
||||
rsc,
|
||||
);
|
||||
|
||||
// If this widget's own reported size changed, its parent's layout
|
||||
// (which placed it using the old size) is now stale and needs to
|
||||
// relay out too. Checked after the real draw, not before it --
|
||||
|
||||
@@ -15,23 +15,19 @@
|
||||
//! +-----------+--------------------------------------+
|
||||
//! ```
|
||||
//!
|
||||
//! **Deliberately left simple, and why**: every incoming SSE event refolds
|
||||
//! the *entire* transcript (`client_core::transcript_fold::fold_event` is
|
||||
//! already `O(items)` and a desktop session's conversation is small) and
|
||||
//! rebuilds the whole right-hand widget tree from scratch, rather than
|
||||
//! reaching for `TranscriptScreen::push_row`'s incremental append.
|
||||
//! `push_row` cannot update a row already on screen -- only append a new
|
||||
//! one -- and a streaming assistant reply is exactly a row whose *text*
|
||||
//! keeps changing after it first appears (see `transcript-ui`'s own doc on
|
||||
//! `fold_event` folding deltas into one growing item). A full rebuild
|
||||
//! shows that growth correctly at the cost of redrawing everything each
|
||||
//! time; fine for this proof, wrong for a long, fast-streaming transcript
|
||||
//! -- the incremental path that fixes it needs `transcript-ui` to expose
|
||||
//! updating a row in place, which it does not yet. The composer's
|
||||
//! in-progress text survives a rebuild (`rebuild_transcript`'s
|
||||
//! `in_progress` local) since the user typing a followup while a reply
|
||||
//! streams in is the one case a naive rebuild would otherwise lose data
|
||||
//! on.
|
||||
//! **Incoming SSE events go through `TranscriptScreen::apply`**, not a
|
||||
//! full rebuild: `client_core::transcript_fold::fold_event` folds the new
|
||||
//! item list as before, then `apply` updates only the row(s) that actually
|
||||
//! changed (almost always the one still-open assistant message a delta
|
||||
//! landed in) instead of rebuilding the whole right-hand widget tree from
|
||||
//! scratch. `rebuild_transcript` still runs the whole tree once, for a
|
||||
//! freshly loaded/selected session and for `apply`'s own rare
|
||||
//! full-rebuild fallback (a `group_tool_runs` regroup touching a row
|
||||
//! before the tail). The composer's in-progress text survives a rebuild
|
||||
//! (`rebuild_transcript`'s `in_progress` local) since the user typing a
|
||||
//! followup while a reply streams in is the one case a naive rebuild
|
||||
//! would otherwise lose data on -- `apply`'s own path never touches the
|
||||
//! composer at all, so this only matters on the fallback.
|
||||
//!
|
||||
//! Background network I/O (`client_core::api`/`event_stream`, both
|
||||
//! blocking by design -- see `client-core`'s `Cargo.toml`) runs on plain
|
||||
@@ -209,8 +205,12 @@ impl DefaultAppState for Client {
|
||||
event,
|
||||
} => {
|
||||
if self.current(&session_id, generation) {
|
||||
let old_items = self.items.clone();
|
||||
self.items = fold_event(&self.items, &event);
|
||||
self.rebuild_transcript(rsc);
|
||||
match &self.screen {
|
||||
Some(screen) => screen.apply(rsc, &old_items, &self.items),
|
||||
None => self.rebuild_transcript(rsc),
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::StreamEnded {
|
||||
|
||||
@@ -68,12 +68,15 @@ fn build_row<Rsc: UiRsc + 'static>(rsc: &mut Rsc, i: usize) -> StrongWidget {
|
||||
let mut span = Span::empty(Dir::DOWN);
|
||||
span.push(text);
|
||||
span.push(img);
|
||||
span.pad(8.0).background(rect(tint)).add_strong(rsc).any()
|
||||
span.pad(dp(8.0))
|
||||
.background(rect(tint))
|
||||
.add_strong(rsc)
|
||||
.any()
|
||||
} else {
|
||||
wtext(row_text(i))
|
||||
.wrap(true)
|
||||
.color(text_color)
|
||||
.pad(8.0)
|
||||
.pad(dp(8.0))
|
||||
.background(rect(tint))
|
||||
.add_strong(rsc)
|
||||
.any()
|
||||
|
||||
@@ -12,6 +12,10 @@ impl<T: HasAndroidUiState> FocusHost for T {
|
||||
self.android_state_mut().focus = id;
|
||||
}
|
||||
|
||||
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
|
||||
self.android_state().focus == Some(id)
|
||||
}
|
||||
|
||||
fn focus_gained(&mut self, region: Option<PixelRegion>) {
|
||||
// Showing the keyboard is a JNI call (`InputMethodManager.showSoftInput`),
|
||||
// and this runs deep inside the platform-agnostic sensor dispatch
|
||||
|
||||
@@ -49,6 +49,52 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
||||
fn focus(&self) -> Option<WeakWidget<TextEdit>> {
|
||||
self.state.android_state().focus
|
||||
}
|
||||
|
||||
/// Tell Gboard where the caret/selection and the composing region
|
||||
/// actually are, via `InputMethodManager.updateSelection` -- every one
|
||||
/// of android-view's own demo's `set_composing_text_internal`/`render`
|
||||
/// calls this, and this bridge never did, which is what left Gboard's
|
||||
/// own model of the field diverging from `TextEdit`'s real one after
|
||||
/// the very first edit (RUST.md's P0 box, "doesn't enter it until I
|
||||
/// hit space, and also doesn't move cursor forward" -- Gboard holds
|
||||
/// its composing keystrokes back until it believes the app has caught
|
||||
/// up, and without this call it never does). Called from
|
||||
/// [`IrisViewPeer::after_input`], the one tail every touch/key/IME
|
||||
/// callback already runs through, rather than duplicated at each of
|
||||
/// this file's mutating methods.
|
||||
///
|
||||
/// `candidates_start`/`candidates_end` report the composing region;
|
||||
/// `-1, -1` when nothing is composing, matching `EditorInfo`'s own
|
||||
/// convention. `compose_len` is tracked in `char`s (this module's doc
|
||||
/// comment), so this reports it as that many UTF-16 units back from the
|
||||
/// caret -- exact for the common BMP case, the same approximation
|
||||
/// `set_composing_text` already makes.
|
||||
pub(super) fn update_ime_selection(&mut self, ctx: &mut CallbackCtx) {
|
||||
let Some(focus) = self.focus() else { return };
|
||||
let text = &self.rsc[focus];
|
||||
let Some(sel) = text.selection_range() else {
|
||||
return;
|
||||
};
|
||||
let content = text.text();
|
||||
let sel_start = byte_to_utf16(content, sel.start) as i32;
|
||||
let sel_end = byte_to_utf16(content, sel.end) as i32;
|
||||
let compose_len = self.state.android_state().compose_len;
|
||||
let (comp_start, comp_end) = if compose_len > 0 {
|
||||
let caret = byte_to_utf16(content, text.caret().unwrap_or(sel.end)) as i32;
|
||||
(caret - compose_len as i32, caret)
|
||||
} else {
|
||||
(-1, -1)
|
||||
};
|
||||
let imm = ctx.view.input_method_manager(&mut ctx.env);
|
||||
imm.update_selection(
|
||||
&mut ctx.env,
|
||||
&ctx.view,
|
||||
sel_start,
|
||||
sel_end,
|
||||
comp_start,
|
||||
comp_end,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
|
||||
|
||||
@@ -17,13 +17,15 @@ mod attr;
|
||||
mod ime;
|
||||
mod input;
|
||||
mod insets;
|
||||
mod platform;
|
||||
mod render;
|
||||
mod view;
|
||||
|
||||
pub use insets::Insets;
|
||||
pub use render::AndroidRenderer;
|
||||
pub use view::{
|
||||
AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState, IrisViewPeer, new_peer,
|
||||
AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState, IrisViewPeer, WindowInsets,
|
||||
new_peer,
|
||||
};
|
||||
|
||||
/// Registers the extra native methods this backend needs beyond what
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
use crate::platform::OpenUrl;
|
||||
use android_view::{
|
||||
View,
|
||||
jni::{
|
||||
JNIEnv,
|
||||
objects::{JObject, JValue},
|
||||
},
|
||||
};
|
||||
|
||||
use super::view::HasAndroidUiState;
|
||||
|
||||
/// Android's URL opener. Like `FocusHost::focus_gained`'s keyboard, the
|
||||
/// real work is a JNI call and this runs deep inside the sensor dispatch
|
||||
/// with no `CallbackCtx` in reach -- so it raises a flag that
|
||||
/// `IrisViewPeer::after_input` consumes, exactly as
|
||||
/// `pending_show_keyboard` does.
|
||||
///
|
||||
/// Last request wins: two links cannot be tapped in one frame, and a URL
|
||||
/// left queued from a frame that somehow never reached `after_input`
|
||||
/// would open at some unrelated later tap, which is worse than dropping
|
||||
/// it.
|
||||
impl<T: HasAndroidUiState> OpenUrl for T {
|
||||
fn open_url(&mut self, url: &str) {
|
||||
self.android_state_mut().pending_open_url = Some(url.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// `startActivity(new Intent(ACTION_VIEW, Uri.parse(url)))` on the view's
|
||||
/// own context.
|
||||
///
|
||||
/// `FLAG_ACTIVITY_NEW_TASK` because the context here is the view's, which
|
||||
/// may be an application context rather than the activity's -- Android
|
||||
/// throws `AndroidRuntimeException` for a non-activity context without it,
|
||||
/// and it is harmless when the context *is* an activity's.
|
||||
///
|
||||
/// Every failure is logged with the URL and returns; there is nothing to
|
||||
/// fall back to, and the reader will see that nothing happened.
|
||||
pub(super) fn open_url<'local>(env: &mut JNIEnv<'local>, view: &View<'local>, url: &str) {
|
||||
match try_open_url(env, view, url) {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
// A pending Java exception makes every later JNI call fail in
|
||||
// ways nowhere near here, so it is cleared at the boundary.
|
||||
let _ = env.exception_clear();
|
||||
log::warn!("could not open {url}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_open_url<'local>(
|
||||
env: &mut JNIEnv<'local>,
|
||||
view: &View<'local>,
|
||||
url: &str,
|
||||
) -> Result<(), android_view::jni::errors::Error> {
|
||||
let context = env
|
||||
.call_method(&view.0, "getContext", "()Landroid/content/Context;", &[])?
|
||||
.l()?;
|
||||
let jurl = env.new_string(url)?;
|
||||
let uri = env.call_static_method(
|
||||
"android/net/Uri",
|
||||
"parse",
|
||||
"(Ljava/lang/String;)Landroid/net/Uri;",
|
||||
&[JValue::Object(jurl.as_ref())],
|
||||
)?;
|
||||
let action = env.new_string("android.intent.action.VIEW")?;
|
||||
let intent = env.new_object(
|
||||
"android/content/Intent",
|
||||
"(Ljava/lang/String;Landroid/net/Uri;)V",
|
||||
&[JValue::Object(action.as_ref()), JValue::Object(&uri.l()?)],
|
||||
)?;
|
||||
env.call_method(
|
||||
&intent,
|
||||
"addFlags",
|
||||
"(I)Landroid/content/Intent;",
|
||||
&[JValue::Int(FLAG_ACTIVITY_NEW_TASK)],
|
||||
)?;
|
||||
env.call_method(
|
||||
&context,
|
||||
"startActivity",
|
||||
"(Landroid/content/Intent;)V",
|
||||
&[JValue::Object(&JObject::from(intent))],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `android.content.Intent.FLAG_ACTIVITY_NEW_TASK`. A constant rather than
|
||||
/// a static-field read: it is part of the platform's stable ABI and
|
||||
/// reading it costs two more JNI calls that can each fail.
|
||||
const FLAG_ACTIVITY_NEW_TASK: i32 = 0x1000_0000;
|
||||
@@ -48,10 +48,65 @@ pub struct AndroidRenderer {
|
||||
config: SurfaceConfiguration,
|
||||
encoder: CommandEncoder,
|
||||
pub ui: UiRenderNode,
|
||||
/// The adapter identity, kept past `new()` for the Diagnostics page --
|
||||
/// `Adapter` itself is not `Clone`, so the three fields the page shows
|
||||
/// are copied out once here rather than holding the adapter.
|
||||
pub adapter_name: String,
|
||||
pub adapter_backend: Backend,
|
||||
pub adapter_driver: String,
|
||||
/// Every uncaptured wgpu error since this renderer was created -- see
|
||||
/// `iris_core::WgpuErrorLog`'s doc comment. Installed on `device` in
|
||||
/// `new()`, kept here so the Diagnostics page and the per-frame log in
|
||||
/// `update()` can both read it without a global.
|
||||
pub wgpu_errors: iris_core::WgpuErrorLog,
|
||||
/// Frames drawn on this surface -- what gates the first-10-frames log
|
||||
/// `update()` writes (RUST.md's P0 box, "the first input frame"
|
||||
/// investigation): a fresh surface is exactly what Iris's own report
|
||||
/// says renders correctly at first, so the frames that matter are the
|
||||
/// first several after each `surface_changed`, not an arbitrary window
|
||||
/// during a long-running session.
|
||||
frame_count: u64,
|
||||
/// Physical pixels per dp -- see `android::view::AndroidUiState::
|
||||
/// content_scale`'s field comment for what this feeds.
|
||||
content_scale: f32,
|
||||
}
|
||||
|
||||
/// One frame's worth of the counters `render/mod.rs`'s doc comments on
|
||||
/// `FrameUpdateStats`/`take_image_bind_group_creates`/
|
||||
/// `take_atlas_pages_grown` describe -- assembled here because the three
|
||||
/// live on two different calling conventions (`FrameUpdateStats` from this
|
||||
/// exact `update()` call; the other two describe the *previous* frame,
|
||||
/// same as `bench_images`' existing use of them) and a diagnostic reader
|
||||
/// should not have to know that split.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct FrameDiagnostics {
|
||||
pub masks_resized: bool,
|
||||
pub moves_resized: bool,
|
||||
/// From the previous frame's `update()` -- see the struct doc.
|
||||
pub atlas_pages_grown_prev: u64,
|
||||
pub image_bind_group_creates_prev: u64,
|
||||
}
|
||||
|
||||
impl AndroidRenderer {
|
||||
pub fn new(window: NativeWindow, width: u32, height: u32) -> Self {
|
||||
/// `Err` holds a full, human-readable report -- wgpu's own error text
|
||||
/// (`UiRenderNode::new`'s doc comment) plus the adapter identity and
|
||||
/// the limits/downlevel flags bind-group-layout validation checks
|
||||
/// against -- rather than the panic wgpu's default error handler would
|
||||
/// otherwise raise with no caller able to see it. This is what aborted
|
||||
/// the P0 bench APK on Iris's phone with only "wgpu error: Validation
|
||||
/// Error" surviving into the crash report (RUST.md's P0 box, "iris
|
||||
/// bench crash on the phone, 2026-09-06"): `create_bind_group_layout`
|
||||
/// validates against *this* adapter's downlevel capabilities and
|
||||
/// limits, which a desktop GPU and the emulator's software renderers
|
||||
/// never exercised. The caller (`android::view::IrisViewPeer::
|
||||
/// surface_changed`) logs this one-line-flattened and shows it on
|
||||
/// screen instead of aborting the process.
|
||||
pub fn new(
|
||||
window: NativeWindow,
|
||||
width: u32,
|
||||
height: u32,
|
||||
content_scale: f32,
|
||||
) -> Result<Self, String> {
|
||||
// `force-gles` (RUST.md's I5 "Where iris's frame time goes") swaps
|
||||
// the software-Vulkan (SwiftShader) path for GLES/virgl on the same
|
||||
// build, to isolate whether the backend itself explains the frame
|
||||
@@ -84,6 +139,18 @@ impl AndroidRenderer {
|
||||
.block_on()
|
||||
.expect("Could not get adapter!");
|
||||
|
||||
// Requesting the device itself still panics on failure: that is a
|
||||
// `RequestDeviceError` (a limit or feature the adapter cannot grant
|
||||
// at all), a different and already-diagnosable failure from the one
|
||||
// this function now recovers from -- `RUST.md`'s "Software mode ...
|
||||
// crashes for a third, different reason" is exactly that class, and
|
||||
// its message already names the limit and the requested/allowed
|
||||
// values with no truncation risk (it never reaches wgpu's
|
||||
// uncaptured-error path). What this function's `Result` return
|
||||
// covers is the *next* class of failure: the adapter grants the
|
||||
// device, and validation only fails once a specific bind group
|
||||
// layout is checked against it.
|
||||
|
||||
// Same request as the winit backend's `UiRenderer::new` -- no
|
||||
// binding-array features, see TEXTURES.md's "Recommended shape".
|
||||
// `iris_core::device_limits()` is shared between the two backends;
|
||||
@@ -96,6 +163,30 @@ impl AndroidRenderer {
|
||||
.block_on()
|
||||
.expect("Could not get device!");
|
||||
|
||||
// wgpu's default handler for an error raised outside `UiRenderNode::
|
||||
// new`'s own error scopes (i.e. everything past device creation --
|
||||
// an ordinary frame's `update`/`draw`) is `panic!`, unconditionally,
|
||||
// with no caller able to intervene: the same mechanism that aborted
|
||||
// the P0 bench APK once already, just at a different call site. Log
|
||||
// and record instead of letting that default stand -- RUST.md's P0
|
||||
// box, "every wgpu uncaptured error ... it must never panic in
|
||||
// release".
|
||||
let wgpu_errors = iris_core::WgpuErrorLog::default();
|
||||
let wgpu_errors_for_handler = wgpu_errors.clone();
|
||||
device.on_uncaptured_error(std::sync::Arc::new(move |error| {
|
||||
log::error!("iris wgpu uncaptured error: {error}");
|
||||
wgpu_errors_for_handler.record(error);
|
||||
}));
|
||||
|
||||
let info = adapter.get_info();
|
||||
let adapter_name = info.name.clone();
|
||||
let adapter_backend = info.backend;
|
||||
let adapter_driver = if info.driver_info.is_empty() {
|
||||
info.driver.clone()
|
||||
} else {
|
||||
format!("{} {}", info.driver, info.driver_info)
|
||||
};
|
||||
|
||||
let surface_caps = surface.get_capabilities(&adapter);
|
||||
let surface_format = surface_caps
|
||||
.formats
|
||||
@@ -117,16 +208,121 @@ impl AndroidRenderer {
|
||||
surface.configure(&device, &config);
|
||||
|
||||
let encoder = Self::create_encoder(&device);
|
||||
let ui = UiRenderNode::new(&device, &queue, &config);
|
||||
// Physical pixels, matching the swapchain's own `width`/`height`
|
||||
// exactly -- see `android::view::AndroidUiState::content_scale`'s
|
||||
// field comment for why this is no longer divided into a separate
|
||||
// logical space (that stopgap is what made text blurry, RUST.md's
|
||||
// P0 box). `Len::dp` folds the density in at layout time instead,
|
||||
// so nothing here needs to know it at all.
|
||||
let window_size = iris_core::util::Vec2::new(width as f32, height as f32);
|
||||
let ui = match UiRenderNode::new(&device, &queue, &config, window_size) {
|
||||
Ok(ui) => ui,
|
||||
Err(wgpu_error) => return Err(Self::diagnostic(&adapter, &wgpu_error)),
|
||||
};
|
||||
|
||||
Self {
|
||||
Ok(Self {
|
||||
surface,
|
||||
device,
|
||||
queue,
|
||||
config,
|
||||
encoder,
|
||||
ui,
|
||||
}
|
||||
adapter_name,
|
||||
adapter_backend,
|
||||
adapter_driver,
|
||||
wgpu_errors,
|
||||
frame_count: 0,
|
||||
content_scale,
|
||||
})
|
||||
}
|
||||
|
||||
/// The adapter identity plus every limit and downlevel flag
|
||||
/// `create_bind_group_layout` validates a storage buffer or texture
|
||||
/// binding against, followed by wgpu's own error text -- everything a
|
||||
/// person reading this off a screenshot needs to tell "this adapter
|
||||
/// lacks X" from "this is a bug in the layout." Named explicitly rather
|
||||
/// than `{limits:?}`/`{flags:?}` wholesale, because `Limits` alone is
|
||||
/// dozens of fields nobody asked for -- these are exactly the ones
|
||||
/// `UiRenderNode::new`'s layouts (`rsc_layout`, `masks_layout`,
|
||||
/// `primitive_layout`) can fail against, per `CreateBindGroupLayoutError`
|
||||
/// (`wgpu-core::binding_model`) and its downlevel-flag checks
|
||||
/// (`wgpu-core::device::resource`, `VERTEX_STORAGE` in particular --
|
||||
/// the one storage buffer here, `move_offsets`, that is visible to the
|
||||
/// vertex stage).
|
||||
fn diagnostic(adapter: &Adapter, wgpu_error: &str) -> String {
|
||||
let info = adapter.get_info();
|
||||
let limits = adapter.limits();
|
||||
let downlevel = adapter.get_downlevel_capabilities();
|
||||
format!(
|
||||
"iris could not start rendering. Copy this text and send it to Iris.\n\n\
|
||||
adapter: {name} ({backend:?}), driver: {driver} {driver_info}\n\
|
||||
limits: max_storage_buffers_per_shader_stage={max_storage_buffers} \
|
||||
max_sampled_textures_per_shader_stage={max_sampled_textures} \
|
||||
max_bind_groups={max_bind_groups} \
|
||||
max_bindings_per_bind_group={max_bindings} \
|
||||
max_storage_buffer_binding_size={max_storage_binding} \
|
||||
min_storage_buffer_offset_alignment={min_storage_align}\n\
|
||||
downlevel flags: {flags:?}\n\n\
|
||||
{wgpu_error}",
|
||||
name = info.name,
|
||||
backend = info.backend,
|
||||
driver = info.driver,
|
||||
driver_info = info.driver_info,
|
||||
max_storage_buffers = limits.max_storage_buffers_per_shader_stage,
|
||||
max_sampled_textures = limits.max_sampled_textures_per_shader_stage,
|
||||
max_bind_groups = limits.max_bind_groups,
|
||||
max_bindings = limits.max_bindings_per_bind_group,
|
||||
max_storage_binding = limits.max_storage_buffer_binding_size,
|
||||
min_storage_align = limits.min_storage_buffer_offset_alignment,
|
||||
flags = downlevel.flags,
|
||||
)
|
||||
}
|
||||
|
||||
/// The Diagnostics page's whole report: adapter identity, font
|
||||
/// resolution, the atlas's own view count, every uncaptured wgpu error
|
||||
/// so far, and the frame report -- RUST.md's P0 box, "a named
|
||||
/// `Diagnostics` control ... adapter info, limits, fonts found, atlas
|
||||
/// format/pages, wgpu errors so far, frame report". One string rather
|
||||
/// than a struct the caller formats, since the only consumer is a
|
||||
/// plain `TextView` with a "copy this and send it to Iris" affordance,
|
||||
/// the same shape `surface_changed`'s crash report already uses
|
||||
/// (UI_RULES.md: a failure -- or here, a state worth reporting --
|
||||
/// carries enough to act on where it's shown).
|
||||
pub fn diagnostics_report(
|
||||
&self,
|
||||
font: &iris_core::FontDiagnostics,
|
||||
frame_report: &str,
|
||||
) -> String {
|
||||
let errors = self.wgpu_errors.snapshot();
|
||||
let errors_text = if errors.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
errors.join("\n ")
|
||||
};
|
||||
format!(
|
||||
"iris diagnostics. Copy this text and send it to Iris.\n\n\
|
||||
adapter: {name} ({backend:?}), driver: {driver}\n\
|
||||
content_scale: {content_scale}\n\
|
||||
atlas format: Rgba8Unorm, views live: {views}\n\
|
||||
fonts: {families_found} families found, default={default_family:?} \
|
||||
mono={default_mono_family:?}\n\
|
||||
fonts resolved: regular={regular:?} bold={bold:?} italic={italic:?} \
|
||||
mono={mono:?}\n\
|
||||
wgpu errors since surface creation:\n {errors_text}\n\n\
|
||||
{frame_report}",
|
||||
name = self.adapter_name,
|
||||
backend = self.adapter_backend,
|
||||
driver = self.adapter_driver,
|
||||
content_scale = self.content_scale,
|
||||
views = self.ui.view_count(),
|
||||
families_found = font.families_found,
|
||||
default_family = font.default_family,
|
||||
default_mono_family = font.default_mono_family,
|
||||
regular = font.regular_resolved,
|
||||
bold = font.bold_resolved,
|
||||
italic = font.italic_resolved,
|
||||
mono = font.mono_resolved,
|
||||
)
|
||||
}
|
||||
|
||||
fn create_encoder(device: &Device) -> CommandEncoder {
|
||||
@@ -135,8 +331,31 @@ impl AndroidRenderer {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update(&mut self, ui: &mut UiData, render: &mut UiRenderState) {
|
||||
self.ui.update(&self.device, &self.queue, ui, render);
|
||||
/// Returns what changed this frame -- see `FrameDiagnostics`'s doc
|
||||
/// comment for why two of its four fields describe the *previous*
|
||||
/// frame rather than this one. `IrisViewPeer::render` logs this for
|
||||
/// the first `DIAGNOSTIC_FRAMES` frames after each `surface_changed`,
|
||||
/// per RUST.md's P0 box ("the first input frame" investigation): the
|
||||
/// glyph-wipe Iris reported happens on the first tap or scroll after a
|
||||
/// fresh surface, so that is exactly the window a report needs to
|
||||
/// cover, not an arbitrary slice of a long session.
|
||||
pub fn update(&mut self, ui: &mut UiData, render: &mut UiRenderState) -> FrameDiagnostics {
|
||||
let atlas_pages_grown_prev = self.ui.take_atlas_pages_grown();
|
||||
let image_bind_group_creates_prev = self.ui.take_image_bind_group_creates();
|
||||
let stats = self.ui.update(&self.device, &self.queue, ui, render);
|
||||
self.frame_count += 1;
|
||||
FrameDiagnostics {
|
||||
masks_resized: stats.masks_resized,
|
||||
moves_resized: stats.moves_resized,
|
||||
atlas_pages_grown_prev,
|
||||
image_bind_group_creates_prev,
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames drawn on this surface so far -- see `frame_count`'s field
|
||||
/// comment.
|
||||
pub fn frame_count(&self) -> u64 {
|
||||
self.frame_count
|
||||
}
|
||||
|
||||
/// Draws and presents one frame, returning the time spent in
|
||||
@@ -179,15 +398,27 @@ impl AndroidRenderer {
|
||||
submit_start.elapsed()
|
||||
}
|
||||
|
||||
/// Physical pixels -- the unit layout and hit-testing use, matching
|
||||
/// the window uniform's own units. See
|
||||
/// `android::view::AndroidUiState::content_scale`'s field comment.
|
||||
pub fn size(&self) -> iris_core::util::Vec2 {
|
||||
(self.config.width, self.config.height).into()
|
||||
iris_core::util::Vec2::new(self.config.width as f32, self.config.height as f32)
|
||||
}
|
||||
|
||||
/// Reconfigures the surface and rewrites the window uniform for a new
|
||||
/// physical size -- deliberately the *only* two things this does.
|
||||
/// `device`, `ui`'s atlas, buffers and bind groups are untouched, so a
|
||||
/// call here (as opposed to a fresh `AndroidRenderer::new`) never
|
||||
/// invalidates a glyph the CPU-side cache already placed in the atlas.
|
||||
/// See `android::view::IrisViewPeer::surface_changed`'s doc comment for
|
||||
/// why that distinction matters -- it is what keeps text on screen
|
||||
/// across an IME resize.
|
||||
pub fn resize(&mut self, width: u32, height: u32) {
|
||||
self.config.width = width;
|
||||
self.config.height = height;
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
self.ui.resize((width, height), &self.queue);
|
||||
let size = iris_core::util::Vec2::new(width as f32, height as f32);
|
||||
self.ui.resize(size, &self.queue);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,11 @@ use accesskit_android::Adapter as AccessAdapter;
|
||||
use android_view::{
|
||||
AccessibilityNodeInfo, AccessibilityNodeProvider, Bundle, CallbackCtx, Context,
|
||||
InputConnection, KeyEvent, MotionEvent, Rect, View, ViewPeer,
|
||||
jni::{JNIEnv, JavaVM, objects::GlobalRef, sys::jint},
|
||||
jni::{
|
||||
JNIEnv, JavaVM,
|
||||
objects::{GlobalRef, JValue},
|
||||
sys::jint,
|
||||
},
|
||||
ndk::event::{Keycode, MotionAction},
|
||||
};
|
||||
// `marker::Sized` explicitly: `crate::prelude::*` below also brings in the
|
||||
@@ -29,6 +33,10 @@ use super::{
|
||||
/// `Option` because a `SurfaceView`'s surface does not outlive backgrounding
|
||||
/// the way a winit `Window` does -- `surfaceDestroyed`/`surfaceCreated` can
|
||||
/// happen any number of times over the life of one `IrisViewPeer`.
|
||||
/// How many frames after each `surface_changed` `render()` logs a full
|
||||
/// diagnostic line for -- see the log site's own comment.
|
||||
const DIAGNOSTIC_FRAMES: u64 = 10;
|
||||
|
||||
pub struct AndroidUiState {
|
||||
pub root: Option<StrongWidget>,
|
||||
pub renderer: Option<AndroidRenderer>,
|
||||
@@ -46,6 +54,10 @@ pub struct AndroidUiState {
|
||||
/// inside the platform-agnostic sensor dispatch with no `CallbackCtx`
|
||||
/// in reach.
|
||||
pub pending_show_keyboard: bool,
|
||||
/// A URL a tapped link asked the platform to open, for the same
|
||||
/// reason `pending_show_keyboard` is a flag rather than a call --
|
||||
/// see `android/platform.rs`.
|
||||
pub pending_open_url: Option<String>,
|
||||
/// Window insets, filled in from outside the normal `ViewPeer` callback
|
||||
/// path -- see `android/insets.rs` for why they need a registry of
|
||||
/// their own.
|
||||
@@ -62,10 +74,41 @@ pub struct AndroidUiState {
|
||||
/// because `dumpsys gfxinfo` cannot see a `SurfaceView`'s own
|
||||
/// GPU-drawn frames at all. See `iris_core::FrameReport`'s own doc.
|
||||
pub frame_report: FrameReport,
|
||||
/// `DisplayMetrics.density` (`new_peer`'s doc comment): physical pixels
|
||||
/// per dp on this device, read once at view construction and carried
|
||||
/// on `UiRenderState::density` (`render.set_density`, `new_peer`) from
|
||||
/// then on -- every `Len::dp` in the widget tree resolves against it at
|
||||
/// layout time (`Len::dp`'s field doc, IRIS_TODO.md's
|
||||
/// "density-independent length unit" item, 2026-09-06).
|
||||
///
|
||||
/// **Everything else in this module is physical pixels, matching the
|
||||
/// real wgpu surface/swapchain resolution** -- window size, touch
|
||||
/// coordinates, insets. That is a correction from an earlier version
|
||||
/// of this comment, which had `window_size`/`surface_changed`'s
|
||||
/// `UiRenderState::resize` call divide by `content_scale` into a
|
||||
/// *logical* coordinate space instead, as a global stopgap for
|
||||
/// RUST.md's P0 box's phone report ("text is far too small"). That
|
||||
/// stopgap fixed the size but not the *sharpness*: dividing to logical
|
||||
/// units meant a `16.0`-sized glyph rasterised at 16 physical px and
|
||||
/// then implicitly upscaled ~3x by the NDC mapping onto the real
|
||||
/// physical framebuffer -- the exact "blurry ... glyphs drawn at
|
||||
/// logical size and stretched by the scale" Iris reported next.
|
||||
/// Resolving `dp` at layout time replaces it: a widget author writes
|
||||
/// `dp(16)` for a size that should look the same physical size on any
|
||||
/// density, and everything downstream (layout, hit-testing, the window
|
||||
/// uniform, and the font size handed to the text shaper) works in the
|
||||
/// display's own physical pixels throughout, so nothing is
|
||||
/// rasterised at one resolution and displayed at another.
|
||||
pub content_scale: f32,
|
||||
/// The last insets `render()` saw -- compared each frame so
|
||||
/// `AndroidAppState::on_insets_changed` fires only when they actually
|
||||
/// change (once at startup for the status bar, again if the device
|
||||
/// rotates), not every frame.
|
||||
last_insets: Insets,
|
||||
}
|
||||
|
||||
impl AndroidUiState {
|
||||
fn new(shared: Rc<RefCell<Shared>>) -> Self {
|
||||
fn new(shared: Rc<RefCell<Shared>>, content_scale: f32) -> Self {
|
||||
Self {
|
||||
root: None,
|
||||
renderer: None,
|
||||
@@ -74,10 +117,13 @@ impl AndroidUiState {
|
||||
last_click: Instant::now(),
|
||||
compose_len: 0,
|
||||
pending_show_keyboard: false,
|
||||
pending_open_url: None,
|
||||
shared,
|
||||
access_adapter: Default::default(),
|
||||
access: AccessTree::new(),
|
||||
frame_report: FrameReport::new(),
|
||||
content_scale,
|
||||
last_insets: Insets::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +166,48 @@ pub trait AndroidAppState: HasAndroidUiState {
|
||||
/// storing them has no effect on that mechanism.
|
||||
#[allow(unused_variables)]
|
||||
fn platform_ready(&mut self, rsc: &mut AndroidRsc<Self>, vm: JavaVM, view: GlobalRef) {}
|
||||
/// Called from `render()` whenever `AndroidUiState::insets()` differs
|
||||
/// from what it was last frame -- once at startup for the status bar
|
||||
/// (RUST.md's P0 box: "the status-bar inset is not applied" reported
|
||||
/// the two top buttons sitting under it, because nothing read `.top`
|
||||
/// at all), and again on a rotation or the keyboard opening/closing.
|
||||
/// `insets` is in the same physical-pixel units everything else in the
|
||||
/// tree now uses (`AndroidUiState::content_scale`'s field comment), so
|
||||
/// a widget can add it to a layout size directly -- `dp(...) +
|
||||
/// abs(insets.top)` if the widget wants a density-independent size
|
||||
/// plus the system bar's own (already-physical) height. The default
|
||||
/// does nothing -- most screens have no chrome that sits under a
|
||||
/// system bar.
|
||||
#[allow(unused_variables)]
|
||||
fn on_insets_changed(&mut self, rsc: &mut AndroidRsc<Self>, insets: WindowInsets) {}
|
||||
}
|
||||
|
||||
/// `insets::Insets` as `f32`, for the widget-facing callback above -- a
|
||||
/// distinct type from `insets::Insets` so a caller of `on_insets_changed`
|
||||
/// is not coupled to that module's own (`i32`, JNI-shaped) representation.
|
||||
/// Both are physical pixels; this used to divide by `content_scale` into a
|
||||
/// separate *logical* unit (hence the old name, `LogicalInsets`), back when
|
||||
/// the rest of layout was logical too -- see `AndroidUiState::content_scale`'s
|
||||
/// field comment for why that stopgap is gone.
|
||||
#[derive(Clone, Copy, Default, Debug, PartialEq)]
|
||||
pub struct WindowInsets {
|
||||
pub left: f32,
|
||||
pub top: f32,
|
||||
pub right: f32,
|
||||
pub bottom: f32,
|
||||
pub ime_bottom: f32,
|
||||
}
|
||||
|
||||
impl WindowInsets {
|
||||
fn from_physical(insets: Insets) -> Self {
|
||||
Self {
|
||||
left: insets.left as f32,
|
||||
top: insets.top as f32,
|
||||
right: insets.right as f32,
|
||||
bottom: insets.bottom as f32,
|
||||
ime_bottom: insets.ime_bottom as f32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The android-view analogue of `default::DefaultRsc` -- identical in
|
||||
@@ -241,6 +329,16 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
||||
if std::mem::take(&mut ui_state.pending_show_keyboard) {
|
||||
show_soft_input(&mut ctx.env, &ctx.view);
|
||||
}
|
||||
if let Some(url) = ui_state.pending_open_url.take() {
|
||||
super::platform::open_url(&mut ctx.env, &ctx.view, &url);
|
||||
}
|
||||
|
||||
// RUST.md's P0 box, "doesn't enter it until I hit space, and also
|
||||
// doesn't move cursor forward": Gboard needs `updateSelection`
|
||||
// after every edit to keep its own model of the field in sync, or
|
||||
// it holds keystrokes back rather than trusting a screen it
|
||||
// believes is stale. See `update_ime_selection`'s own doc.
|
||||
self.update_ime_selection(ctx);
|
||||
|
||||
let ui_state = self.state.android_state_mut();
|
||||
ui_state.cursor.end_frame();
|
||||
@@ -266,10 +364,38 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
||||
/// these in until that is root-caused; removing them loses the exact
|
||||
/// evidence a `logcat` capture needs to reproduce the state.
|
||||
fn render(&mut self, ctx: &mut CallbackCtx) {
|
||||
let ui_state = self.state.android_state();
|
||||
if ui_state.renderer.is_none() {
|
||||
if self.state.android_state().renderer.is_none() {
|
||||
return;
|
||||
}
|
||||
// See `AndroidAppState::on_insets_changed`'s doc comment: fires
|
||||
// exactly when insets actually differ from last frame, not every
|
||||
// frame -- most frames this is one `Insets` equality check against
|
||||
// a `Copy` struct. Done before `ui_state` is bound below, since
|
||||
// `on_insets_changed` needs `&mut self.state`/`&mut self.rsc` both.
|
||||
let ui_state = self.state.android_state();
|
||||
let current_insets = ui_state.insets();
|
||||
if current_insets != ui_state.last_insets {
|
||||
let physical = WindowInsets::from_physical(current_insets);
|
||||
// One line per real insets change. Iris's phone is the only
|
||||
// place several of these bugs reproduce and `adb logcat` is
|
||||
// the only instrument there (this-machine-android: system
|
||||
// tracing is broken on that device), so the numbers a layout
|
||||
// is actually fed have to reach the log -- "the composer
|
||||
// floats at launch" is unanswerable from a screenshot alone.
|
||||
log::info!(
|
||||
"iris insets: left={} top={} right={} bottom={} ime_bottom={} window={:?}",
|
||||
physical.left,
|
||||
physical.top,
|
||||
physical.right,
|
||||
physical.bottom,
|
||||
physical.ime_bottom,
|
||||
self.window_size(),
|
||||
);
|
||||
self.state.android_state_mut().last_insets = current_insets;
|
||||
self.state.on_insets_changed(&mut self.rsc, physical);
|
||||
}
|
||||
|
||||
let ui_state = self.state.android_state();
|
||||
log::debug!(
|
||||
"render(): root={:?} widgets={} active={} root_px={:?} out_size={:?}",
|
||||
ui_state.root.is_some(),
|
||||
@@ -294,7 +420,27 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
||||
let Some(renderer) = &mut ui_state.renderer else {
|
||||
return;
|
||||
};
|
||||
renderer.update(&mut self.rsc.ui, &mut self.render);
|
||||
let frame_diagnostics = renderer.update(&mut self.rsc.ui, &mut self.render);
|
||||
// First `DIAGNOSTIC_FRAMES` frames after each `surface_changed`
|
||||
// only -- RUST.md's P0 box, "the first input frame" investigation:
|
||||
// the glyph-wipe Iris reported happens on the first tap or scroll
|
||||
// after a fresh surface, so a report from that window is what
|
||||
// would show whether an atlas grow, a masks/move_offsets resize, or
|
||||
// a fresh wgpu error coincided with it. `frame_count()` was just
|
||||
// incremented inside `update()`, so `<=` counts frame 1 through
|
||||
// `DIAGNOSTIC_FRAMES` inclusive.
|
||||
if renderer.frame_count() <= DIAGNOSTIC_FRAMES {
|
||||
log::info!(
|
||||
"iris frame diagnostics: frame={} masks_resized={} moves_resized={} \
|
||||
atlas_pages_grown_prev={} image_bind_group_creates_prev={} wgpu_errors={}",
|
||||
renderer.frame_count(),
|
||||
frame_diagnostics.masks_resized,
|
||||
frame_diagnostics.moves_resized,
|
||||
frame_diagnostics.atlas_pages_grown_prev,
|
||||
frame_diagnostics.image_bind_group_creates_prev,
|
||||
renderer.wgpu_errors.snapshot().len(),
|
||||
);
|
||||
}
|
||||
let submit_to_present = renderer.draw();
|
||||
self.state
|
||||
.android_state_mut()
|
||||
@@ -337,6 +483,31 @@ fn show_soft_input<'local>(env: &mut JNIEnv<'local>, view: &View<'local>) {
|
||||
imm.show_soft_input(env, view, 0);
|
||||
}
|
||||
|
||||
/// Replaces the activity's content with a plain, selectable, scrollable
|
||||
/// text view holding `report` -- the on-screen half of `surface_changed`'s
|
||||
/// renderer-failure path (UI_RULES.md: "a failure is reported where it
|
||||
/// happened, and says what to do next," here "copy this and send it").
|
||||
/// Goes through an ordinary instance method on the Java side
|
||||
/// (`IrisView.showRendererError`) rather than a new `native` method: this
|
||||
/// call is Rust reaching *into* Java, the opposite direction from every
|
||||
/// `native fn` android-view/`IrisView` declare, and an ordinary virtual
|
||||
/// call resolves against `ctx.view`'s real runtime class (`IrisView`) the
|
||||
/// same way any other JNI method call here does. Silently does nothing on
|
||||
/// any JNI failure -- there is no more-fallback screen to fall back to,
|
||||
/// and the `log::error!` in `surface_changed` already reached logcat
|
||||
/// first.
|
||||
fn show_renderer_error<'local>(env: &mut JNIEnv<'local>, view: &View<'local>, report: &str) {
|
||||
let Ok(message) = env.new_string(report) else {
|
||||
return;
|
||||
};
|
||||
let _ = env.call_method(
|
||||
&view.0,
|
||||
"showRendererError",
|
||||
"(Ljava/lang/String;)V",
|
||||
&[JValue::Object(message.as_ref())],
|
||||
);
|
||||
}
|
||||
|
||||
impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
||||
fn on_key_down<'local>(
|
||||
&mut self,
|
||||
@@ -379,6 +550,8 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
||||
) -> bool {
|
||||
self.drain_tasks();
|
||||
let action = event.action_masked(&mut ctx.env);
|
||||
// Device (physical) pixels, same space layout now uses throughout
|
||||
// -- see `AndroidUiState::content_scale`'s field comment.
|
||||
let x = event.x(&mut ctx.env);
|
||||
let y = event.y(&mut ctx.env);
|
||||
let ui_state = self.state.android_state_mut();
|
||||
@@ -431,7 +604,6 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
||||
height: i32,
|
||||
) {
|
||||
self.drain_tasks();
|
||||
let window = holder.surface(&mut ctx.env).to_native_window(&mut ctx.env);
|
||||
// The layout engine's own notion of the canvas size is separate
|
||||
// from the wgpu surface's -- winit's backend sets it from
|
||||
// `WindowEvent::Resized`, and there is no equivalent automatic
|
||||
@@ -440,13 +612,130 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
||||
// nothing but the clear colour: the widget tree laid out against
|
||||
// whatever size `UiRenderState::new` starts at instead of the
|
||||
// surface's real one.
|
||||
self.render.resize((width as u32, height as u32));
|
||||
// Drop the old renderer (and the surface it owns) before building
|
||||
// one from the new window -- see `AndroidRenderer`'s doc comment.
|
||||
let ui_state = self.state.android_state_mut();
|
||||
ui_state.renderer = None;
|
||||
ui_state.renderer = Some(AndroidRenderer::new(window, width as u32, height as u32));
|
||||
self.render(ctx);
|
||||
//
|
||||
// **Physical pixels, matching `AndroidRenderer`'s own
|
||||
// `size()`/`resize()`/`new()`** -- `AndroidUiState::content_scale`'s
|
||||
// field comment. This call sets `UiRenderState::output_size`, which
|
||||
// every `rel`/`rest` length resolves against and every `abs`
|
||||
// pixel-region compares to directly; a `dp(56)` height now folds
|
||||
// in the density at `Len::apply_rest` time instead of this call
|
||||
// dividing the whole window into a separate logical space, which
|
||||
// is what used to make every `abs`-unit size (a fixed `.height(56)`
|
||||
// in particular) mean something different from a `rest`-based one.
|
||||
self.render.resize((width as f32, height as f32));
|
||||
|
||||
// **Reuse the existing renderer (device, atlas, buffers, bind
|
||||
// groups) when one is already live -- only reconfigure the
|
||||
// surface.** `surfaceChanged` fires on *every* size or format
|
||||
// change, not only on a genuinely new `Surface`/window: showing
|
||||
// the IME under `adjustResize` resizes the same `SurfaceView` and
|
||||
// is reported through this exact callback. Rebuilding the whole
|
||||
// `AndroidRenderer` here used to mean a fresh `UiRenderNode::new`
|
||||
// -- a brand-new, empty glyph atlas and fresh GPU buffers -- while
|
||||
// `iris_core`'s CPU-side glyph cache (`primitive/text.rs`) kept the
|
||||
// atlas coordinates it had already handed out against the *old*
|
||||
// atlas. Every glyph then drew from a UV rectangle that pointed
|
||||
// into a texture that had just been recreated empty, so text
|
||||
// vanished on the first keyboard open while rects (which never go
|
||||
// through the atlas) kept drawing -- exactly the "rectangles stay,
|
||||
// glyphs disappear" Iris reported. Confirmed by reading this path
|
||||
// end to end (no fresh-atlas rebuild anywhere in `resize()` below,
|
||||
// only in `AndroidRenderer::new`) before changing anything, per
|
||||
// AGENTS.md's "verify before finishing".
|
||||
//
|
||||
// `AndroidRenderer::resize` only reconfigures the wgpu surface and
|
||||
// rewrites the window uniform -- device, atlas, buffers and bind
|
||||
// groups are untouched, so the glyph cache's coordinates stay
|
||||
// valid. A genuinely new surface (after `surface_destroyed`, e.g.
|
||||
// backgrounding) still goes through `AndroidRenderer::new` below,
|
||||
// since `renderer` is `None` in that case.
|
||||
let already_live = self.state.android_state().renderer.is_some();
|
||||
log::info!(
|
||||
"iris surface: surface_changed {width}x{height} already_live={already_live} \
|
||||
glyphs_cached={} atlas_pages={}",
|
||||
self.rsc.ui.text.atlas.glyph_count(),
|
||||
self.rsc.ui.text.atlas.page_count(),
|
||||
);
|
||||
if already_live {
|
||||
let ui_state = self.state.android_state_mut();
|
||||
ui_state
|
||||
.renderer
|
||||
.as_mut()
|
||||
.expect("checked Some above")
|
||||
.resize(width as u32, height as u32);
|
||||
self.render(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
let window = holder.surface(&mut ctx.env).to_native_window(&mut ctx.env);
|
||||
// `AndroidRenderer::new` used to panic here through wgpu's own
|
||||
// default uncaptured-error handler on a bind-group-layout
|
||||
// validation failure -- exactly what aborted the P0 bench APK on
|
||||
// Iris's phone with the message truncated to "wgpu error:
|
||||
// Validation Error" and nothing else recoverable from the crash
|
||||
// report (RUST.md's P0 box, "iris bench crash on the phone,
|
||||
// 2026-09-06"). It now returns the full diagnostic instead; this is
|
||||
// the one place in the app that can turn it into something a
|
||||
// person can read, since `ctx.view`/`ctx.env` (needed to reach the
|
||||
// Java side) are only in scope inside a `ViewPeer` callback.
|
||||
//
|
||||
// `content_scale` reaches `AndroidRenderer` only for the
|
||||
// Diagnostics page's report text now -- window size and the
|
||||
// shader's window uniform are physical pixels throughout (see the
|
||||
// `resize` call above), not divided by it.
|
||||
let content_scale = self.state.android_state().content_scale;
|
||||
match AndroidRenderer::new(window, width as u32, height as u32, content_scale) {
|
||||
Ok(renderer) => {
|
||||
// A genuinely new renderer means a genuinely new GPU device
|
||||
// and a fresh, empty glyph atlas -- the CPU-side glyph
|
||||
// cache (`TextData::atlas`) and the texture bookkeeping it
|
||||
// is built on (`UiData::textures`) both outlive `renderer`
|
||||
// itself (they live on `self.rsc`, not on `AndroidRenderer`),
|
||||
// so without this they would keep pointing at the *old*
|
||||
// device's now-gone textures -- the app-switch counterpart
|
||||
// to the keyboard-resize glyph wipe this same function's
|
||||
// `already_live` branch above already fixed by reusing the
|
||||
// renderer instead of rebuilding it. One mechanism either
|
||||
// way: this call only runs on the branch that actually
|
||||
// builds a new renderer, exactly where invalidation is
|
||||
// needed, never on the reuse branch, where it would throw
|
||||
// away perfectly valid GPU state for nothing.
|
||||
log::info!(
|
||||
"iris surface: new renderer built ({:?}), clearing glyph atlas: \
|
||||
glyphs={} pages={}",
|
||||
renderer.adapter_backend,
|
||||
self.rsc.ui.text.atlas.glyph_count(),
|
||||
self.rsc.ui.text.atlas.page_count(),
|
||||
);
|
||||
self.rsc.ui.text.atlas.clear();
|
||||
self.rsc.ui.textures.reset();
|
||||
self.state.android_state_mut().renderer = Some(renderer);
|
||||
self.render(ctx);
|
||||
}
|
||||
Err(report) => {
|
||||
// One line for logcat (UI_RULES.md: "the full text for
|
||||
// whoever can read the log" lives here), the multi-line
|
||||
// original on screen -- `show_renderer_error` below.
|
||||
log::error!("iris renderer init failed: {}", report.replace('\n', " | "));
|
||||
// Deferred, not called directly: `Activity::setContentView`
|
||||
// tears the old view hierarchy down synchronously, which
|
||||
// fires `IrisView`'s own `onFocusChanged` before
|
||||
// `setContentView` returns -- straight back into this same
|
||||
// `IrisViewPeer` through `on_focus_changed` while
|
||||
// `with_peer` (android-view's dispatch, `view.rs` upstream)
|
||||
// still holds this peer's `RefCell` borrow for the
|
||||
// `surface_changed` call in progress. Found by inducing a
|
||||
// validation error and hitting `RefCell already borrowed`
|
||||
// at exactly that reentrant call (RUST.md's P0 box).
|
||||
// `push_dynamic_deferred_callback` runs after `with_peer`
|
||||
// drops the borrow, which is what every other callback in
|
||||
// this file that reaches into Java already relies on
|
||||
// (`raise_if_enabled`, above).
|
||||
ctx.push_dynamic_deferred_callback(move |env, view| {
|
||||
show_renderer_error(env, view, &report);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn surface_destroyed<'local>(
|
||||
@@ -454,6 +743,12 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
||||
_ctx: &mut CallbackCtx<'local>,
|
||||
_holder: &android_view::SurfaceHolder<'local>,
|
||||
) {
|
||||
log::info!(
|
||||
"iris surface: surface_destroyed, tearing the renderer down \
|
||||
(glyphs_cached={} atlas_pages={})",
|
||||
self.rsc.ui.text.atlas.glyph_count(),
|
||||
self.rsc.ui.text.atlas.page_count(),
|
||||
);
|
||||
self.state.android_state_mut().renderer = None;
|
||||
}
|
||||
|
||||
@@ -557,10 +852,19 @@ impl<State: AndroidAppState> AccessibilityNodeProvider for IrisViewPeer<State> {
|
||||
/// `register_view_class`, which wants a plain function pointer) -- see
|
||||
/// `iris/android-app/src/lib.rs`.
|
||||
pub fn new_peer<'local, State: AndroidAppState>(
|
||||
env: JNIEnv<'local>,
|
||||
mut env: JNIEnv<'local>,
|
||||
view: View<'local>,
|
||||
_context: Context<'local>,
|
||||
context: Context<'local>,
|
||||
) -> android_view::jni::sys::jlong {
|
||||
// `DisplayMetrics.density` -- physical pixels per dp on this device.
|
||||
// Read once here, at the one point in this file already handed a
|
||||
// `Context`, and carried on `AndroidUiState` from then on (see
|
||||
// `content_scale`'s field comment for what depends on it).
|
||||
let content_scale = context
|
||||
.resources(&mut env)
|
||||
.display_metrics(&mut env)
|
||||
.density(&mut env);
|
||||
log::info!("iris: new_peer content_scale={content_scale}");
|
||||
let vm = env.get_java_vm().unwrap();
|
||||
let global_view = env.new_global_ref(&view.0).unwrap();
|
||||
let redraw: Arc<dyn RequestRedraw> = Arc::new(AndroidRedrawHandle::new(vm, global_view));
|
||||
@@ -572,15 +876,22 @@ pub fn new_peer<'local, State: AndroidAppState>(
|
||||
state: Default::default(),
|
||||
_state: PhantomData,
|
||||
};
|
||||
// See `TextData::density`'s field doc for why this is set alongside
|
||||
// `render.set_density` below rather than read from there.
|
||||
rsc.ui.text.density = content_scale;
|
||||
let shared = Rc::new(RefCell::new(Shared::default()));
|
||||
let ui_state = AndroidUiState::new(shared.clone());
|
||||
let ui_state = AndroidUiState::new(shared.clone(), content_scale);
|
||||
let mut state = State::new(ui_state, &mut rsc);
|
||||
let platform_vm = env.get_java_vm().unwrap();
|
||||
let platform_view = env.new_global_ref(&view.0).unwrap();
|
||||
state.platform_ready(&mut rsc, platform_vm, platform_view);
|
||||
let mut render = UiRenderState::new();
|
||||
// Every `Len::dp` in the tree resolves against this from now on -- see
|
||||
// `UiRenderState::density`'s field doc and `Len::dp`'s.
|
||||
render.set_density(content_scale);
|
||||
let peer = IrisViewPeer {
|
||||
rsc,
|
||||
render: UiRenderState::new(),
|
||||
render,
|
||||
state,
|
||||
task_recv,
|
||||
};
|
||||
|
||||
@@ -22,6 +22,13 @@ pub trait FocusHost {
|
||||
/// it was hit in (`None` when the widget could not be located, which
|
||||
/// happens for one it was just deselected from).
|
||||
fn focus_gained(&mut self, region: Option<PixelRegion>);
|
||||
/// Whether `id` is the current focus target -- what [`select`] uses to
|
||||
/// tell a fresh press (which must wait to see whether it becomes a tap
|
||||
/// or a drag before focusing/showing the IME, Iris 2026-09-06: "if I
|
||||
/// swipe over the input bar it brings up the keyboard") from a drag
|
||||
/// continuing inside a field that was already focused (an ordinary
|
||||
/// drag-to-select, unaffected).
|
||||
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool;
|
||||
}
|
||||
|
||||
/// Helper shared by every `FocusHost` impl, so the double-click window is
|
||||
@@ -33,6 +40,17 @@ pub fn recent_click(last_click: &mut Instant) -> bool {
|
||||
recent
|
||||
}
|
||||
|
||||
/// `PressStart`/`Pressing`/`PressEnd`, all for the left button -- what
|
||||
/// [`Selector`]/[`Selectable`] register instead of [`CursorSense::
|
||||
/// click_or_drag`], so their shared handler (`on_press`, below) sees every
|
||||
/// frame of a gesture and can tell a completed tap from a drag itself,
|
||||
/// rather than reacting to `PressStart` alone the way `click_or_drag`'s
|
||||
/// consumer used to (Iris, 2026-09-06: "if I swipe over the input bar it
|
||||
/// brings up the keyboard").
|
||||
fn press_track() -> CursorSenses {
|
||||
CursorSense::click() | CursorSense::Pressing(CursorButton::Left) | CursorSense::unclick()
|
||||
}
|
||||
|
||||
pub struct Selector;
|
||||
|
||||
impl<Rsc: HasEvents, W: Widget + 'static> WidgetAttr<Rsc, W> for Selector
|
||||
@@ -42,7 +60,7 @@ where
|
||||
type Input = WeakWidget<TextEdit>;
|
||||
|
||||
fn run(rsc: &mut Rsc, container: WeakWidget<W>, id: Self::Input) {
|
||||
rsc.register_event(container, CursorSense::click_or_drag(), move |ctx, rsc| {
|
||||
rsc.register_event(container, press_track(), move |ctx, rsc| {
|
||||
let region = ctx.data.render.window_region(&id, &*rsc).unwrap();
|
||||
let id_pos = region.top_left;
|
||||
let container_pos = ctx
|
||||
@@ -53,14 +71,14 @@ where
|
||||
.top_left;
|
||||
let pos = ctx.data.pos + container_pos - id_pos;
|
||||
let size = region.size();
|
||||
select(
|
||||
on_press(
|
||||
rsc,
|
||||
ctx.data.render,
|
||||
ctx.state,
|
||||
id,
|
||||
pos,
|
||||
size,
|
||||
ctx.data.sense.is_dragging(),
|
||||
ctx.data.sense,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -75,31 +93,102 @@ where
|
||||
type Input = ();
|
||||
|
||||
fn run(rsc: &mut Rsc, id: WeakWidget<TextEdit>, _: Self::Input) {
|
||||
rsc.register_event(id, CursorSense::click_or_drag(), move |ctx, rsc| {
|
||||
select(
|
||||
rsc.register_event(id, press_track(), move |ctx, rsc| {
|
||||
on_press(
|
||||
rsc,
|
||||
ctx.data.render,
|
||||
ctx.state,
|
||||
id,
|
||||
ctx.data.pos,
|
||||
ctx.data.size,
|
||||
ctx.data.sense.is_dragging(),
|
||||
ctx.data.sense,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn select(
|
||||
/// One press-track frame (`PressStart`, `Pressing` or `PressEnd`) over a
|
||||
/// selectable field. A field that is *already* focused behaves exactly as
|
||||
/// `click_or_drag` always did -- every frame updates the selection, which
|
||||
/// is what lets a finger already inside a focused field drag out a
|
||||
/// selection. A field that is **not** focused withholds `select`'s
|
||||
/// focus-granting side effects (and so the platform-specific `focus_gained`
|
||||
/// that shows the keyboard) until the press resolves as a tap: `PressEnd`
|
||||
/// with no frame in between having moved past [`DRAG_SLOP`] from where the
|
||||
/// press began. A drag recognised before release simply cancels the
|
||||
/// pending tap and does nothing further here -- it is not consumed, so
|
||||
/// whatever is behind the field (a list to pan) still sees every frame of
|
||||
/// it, the same as a drag that never touched a selectable field at all.
|
||||
fn on_press(
|
||||
rsc: &mut impl UiRsc,
|
||||
render: &UiRenderState,
|
||||
state: &mut impl FocusHost,
|
||||
id: WeakWidget<TextEdit>,
|
||||
pos: Vec2,
|
||||
size: Vec2,
|
||||
dragging: bool,
|
||||
sense: CursorSense,
|
||||
) {
|
||||
let recent = state.recent_click();
|
||||
id.edit(rsc).select(pos, size, dragging, recent);
|
||||
state.set_focus(Some(id));
|
||||
state.focus_gained(render.window_region(&id, &*rsc));
|
||||
if state.is_focused(id) {
|
||||
// Already focused, so there is no keyboard to withhold -- but a
|
||||
// vertical drag still is not a selection. Android's own `EditText`
|
||||
// scrolls its overflowed text on a vertical drag and starts a
|
||||
// selection only from a long press; a scroll area wrapping this
|
||||
// field (`Scroll::drag`) is what actually pans, and it needs the
|
||||
// first frames of the gesture not to have selected anything behind
|
||||
// it before it crosses `DRAG_SLOP` and takes pointer capture.
|
||||
// `press_origin` carries the same meaning here as in the unfocused
|
||||
// branch below -- "this gesture is still eligible", cleared the
|
||||
// moment it becomes a drag -- so there is one flag, not two.
|
||||
match sense {
|
||||
CursorSense::PressStart(_) => {
|
||||
let recent = state.recent_click();
|
||||
id.edit(rsc).text.press_origin = Some(pos);
|
||||
id.edit(rsc).select(pos, size, false, recent);
|
||||
}
|
||||
CursorSense::Pressing(_) | CursorSense::PressEnd(_) => {
|
||||
let mut ctx = id.edit(rsc);
|
||||
let Some(origin) = ctx.text.press_origin else {
|
||||
return;
|
||||
};
|
||||
let (dx, dy) = (pos.x - origin.x, pos.y - origin.y);
|
||||
if dy.abs() > DRAG_SLOP && dy.abs() >= dx.abs() {
|
||||
ctx.text.press_origin = None;
|
||||
return;
|
||||
}
|
||||
if matches!(sense, CursorSense::PressEnd(_)) {
|
||||
ctx.text.press_origin = None;
|
||||
}
|
||||
ctx.select(pos, size, true, false);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
match sense {
|
||||
CursorSense::PressStart(_) => {
|
||||
id.edit(rsc).text.press_origin = Some(pos);
|
||||
}
|
||||
CursorSense::Pressing(_) => {
|
||||
let ctx = id.edit(rsc);
|
||||
if let Some(origin) = ctx.text.press_origin
|
||||
&& ((pos.x - origin.x).abs() > DRAG_SLOP || (pos.y - origin.y).abs() > DRAG_SLOP)
|
||||
{
|
||||
// Past the slop before release: this is a drag, not a tap
|
||||
// -- give up the pending focus rather than granting it once
|
||||
// the finger lifts wherever it happens to be by then.
|
||||
ctx.text.press_origin = None;
|
||||
}
|
||||
}
|
||||
CursorSense::PressEnd(_) => {
|
||||
let was_tap = id.edit(rsc).text.press_origin.take().is_some();
|
||||
if was_tap {
|
||||
let recent = state.recent_click();
|
||||
id.edit(rsc).select(pos, size, false, recent);
|
||||
state.set_focus(Some(id));
|
||||
state.focus_gained(render.window_region(&id, &*rsc));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,10 @@ impl<T: HasDefaultUiState> FocusHost for T {
|
||||
self.default_state_mut().focus = id;
|
||||
}
|
||||
|
||||
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
|
||||
self.default_state().focus == Some(id)
|
||||
}
|
||||
|
||||
fn focus_gained(&mut self, region: Option<PixelRegion>) {
|
||||
let state = self.default_state_mut();
|
||||
let Some(region) = region else { return };
|
||||
|
||||
@@ -11,10 +11,15 @@ pub struct Input {
|
||||
}
|
||||
|
||||
impl Input {
|
||||
pub fn event(&mut self, event: &WindowEvent) -> bool {
|
||||
/// `scale_factor` converts winit's physical-pixel event coordinates
|
||||
/// into the same logical units `UiRenderNode`'s window uniform now uses
|
||||
/// (`default::render::UiRenderer::new`'s doc comment) -- without it,
|
||||
/// a cursor position and the widget tree it's tested against would be
|
||||
/// in two different units on any monitor whose scale factor isn't 1.0.
|
||||
pub fn event(&mut self, event: &WindowEvent, scale_factor: f32) -> bool {
|
||||
match event {
|
||||
WindowEvent::CursorMoved { position, .. } => {
|
||||
self.cursor.pos = Vec2::new(position.x as f32, position.y as f32);
|
||||
self.cursor.pos = Vec2::new(position.x as f32, position.y as f32) / scale_factor;
|
||||
self.cursor.exists = true;
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } => {
|
||||
@@ -30,7 +35,9 @@ impl Input {
|
||||
WindowEvent::MouseWheel { delta, .. } => {
|
||||
let mut delta = match *delta {
|
||||
MouseScrollDelta::LineDelta(x, y) => Vec2::new(x, y),
|
||||
MouseScrollDelta::PixelDelta(pos) => Vec2::new(pos.x as f32, pos.y as f32),
|
||||
MouseScrollDelta::PixelDelta(pos) => {
|
||||
Vec2::new(pos.x as f32, pos.y as f32) / scale_factor
|
||||
}
|
||||
};
|
||||
if delta.x == 0.0 && self.modifiers.shift {
|
||||
delta.x = delta.y;
|
||||
@@ -68,8 +75,13 @@ impl Input {
|
||||
|
||||
impl DefaultUiState {
|
||||
pub fn window_size(&self) -> Vec2 {
|
||||
let size = self.renderer.window().inner_size();
|
||||
(size.width, size.height).into()
|
||||
let window = self.renderer.window();
|
||||
let size = window.inner_size();
|
||||
let scale_factor = window.scale_factor() as f32;
|
||||
Vec2::new(
|
||||
size.width as f32 / scale_factor,
|
||||
size.height as f32 / scale_factor,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn cursor_state(&self) -> &CursorState {
|
||||
|
||||
@@ -15,6 +15,7 @@ mod access;
|
||||
mod app;
|
||||
mod attr;
|
||||
mod input;
|
||||
mod platform;
|
||||
mod render;
|
||||
|
||||
pub use access::*;
|
||||
@@ -246,7 +247,8 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
||||
ui_state
|
||||
.access_adapter
|
||||
.process_event(&ui_state.window, &event);
|
||||
let input_changed = ui_state.input.event(&event);
|
||||
let scale_factor = ui_state.renderer.window().scale_factor() as f32;
|
||||
let input_changed = ui_state.input.event(&event, scale_factor);
|
||||
let cursor_state = ui_state.cursor_state().clone();
|
||||
let old = ui_state.focus;
|
||||
if cursor_state.buttons.left.is_start() {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
use crate::platform::OpenUrl;
|
||||
use crate::prelude::HasDefaultUiState;
|
||||
|
||||
/// The desktop's URL opener: the platform's own "open this with whatever
|
||||
/// is registered for it" command, detached so a browser starting slowly
|
||||
/// cannot stall the event loop.
|
||||
///
|
||||
/// A command rather than a crate: `xdg-open`/`open`/`start` is what every
|
||||
/// such crate shells out to anyway, and this is one call site.
|
||||
impl<T: HasDefaultUiState> OpenUrl for T {
|
||||
fn open_url(&mut self, url: &str) {
|
||||
let (program, first): (&str, &[&str]) = if cfg!(target_os = "macos") {
|
||||
("open", &[])
|
||||
} else if cfg!(target_os = "windows") {
|
||||
// `start` is a shell builtin, and its first argument is the
|
||||
// window title -- an empty one, or a URL containing `&` ends
|
||||
// up split.
|
||||
("cmd", &["/C", "start", ""])
|
||||
} else {
|
||||
("xdg-open", &[])
|
||||
};
|
||||
match std::process::Command::new(program)
|
||||
.args(first)
|
||||
.arg(url)
|
||||
.spawn()
|
||||
{
|
||||
Ok(_) => {}
|
||||
// Named with the command that failed and the link it was for,
|
||||
// since neither is recoverable from the OS error alone.
|
||||
Err(e) => log::warn!("could not open {url} with {program}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::task::RequestRedraw;
|
||||
use iris_core::{UiData, UiRenderNode, UiRenderState};
|
||||
use iris_core::{UiData, UiRenderNode, UiRenderState, util::Vec2};
|
||||
use pollster::FutureExt;
|
||||
use std::sync::Arc;
|
||||
use wgpu::*;
|
||||
@@ -66,7 +66,13 @@ impl UiRenderer {
|
||||
self.config.width = size.width;
|
||||
self.config.height = size.height;
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
self.ui.resize((size.width, size.height), &self.queue);
|
||||
// Logical, matching `new`'s own seed -- see the comment there.
|
||||
let scale_factor = self.window.scale_factor() as f32;
|
||||
let logical = Vec2::new(
|
||||
size.width as f32 / scale_factor,
|
||||
size.height as f32 / scale_factor,
|
||||
);
|
||||
self.ui.resize(logical, &self.queue);
|
||||
}
|
||||
|
||||
fn create_encoder(device: &Device) -> CommandEncoder {
|
||||
@@ -79,7 +85,16 @@ impl UiRenderer {
|
||||
let size = window.inner_size();
|
||||
|
||||
let instance = Instance::new(&InstanceDescriptor {
|
||||
backends: Backends::PRIMARY,
|
||||
// `force-gles` on the desktop too, not just on Android: the
|
||||
// GLES backend has behaviour of its own (a one-layer array
|
||||
// texture is a `GL_TEXTURE_2D` -- see
|
||||
// `GpuTextures::create_array_texture`), and a machine with a
|
||||
// real GPU is where that is cheap to reproduce and screenshot.
|
||||
backends: if cfg!(feature = "force-gles") {
|
||||
Backends::GL
|
||||
} else {
|
||||
Backends::PRIMARY
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
@@ -141,7 +156,28 @@ impl UiRenderer {
|
||||
|
||||
let encoder = Self::create_encoder(&device);
|
||||
|
||||
let ui = UiRenderNode::new(&device, &queue, &config);
|
||||
// Unlike the Android backend, the desktop backend has no on-screen
|
||||
// fallback to show a diagnostic through, so a renderer-creation
|
||||
// failure still panics here -- but now with wgpu's full "Caused
|
||||
// by:" chain as the message, since `UiRenderNode::new` returns it
|
||||
// rather than letting wgpu's own default handler panic first (see
|
||||
// that function's doc comment).
|
||||
// Logical size (physical / `scale_factor`), matching what the
|
||||
// Android backend now reports too (`android::render::
|
||||
// AndroidRenderer::new`, `content_scale`) -- the swapchain still
|
||||
// configures at the real physical resolution above; only the
|
||||
// window uniform layout/hit-testing agree on is scaled. Without
|
||||
// this a window on any monitor whose scale factor isn't 1.0 would
|
||||
// have the identical "everything too small" bug RUST.md's P0 box
|
||||
// found on Iris's phone, just never noticed here because this
|
||||
// crate's own dev monitors happen to run at 1.0.
|
||||
let scale_factor = window.scale_factor() as f32;
|
||||
let logical_size = Vec2::new(
|
||||
size.width as f32 / scale_factor,
|
||||
size.height as f32 / scale_factor,
|
||||
);
|
||||
let ui = UiRenderNode::new(&device, &queue, &config, logical_size)
|
||||
.expect("Could not create iris render node!");
|
||||
|
||||
Self {
|
||||
surface,
|
||||
|
||||
@@ -68,7 +68,7 @@ fn an_unchanged_frame_draws_and_rewrites_nothing() {
|
||||
render.take_counters(); // discard the first, real draw
|
||||
|
||||
render.update(&root, &mut rsc);
|
||||
let (draws, rewrites, moves) = render.take_counters();
|
||||
let (draws, rewrites, moves, _shapes) = render.take_counters();
|
||||
assert_eq!((draws, rewrites, moves), (0, 0, 0));
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ fn scrolling_moves_in_o1_without_a_redraw() {
|
||||
// already clamped) rather than actually moving anything.
|
||||
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-40.0);
|
||||
render.update(&root, &mut rsc);
|
||||
let (draws, _rewrites, moves) = render.take_counters();
|
||||
let (draws, _rewrites, moves, _shapes) = render.take_counters();
|
||||
|
||||
// The pass condition (LAYOUT.md section 8, condition 3) is 0 draws and
|
||||
// 1 move_offsets write, independent of how many rects are in the
|
||||
@@ -147,6 +147,37 @@ fn hit_testing_follows_a_scrolled_widget() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `ActiveData::mask` is the mask a widget was drawn **under**, not the one
|
||||
/// it set for itself -- `redraw` feeds it straight back in as the inherited
|
||||
/// mask, so storing the set one hands a `Masked` its own mask the second
|
||||
/// time round and trips `Painter::set_mask`'s nested-mask assert. That was
|
||||
/// an abort (`assertion failed: self.mask == MaskIdx::NONE`) the first time
|
||||
/// the composer's new scroll area was redrawn on the emulator; a targeted
|
||||
/// redraw of a `Masked` is what any real screen does whenever anything
|
||||
/// inside it changes.
|
||||
#[test]
|
||||
fn redrawing_a_masked_widget_does_not_nest_its_own_mask() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let (_scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 8);
|
||||
let masked = rsc.ui.widgets.add_strong(Masked { inner: inner_root });
|
||||
let masked_id = masked.id();
|
||||
let root = masked.any();
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((800.0, 600.0));
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
render.redraw(masked_id, &mut rsc);
|
||||
render.redraw(masked_id, &mut rsc);
|
||||
|
||||
assert_eq!(
|
||||
render.active.get(&masked_id).unwrap().mask,
|
||||
MaskIdx::NONE,
|
||||
"a `Masked` at the root is drawn under no mask of its own"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mask_stays_put_while_its_scrolled_content_moves() {
|
||||
let mut rsc = TestRsc {
|
||||
@@ -183,3 +214,444 @@ fn a_mask_stays_put_while_its_scrolled_content_moves() {
|
||||
assert_eq!(mask_delta_before, [0.0, 0.0]);
|
||||
assert_eq!(mask_delta_after, [0.0, 0.0]);
|
||||
}
|
||||
|
||||
/// Reproduces `transcript_ui::composer::build_composer`'s exact tree shape
|
||||
/// (a `Rect` background stacked behind a `Span::RIGHT`-wrapped, padded,
|
||||
/// `rest`-width `TextEdit`, itself the second child of an outer
|
||||
/// `Span::DOWN` beside a `rest(1)`-height sibling) without the event/
|
||||
/// resource plumbing `composer.rs`'s builders need, to isolate whether the
|
||||
/// bug Iris reported on 2026-09-06 ("text seems to not appear in box")
|
||||
/// is this crate's layout engine or something specific to the real
|
||||
/// composer/screen. `TextEditable::edit` only needs `UiRsc`, so a plain
|
||||
/// insert exercises the exact redraw path a keystroke does.
|
||||
fn composer_like_tree(rsc: &mut TestRsc) -> (WeakWidget<TextEdit>, StrongWidget) {
|
||||
let field = wtext("")
|
||||
.editable(EditMode::MultiLine)
|
||||
.text_align(Align::LEFT)
|
||||
.wrap(true)
|
||||
.size(18)
|
||||
.color(UiColor::WHITE)
|
||||
.add(rsc);
|
||||
let bar = (field.pad(dp(12)).width(rest(1)),)
|
||||
.span(Dir::RIGHT)
|
||||
.background(rect(UiColor::new(40, 40, 46, 255)))
|
||||
.add(rsc);
|
||||
let list_stand_in = rect(UiColor::BLACK).height(rest(1)).add(rsc);
|
||||
let tree = (list_stand_in, bar).span(Dir::DOWN).add_strong(rsc).any();
|
||||
(field, tree)
|
||||
}
|
||||
|
||||
/// The reproduction itself. A window this tall stands in for the keyboard
|
||||
/// closed; the second, shorter `resize` stands in for `adjustResize`
|
||||
/// shrinking the surface when the IME opens -- exactly the sequence
|
||||
/// `IrisViewPeer::surface_changed` drives on a real keyboard open. Typing
|
||||
/// happens both before and after, since Iris's report was specifically
|
||||
/// that text typed *after* the keyboard was already up did not appear.
|
||||
#[test]
|
||||
fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let (field, root) = composer_like_tree(&mut rsc);
|
||||
let mut render = UiRenderState::new();
|
||||
|
||||
render.resize((1080.0, 2298.0));
|
||||
render.update(&root, &mut rsc);
|
||||
// Focusing a field is what places its caret on a real tap
|
||||
// (`attr.rs`'s `on_press` -> `TextEditCtx::select`), and an insert
|
||||
// with no caret is a routing bug rather than a state to simulate --
|
||||
// `insert_str`'s own `debug_assert!` says so, and caught this test
|
||||
// typing into an unfocused field when it was added.
|
||||
field
|
||||
.edit(&mut rsc)
|
||||
.select(vec2(40.0, 2250.0), vec2(1080.0, 2298.0), false, false);
|
||||
field.edit(&mut rsc).insert("a");
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
let before_px = render.window_region(&field, &rsc).unwrap();
|
||||
// The field is one line plus 12dp of padding on a 2298-tall window --
|
||||
// nowhere near the whole window's height, and anchored at the bottom.
|
||||
assert!(
|
||||
before_px.bot_right.y - before_px.top_left.y < 200.0,
|
||||
"before a resize: {before_px:?}"
|
||||
);
|
||||
assert!(
|
||||
before_px.top_left.y > 1800.0,
|
||||
"expected the bar near the bottom before a resize: {before_px:?}"
|
||||
);
|
||||
|
||||
// The keyboard opens: a real `surface_changed`/`resize` to a shorter
|
||||
// window, then a further keystroke -- the redraw that must land in the
|
||||
// bar's new (also short) region, not whatever region a provisional
|
||||
// measurement pass used along the way.
|
||||
render.resize((1080.0, 1478.0));
|
||||
render.update(&root, &mut rsc);
|
||||
field.edit(&mut rsc).insert("b");
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
let after_px = render.window_region(&field, &rsc).unwrap();
|
||||
assert!(
|
||||
after_px.bot_right.y - after_px.top_left.y < 200.0,
|
||||
"after a resize + keystroke: {after_px:?}"
|
||||
);
|
||||
assert!(
|
||||
after_px.top_left.y > 1200.0,
|
||||
"expected the bar near the bottom of the shorter window: {after_px:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `Scroll` used to be documented as resolving its own lengths against
|
||||
/// `Painter::output_size` -- the window -- which read as if a scroll area
|
||||
/// smaller than the screen could not work, and cost a session's
|
||||
/// investigation before the composer was wired up (docs/RUST.md,
|
||||
/// 2026-09-06). It measures `painter.px_size()` now, so this pins the
|
||||
/// three numbers that follow from the offered box: what it reports
|
||||
/// upward, what its capping parent reports, and how far it can pan.
|
||||
#[test]
|
||||
fn a_scroll_measures_the_box_it_was_offered_not_the_window() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||
let tall = rsc.ui.widgets.add_strong(Sized {
|
||||
inner: rect.any(),
|
||||
x: None,
|
||||
y: Some(Len::abs(1000.0)),
|
||||
});
|
||||
let scroll = rsc.ui.widgets.add_strong(Scroll::new(tall.any(), Axis::Y));
|
||||
let scroll_w = scroll.weak();
|
||||
let scroll_id = scroll.id();
|
||||
let capped = rsc.ui.widgets.add_strong(MaxSize {
|
||||
inner: scroll.any(),
|
||||
x: None,
|
||||
y: Some(Len::abs(100.0)),
|
||||
});
|
||||
let capped_id = capped.id();
|
||||
let root = capped.any();
|
||||
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((800.0, 600.0));
|
||||
// Two passes: the first offers the content a zero-length region
|
||||
// (nothing measured yet) and learns the real content length from what
|
||||
// comes back -- see `scrolling_moves_in_o1_without_a_redraw` for why
|
||||
// that warm-up is deliberate rather than a bug.
|
||||
render.update(&root, &mut rsc);
|
||||
rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(0.0);
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
// Reports the *content*, so the cap above it has something to cap;
|
||||
// reporting the container instead would make the answer a function of
|
||||
// itself, since the container is sized from this very number.
|
||||
assert_eq!(
|
||||
render.active.get(&scroll_id).unwrap().size.y,
|
||||
Len::abs(1000.0)
|
||||
);
|
||||
assert_eq!(
|
||||
render.active.get(&capped_id).unwrap().size.y,
|
||||
Len::abs(100.0),
|
||||
"the cap, not the content and not the window"
|
||||
);
|
||||
|
||||
// Panning is bounded by content minus *container*: 900, not the 400
|
||||
// a 600px window would give.
|
||||
rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(-10_000.0);
|
||||
assert!(
|
||||
(rsc.ui.widgets.get_mut(&scroll_w).unwrap().amt() - 900.0).abs() < 0.01,
|
||||
"amt={}",
|
||||
rsc.ui.widgets.get_mut(&scroll_w).unwrap().amt()
|
||||
);
|
||||
}
|
||||
|
||||
/// The half `hit_testing_follows_a_scrolled_widget` could not see: it
|
||||
/// checks a *descendant* of the widget `Scroll` actually moves, whose own
|
||||
/// `region` is stale and is corrected entirely by the move chain. The
|
||||
/// moved widget itself had its `region` updated *and* the chain delta
|
||||
/// added on top, so its hit box sat at twice the pan -- which is why a
|
||||
/// finger pan of the composer left its field untappable. See
|
||||
/// `ActiveData::move_applied`.
|
||||
#[test]
|
||||
fn a_panned_widgets_own_hit_box_moves_exactly_once() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||
let tall = rsc.ui.widgets.add_strong(Sized {
|
||||
inner: rect.any(),
|
||||
x: None,
|
||||
y: Some(Len::abs(1000.0)),
|
||||
});
|
||||
let tall_w = tall.weak();
|
||||
let scroll = rsc.ui.widgets.add_strong(Scroll::new(tall.any(), Axis::Y));
|
||||
let scroll_w = scroll.weak();
|
||||
let root = scroll.any();
|
||||
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((800.0, 600.0));
|
||||
render.update(&root, &mut rsc);
|
||||
rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(0.0);
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
let before = render.window_region(&tall_w, &rsc).unwrap();
|
||||
rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(-37.0);
|
||||
render.update(&root, &mut rsc);
|
||||
let after = render.window_region(&tall_w, &rsc).unwrap();
|
||||
|
||||
assert!(
|
||||
(after.top_left.y - (before.top_left.y - 37.0)).abs() < 0.01,
|
||||
"the pan was applied twice: before={before:?} after={after:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A `Masked` used to allocate a **new** mask slot on every draw, and
|
||||
/// `draw_inner`'s unchanged-region fast path means its descendants are
|
||||
/// mostly *not* redrawn with it -- so they went on referencing the slot
|
||||
/// they were first drawn under, whose region had since stopped being the
|
||||
/// widget's. Measured 2026-09-06 on the composer's tree: four live mask
|
||||
/// entries, none of them the `Masked`'s current box, and the field it was
|
||||
/// meant to clip drew nothing at all on the emulator. The slot is
|
||||
/// allocated once and rewritten in place now (`ActiveData::own_mask`), so
|
||||
/// this pins both halves: one entry, and that entry is the widget's own
|
||||
/// region.
|
||||
#[test]
|
||||
fn a_masked_widget_keeps_one_mask_slot_that_is_always_its_own_region() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let (_scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 8);
|
||||
let masked = rsc.ui.widgets.add_strong(Masked { inner: inner_root });
|
||||
let masked_id = masked.id();
|
||||
// Placed at the bottom of a `Span::DOWN` behind a `rest(1)` sibling,
|
||||
// which is what moves the bar away from the provisional slot it is
|
||||
// first drawn at -- the move that left the stale mask behind.
|
||||
let filler = rsc.ui.widgets.add_strong(Rect::new(UiColor::BLACK));
|
||||
let filler = rsc.ui.widgets.add_strong(Sized {
|
||||
inner: filler.any(),
|
||||
x: None,
|
||||
y: Some(rest(1)),
|
||||
});
|
||||
let capped = rsc.ui.widgets.add_strong(MaxSize {
|
||||
inner: masked.any(),
|
||||
x: None,
|
||||
y: Some(Len::abs(60.0)),
|
||||
});
|
||||
let mut span = Span::empty(Dir::DOWN);
|
||||
span.push(filler.any());
|
||||
span.push(capped.any());
|
||||
let root = rsc.ui.widgets.add_strong(span).any();
|
||||
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((800.0, 600.0));
|
||||
for _ in 0..3 {
|
||||
render.update(&root, &mut rsc);
|
||||
render.redraw(masked_id, &mut rsc);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
rsc.ui.masks.iter().count(),
|
||||
1,
|
||||
"one `Masked` must own exactly one mask slot, however often it is redrawn"
|
||||
);
|
||||
let mask = *rsc.ui.masks.iter().next().unwrap();
|
||||
assert_eq!(
|
||||
mask.region,
|
||||
render.active.get(&masked_id).unwrap().region,
|
||||
"the mask a descendant clips against must be this widget's current box"
|
||||
);
|
||||
}
|
||||
|
||||
/// A `dp` cap that has done its job must be reported in pixels. `Span`
|
||||
/// places a child using the `abs`/`rel` of the length it reported, so a
|
||||
/// `MaxSize` handing back the caller's own `dp(168)` gave the composer's
|
||||
/// bar a slot of **zero** the moment its content grew past six lines --
|
||||
/// and the `Scroll` inside then measured its container at -63px (the
|
||||
/// padding, subtracted from nothing) and panned the whole message out of
|
||||
/// view. Measured on this checkout's emulator, 2026-09-06:
|
||||
/// `container=-63 content=415.8 amt=478.8`. See `Len::fold_dp`.
|
||||
#[test]
|
||||
fn a_dp_cap_is_reported_in_pixels_so_a_span_can_place_it() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||
let tall = rsc.ui.widgets.add_strong(Sized {
|
||||
inner: rect.any(),
|
||||
x: None,
|
||||
y: Some(Len::abs(1000.0)),
|
||||
});
|
||||
let capped = rsc.ui.widgets.add_strong(MaxSize {
|
||||
inner: tall.any(),
|
||||
x: None,
|
||||
y: Some(Len::dp(100.0)),
|
||||
});
|
||||
let capped_w = capped.weak();
|
||||
let filler = rsc.ui.widgets.add_strong(Rect::new(UiColor::BLACK));
|
||||
let filler = rsc.ui.widgets.add_strong(Sized {
|
||||
inner: filler.any(),
|
||||
x: None,
|
||||
y: Some(rest(1)),
|
||||
});
|
||||
let mut span = Span::empty(Dir::DOWN);
|
||||
span.push(filler.any());
|
||||
span.push(capped.any());
|
||||
let root = rsc.ui.widgets.add_strong(span).any();
|
||||
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((800.0, 600.0));
|
||||
render.set_density(2.5);
|
||||
render.update(&root, &mut rsc);
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
let box_px = render.window_region(&capped_w, &rsc).unwrap();
|
||||
let height = box_px.bot_right.y - box_px.top_left.y;
|
||||
assert!(
|
||||
(height - 250.0).abs() < 0.01,
|
||||
"expected the 100dp cap at density 2.5 to be a 250px slot, got {height} ({box_px:?})"
|
||||
);
|
||||
}
|
||||
|
||||
/// The sibling of `a_panned_widgets_own_hit_box_moves_exactly_once`, on
|
||||
/// the branch that fix had no reason to touch: `draw_inner`'s
|
||||
/// size-independent fast path rewrites a widget's primitives *in place*
|
||||
/// and leaves its move slot alone, so unlike `mov` there is no slot delta
|
||||
/// for `region` to have absorbed. Counting one there anyway makes
|
||||
/// `resolved_region` subtract a delta the chain never held, and the
|
||||
/// widget's hit box lands short of where it is drawn by exactly the
|
||||
/// distance it just moved -- with nothing on screen to say so, since the
|
||||
/// primitives are in the right place.
|
||||
#[test]
|
||||
fn a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let top = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||
let spacer = rsc.ui.widgets.add_strong(Sized {
|
||||
inner: top.any(),
|
||||
x: None,
|
||||
y: Some(Len::abs(100.0)),
|
||||
});
|
||||
let spacer_w = spacer.weak();
|
||||
// `Rect` is `is_size_independent`, so growing the spacer above it
|
||||
// offers this one a region that changed *both* position and size --
|
||||
// the one shape that reaches the branch under test.
|
||||
let below = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||
let below_w = below.weak();
|
||||
let mut span = Span::empty(Dir::DOWN);
|
||||
span.push(spacer.any());
|
||||
span.push(below.any());
|
||||
let root = rsc.ui.widgets.add_strong(span).any();
|
||||
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((800.0, 600.0));
|
||||
render.update(&root, &mut rsc);
|
||||
// `Span` draws each child once at the full region to measure it and
|
||||
// then places it, so this widget has already been through the branch
|
||||
// once by the end of the very first frame.
|
||||
let first = render.window_region(&below_w, &rsc).unwrap();
|
||||
assert!(
|
||||
(first.top_left.y - 100.0).abs() < 0.01,
|
||||
"hit box at {:?}, drawn at y=100",
|
||||
first.top_left
|
||||
);
|
||||
|
||||
rsc.ui.widgets.get_mut(&spacer_w).unwrap().y = Some(Len::abs(250.0));
|
||||
render.update(&root, &mut rsc);
|
||||
let after = render.window_region(&below_w, &rsc).unwrap();
|
||||
assert!(
|
||||
(after.top_left.y - 250.0).abs() < 0.01,
|
||||
"hit box at {:?}, drawn at y=250",
|
||||
after.top_left
|
||||
);
|
||||
}
|
||||
|
||||
/// A parent that both `mov`s a child (its own layout moved the box it
|
||||
/// offers) and `reposition`s it inside that box in the same frame -- what
|
||||
/// `List::place`'s Bottom-known branch does once a row's cached height
|
||||
/// stops matching what the row reports, which is reachable as soon as a
|
||||
/// transcript row's blocks wrap (docs/IRIS_TODO.md's "Found by P1a").
|
||||
struct MoveThenPlace {
|
||||
inner: StrongWidget,
|
||||
/// Where the child is *offered* a (constant-size) box, moved between
|
||||
/// frames by the test.
|
||||
offer_top: f32,
|
||||
/// Where the child is then placed within this widget's own region.
|
||||
place_top: f32,
|
||||
}
|
||||
|
||||
impl Widget for MoveThenPlace {
|
||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||
let offer = UiRegion::new(
|
||||
UiSpan::FULL,
|
||||
UiSpan::new(
|
||||
UiScalar::abs(self.offer_top),
|
||||
UiScalar::abs(self.offer_top + 40.0),
|
||||
),
|
||||
);
|
||||
painter.widget_within(&self.inner, offer);
|
||||
let place = UiRegion::new(
|
||||
UiSpan::FULL,
|
||||
UiSpan::new(
|
||||
UiScalar::abs(self.place_top),
|
||||
UiScalar::abs(self.place_top + 40.0),
|
||||
),
|
||||
);
|
||||
painter.reposition(&self.inner, place);
|
||||
Size::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// `mov` accumulates a delta onto a widget's move slot and `reposition`
|
||||
/// overwrites it, and both can legitimately land on one widget in one
|
||||
/// frame (see `MoveThenPlace`). `reposition` used to write its own delta
|
||||
/// alone, which dropped the move and put the child back at the position
|
||||
/// the offered box had *before* it moved; a `debug_assert!` that
|
||||
/// `move_applied` was zero hid that behind a panic instead of fixing it.
|
||||
/// The slot has one owner and one meaning now --
|
||||
/// `move_applied + repositioned` -- so the child stays where it was
|
||||
/// placed however its offered box moves. Fails at the offer's position
|
||||
/// (200) rather than the placement's (100) without that.
|
||||
#[test]
|
||||
fn a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||
let child = rsc.ui.widgets.add_strong(Sized {
|
||||
inner: rect.any(),
|
||||
x: None,
|
||||
y: Some(Len::abs(40.0)),
|
||||
});
|
||||
let child_w = child.weak();
|
||||
let parent = rsc.ui.widgets.add_strong(MoveThenPlace {
|
||||
inner: child.any(),
|
||||
offer_top: 0.0,
|
||||
place_top: 100.0,
|
||||
});
|
||||
let parent_w = parent.weak();
|
||||
let root = parent.any();
|
||||
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((200.0, 400.0));
|
||||
render.update(&root, &mut rsc);
|
||||
let before = render.window_region(&child_w, &rsc).unwrap();
|
||||
assert!(
|
||||
(before.top_left.y - 100.0).abs() < 0.01,
|
||||
"the child should be drawn where it was placed, not where it was offered: {before:?}"
|
||||
);
|
||||
|
||||
// Move the offered box without changing its size (the `mov` fast path)
|
||||
// and place the child at the same spot as before. Marking the parent
|
||||
// dirty is what a real container's own content change does; the child
|
||||
// itself is untouched, which is the case `mov` exists for.
|
||||
{
|
||||
let parent = rsc.ui.widgets.get_mut(&parent_w).unwrap();
|
||||
parent.offer_top = 200.0;
|
||||
}
|
||||
rsc.ui.widgets.needs_redraw.insert(parent_w.id());
|
||||
render.update(&root, &mut rsc);
|
||||
let after = render.window_region(&child_w, &rsc).unwrap();
|
||||
assert!(
|
||||
(after.top_left.y - 100.0).abs() < 0.01,
|
||||
"the placement did not change, so neither should the child: before={before:?} \
|
||||
after={after:?}"
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ pub mod default;
|
||||
|
||||
pub mod attr;
|
||||
pub mod event;
|
||||
pub mod platform;
|
||||
pub mod sense;
|
||||
pub mod state;
|
||||
pub mod task;
|
||||
@@ -47,6 +48,7 @@ pub mod prelude {
|
||||
pub use event::*;
|
||||
pub use iris_core::*;
|
||||
pub use iris_macro::*;
|
||||
pub use platform::*;
|
||||
pub use sense::*;
|
||||
pub use state::*;
|
||||
pub use task::*;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
//! Capabilities a widget tree needs from whatever is hosting it, that
|
||||
//! neither iris nor the app can perform itself.
|
||||
//!
|
||||
//! Same shape as [`crate::attr::FocusHost`], and for the same reason: the
|
||||
//! interface is declared here, below, and implemented by each backend
|
||||
//! above (`default/platform.rs`, `android/platform.rs`), so a widget can
|
||||
//! ask for the capability by trait bound instead of a caller threading a
|
||||
//! callback down through every builder.
|
||||
|
||||
/// Hand a URL to whatever the platform opens URLs with.
|
||||
///
|
||||
/// One method rather than a general "run an intent"/"exec" surface: the
|
||||
/// only thing a transcript needs is to follow a link a reader tapped, and
|
||||
/// a narrower capability is a narrower thing to get wrong.
|
||||
///
|
||||
/// **Nothing is reported back.** There is no answer worth branching on --
|
||||
/// the platform either shows a browser or does not, and both are outside
|
||||
/// this process -- so failures are logged where they happen (each impl)
|
||||
/// rather than turned into a `Result` every call site would discard.
|
||||
pub trait OpenUrl {
|
||||
fn open_url(&mut self, url: &str);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::prelude::*;
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
ops::{BitOr, Deref, DerefMut},
|
||||
rc::Rc,
|
||||
time::{Duration, Instant},
|
||||
@@ -21,6 +22,14 @@ pub enum CursorSense {
|
||||
Hovering,
|
||||
HoverEnd,
|
||||
Scroll,
|
||||
/// Delivered exactly once, in place of `PressEnd`, to whichever widget
|
||||
/// currently holds pointer capture (`UiRenderState::capture_pointer`)
|
||||
/// when the button lifts -- see `iris::sense`'s pointer-capture doc
|
||||
/// and `DragGesture`. A widget must register this explicitly (it is
|
||||
/// never bundled into `click_or_drag`/`unclick`, since most widgets
|
||||
/// never call `capture_pointer` and have no use for it) to receive it
|
||||
/// at all; ordinary hit-tested widgets keep seeing `PressEnd`.
|
||||
Drop,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -30,6 +39,21 @@ impl Event for CursorSenses {
|
||||
type Data<'a> = CursorData<'a>;
|
||||
type State = SensorState;
|
||||
fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option<Self::Data<'a>> {
|
||||
// `Drop` is never derived from raw cursor/hover state below (the
|
||||
// free `should_run`'s own arm for it is only ever asked here,
|
||||
// never independently true or false against the button) -- it is
|
||||
// set exclusively by `run_sensors`' pointer-capture branch, which
|
||||
// has already decided this exact frame is the captured widget's
|
||||
// terminal event. Matching it by identity, ahead of the general
|
||||
// derivation, matters because a captured widget's registration
|
||||
// list very likely also carries `PressEnd` (`unclick()`, for the
|
||||
// ordinary un-captured case) -- the same button-lift condition
|
||||
// `PressEnd` matches on, so falling through to the loop below
|
||||
// would let whichever of the two happens to be registered first
|
||||
// win, silently swallowing the `Drop` a caller relied on.
|
||||
if data.sense == CursorSense::Drop {
|
||||
return self.contains(&CursorSense::Drop).then(|| data.clone());
|
||||
}
|
||||
if let Some(sense) = should_run(self, &data.cursor, data.hover) {
|
||||
let mut data = data.clone();
|
||||
data.sense = sense;
|
||||
@@ -176,6 +200,48 @@ impl SensorUi for UiRenderState {
|
||||
cursor: CursorState,
|
||||
window_size: Vec2,
|
||||
) {
|
||||
// Exclusive pointer capture (`UiRenderState::capture_pointer`,
|
||||
// `DragGesture`): once some widget has committed to a drag, every
|
||||
// other widget sees nothing from this pointer at all -- no hover,
|
||||
// no click, no press -- until it releases. This is what lets a
|
||||
// fast pan or a selection keep going once the finger has moved
|
||||
// off whatever hit region first noticed the press (including
|
||||
// right off the end of the gesture, at `PressEnd`/`Cancel`): a
|
||||
// per-widget hit test would otherwise silently stop delivering to
|
||||
// *anyone* the moment the pointer left every registered region,
|
||||
// which is exactly what used to leave a fling never started (no
|
||||
// widget ever saw the release). The captured widget keeps getting
|
||||
// ordinary `Pressing` frames while the button is down and gets
|
||||
// exactly one `Drop` -- not `PressEnd` -- the frame it lifts,
|
||||
// which also releases the capture.
|
||||
if let Some(id) = self.captured_pointer() {
|
||||
let Some(shape) = self.resolved_region(&id, rsc) else {
|
||||
self.release_pointer();
|
||||
return;
|
||||
};
|
||||
let region = shape.to_px(window_size);
|
||||
let button_down = cursor.buttons.select(&CursorButton::Left).is_on();
|
||||
let sense = if button_down {
|
||||
CursorSense::Pressing(CursorButton::Left)
|
||||
} else {
|
||||
CursorSense::Drop
|
||||
};
|
||||
let data = CursorData {
|
||||
pos: cursor.pos - region.top_left,
|
||||
size: region.bot_right - region.top_left,
|
||||
scroll_delta: cursor.scroll_delta,
|
||||
hover: ActivationState::On,
|
||||
cursor: cursor.clone(),
|
||||
sense,
|
||||
render: self,
|
||||
};
|
||||
rsc.run_event::<CursorSense>(id, data, state);
|
||||
if !button_down {
|
||||
self.release_pointer();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// in order to remove this take, need to store active list in UiRenderState somehow
|
||||
// this would probably be done through a generic parameter that adds yet another rsc /
|
||||
// state like thing, but local to render state, and is passed to UiRsc events so you can
|
||||
@@ -265,6 +331,15 @@ pub fn should_run(
|
||||
CursorSense::Hovering => hover.is_on(),
|
||||
CursorSense::HoverEnd => hover.is_end(),
|
||||
CursorSense::Scroll => cursor.scroll_delta != Vec2::ZERO,
|
||||
// Never derived here -- `Drop` only ever fires through
|
||||
// `CursorSenses::should_run`'s own special case, ahead of this
|
||||
// loop, for the one widget `run_sensors`' capture branch is
|
||||
// delivering it to this frame. If this arm answered from raw
|
||||
// button state instead, an ordinary hit-tested widget that
|
||||
// happened to register `Drop` (with no capture involved at
|
||||
// all) would see it fire on every plain button-up under the
|
||||
// cursor.
|
||||
CursorSense::Drop => false,
|
||||
} {
|
||||
return Some(*sense);
|
||||
}
|
||||
@@ -412,6 +487,12 @@ enum ArbiterState {
|
||||
/// caller-supplied `Instant` rather than a real clock.
|
||||
pub struct DragArbiter {
|
||||
state: ArbiterState,
|
||||
/// Which way a pan runs. A transcript pans down its list and a code
|
||||
/// fence pans across its own long lines, and the two decisions are
|
||||
/// the same one with the axes swapped -- so the axis is a field
|
||||
/// rather than a second copy of this state machine, and everything
|
||||
/// below reads `along`/`across` instead of `dy`/`dx`.
|
||||
axis: Axis,
|
||||
origin: Vec2,
|
||||
origin_at: Instant,
|
||||
last: Vec2,
|
||||
@@ -419,20 +500,28 @@ pub struct DragArbiter {
|
||||
|
||||
impl Default for DragArbiter {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: ArbiterState::Idle,
|
||||
origin: Vec2::ZERO,
|
||||
origin_at: Instant::now(),
|
||||
last: Vec2::ZERO,
|
||||
}
|
||||
Self::on(Axis::Y)
|
||||
}
|
||||
}
|
||||
|
||||
impl DragArbiter {
|
||||
/// A vertical arbiter -- what a list, and every caller before the
|
||||
/// axis became a field, wants.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// An arbiter whose pan runs along `axis`.
|
||||
pub fn on(axis: Axis) -> Self {
|
||||
Self {
|
||||
state: ArbiterState::Idle,
|
||||
axis,
|
||||
origin: Vec2::ZERO,
|
||||
origin_at: Instant::now(),
|
||||
last: Vec2::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
/// A fresh press-down at `pos`. `already_selected` is whatever the
|
||||
/// caller's selection state was *before* this press -- it decides
|
||||
/// whether an early horizontal move extends that selection instead of
|
||||
@@ -473,28 +562,46 @@ impl DragArbiter {
|
||||
match self.state {
|
||||
ArbiterState::Idle => DragOutcome::Undecided,
|
||||
ArbiterState::Panning => {
|
||||
let dy = pos.y - self.last.y;
|
||||
let along = pos.axis(self.axis) - self.last.axis(self.axis);
|
||||
self.last = pos;
|
||||
DragOutcome::Pan(dy)
|
||||
DragOutcome::Pan(along)
|
||||
}
|
||||
ArbiterState::Selecting => {
|
||||
self.last = pos;
|
||||
DragOutcome::SelectExtend
|
||||
}
|
||||
ArbiterState::Undecided { already_selected } => {
|
||||
let dx = pos.x - self.origin.x;
|
||||
let dy = pos.y - self.origin.y;
|
||||
if already_selected && dx.abs() > DRAG_SLOP && dx.abs() > dy.abs() {
|
||||
let along = pos.axis(self.axis) - self.origin.axis(self.axis);
|
||||
let across = pos.axis(!self.axis) - self.origin.axis(!self.axis);
|
||||
if already_selected && across.abs() > DRAG_SLOP && across.abs() > along.abs() {
|
||||
self.state = ArbiterState::Selecting;
|
||||
self.last = pos;
|
||||
DragOutcome::SelectExtend
|
||||
} else if dy.abs() > DRAG_SLOP && dy.abs() >= dx.abs() {
|
||||
} else if along.abs() > DRAG_SLOP && along.abs() >= across.abs() {
|
||||
self.state = ArbiterState::Panning;
|
||||
self.last = pos;
|
||||
DragOutcome::Pan(dy)
|
||||
// `along` here is the *whole* drag since `press_start`,
|
||||
// not since the last frame -- nothing panned while
|
||||
// `Undecided` was withholding the slop, so applying it
|
||||
// in full on this one frame is a visible jump the
|
||||
// instant `DRAG_SLOP` is crossed (IRIS_TODO.md's
|
||||
// "scrolling down sometimes jitters the text," root-
|
||||
// caused by tracing `List`'s per-frame offset against
|
||||
// a synthetic monotonic drag: the offset held flat for
|
||||
// every `Undecided` frame, then stepped by several
|
||||
// frames' worth of motion at once on the frame slop
|
||||
// was crossed, before resuming ordinary per-frame
|
||||
// deltas). Only the excess past the slop threshold is
|
||||
// real, undecided motion the reader hasn't seen
|
||||
// reflected yet -- so only that excess is applied now,
|
||||
// the same way Android's own touch handling consumes
|
||||
// `ViewConfiguration.getScaledTouchSlop()` once from
|
||||
// the first scroll past it rather than replaying the
|
||||
// whole pre-threshold drag in one step.
|
||||
DragOutcome::Pan(along - DRAG_SLOP.copysign(along))
|
||||
} else if now.duration_since(self.origin_at) >= LONG_PRESS
|
||||
&& dx.abs() <= DRAG_SLOP
|
||||
&& dy.abs() <= DRAG_SLOP
|
||||
&& across.abs() <= DRAG_SLOP
|
||||
&& along.abs() <= DRAG_SLOP
|
||||
{
|
||||
self.state = ArbiterState::Selecting;
|
||||
self.last = pos;
|
||||
@@ -510,6 +617,558 @@ impl DragArbiter {
|
||||
pub fn release(&mut self) {
|
||||
self.state = ArbiterState::Idle;
|
||||
}
|
||||
|
||||
/// Whether the arbiter's current gesture (if any) has committed to
|
||||
/// panning -- what a caller checks at release time to decide whether
|
||||
/// to hand the tracked velocity to [`crate::widget::List::fling`], per
|
||||
/// IRIS_TODO.md's "swiping has no momentum": a fling must only follow
|
||||
/// a pan, never a text selection that happened to end with the finger
|
||||
/// still moving.
|
||||
pub fn is_panning(&self) -> bool {
|
||||
matches!(self.state, ArbiterState::Panning)
|
||||
}
|
||||
|
||||
/// Whether a press is in flight that has committed to neither a pan
|
||||
/// nor a selection -- what a release checks to tell a **tap** from
|
||||
/// the end of a drag. A tap is exactly "pressed and let go without
|
||||
/// ever deciding", so it is read here rather than timed separately:
|
||||
/// one gesture machine, one answer.
|
||||
pub fn is_undecided(&self) -> bool {
|
||||
matches!(self.state, ArbiterState::Undecided { .. })
|
||||
}
|
||||
}
|
||||
|
||||
/// What a [`DragGesture`] decided this frame -- [`DragOutcome`] plus the
|
||||
/// one further state a shared gesture needs: the drag ending, with the
|
||||
/// released velocity if (and only if) it had committed to panning.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum GestureOutcome {
|
||||
Undecided,
|
||||
/// Same units and sign as [`DragOutcome::Pan`] -- the caller's own
|
||||
/// convention (`List::scroll`'s, for a transcript) to apply.
|
||||
Pan(f32),
|
||||
SelectStart,
|
||||
SelectExtend,
|
||||
/// The press ended without ever committing to a pan or a selection --
|
||||
/// a tap. Distinct from `Released(None)`, which is the end of a
|
||||
/// gesture that *did* commit (a selection, or a pan too slow to
|
||||
/// fling): a caller acting on a tap -- following a markdown link --
|
||||
/// must not also act when the finger was panning the list past that
|
||||
/// link, which is the tap-vs-drag rule this enum exists to state
|
||||
/// once for every caller rather than per widget.
|
||||
Tapped,
|
||||
/// The drag ended -- `PressEnd` or the capture's own terminal `Drop`.
|
||||
/// `Some(velocity)` only if the gesture had committed to panning
|
||||
/// (never a tap, a long-press selection, or one still `Undecided`);
|
||||
/// same units as `Pan`, so a caller hands it to `List::fling` with
|
||||
/// whatever sign flip it already applies to `Pan`.
|
||||
Released(Option<f32>),
|
||||
}
|
||||
|
||||
/// Bundles a [`DragArbiter`] and a [`VelocityTracker`] into the one thing
|
||||
/// most drag-driven widgets need: arbitrate pan-vs-hold, track the pan's
|
||||
/// velocity, and take pointer capture (`UiRenderState::capture_pointer`)
|
||||
/// the moment the gesture commits so the rest of it -- including the
|
||||
/// terminal release -- keeps reaching the same widget even after the
|
||||
/// finger has moved off whatever hit region first noticed the press. Iris
|
||||
/// asked for this to live here rather than in `transcript-ui::Selection`
|
||||
/// (2026-09-06, recorded in `IRIS.md`): "dragging should be part of the
|
||||
/// default input system ... anything that provides good performance and
|
||||
/// can be generalized well is part of iris rather than the app." A caller
|
||||
/// still decides what a committed pan or a completed selection *means*
|
||||
/// (transcript-ui's pan-vs-select is one call site; a slider or a plain
|
||||
/// scroll area is another) -- this only owns the *mechanics* every one of
|
||||
/// them would otherwise duplicate.
|
||||
pub struct DragGesture {
|
||||
arbiter: DragArbiter,
|
||||
velocity: VelocityTracker,
|
||||
}
|
||||
|
||||
impl Default for DragGesture {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DragGesture {
|
||||
pub fn new() -> Self {
|
||||
Self::on(Axis::Y)
|
||||
}
|
||||
|
||||
/// A gesture whose pan runs along `axis` -- see [`DragArbiter::on`].
|
||||
pub fn on(axis: Axis) -> Self {
|
||||
Self {
|
||||
arbiter: DragArbiter::on(axis),
|
||||
velocity: VelocityTracker::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this gesture has no press in flight -- a thin passthrough
|
||||
/// to the underlying `DragArbiter::is_idle`, for a caller (a test, a
|
||||
/// diagnostic) that wants to observe the recovery behaviour `handle`'s
|
||||
/// idle-recovery branch documents without reaching into a private
|
||||
/// field.
|
||||
pub fn is_idle(&self) -> bool {
|
||||
self.arbiter.is_idle()
|
||||
}
|
||||
|
||||
/// Feed one frame of a gesture through. `id` is the widget iris should
|
||||
/// give exclusive pointer input to once this gesture commits to
|
||||
/// panning or selecting -- a stable widget that outlives the gesture
|
||||
/// (a `List`'s own id, not one of its virtualised rows, which can be
|
||||
/// retired mid-drag as content scrolls). `render` is `CursorData`'s
|
||||
/// own field, already in hand at every call site. `already_selected`
|
||||
/// only matters for the first frame of a gesture (`PressStart`, or the
|
||||
/// recovery branch below) -- see `DragArbiter::press_start`'s doc.
|
||||
pub fn handle(
|
||||
&mut self,
|
||||
render: &UiRenderState,
|
||||
id: WidgetId,
|
||||
sense: CursorSense,
|
||||
pos_window: Vec2,
|
||||
now: Instant,
|
||||
already_selected: bool,
|
||||
) -> GestureOutcome {
|
||||
match sense {
|
||||
CursorSense::PressStart(_) => {
|
||||
self.velocity.reset();
|
||||
self.arbiter.press_start(pos_window, now, already_selected);
|
||||
self.dispatch(render, id, pos_window, now)
|
||||
}
|
||||
CursorSense::Drop | CursorSense::PressEnd(_) => {
|
||||
let outcome = if self.arbiter.is_panning() {
|
||||
GestureOutcome::Released(Some(self.velocity.velocity()))
|
||||
} else if self.arbiter.is_undecided() {
|
||||
GestureOutcome::Tapped
|
||||
} else {
|
||||
GestureOutcome::Released(None)
|
||||
};
|
||||
self.arbiter.release();
|
||||
render.release_pointer();
|
||||
outcome
|
||||
}
|
||||
// See `DragArbiter::update`'s own doc: a `Pressing` frame can
|
||||
// arrive with no matching `PressStart` if the touch-down
|
||||
// landed outside whichever hit region first noticed it.
|
||||
_ if self.arbiter.is_idle() => {
|
||||
self.velocity.reset();
|
||||
self.arbiter.press_start(pos_window, now, already_selected);
|
||||
self.dispatch(render, id, pos_window, now)
|
||||
}
|
||||
_ => self.dispatch(render, id, pos_window, now),
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch(
|
||||
&mut self,
|
||||
render: &UiRenderState,
|
||||
id: WidgetId,
|
||||
pos: Vec2,
|
||||
now: Instant,
|
||||
) -> GestureOutcome {
|
||||
match self.arbiter.update(pos, now) {
|
||||
DragOutcome::Undecided => GestureOutcome::Undecided,
|
||||
DragOutcome::Pan(dy) => {
|
||||
render.capture_pointer(id);
|
||||
self.velocity.add_sample(dy, now);
|
||||
GestureOutcome::Pan(dy)
|
||||
}
|
||||
DragOutcome::SelectStart => {
|
||||
render.capture_pointer(id);
|
||||
GestureOutcome::SelectStart
|
||||
}
|
||||
DragOutcome::SelectExtend => {
|
||||
render.capture_pointer(id);
|
||||
GestureOutcome::SelectExtend
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How far back a [`VelocityTracker`] looks when estimating a fling's
|
||||
/// initial speed -- Android's own `VelocityTracker` defaults to a similar
|
||||
/// short window so a gesture's last flick dominates over its slower start.
|
||||
const VELOCITY_WINDOW: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Tracks a drag's speed along one axis from its last ~100ms of motion, so
|
||||
/// a release can be handed a realistic initial velocity for
|
||||
/// [`AndroidFlingSpline`]/[`FlingCalculator`] rather than a single frame's
|
||||
/// noisy last delta. Fed one timestamped pan delta per frame
|
||||
/// (`add_sample`, the same `dy`/`-dy` quantity `DragArbiter::update`'s
|
||||
/// `Pan` outcome already carries) and answers `velocity()` in units per
|
||||
/// second, matching whatever unit the deltas were in.
|
||||
#[derive(Default)]
|
||||
pub struct VelocityTracker {
|
||||
/// `(when, delta)` pairs, oldest first, trimmed to `VELOCITY_WINDOW`
|
||||
/// on every `add_sample` -- so this never grows past however many
|
||||
/// frames land in that window.
|
||||
samples: VecDeque<(Instant, f32)>,
|
||||
}
|
||||
|
||||
impl VelocityTracker {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Forget everything -- called on a fresh press, so a new gesture's
|
||||
/// velocity is never contaminated by the tail of the previous one.
|
||||
pub fn reset(&mut self) {
|
||||
self.samples.clear();
|
||||
}
|
||||
|
||||
/// Record one frame's motion. `delta` is this frame's movement since
|
||||
/// the last sample, not a cumulative position.
|
||||
pub fn add_sample(&mut self, delta: f32, at: Instant) {
|
||||
// A caller that samples out of order (a restored/replayed
|
||||
// gesture, a test) would silently produce a negative `span` in
|
||||
// `velocity`, handled only by its `span <= 0.0 => 0.0` catch-all
|
||||
// -- masking the bug that produced it rather than surfacing it
|
||||
// (docs/REVIEW-2026-09-06.md finding 4).
|
||||
debug_assert!(self.samples.back().is_none_or(|&(last, _)| at >= last));
|
||||
self.samples.push_back((at, delta));
|
||||
while let Some(&(when, _)) = self.samples.front() {
|
||||
if at.duration_since(when) > VELOCITY_WINDOW {
|
||||
self.samples.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The estimated speed, in units-per-second, over whatever samples
|
||||
/// currently fall inside the tracking window: total motion divided by
|
||||
/// the elapsed time between the oldest and newest sample still held.
|
||||
/// `0.0` with fewer than two samples (no time span to divide by).
|
||||
pub fn velocity(&self) -> f32 {
|
||||
if self.samples.len() < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
let total: f32 = self.samples.iter().map(|&(_, d)| d).sum();
|
||||
let span = self
|
||||
.samples
|
||||
.back()
|
||||
.unwrap()
|
||||
.0
|
||||
.duration_since(self.samples.front().unwrap().0)
|
||||
.as_secs_f32();
|
||||
if span <= 0.0 { 0.0 } else { total / span }
|
||||
}
|
||||
}
|
||||
|
||||
/// Android's fling deceleration curve, ported from AOSP's
|
||||
/// `android.widget.OverScroller.SplineOverScroller` (the same curve
|
||||
/// Compose's `androidx.compose.ui.gestures.AndroidFlingSpline` and
|
||||
/// `androidx.compose.foundation.gestures.FlingCalculator` reuse) so a
|
||||
/// fling here travels the same distance a Compose `LazyColumn`'s own
|
||||
/// `ScrollableDefaults.flingBehavior()` would for the same initial
|
||||
/// velocity -- RUST.md's "Benchmark v2" box asked the two apps' fling
|
||||
/// phase to be comparable, and IRIS_TODO.md's "swiping has no momentum"
|
||||
/// asked for the same physics a reader's muscle memory already expects
|
||||
/// from every other Android scroll view.
|
||||
///
|
||||
/// The curve is a cubic-Bezier-derived spline sampled into two lookup
|
||||
/// tables at start-up (`SPLINE`, built once via [`std::sync::OnceLock`]):
|
||||
/// `SPLINE_POSITION[i]`/`SPLINE_TIME[i]` give the fraction of total
|
||||
/// distance/time elapsed at the `i`th of 100 even steps along the curve's
|
||||
/// own parameter. A lookup at an arbitrary time fraction interpolates
|
||||
/// between the two bracketing samples.
|
||||
mod android_fling_spline {
|
||||
use std::sync::OnceLock;
|
||||
|
||||
const NB_SAMPLES: usize = 100;
|
||||
/// Where the two cubic tension lines cross (AOSP's own constant name
|
||||
/// and value, `SplineOverScroller.INFLEXION`).
|
||||
pub(super) const INFLEXION: f32 = 0.35;
|
||||
const START_TENSION: f32 = 0.5;
|
||||
const END_TENSION: f32 = 1.0;
|
||||
const P1: f32 = START_TENSION * INFLEXION;
|
||||
const P2: f32 = 1.0 - END_TENSION * (1.0 - INFLEXION);
|
||||
|
||||
pub(super) struct Spline {
|
||||
position: [f32; NB_SAMPLES + 1],
|
||||
time: [f32; NB_SAMPLES + 1],
|
||||
}
|
||||
|
||||
fn build() -> Spline {
|
||||
let mut position = [0.0f32; NB_SAMPLES + 1];
|
||||
let mut time = [0.0f32; NB_SAMPLES + 1];
|
||||
let (mut x_min, mut y_min) = (0.0f32, 0.0f32);
|
||||
for i in 0..NB_SAMPLES {
|
||||
let alpha = i as f32 / NB_SAMPLES as f32;
|
||||
|
||||
let mut x_max = 1.0f32;
|
||||
let (mut x, mut coef);
|
||||
loop {
|
||||
x = x_min + (x_max - x_min) / 2.0;
|
||||
coef = 3.0 * x * (1.0 - x);
|
||||
let tx = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x;
|
||||
if (tx - alpha).abs() < 1e-5 {
|
||||
break;
|
||||
}
|
||||
if tx > alpha {
|
||||
x_max = x;
|
||||
} else {
|
||||
x_min = x;
|
||||
}
|
||||
}
|
||||
position[i] = coef * ((1.0 - x) * P1 + x * P2) + x * x * x;
|
||||
|
||||
let mut y_max = 1.0f32;
|
||||
let (mut y, mut coef_y);
|
||||
loop {
|
||||
y = y_min + (y_max - y_min) / 2.0;
|
||||
coef_y = 3.0 * y * (1.0 - y);
|
||||
let dy = coef_y * ((1.0 - y) * START_TENSION + y * END_TENSION) + y * y * y;
|
||||
if (dy - alpha).abs() < 1e-5 {
|
||||
break;
|
||||
}
|
||||
if dy > alpha {
|
||||
y_max = y;
|
||||
} else {
|
||||
y_min = y;
|
||||
}
|
||||
}
|
||||
time[i] = coef_y * ((1.0 - y) * P1 + y * P2) + y * y * y;
|
||||
}
|
||||
position[NB_SAMPLES] = 1.0;
|
||||
time[NB_SAMPLES] = 1.0;
|
||||
Spline { position, time }
|
||||
}
|
||||
|
||||
static SPLINE: OnceLock<Spline> = OnceLock::new();
|
||||
|
||||
/// The fraction of total distance covered at `time_fraction` (0..=1
|
||||
/// of the fling's total duration). Finds the bracketing samples in
|
||||
/// `SPLINE_TIME` and interpolates linearly between their matching
|
||||
/// `SPLINE_POSITION` entries, exactly as AOSP's `SplineOverScroller
|
||||
/// .flingPosition` does.
|
||||
pub(super) fn distance_fraction(time_fraction: f32) -> f32 {
|
||||
let spline = SPLINE.get_or_init(build);
|
||||
let t = time_fraction.clamp(0.0, 1.0);
|
||||
let index = ((t * NB_SAMPLES as f32) as usize).min(NB_SAMPLES - 1);
|
||||
let t_inf = spline.time[index];
|
||||
let t_sup = spline.time[index + 1];
|
||||
let d_inf = spline.position[index];
|
||||
let d_sup = spline.position[index + 1];
|
||||
let span = t_sup - t_inf;
|
||||
if span <= 0.0 {
|
||||
d_inf
|
||||
} else {
|
||||
d_inf + (d_sup - d_inf) * (t - t_inf) / span
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AOSP `SplineOverScroller`'s two other physical constants: the default
|
||||
/// `ViewConfiguration.getScrollFriction()` and the deceleration rate a
|
||||
/// friction of `0.84` per frame at 60Hz corresponds to
|
||||
/// (`ln(0.78)/ln(0.9)`, `SplineOverScroller.DECELERATION_RATE`).
|
||||
const FLING_FRICTION: f32 = 0.015;
|
||||
fn deceleration_rate() -> f32 {
|
||||
(0.78f32.ln()) / (0.9f32.ln())
|
||||
}
|
||||
const GRAVITY_EARTH: f32 = 9.80665;
|
||||
|
||||
/// Turns an initial fling velocity into a total travel distance and
|
||||
/// duration, following AOSP `SplineOverScroller`'s own closed-form
|
||||
/// formulas (`getSplineFlingDistance`/the duration half of `fling()`) --
|
||||
/// ported the same way Compose's `FlingCalculator` is, including its
|
||||
/// `density`-dependent physical coefficient (`computeDeceleration`,
|
||||
/// `GravityEarth * 39.37 * density * 160 * friction`). Density and
|
||||
/// velocity/distance units cancel algebraically as long as velocity and
|
||||
/// the returned distance share one pixel space (physical or logical) --
|
||||
/// [`crate::widget::List::fling`] relies on exactly that cancellation to
|
||||
/// avoid needing a display density of its own, since iris's `List`
|
||||
/// already works in logical (density-independent) pixels throughout.
|
||||
pub struct FlingCalculator {
|
||||
physical_coefficient: f32,
|
||||
}
|
||||
|
||||
impl FlingCalculator {
|
||||
pub fn new(density: f32) -> Self {
|
||||
Self {
|
||||
physical_coefficient: GRAVITY_EARTH * 39.37 * density * 160.0 * FLING_FRICTION,
|
||||
}
|
||||
}
|
||||
|
||||
fn deceleration_for(&self, velocity: f32) -> f32 {
|
||||
(android_fling_spline::INFLEXION * velocity.abs()
|
||||
/ (FLING_FRICTION * self.physical_coefficient))
|
||||
.ln()
|
||||
}
|
||||
|
||||
/// Total signed distance the fling travels before settling, in the
|
||||
/// same pixel units `velocity` was given in.
|
||||
pub fn distance(&self, velocity: f32) -> f32 {
|
||||
// See `List::fling`'s matching assertion -- a non-finite velocity
|
||||
// here silently produces a NaN distance rather than surfacing the
|
||||
// bug that produced it (docs/REVIEW-2026-09-06.md finding 3).
|
||||
debug_assert!(velocity.is_finite());
|
||||
if velocity == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let l = self.deceleration_for(velocity);
|
||||
let rate = deceleration_rate();
|
||||
let magnitude =
|
||||
FLING_FRICTION * self.physical_coefficient * (rate / (rate - 1.0) * l).exp();
|
||||
magnitude.copysign(velocity)
|
||||
}
|
||||
|
||||
/// How long the fling takes to settle.
|
||||
pub fn duration(&self, velocity: f32) -> Duration {
|
||||
// See `distance`'s matching assertion, above.
|
||||
debug_assert!(velocity.is_finite());
|
||||
if velocity == 0.0 {
|
||||
return Duration::ZERO;
|
||||
}
|
||||
let l = self.deceleration_for(velocity);
|
||||
let rate = deceleration_rate();
|
||||
Duration::from_secs_f32((l / (rate - 1.0)).exp())
|
||||
}
|
||||
|
||||
/// The signed distance covered by `elapsed` into a fling of this
|
||||
/// `velocity` that started at `t0` -- what a per-frame ticker
|
||||
/// (`List::tick_fling`) calls to find how far to have scrolled by now.
|
||||
/// Clamped to the full `distance()` once `elapsed` reaches
|
||||
/// `duration()`, so a caller need not special-case "past the end."
|
||||
pub fn position_at(&self, velocity: f32, elapsed: Duration) -> f32 {
|
||||
let duration = self.duration(velocity);
|
||||
if duration.is_zero() {
|
||||
return 0.0;
|
||||
}
|
||||
let fraction = (elapsed.as_secs_f32() / duration.as_secs_f32()).min(1.0);
|
||||
self.distance(velocity) * android_fling_spline::distance_fraction(fraction)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod velocity_tracker_tests {
|
||||
use super::*;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
// A single fixed base rather than a fresh `Instant::now()` per call --
|
||||
// computing it once per test keeps every sample's spacing exact
|
||||
// instead of at the mercy of however long the test itself takes to
|
||||
// run between calls, the same reasoning `drag_arbiter_tests::t` uses.
|
||||
static BASE: LazyLock<Instant> = LazyLock::new(Instant::now);
|
||||
|
||||
fn t(ms: u64) -> Instant {
|
||||
*BASE + Duration::from_millis(ms)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fewer_than_two_samples_reports_zero() {
|
||||
let mut v = VelocityTracker::new();
|
||||
assert_eq!(v.velocity(), 0.0);
|
||||
v.add_sample(10.0, t(0));
|
||||
assert_eq!(v.velocity(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_steady_drag_reports_its_own_speed() {
|
||||
// 5px every 10ms, 11 samples spanning 100ms, sums to 55px over
|
||||
// 0.1s -- 550px/s by this tracker's own "sum of deltas over the
|
||||
// span between the oldest and newest held sample" definition.
|
||||
let mut v = VelocityTracker::new();
|
||||
for i in 0..=10 {
|
||||
v.add_sample(5.0, t(i * 10));
|
||||
}
|
||||
assert!((v.velocity() - 550.0).abs() < 1.0, "got {}", v.velocity());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_last_100ms_of_samples_count() {
|
||||
// An old, fast burst well outside the window followed by a slow,
|
||||
// steady drag should report the recent speed, not the average of
|
||||
// both -- otherwise a flick that trails off would still fling at
|
||||
// its earlier, faster speed. The burst sits 110ms before the last
|
||||
// sample, just past the 100ms window, so it is evicted.
|
||||
let mut v = VelocityTracker::new();
|
||||
v.add_sample(1000.0, t(0)); // will be 110ms old by the last sample
|
||||
for i in 1..=11 {
|
||||
v.add_sample(1.0, t(i * 10)); // 1px/10ms = 100px/s
|
||||
}
|
||||
assert!(
|
||||
(v.velocity() - 110.0).abs() < 5.0,
|
||||
"old burst leaked into the window: got {}",
|
||||
v.velocity()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_forgets_prior_samples() {
|
||||
let mut v = VelocityTracker::new();
|
||||
v.add_sample(500.0, t(0));
|
||||
v.add_sample(500.0, t(10));
|
||||
assert!(v.velocity() != 0.0);
|
||||
v.reset();
|
||||
assert_eq!(v.velocity(), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fling_calculator_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn zero_velocity_flings_nowhere() {
|
||||
let calc = FlingCalculator::new(1.0);
|
||||
assert_eq!(calc.distance(0.0), 0.0);
|
||||
assert_eq!(calc.duration(0.0), Duration::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distance_grows_with_velocity_and_keeps_its_sign() {
|
||||
let calc = FlingCalculator::new(2.75); // a typical phone's density
|
||||
let d_slow = calc.distance(2000.0);
|
||||
let d_fast = calc.distance(12000.0);
|
||||
assert!(d_slow > 0.0);
|
||||
assert!(d_fast > d_slow);
|
||||
assert_eq!(calc.distance(-12000.0), -d_fast);
|
||||
}
|
||||
|
||||
/// Summing the spline's own per-frame position deltas across the
|
||||
/// whole fling has to land within 1% of the closed-form `distance()`
|
||||
/// -- this is the guarantee that `List::tick_fling`'s per-frame reads
|
||||
/// of `position_at` actually add up to the total the fling promised,
|
||||
/// not merely that the two formulas look plausible independently.
|
||||
#[test]
|
||||
fn integrating_position_at_matches_the_closed_form_distance() {
|
||||
let calc = FlingCalculator::new(1.0);
|
||||
for velocity in [1500.0f32, 5000.0, 12000.0, -12000.0] {
|
||||
let total = calc.distance(velocity);
|
||||
let duration = calc.duration(velocity);
|
||||
let final_position = calc.position_at(velocity, duration);
|
||||
let err = (final_position - total).abs() / total.abs();
|
||||
assert!(
|
||||
err < 0.01,
|
||||
"velocity {velocity}: position_at(duration)={final_position} vs distance()={total}, err={err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_at_is_monotonic_and_clamped_past_the_end() {
|
||||
let calc = FlingCalculator::new(1.0);
|
||||
let velocity = 12000.0f32;
|
||||
let duration = calc.duration(velocity);
|
||||
let total = calc.distance(velocity);
|
||||
let mut last = 0.0;
|
||||
let mut t = Duration::ZERO;
|
||||
while t < duration {
|
||||
let p = calc.position_at(velocity, t);
|
||||
assert!(p >= last - 0.01, "position went backwards at {t:?}");
|
||||
last = p;
|
||||
t += Duration::from_millis(16);
|
||||
}
|
||||
// Well past the end, it stays pinned at the total -- a caller
|
||||
// must be able to ask "where would this fling be" without first
|
||||
// checking whether it has already settled.
|
||||
assert_eq!(
|
||||
calc.position_at(velocity, duration + Duration::from_secs(5)),
|
||||
total
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -534,9 +1193,13 @@ mod drag_arbiter_tests {
|
||||
fn a_vertical_drag_pans_immediately() {
|
||||
let mut a = DragArbiter::new();
|
||||
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
|
||||
// The transition frame applies only the motion past `DRAG_SLOP`
|
||||
// (20 - 8 = 12), not the full 20px since `press_start` -- see the
|
||||
// `Pan` arm's own comment for why replaying the whole withheld
|
||||
// drag in one step is the scroll-jitter bug this guards against.
|
||||
assert_eq!(
|
||||
a.update(Vec2::new(0.0, 20.0), t(10)),
|
||||
DragOutcome::Pan(20.0)
|
||||
DragOutcome::Pan(12.0)
|
||||
);
|
||||
// Subsequent frames keep panning, by the delta since last frame.
|
||||
assert_eq!(
|
||||
@@ -545,6 +1208,22 @@ mod drag_arbiter_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Direct regression test for the fix: a slow drag that crosses
|
||||
/// `DRAG_SLOP` by only a fraction of a pixel must not still produce a
|
||||
/// visible jump -- the amount applied on the crossing frame should
|
||||
/// itself shrink toward zero as the crossing gets closer to exactly
|
||||
/// `DRAG_SLOP`, rather than always dumping the whole pre-threshold
|
||||
/// distance at once.
|
||||
#[test]
|
||||
fn crossing_the_slop_by_a_little_pans_by_a_little() {
|
||||
let mut a = DragArbiter::new();
|
||||
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
|
||||
assert_eq!(
|
||||
a.update(Vec2::new(0.0, DRAG_SLOP + 0.5), t(10)),
|
||||
DragOutcome::Pan(0.5)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_horizontal_drag_with_nothing_selected_does_not_select() {
|
||||
let mut a = DragArbiter::new();
|
||||
@@ -603,7 +1282,7 @@ mod drag_arbiter_tests {
|
||||
a.press_start(Vec2::new(0.0, 0.0), t(0), true);
|
||||
assert_eq!(
|
||||
a.update(Vec2::new(0.0, 20.0), t(10)),
|
||||
DragOutcome::Pan(20.0)
|
||||
DragOutcome::Pan(12.0)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -646,11 +1325,79 @@ mod drag_arbiter_tests {
|
||||
a.press_start(Vec2::new(0.0, 700.0), t(0), false);
|
||||
assert_eq!(
|
||||
a.update(Vec2::new(0.0, 720.0), t(10)),
|
||||
DragOutcome::Pan(20.0)
|
||||
DragOutcome::Pan(12.0)
|
||||
);
|
||||
assert!(!a.is_idle());
|
||||
}
|
||||
|
||||
/// The tap-vs-drag rule a markdown link is followed by
|
||||
/// (`transcript-ui`'s `row.rs`): a press that never committed is a
|
||||
/// tap, and a press that panned or selected is not -- read from this
|
||||
/// one machine rather than timed a second time beside it.
|
||||
#[test]
|
||||
fn a_press_that_never_moved_is_still_undecided_at_release() {
|
||||
let mut a = DragArbiter::new();
|
||||
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
|
||||
a.update(Vec2::new(1.0, 1.0), t(10));
|
||||
assert!(a.is_undecided());
|
||||
assert!(!a.is_panning());
|
||||
}
|
||||
|
||||
/// The half the tap rule had no reason to touch: a gesture that
|
||||
/// panned must not also read as a tap when the finger comes up over
|
||||
/// the link it started on.
|
||||
#[test]
|
||||
fn a_press_that_panned_is_not_undecided_at_release() {
|
||||
let mut a = DragArbiter::new();
|
||||
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
|
||||
a.update(Vec2::new(0.0, 40.0), t(10));
|
||||
assert!(a.is_panning());
|
||||
assert!(!a.is_undecided());
|
||||
}
|
||||
|
||||
/// A long press that grew a selection is not a tap either.
|
||||
#[test]
|
||||
fn a_long_press_that_selected_is_not_undecided() {
|
||||
let mut a = DragArbiter::new();
|
||||
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
|
||||
assert_eq!(
|
||||
a.update(Vec2::new(0.0, 1.0), t(LONG_PRESS.as_millis() as u64 + 10)),
|
||||
DragOutcome::SelectStart
|
||||
);
|
||||
assert!(!a.is_undecided());
|
||||
}
|
||||
|
||||
/// Both axes are one machine with the axis passed in: a horizontal
|
||||
/// arbiter (a code fence panning across its own long lines) pans on
|
||||
/// exactly the drag a vertical one ignores, and ignores the one it
|
||||
/// pans on.
|
||||
#[test]
|
||||
fn a_horizontal_arbiter_pans_on_the_drag_a_vertical_one_ignores() {
|
||||
let mut across = DragArbiter::on(Axis::X);
|
||||
across.press_start(Vec2::new(0.0, 0.0), t(0), false);
|
||||
assert_eq!(
|
||||
across.update(Vec2::new(20.0, 0.0), t(10)),
|
||||
DragOutcome::Pan(12.0)
|
||||
);
|
||||
|
||||
let mut down = DragArbiter::new();
|
||||
down.press_start(Vec2::new(0.0, 0.0), t(0), false);
|
||||
assert_eq!(
|
||||
down.update(Vec2::new(20.0, 0.0), t(10)),
|
||||
DragOutcome::Undecided
|
||||
);
|
||||
|
||||
// ...and a vertical drag over the horizontal one stays undecided,
|
||||
// which is what lets the list behind a code fence still be
|
||||
// panned by a finger that started on the fence.
|
||||
let mut across = DragArbiter::on(Axis::X);
|
||||
across.press_start(Vec2::new(0.0, 0.0), t(0), false);
|
||||
assert_eq!(
|
||||
across.update(Vec2::new(0.0, 20.0), t(10)),
|
||||
DragOutcome::Undecided
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_resets_to_idle() {
|
||||
let mut a = DragArbiter::new();
|
||||
|
||||
@@ -122,3 +122,197 @@ fn a_button_over_a_list_scrolls_the_list_and_still_clicks() {
|
||||
"the button on top must still receive an actual click"
|
||||
);
|
||||
}
|
||||
|
||||
/// The bug behind "finger flings do nothing" (RUST.md's P0 phone report,
|
||||
/// defect 2): a fast gesture's `PressEnd` can land at a screen position
|
||||
/// nothing is registered at -- past the edge of whatever widget noticed
|
||||
/// the press, in a gap, or off the loaded content entirely. Before pointer
|
||||
/// capture, `run_sensors`' hit test simply delivered nothing that frame,
|
||||
/// so a widget mid-drag never saw its release and never got a chance to
|
||||
/// start a fling. `UiRenderState::capture_pointer`/`DragGesture` fix this
|
||||
/// by giving the drag's widget every frame regardless of where the
|
||||
/// pointer is, including the terminal `Drop` in place of `PressEnd`.
|
||||
#[test]
|
||||
fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() {
|
||||
let mut rsc = SenseRsc {
|
||||
ui: UiData::default(),
|
||||
events: EventManager::default(),
|
||||
};
|
||||
|
||||
// A small draggable widget in the corner -- the release below lands
|
||||
// far outside it, exactly the "moved off the hit region" case.
|
||||
let draggable = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any();
|
||||
let draggable_weak = draggable.weak();
|
||||
|
||||
let dropped = Rc::new(Cell::new(false));
|
||||
{
|
||||
let dropped = dropped.clone();
|
||||
rsc.register_event(
|
||||
draggable_weak,
|
||||
CursorSense::click_or_drag() | CursorSense::unclick() | CursorSense::Drop,
|
||||
move |ctx, rsc| match ctx.data.sense {
|
||||
CursorSense::PressStart(_) | CursorSense::Pressing(_) => {
|
||||
// Any committed drag takes capture -- a real caller
|
||||
// would gate this on a `DragArbiter`/`DragGesture`
|
||||
// decision, but this test only needs to exercise the
|
||||
// capture-and-release mechanics themselves.
|
||||
ctx.data.render.capture_pointer(draggable_weak.id());
|
||||
let _ = rsc;
|
||||
}
|
||||
CursorSense::Drop => dropped.set(true),
|
||||
_ => {}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((100.0, 100.0));
|
||||
render.update(&draggable, &mut rsc);
|
||||
|
||||
let mut state = ();
|
||||
let mut press = cursor_at((5.0, 5.0).into());
|
||||
press.buttons.left = ActivationState::Start;
|
||||
render.run_sensors(&mut rsc, &mut state, press, (100.0, 100.0).into());
|
||||
assert_eq!(
|
||||
render.captured_pointer(),
|
||||
Some(draggable.id()),
|
||||
"the press should have taken capture"
|
||||
);
|
||||
|
||||
// The release lands nowhere near the widget's own region -- the exact
|
||||
// shape of a fast fling's `ACTION_UP`.
|
||||
let mut release = cursor_at((95.0, 95.0).into());
|
||||
release.buttons.left = ActivationState::End;
|
||||
render.run_sensors(&mut rsc, &mut state, release, (100.0, 100.0).into());
|
||||
|
||||
assert!(
|
||||
dropped.get(),
|
||||
"a release outside every widget's hit region must still reach \
|
||||
the widget holding pointer capture"
|
||||
);
|
||||
assert_eq!(
|
||||
render.captured_pointer(),
|
||||
None,
|
||||
"Drop must release the capture"
|
||||
);
|
||||
}
|
||||
|
||||
/// A widget that never registers `CursorSense::Drop` at all must not be
|
||||
/// affected by someone else's capture -- capture is per-gesture, not
|
||||
/// global suppression of the whole input system for widgets that were
|
||||
/// never party to it. (Practically this matters because a captured
|
||||
/// widget's registration list still has to include `Drop` for `should_run`
|
||||
/// to ever match it; this pins that half of the contract.)
|
||||
#[test]
|
||||
fn capturing_one_widget_starves_every_other_widget_of_events() {
|
||||
let mut rsc = SenseRsc {
|
||||
ui: UiData::default(),
|
||||
events: EventManager::default(),
|
||||
};
|
||||
|
||||
let a = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||
let a_weak = a.weak();
|
||||
let b = rsc.ui.widgets.add_strong(Rect::new(UiColor::RED));
|
||||
let b_weak = b.weak();
|
||||
|
||||
let b_hovered = Rc::new(Cell::new(false));
|
||||
{
|
||||
let b_hovered = b_hovered.clone();
|
||||
rsc.register_event(b_weak, CursorSense::Hovering, move |_ctx, _rsc| {
|
||||
b_hovered.set(true);
|
||||
});
|
||||
}
|
||||
|
||||
let root = rsc
|
||||
.ui
|
||||
.widgets
|
||||
.add_strong(Stack {
|
||||
children: vec![a.any(), b.any()],
|
||||
size: StackSize::default(),
|
||||
})
|
||||
.any();
|
||||
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((100.0, 100.0));
|
||||
render.update(&root, &mut rsc);
|
||||
render.capture_pointer(a_weak.id());
|
||||
|
||||
let mut state = ();
|
||||
let cursor = cursor_at((50.0, 50.0).into());
|
||||
render.run_sensors(&mut rsc, &mut state, cursor, (100.0, 100.0).into());
|
||||
|
||||
assert!(
|
||||
!b_hovered.get(),
|
||||
"while a's drag holds capture, b must see no hover at all"
|
||||
);
|
||||
}
|
||||
|
||||
/// IRIS_TODO.md's "the composer has no touch-drag scroll": `Scroll` only
|
||||
/// answered a wheel, so a finger drag over overflowed text did nothing.
|
||||
/// End-to-end over the real wiring -- `scrollable()`'s own registration,
|
||||
/// `run_sensors`' dispatch, `Scroll::drag`, `DragGesture`'s arbitration and
|
||||
/// pointer capture -- rather than only `Scroll::drag`'s own unit tests in
|
||||
/// `scroll.rs`, because the registration is exactly the half those cannot
|
||||
/// see.
|
||||
#[test]
|
||||
fn a_finger_drag_over_a_scroll_area_pans_it() {
|
||||
let mut rsc = SenseRsc {
|
||||
ui: UiData::default(),
|
||||
events: EventManager::default(),
|
||||
};
|
||||
|
||||
// 1000px of content in a 100px window: room to pan.
|
||||
let scroll_strong = rect(UiColor::WHITE)
|
||||
.height(Len::abs(1000.0))
|
||||
.scrollable()
|
||||
.add_strong(&mut rsc);
|
||||
let scroll = scroll_strong.weak();
|
||||
let root = scroll_strong.any();
|
||||
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((100.0, 100.0));
|
||||
render.update(&root, &mut rsc);
|
||||
// `Scroll` reads its content length back from the draw it just did, so
|
||||
// the frame after is the first one that knows there is anything to pan
|
||||
// -- the one-frame lag LAYOUT.md section 4 documents. `scroll(0.0)` is
|
||||
// how `layout_tests.rs` asks for that second frame, and it also drops
|
||||
// `snap_end`, leaving this parked at the start of the content.
|
||||
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
|
||||
render.update(&root, &mut rsc);
|
||||
assert_eq!(rsc.ui.widgets.get(&scroll).unwrap().amt(), 0.0);
|
||||
|
||||
let mut state = ();
|
||||
let mut down = cursor_at((50.0, 80.0).into());
|
||||
down.buttons.left = ActivationState::Start;
|
||||
render.run_sensors(&mut rsc, &mut state, down, (100.0, 100.0).into());
|
||||
assert_eq!(
|
||||
rsc.ui.widgets.get(&scroll).unwrap().amt(),
|
||||
0.0,
|
||||
"the touch-down alone must not move anything"
|
||||
);
|
||||
|
||||
// Inside the slop: still a tap as far as anything can tell.
|
||||
let mut nudge = cursor_at((50.0, 80.0 - (DRAG_SLOP - 1.0)).into());
|
||||
nudge.buttons.left = ActivationState::On;
|
||||
render.run_sensors(&mut rsc, &mut state, nudge, (100.0, 100.0).into());
|
||||
assert_eq!(
|
||||
rsc.ui.widgets.get(&scroll).unwrap().amt(),
|
||||
0.0,
|
||||
"a press inside DRAG_SLOP must not scroll"
|
||||
);
|
||||
|
||||
// Past it, upward: the content follows the finger up, which for this
|
||||
// widget means more `amt`.
|
||||
let mut drag = cursor_at((50.0, 80.0 - (DRAG_SLOP + 40.0)).into());
|
||||
drag.buttons.left = ActivationState::On;
|
||||
render.run_sensors(&mut rsc, &mut state, drag, (100.0, 100.0).into());
|
||||
let after = rsc.ui.widgets.get(&scroll).unwrap().amt();
|
||||
assert!(
|
||||
(after - 40.0).abs() < 0.01,
|
||||
"expected the 40px past the slop to pan it, got {after}"
|
||||
);
|
||||
|
||||
// And the gesture holds the pointer, so the rest of it reaches this
|
||||
// widget even once the finger leaves its box.
|
||||
assert_eq!(render.captured_pointer(), Some(scroll.id()));
|
||||
}
|
||||
@@ -102,7 +102,7 @@
|
||||
|
||||
use crate::prelude::*;
|
||||
use iris_core::util::HashMap;
|
||||
use std::collections::VecDeque;
|
||||
use std::{collections::VecDeque, sync::Arc, time::Instant};
|
||||
|
||||
/// A stable identifier for a loaded row, reused across pages so that a row
|
||||
/// already measured and drawn is not treated as new when data is inserted
|
||||
@@ -213,6 +213,39 @@ pub struct List {
|
||||
/// row is evicted (`pop_front`/`pop_back`) so this cannot grow past
|
||||
/// however many rows are currently loaded.
|
||||
heights: HashMap<RowKey, f32>,
|
||||
/// A fling in progress, or `None` if the list is at rest -- see
|
||||
/// `fling`/`tick_fling`/`is_scrolling`, IRIS_TODO.md's "swiping has no
|
||||
/// momentum."
|
||||
fling: Option<Fling>,
|
||||
/// What `tick_fling` re-arms every frame a fling is still running, so
|
||||
/// the list keeps animating without needing a caller to poll it --
|
||||
/// set once via `set_redraw_handle` by whoever owns the surface this
|
||||
/// list draws into (the same handle `iris::task::Tasks::redraw_handle`
|
||||
/// hands out elsewhere). `None` for a list that never flings
|
||||
/// (headless tests, a caller driving `tick_fling` by hand as
|
||||
/// `bench_client.rs`'s scripted phases do).
|
||||
redraw: Option<Arc<dyn RequestRedraw>>,
|
||||
/// Whether the last `draw` found no more content above the topmost
|
||||
/// visible row (its top edge at or past the viewport's own top, with
|
||||
/// no `prev_slot`) -- what `tick_fling` clamps a fling moving toward
|
||||
/// the start against. Stale (from whatever the last draw found) on a
|
||||
/// list that hasn't drawn yet; `false` by default, matching "assume
|
||||
/// there is more content until a draw proves otherwise."
|
||||
at_start: bool,
|
||||
/// The mirror of `at_start` for the newest end.
|
||||
at_end: bool,
|
||||
}
|
||||
|
||||
/// One in-flight fling: the physics answer (`FlingCalculator`) plus how
|
||||
/// much of its total distance has already been applied to the anchor, so
|
||||
/// `tick_fling` only ever moves the list by this frame's *incremental*
|
||||
/// delta -- matching every other place in this widget that scrolls by
|
||||
/// writing `anchor.offset`.
|
||||
struct Fling {
|
||||
calc: FlingCalculator,
|
||||
velocity: f32,
|
||||
started_at: Instant,
|
||||
applied: f32,
|
||||
}
|
||||
|
||||
impl List {
|
||||
@@ -226,6 +259,10 @@ impl List {
|
||||
snap_end: true,
|
||||
viewport_len: 0.0,
|
||||
last_viewport_len: 0.0,
|
||||
fling: None,
|
||||
redraw: None,
|
||||
at_start: false,
|
||||
at_end: false,
|
||||
pending_tap: None,
|
||||
extents: HashMap::default(),
|
||||
heights: HashMap::default(),
|
||||
@@ -317,6 +354,40 @@ impl List {
|
||||
self.extents.clear();
|
||||
}
|
||||
|
||||
/// Swap the last row's widget for a new one **without moving it**: the
|
||||
/// slot index is unchanged, so an anchor already pointing at this slot
|
||||
/// (in particular `snap_end`'s pinned-to-newest case) stays pinned, and
|
||||
/// an anchor pointing anywhere else -- this row scrolled out of view --
|
||||
/// is untouched, so nothing currently on screen moves. This is what a
|
||||
/// streamed reply needs: the row whose *content* keeps changing after
|
||||
/// it first appears is still the same row by position, even if its
|
||||
/// `RowKey` happens to change too (rare -- only `heights`/`extents` care
|
||||
/// about the key, and both are invalidated here the same way
|
||||
/// `pop_back` already invalidates them for the row it removes).
|
||||
/// `None` if the list is empty. O(1), same as `push_back`/`pop_back`.
|
||||
pub fn replace_back(&mut self, row: ListRow) -> Option<ListRow> {
|
||||
let idx = self.items.len().checked_sub(1)?;
|
||||
let old = std::mem::replace(&mut self.items[idx], row);
|
||||
self.heights.remove(&old.key);
|
||||
self.extents.clear();
|
||||
Some(old)
|
||||
}
|
||||
|
||||
/// Drop every loaded row and reset to the same state `List::new` would
|
||||
/// give -- the fallback path for a change `apply`-style incremental
|
||||
/// callers can't express as a replace-or-append (RUST.md: `group_tool_runs`
|
||||
/// regrouping an earlier row). `more_before`/`more_after` are left
|
||||
/// alone: a full paging reset is a different operation from "the
|
||||
/// content changed," and a caller that wants both calls
|
||||
/// `set_more_before(None)`/`set_more_after(None)` itself.
|
||||
pub fn clear(&mut self) {
|
||||
self.items.clear();
|
||||
self.anchor = None;
|
||||
self.snap_end = true;
|
||||
self.heights.clear();
|
||||
self.extents.clear();
|
||||
}
|
||||
|
||||
/// Move the anchor's edge by `amt` pixels. Positive moves later
|
||||
/// content into view (mirrors `Scroll::scroll`'s sign convention).
|
||||
/// Deliberately unclamped -- see the module doc's "what is not
|
||||
@@ -327,6 +398,122 @@ impl List {
|
||||
}
|
||||
}
|
||||
|
||||
/// Give this list a way to ask for another frame on its own, so a
|
||||
/// fling keeps animating without a caller polling it every tick --
|
||||
/// see the `redraw` field's doc. Pass the same handle
|
||||
/// `iris::task::Tasks::redraw_handle` hands a `spawn`ed task; a list
|
||||
/// that never calls this can still `fling`, but has to be driven by a
|
||||
/// caller-owned loop instead (`bench_client.rs`'s scripted phases do
|
||||
/// exactly that, since they need to await settling rather than let it
|
||||
/// run in the background).
|
||||
pub fn set_redraw_handle(&mut self, handle: Arc<dyn RequestRedraw>) {
|
||||
self.redraw = Some(handle);
|
||||
}
|
||||
|
||||
/// Start a fling at `velocity_px_per_s` (this widget's own pixel
|
||||
/// space, same sign convention as `scroll`'s `amt`: positive continues
|
||||
/// moving later content into view). Cancels any fling already in
|
||||
/// progress. A caller with a live touch/press must cancel this on the
|
||||
/// next touch-down (`cancel_fling`) -- `AndroidFlingSpline`'s curve
|
||||
/// has no idea a finger came back down, and Android's own `Scroller`
|
||||
/// relies on the view calling `abortAnimation` for the same reason.
|
||||
///
|
||||
/// Density cancels out of the underlying spline as long as velocity
|
||||
/// and the distance it produces share one pixel space (see
|
||||
/// `FlingCalculator`'s own doc) -- `List` works entirely in logical
|
||||
/// pixels, so `1.0` here is not a placeholder for "unknown density,"
|
||||
/// it is the correct density for a self-consistent unit system.
|
||||
pub fn fling(&mut self, velocity_px_per_s: f32) {
|
||||
// A NaN/inf velocity (a `VelocityTracker::velocity()` divide-by-
|
||||
// near-zero span, or a caller passing a raw device value straight
|
||||
// through) would propagate silently into `deceleration_for`'s
|
||||
// `.ln()` -- the fling either never settles or jumps to NaN
|
||||
// positions with nothing on screen saying why (docs/
|
||||
// REVIEW-2026-09-06.md finding 3).
|
||||
debug_assert!(velocity_px_per_s.is_finite());
|
||||
if velocity_px_per_s == 0.0 || self.anchor.is_none() {
|
||||
self.fling = None;
|
||||
return;
|
||||
}
|
||||
self.fling = Some(Fling {
|
||||
calc: FlingCalculator::new(1.0),
|
||||
velocity: velocity_px_per_s,
|
||||
started_at: Instant::now(),
|
||||
applied: 0.0,
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether a fling is currently animating. What a caller's own
|
||||
/// per-frame loop polls to know when to stop driving `tick_fling`
|
||||
/// (`bench_client.rs`'s fling phase) or to decide whether the list is
|
||||
/// "moving on its own" for any other purpose.
|
||||
pub fn is_scrolling(&self) -> bool {
|
||||
self.fling.is_some()
|
||||
}
|
||||
|
||||
/// Cancel any fling in progress with no further movement -- the next
|
||||
/// touch-down's job, per `fling`'s own doc.
|
||||
pub fn cancel_fling(&mut self) {
|
||||
self.fling = None;
|
||||
}
|
||||
|
||||
/// Advance an in-flight fling to `now`, applying this call's share of
|
||||
/// its total travel via `scroll` and re-arming this list's own redraw
|
||||
/// handle (if it has one) for another frame. Returns whether the
|
||||
/// fling is still going after this call -- `false` either because it
|
||||
/// settled on its own spline-decided schedule or because it reached
|
||||
/// `at_start`/`at_end` (the module doc's clamp: a fling must not carry
|
||||
/// the list past content that does not exist, unlike an ordinary
|
||||
/// touch-pan, which this widget already leaves unclamped by design).
|
||||
///
|
||||
/// Safe to call even with no fling active (a no-op returning `false`),
|
||||
/// so a caller does not need to check `is_scrolling` first.
|
||||
pub fn tick_fling(&mut self, now: Instant) -> bool {
|
||||
let Some(f) = &mut self.fling else {
|
||||
return false;
|
||||
};
|
||||
let elapsed = now.saturating_duration_since(f.started_at);
|
||||
let target = f.calc.position_at(f.velocity, elapsed);
|
||||
let delta = target - f.applied;
|
||||
f.applied = target;
|
||||
let settled_on_schedule = elapsed >= f.calc.duration(f.velocity);
|
||||
let velocity = f.velocity;
|
||||
self.scroll(delta);
|
||||
|
||||
// Clamp: a fling moving toward the start that has already reached
|
||||
// it (or one moving toward the end that has already reached that)
|
||||
// stops rather than continuing to spend its remaining distance on
|
||||
// a part of the list that will never scroll further.
|
||||
let hit_bound = (velocity < 0.0 && self.at_start) || (velocity > 0.0 && self.at_end);
|
||||
|
||||
if settled_on_schedule || hit_bound {
|
||||
self.fling = None;
|
||||
return false;
|
||||
}
|
||||
if let Some(redraw) = &self.redraw {
|
||||
redraw.request_redraw();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The anchor's own row index and pixel offset, formatted the same
|
||||
/// shape Compose's `firstVisibleItemIndex`/`firstVisibleItemScrollOffset`
|
||||
/// report (`idx=N/off=Mpx`) -- what RUST.md's "Benchmark v2" fling
|
||||
/// phase reads before/after/between its fling runs so the two apps'
|
||||
/// travel can be compared directly. `more_before`/`more_after`
|
||||
/// sentinels print as `idx=more-before`/`idx=more-after` rather than
|
||||
/// leaking their internal `isize` representation; `idx=none` if the
|
||||
/// list has never drawn (no anchor yet -- e.g. right after
|
||||
/// `jump_to_end` and before the next frame runs `repair_anchor`).
|
||||
pub fn anchor_position_display(&self) -> String {
|
||||
match self.anchor {
|
||||
None => "idx=none".to_string(),
|
||||
Some(a) if a.slot == BEFORE_SLOT => "idx=more-before".to_string(),
|
||||
Some(a) if a.slot == AFTER_SLOT => "idx=more-after".to_string(),
|
||||
Some(a) => format!("idx={}/off={}px", a.slot, a.offset.round() as i64),
|
||||
}
|
||||
}
|
||||
|
||||
/// Snap to the newest content (last item, or the `more_after`
|
||||
/// sentinel if set), bottom-aligned to the viewport. O(1).
|
||||
pub fn jump_to_end(&mut self) {
|
||||
@@ -372,6 +559,20 @@ impl List {
|
||||
self.extents.get(&key).map(|e| (e.top, e.bottom))
|
||||
}
|
||||
|
||||
/// The row whose on-screen box (as of the last layout) contains
|
||||
/// `viewport_pos`, or `None` if it falls outside every row currently
|
||||
/// drawn (a gap, a header, or off the loaded content entirely). O
|
||||
/// (visible rows), same as `reanchor_at_tap`. What a caller resolves a
|
||||
/// pointer-captured gesture's row-under-the-finger against once the
|
||||
/// gesture is no longer being delivered through any one row's own hit
|
||||
/// region -- see `iris::sense`'s pointer-capture doc.
|
||||
pub fn key_at(&self, viewport_pos: f32) -> Option<RowKey> {
|
||||
self.extents
|
||||
.iter()
|
||||
.find(|(_, ext)| viewport_pos >= ext.top && viewport_pos <= ext.bottom)
|
||||
.map(|(&key, _)| key)
|
||||
}
|
||||
|
||||
fn slot_exists(&self, slot: isize) -> bool {
|
||||
match slot {
|
||||
BEFORE_SLOT => self.more_before.is_some(),
|
||||
@@ -569,12 +770,24 @@ impl List {
|
||||
/// one-frame lag `Scroll`'s own content-length cache accepts, per
|
||||
/// LAYOUT.md.
|
||||
fn place(&mut self, painter: &mut Painter, slot: isize, placement: Placement) -> (f32, f32) {
|
||||
// Every current caller derives `slot` from `repair_anchor`/
|
||||
// `prev_slot`/`next_slot`, which already check existence -- but
|
||||
// that invariant is enforced by convention across three call
|
||||
// sites, not by this function, which would otherwise fail with a
|
||||
// bare "index out of bounds" and no context (docs/
|
||||
// REVIEW-2026-09-06.md finding 2). `slot_widget`, called from
|
||||
// here, is what actually indexes/`.expect`s on it.
|
||||
debug_assert!(
|
||||
self.slot_exists(slot),
|
||||
"place() called with a slot that doesn't exist: {slot:?}"
|
||||
);
|
||||
let axis = self.axis;
|
||||
let output_len = painter.output_size().axis(axis);
|
||||
let container_len = painter.region().axis(axis).len();
|
||||
let density = painter.density();
|
||||
let resolve = move |used: Size| -> f32 {
|
||||
used.axis(axis)
|
||||
.apply_rest()
|
||||
.apply_rest(density)
|
||||
.within_len(container_len)
|
||||
.to_abs(output_len)
|
||||
};
|
||||
@@ -690,26 +903,33 @@ impl Widget for List {
|
||||
};
|
||||
let (mut top, mut bottom) = self.place(painter, anchor.slot, placement);
|
||||
|
||||
let mut idx = anchor.slot;
|
||||
let mut idx_top = anchor.slot;
|
||||
while top > 0.0 {
|
||||
let Some(prev) = self.prev_slot(idx) else {
|
||||
let Some(prev) = self.prev_slot(idx_top) else {
|
||||
break;
|
||||
};
|
||||
let (t, _) = self.place(painter, prev, Placement::Bottom(top));
|
||||
top = t;
|
||||
idx = prev;
|
||||
idx_top = prev;
|
||||
}
|
||||
|
||||
idx = anchor.slot;
|
||||
let mut idx_bottom = anchor.slot;
|
||||
while bottom < self.viewport_len {
|
||||
let Some(next) = self.next_slot(idx) else {
|
||||
let Some(next) = self.next_slot(idx_bottom) else {
|
||||
break;
|
||||
};
|
||||
let (_, b) = self.place(painter, next, Placement::Top(bottom));
|
||||
bottom = b;
|
||||
idx = next;
|
||||
idx_bottom = next;
|
||||
}
|
||||
|
||||
// What `tick_fling` clamps a fling against -- see `at_start`'s
|
||||
// field doc. `top`/`bottom` are the extreme edges actually placed
|
||||
// this frame, and `prev_slot`/`next_slot` returning `None` is what
|
||||
// "no more content" means everywhere else in this widget.
|
||||
self.at_start = self.prev_slot(idx_top).is_none() && top >= 0.0;
|
||||
self.at_end = self.next_slot(idx_bottom).is_none() && bottom <= self.viewport_len;
|
||||
|
||||
self.update_snap_end();
|
||||
Size::REST
|
||||
}
|
||||
@@ -884,7 +1104,7 @@ mod tests {
|
||||
.push_front(ListRow::new(key, w));
|
||||
}
|
||||
render.update(&root, &mut rsc);
|
||||
let (draws, _rewrites, _moves) = render.take_counters();
|
||||
let (draws, _rewrites, _moves, _shapes) = render.take_counters();
|
||||
|
||||
// None of the already-visible rows (11, 12) were touched: the
|
||||
// extents for those keys are numerically unchanged, and the only
|
||||
@@ -1022,7 +1242,7 @@ mod tests {
|
||||
|
||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(5.0);
|
||||
render.update(&root, &mut rsc);
|
||||
let (draws, _rewrites, moves) = render.take_counters();
|
||||
let (draws, _rewrites, moves, _shapes) = render.take_counters();
|
||||
|
||||
// The visible window is a fixed ~10 rows regardless of n; an
|
||||
// O(n) regression would show up as draws/moves scaling with
|
||||
@@ -1032,4 +1252,507 @@ mod tests {
|
||||
assert!(moves <= 12, "n={n}: expected O(visible) moves, got {moves}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The streamed-reply case (RUST.md's "streaming still costs a full
|
||||
/// rebuild" fix, `transcript-ui::TranscriptScreen::apply`): a delta
|
||||
/// swaps the last row's widget for a taller one, same key, same slot.
|
||||
/// A list flush with its own end (the default, `snap_end`) must stay
|
||||
/// flush -- the row grows *upward* from the pinned bottom edge, not
|
||||
/// the other way around, exactly like an ordinary resize of that same
|
||||
/// row would (`expanding_a_row_holds_the_bottom_edge_when_tap_is_lower`).
|
||||
#[test]
|
||||
fn replacing_the_last_row_stays_pinned_to_the_bottom() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let mut list = List::new(Axis::Y);
|
||||
push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0);
|
||||
let (list_weak, root) = add_list(&mut rsc, list);
|
||||
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((100.0, 60.0));
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
// Row 4 is flush with the viewport's bottom edge before the replace.
|
||||
{
|
||||
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
||||
assert!((list_ref.extents[&4].bottom - 60.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
let (_weak, new_row) = fixed_row(&mut rsc, 40.0);
|
||||
let old = rsc
|
||||
.ui
|
||||
.widgets
|
||||
.get_mut(&list_weak)
|
||||
.unwrap()
|
||||
.replace_back(ListRow::new(4, new_row));
|
||||
assert!(
|
||||
old.is_some(),
|
||||
"replace_back should hand back the row it evicted"
|
||||
);
|
||||
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
||||
let row4 = list_ref.extents[&4];
|
||||
assert!(
|
||||
(row4.bottom - 60.0).abs() < 0.01,
|
||||
"still pinned to the newest end after the replace: {row4:?}"
|
||||
);
|
||||
assert!(
|
||||
(row4.top - 20.0).abs() < 0.01,
|
||||
"grew upward, from the pinned bottom edge: {row4:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Neither `replacing_the_last_row_stays_pinned_to_the_bottom` nor
|
||||
/// its sibling below ever asserts the *evicted* key's own bookkeeping
|
||||
/// is actually gone -- both replace row 4 with another row also keyed
|
||||
/// `4`, so `heights.remove(&old.key)` removing and re-inserting the
|
||||
/// same key would pass either test even if it did nothing (docs/
|
||||
/// REVIEW-2026-09-06.md finding 10; this is `Selection`'s finding 1
|
||||
/// class of bug -- a stale handle outliving what it points to --
|
||||
/// production-tested from `List`'s own side). Replacing with a
|
||||
/// **different** key is what actually exercises the removal.
|
||||
#[test]
|
||||
fn replace_back_forgets_the_evicted_keys_own_height() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let mut list = List::new(Axis::Y);
|
||||
push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0);
|
||||
let (list_weak, root) = add_list(&mut rsc, list);
|
||||
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((100.0, 60.0));
|
||||
render.update(&root, &mut rsc);
|
||||
assert!(
|
||||
rsc.ui
|
||||
.widgets
|
||||
.get(&list_weak)
|
||||
.unwrap()
|
||||
.heights
|
||||
.contains_key(&4)
|
||||
);
|
||||
|
||||
let (_weak, new_row) = fixed_row(&mut rsc, 40.0);
|
||||
let old = rsc
|
||||
.ui
|
||||
.widgets
|
||||
.get_mut(&list_weak)
|
||||
.unwrap()
|
||||
.replace_back(ListRow::new(100, new_row));
|
||||
|
||||
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
||||
assert_eq!(old.map(|o| o.key), Some(4));
|
||||
assert!(
|
||||
!list_ref.heights.contains_key(&4),
|
||||
"the evicted key's cached height must not outlive the row it measured"
|
||||
);
|
||||
}
|
||||
|
||||
/// The other half of the same fix's contract: replacing a row that is
|
||||
/// *not* on screen must not move anything that is. `replace_back` only
|
||||
/// touches the last slot's own widget and this file's own `heights`/
|
||||
/// `extents` caches for that one key -- nothing about `Anchor` changes
|
||||
/// -- so the already-placed rows above it should come out at the exact
|
||||
/// same boxes on the next frame.
|
||||
#[test]
|
||||
fn replacing_the_last_row_out_of_view_does_not_move_visible_rows() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let mut list = List::new(Axis::Y);
|
||||
push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0);
|
||||
let (list_weak, root) = add_list(&mut rsc, list);
|
||||
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((100.0, 60.0));
|
||||
// Settle at the default (bottom) anchor first -- `jump_to_start`
|
||||
// does not touch `snap_end`, and `repair_anchor` only leaves a
|
||||
// freshly-set anchor's offset alone once `viewport_len` has
|
||||
// already matched `last_viewport_len` once, the same reason
|
||||
// `moves_stay_o1_across_list_size` settles before the tick it
|
||||
// actually measures.
|
||||
render.update(&root, &mut rsc);
|
||||
// Scrolled to the oldest content: rows 0,1,2 visible, row 4 is far
|
||||
// below the viewport.
|
||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().jump_to_start();
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
let (before0, before1, before2) = {
|
||||
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
||||
assert!(!list_ref.extents.contains_key(&4));
|
||||
(
|
||||
list_ref.extents[&0],
|
||||
list_ref.extents[&1],
|
||||
list_ref.extents[&2],
|
||||
)
|
||||
};
|
||||
|
||||
let (_weak, new_row) = fixed_row(&mut rsc, 999.0);
|
||||
rsc.ui
|
||||
.widgets
|
||||
.get_mut(&list_weak)
|
||||
.unwrap()
|
||||
.replace_back(ListRow::new(4, new_row));
|
||||
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
||||
for (key, before) in [(0u64, before0), (1, before1), (2, before2)] {
|
||||
let after = list_ref.extents[&key];
|
||||
assert_eq!(
|
||||
(after.top, after.bottom),
|
||||
(before.top, before.bottom),
|
||||
"row {key} moved after an off-screen replace"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// RUST.md's P0 phone report (Iris's screenshot, 2026-09-06): a
|
||||
/// replaced row's primitives drawn a second time, overlapping the
|
||||
/// replacement. Reproduces the exact path `TranscriptScreen::apply`'s
|
||||
/// `ReplaceLast` case drives up to 400 times during a streamed reply
|
||||
/// (`bench_client.rs`'s stream phase): the last slot's widget is
|
||||
/// swapped for a brand-new one, same key, and (since a fresh widget
|
||||
/// has no cached height) placed via `place`'s `draw_twice` path every
|
||||
/// time -- the provisional-then-real two-draw sequence LAYOUT.md
|
||||
/// documents as the one place in this crate that deliberately draws a
|
||||
/// widget twice. If `draw_inner`'s old-children diffing or
|
||||
/// `UiRenderState::remove`'s primitive freeing ever failed to retire
|
||||
/// the evicted widget (or the provisional draw's own primitives), it
|
||||
/// would show up here as `active_widgets` growing without bound.
|
||||
/// **Passes as written** -- this pins the widget-arena layer as
|
||||
/// correct in isolation; see the P0 box for where the duplicate was
|
||||
/// actually chased to instead (`Span`'s two-phase draw and the
|
||||
/// `redraw_all`-vs-`redraw_updates` split, still open).
|
||||
#[test]
|
||||
fn replacing_the_last_row_many_times_does_not_leak_primitives() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let mut list = List::new(Axis::Y);
|
||||
for key in 0..5u64 {
|
||||
let (_bg_id, row) = background_styled_row(&mut rsc, 20.0);
|
||||
list.push_back(ListRow::new(key, row));
|
||||
}
|
||||
let (list_weak, root) = add_list(&mut rsc, list);
|
||||
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((100.0, 100.0));
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
let before = render.active_widgets();
|
||||
for i in 0..400u32 {
|
||||
// A varying height keeps every replace on the `draw_twice`
|
||||
// (cache-miss) path rather than settling into the O(1)
|
||||
// same-size `mov` fast path once the height happens to repeat.
|
||||
let (_bg_id, new_row) = background_styled_row(&mut rsc, 20.0 + (i % 3) as f32);
|
||||
rsc.ui
|
||||
.widgets
|
||||
.get_mut(&list_weak)
|
||||
.unwrap()
|
||||
.replace_back(ListRow::new(4, new_row));
|
||||
render.update(&root, &mut rsc);
|
||||
}
|
||||
let after = render.active_widgets();
|
||||
|
||||
assert_eq!(
|
||||
before, after,
|
||||
"400 replaces of the last row must leave exactly the same \
|
||||
number of active widgets as before a leaked id (and the \
|
||||
primitives that live as long as its ActiveData does) would \
|
||||
show up here as growth"
|
||||
);
|
||||
}
|
||||
|
||||
/// The doubled `Compacted:` row from Iris's phone (docs/bench/
|
||||
/// iris-phone-v2-2026-09-06.md), reproduced at its mechanism.
|
||||
///
|
||||
/// `replacing_the_last_row_many_times_does_not_leak_primitives` above
|
||||
/// counts *widgets*, which is why it passed all along: the orphan's
|
||||
/// owner is very much alive -- it is an earlier set of that same
|
||||
/// widget's primitives that got stranded. What strands them is a row
|
||||
/// marked dirty and then reached by its **ancestor's** redraw rather
|
||||
/// than by its own: `draw_inner` only *read* the dirty mark, so the
|
||||
/// whole branch that frees a redrawn widget's previous primitives was
|
||||
/// skipped, and the fresh `ActiveData` overwrote the only handles that
|
||||
/// could ever have freed them. `List` sets no mask, so that copy then
|
||||
/// draws every frame at whatever region it last had -- including,
|
||||
/// where the row was being measured at `GENEROUS_PADDING`, well below
|
||||
/// the list's own box and under the composer.
|
||||
///
|
||||
/// Two rows, two shapes of the same fault: row 2 has a cached height
|
||||
/// (one `widget_within`), row 4 is replaced so it has none (`place`'s
|
||||
/// `draw_twice`, which reaches `draw_inner` twice for one id in one
|
||||
/// frame and so orphans a copy even with no ancestor involved).
|
||||
#[test]
|
||||
fn an_ancestor_redrawing_a_dirty_row_leaves_no_stale_copy() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let mut list = List::new(Axis::Y);
|
||||
// Rows that own a primitive *at their own id* (a background rect),
|
||||
// not only through a child: an orphan is a widget's own primitive
|
||||
// outliving its own redraw, so a row whose top-level widget paints
|
||||
// nothing itself cannot show one however broken the path is.
|
||||
let mut rows = Vec::new();
|
||||
for key in 0..5u64 {
|
||||
let (bg_id, row) = background_styled_row(&mut rsc, 20.0);
|
||||
rows.push((row.id(), bg_id));
|
||||
list.push_back(ListRow::new(key, row));
|
||||
}
|
||||
let (list_weak, root) = add_list(&mut rsc, list);
|
||||
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((100.0, 100.0));
|
||||
render.update(&root, &mut rsc);
|
||||
assert!(render.orphaned_primitives().is_empty());
|
||||
|
||||
// A streamed row's content changing: the row is marked dirty (any
|
||||
// `.set()` on it does this)...
|
||||
let (row2, row2_bg) = rows[2];
|
||||
rsc.ui.widgets.get_dyn_mut(row2).unwrap();
|
||||
rsc.ui.widgets.get_dyn_mut(row2_bg).unwrap();
|
||||
|
||||
// Redraw the *list* by name, so the dirty row is reached by its
|
||||
// ancestor's draw rather than by `redraw_updates` happening to
|
||||
// pick it first -- which is the order `HashSet` iteration makes
|
||||
// arbitrary, and the reason this went unnoticed.
|
||||
render.redraw(list_weak.id(), &mut rsc);
|
||||
|
||||
let orphans = render.orphaned_primitives();
|
||||
assert!(
|
||||
orphans.is_empty(),
|
||||
"{} primitive(s) survived their own widget's redraw: {orphans:?}",
|
||||
orphans.len(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Enough rows, tall enough, that a fling toward the start has real
|
||||
/// room to travel before `at_start` clamps it -- shared by the fling
|
||||
/// tests below.
|
||||
fn build_flingable_list(rsc: &mut TestRsc) -> (WeakWidget<List>, StrongWidget, UiRenderState) {
|
||||
let mut list = List::new(Axis::Y);
|
||||
push_rows(rsc, &mut list, &(0..200).collect::<Vec<_>>(), 20.0);
|
||||
let (list_weak, root) = add_list(rsc, list);
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((100.0, 600.0));
|
||||
render.update(&root, rsc);
|
||||
(list_weak, root, render)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fling_moves_the_list_and_then_settles() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
|
||||
|
||||
// A fling toward the start: negative velocity, matching `scroll`'s
|
||||
// sign convention (`Selection::drag` calls `scroll(-dy)` for a
|
||||
// downward finger motion revealing older content).
|
||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(-8000.0);
|
||||
assert!(rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
|
||||
|
||||
let start = Instant::now();
|
||||
let mut last_still_scrolling = true;
|
||||
for step in 0..600 {
|
||||
let now = start + std::time::Duration::from_millis(step * 16);
|
||||
last_still_scrolling = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now);
|
||||
render.update(&root, &mut rsc);
|
||||
if !last_still_scrolling {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!last_still_scrolling,
|
||||
"fling never settled within 600 steps"
|
||||
);
|
||||
assert!(!rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fling_distance_is_positive_toward_the_end() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
|
||||
// Start scrolled away from the newest end so there is room for an
|
||||
// end-ward fling to actually move.
|
||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().jump_to_start();
|
||||
render.update(&root, &mut rsc);
|
||||
let before = rsc.ui.widgets.get(&list_weak).unwrap().extents[&0];
|
||||
|
||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(8000.0);
|
||||
let start = Instant::now();
|
||||
for step in 0..600 {
|
||||
let now = start + std::time::Duration::from_millis(step * 16);
|
||||
let still = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now);
|
||||
render.update(&root, &mut rsc);
|
||||
if !still {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
||||
// Row 0 either scrolled out of the loaded extents (flung well past
|
||||
// it) or moved upward (smaller top) -- either way, real motion
|
||||
// happened toward the end rather than staying put.
|
||||
if let Some(after) = list_ref.extents.get(&0) {
|
||||
assert!(
|
||||
after.top < before.top,
|
||||
"fling toward the end did not move content up"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `fling_moves_the_list_and_then_settles`/
|
||||
/// `fling_distance_is_positive_toward_the_end` only check that a fling
|
||||
/// started, moved the right way and eventually stopped -- both
|
||||
/// unaffected by *how* the interior ticks split up the total travel
|
||||
/// (docs/REVIEW-2026-09-06.md finding 9). A regression that made
|
||||
/// `tick_fling` apply the whole spline distance every tick instead of
|
||||
/// just this tick's incremental slice would still pass both, while
|
||||
/// being wildly wrong every intermediate frame -- this pins the
|
||||
/// per-tick delta to a decelerating curve (`FlingCalculator::
|
||||
/// position_at`'s own monotonic-and-clamped property, one level
|
||||
/// down, already covers the calculator alone; this is the same
|
||||
/// property through `List::tick_fling`'s `scroll`/`extents`
|
||||
/// accumulation).
|
||||
#[test]
|
||||
fn tick_fling_applies_shrinking_incremental_deltas() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
|
||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().jump_to_start();
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(8000.0);
|
||||
let start = Instant::now();
|
||||
let mut prev_top = rsc.ui.widgets.get(&list_weak).unwrap().extents[&0].top;
|
||||
let mut deltas = Vec::new();
|
||||
for step in 1..600 {
|
||||
let now = start + std::time::Duration::from_millis(step * 16);
|
||||
let still = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now);
|
||||
render.update(&root, &mut rsc);
|
||||
let Some(top) = rsc
|
||||
.ui
|
||||
.widgets
|
||||
.get(&list_weak)
|
||||
.unwrap()
|
||||
.extents
|
||||
.get(&0)
|
||||
.map(|e| e.top)
|
||||
else {
|
||||
break; // row 0 scrolled out of the loaded extents
|
||||
};
|
||||
deltas.push((prev_top - top).abs());
|
||||
prev_top = top;
|
||||
if !still {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
deltas.len() >= 3,
|
||||
"fling settled or left row 0's extent before collecting enough samples"
|
||||
);
|
||||
// Skip the first tick (the slop-transition jump the arbiter
|
||||
// applies is a `List::fling`-adjacent concern, not this curve,
|
||||
// but the very first frame can still carry rounding noise from
|
||||
// `jump_to_start`'s own layout settling).
|
||||
for w in deltas[1..].windows(2) {
|
||||
assert!(
|
||||
w[1] <= w[0] + 0.01,
|
||||
"fling's per-tick delta grew instead of decelerating: {:?} then {:?}",
|
||||
w[0],
|
||||
w[1]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_fling_stops_it_with_no_further_movement() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
|
||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(-8000.0);
|
||||
let start = Instant::now();
|
||||
rsc.ui
|
||||
.widgets
|
||||
.get_mut(&list_weak)
|
||||
.unwrap()
|
||||
.tick_fling(start + std::time::Duration::from_millis(16));
|
||||
render.update(&root, &mut rsc);
|
||||
assert!(rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
|
||||
|
||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().cancel_fling();
|
||||
assert!(!rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
|
||||
|
||||
let before = rsc.ui.widgets.get(&list_weak).unwrap().extents[&199];
|
||||
// A tick after cancelling must be a no-op -- this is what a fresh
|
||||
// touch-down relies on to stop a fling in its tracks.
|
||||
let still = rsc
|
||||
.ui
|
||||
.widgets
|
||||
.get_mut(&list_weak)
|
||||
.unwrap()
|
||||
.tick_fling(start + std::time::Duration::from_millis(200));
|
||||
render.update(&root, &mut rsc);
|
||||
assert!(!still);
|
||||
let after = rsc.ui.widgets.get(&list_weak).unwrap().extents[&199];
|
||||
assert_eq!((before.top, before.bottom), (after.top, after.bottom));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fling_toward_the_start_stops_at_the_first_row() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
|
||||
// An enormous velocity that would travel far past all 200 rows if
|
||||
// unclamped -- this is exactly what IRIS_TODO.md's "way faster...
|
||||
// better for stress testing" fling asks for.
|
||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(-50_000.0);
|
||||
let start = Instant::now();
|
||||
for step in 0..2000 {
|
||||
let now = start + std::time::Duration::from_millis(step * 16);
|
||||
let still = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now);
|
||||
render.update(&root, &mut rsc);
|
||||
if !still {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
||||
assert!(
|
||||
list_ref.at_start,
|
||||
"fling should have clamped at the first row"
|
||||
);
|
||||
let first = list_ref.extents[&0];
|
||||
assert!(
|
||||
first.top >= -0.5,
|
||||
"clamped fling overshot the first row's top: {}",
|
||||
first.top
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_position_display_before_any_draw_is_none() {
|
||||
let list = List::new(Axis::Y);
|
||||
assert_eq!(list.anchor_position_display(), "idx=none");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_position_display_reports_slot_and_offset() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
|
||||
let _ = (&root, &mut render);
|
||||
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
||||
assert!(list_ref.anchor_position_display().starts_with("idx="));
|
||||
assert!(!list_ref.anchor_position_display().contains("none"));
|
||||
}
|
||||
}
|
||||
@@ -17,14 +17,15 @@ impl Widget for Aligned {
|
||||
// already-resolved region double-applies that composition and is
|
||||
// wrong for any widget nested below the root.
|
||||
let used = painter.widget(&self.inner);
|
||||
let density = painter.density();
|
||||
let region = match self.align.tuple() {
|
||||
(Some(x), Some(y)) => used.to_uivec2().align(RegionAlign { x, y }),
|
||||
(Some(x), Some(y)) => used.to_uivec2(density).align(RegionAlign { x, y }),
|
||||
(Some(x), None) => {
|
||||
let x = used.x.apply_rest().align(x);
|
||||
let x = used.x.apply_rest(density).align(x);
|
||||
UiRegion::new(x, UiSpan::FULL)
|
||||
}
|
||||
(None, Some(y)) => {
|
||||
let y = used.y.apply_rest().align(y);
|
||||
let y = used.y.apply_rest(density).align(y);
|
||||
UiRegion::new(UiSpan::FULL, y)
|
||||
}
|
||||
(None, None) => UiRegion::FULL,
|
||||
|
||||
@@ -9,13 +9,20 @@ pub struct MaxSize {
|
||||
impl MaxSize {
|
||||
/// Caps a reported length at `max`, comparing in pixels since `Len`'s
|
||||
/// rel/abs/rest components are not otherwise comparable.
|
||||
fn clamp(len: Len, max: Option<Len>, output: f32) -> Len {
|
||||
fn clamp(len: Len, max: Option<Len>, output: f32, density: f32) -> Len {
|
||||
let Some(max) = max else {
|
||||
return len;
|
||||
};
|
||||
let len_px = len.apply_rest().to_abs(output);
|
||||
let max_px = max.apply_rest().to_abs(output);
|
||||
if len_px > max_px { max } else { len }
|
||||
let len_px = len.apply_rest(density).to_abs(output);
|
||||
let max_px = max.apply_rest(density).to_abs(output);
|
||||
// `fold_dp`, not the caller's `max` as written: a reported `Len`
|
||||
// may not carry an unresolved `dp` -- see `Len::fold_dp` for the
|
||||
// collapsed composer bar this caused.
|
||||
if len_px > max_px {
|
||||
max.fold_dp(density)
|
||||
} else {
|
||||
len
|
||||
}
|
||||
}
|
||||
|
||||
/// The span (in this widget's own local, `UiRegion::FULL`-relative
|
||||
@@ -24,11 +31,11 @@ impl MaxSize {
|
||||
/// start, if it does not. Needed so the child is never painted bigger
|
||||
/// than the size this widget reports for it -- see the identical
|
||||
/// requirement noted on `Sized::draw`.
|
||||
fn clamp_region(offered_px: f32, max: Option<Len>, output: f32) -> UiSpan {
|
||||
fn clamp_region(offered_px: f32, max: Option<Len>, output: f32, density: f32) -> UiSpan {
|
||||
let Some(max) = max else {
|
||||
return UiSpan::FULL;
|
||||
};
|
||||
let max_scalar = max.apply_rest();
|
||||
let max_scalar = max.apply_rest(density);
|
||||
let max_px = max_scalar.to_abs(output);
|
||||
if offered_px > max_px {
|
||||
max_scalar.align(AxisAlign::Neg)
|
||||
@@ -41,15 +48,16 @@ impl MaxSize {
|
||||
impl Widget for MaxSize {
|
||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||
let output = painter.output_size();
|
||||
let density = painter.density();
|
||||
let offered = painter.px_size();
|
||||
let region = UiRegion {
|
||||
x: Self::clamp_region(offered.x, self.x, output.x),
|
||||
y: Self::clamp_region(offered.y, self.y, output.y),
|
||||
x: Self::clamp_region(offered.x, self.x, output.x, density),
|
||||
y: Self::clamp_region(offered.y, self.y, output.y, density),
|
||||
};
|
||||
let used = painter.widget_within(&self.inner, region);
|
||||
Size {
|
||||
x: Self::clamp(used.x, self.x, output.x),
|
||||
y: Self::clamp(used.y, self.y, output.y),
|
||||
x: Self::clamp(used.x, self.x, output.x, density),
|
||||
y: Self::clamp(used.y, self.y, output.y, density),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,12 @@ pub struct Pad {
|
||||
|
||||
impl Widget for Pad {
|
||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||
let used = painter.widget_within(&self.inner, self.padding.region());
|
||||
let width = self.padding.left + self.padding.right;
|
||||
let height = self.padding.top + self.padding.bottom;
|
||||
let density = painter.density();
|
||||
let used = painter.widget_within(&self.inner, self.padding.region(density));
|
||||
let width =
|
||||
self.padding.left.apply_rest(density).abs + self.padding.right.apply_rest(density).abs;
|
||||
let height =
|
||||
self.padding.top.apply_rest(density).abs + self.padding.bottom.apply_rest(density).abs;
|
||||
Size {
|
||||
x: used.x + Len::abs(width),
|
||||
y: used.y + Len::abs(height),
|
||||
@@ -17,23 +20,29 @@ impl Widget for Pad {
|
||||
}
|
||||
}
|
||||
|
||||
/// Each side is a `Len`, not a bare `f32`, so `.pad(dp(10))` resolves
|
||||
/// against the display's density the same way any other size does -- see
|
||||
/// `Len::dp`'s field doc. `.pad(10)` (a bare number) still works via
|
||||
/// `From<T: UiNum>` below, unchanged: it becomes an `abs` (physical-pixel)
|
||||
/// `Len`, exactly as a bare number always has meant elsewhere in this
|
||||
/// crate.
|
||||
pub struct Padding {
|
||||
pub left: f32,
|
||||
pub right: f32,
|
||||
pub top: f32,
|
||||
pub bottom: f32,
|
||||
pub left: Len,
|
||||
pub right: Len,
|
||||
pub top: Len,
|
||||
pub bottom: Len,
|
||||
}
|
||||
|
||||
impl Padding {
|
||||
pub const ZERO: Self = Self {
|
||||
left: 0.0,
|
||||
right: 0.0,
|
||||
top: 0.0,
|
||||
bottom: 0.0,
|
||||
left: Len::ZERO,
|
||||
right: Len::ZERO,
|
||||
top: Len::ZERO,
|
||||
bottom: Len::ZERO,
|
||||
};
|
||||
|
||||
pub fn uniform(amt: impl UiNum) -> Self {
|
||||
let amt = amt.to_f32();
|
||||
pub fn uniform(amt: impl Into<Len>) -> Self {
|
||||
let amt = amt.into();
|
||||
Self {
|
||||
left: amt,
|
||||
right: amt,
|
||||
@@ -41,80 +50,84 @@ impl Padding {
|
||||
bottom: amt,
|
||||
}
|
||||
}
|
||||
pub fn region(&self) -> UiRegion {
|
||||
pub fn region(&self, density: f32) -> UiRegion {
|
||||
let mut region = UiRegion::FULL;
|
||||
region.x.start.abs += self.left;
|
||||
region.y.start.abs += self.top;
|
||||
region.x.end.abs -= self.right;
|
||||
region.y.end.abs -= self.bottom;
|
||||
region.x.start.abs += self.left.apply_rest(density).abs;
|
||||
region.y.start.abs += self.top.apply_rest(density).abs;
|
||||
region.x.end.abs -= self.right.apply_rest(density).abs;
|
||||
region.y.end.abs -= self.bottom.apply_rest(density).abs;
|
||||
region
|
||||
}
|
||||
pub fn x(amt: impl UiNum) -> Self {
|
||||
let amt = amt.to_f32();
|
||||
pub fn x(amt: impl Into<Len>) -> Self {
|
||||
let amt = amt.into();
|
||||
Self {
|
||||
left: amt,
|
||||
right: amt,
|
||||
top: 0.0,
|
||||
bottom: 0.0,
|
||||
top: Len::ZERO,
|
||||
bottom: Len::ZERO,
|
||||
}
|
||||
}
|
||||
pub fn y(amt: impl UiNum) -> Self {
|
||||
let amt = amt.to_f32();
|
||||
pub fn y(amt: impl Into<Len>) -> Self {
|
||||
let amt = amt.into();
|
||||
Self {
|
||||
left: 0.0,
|
||||
right: 0.0,
|
||||
left: Len::ZERO,
|
||||
right: Len::ZERO,
|
||||
top: amt,
|
||||
bottom: amt,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn top(amt: impl UiNum) -> Self {
|
||||
pub fn top(amt: impl Into<Len>) -> Self {
|
||||
let mut s = Self::ZERO;
|
||||
s.top = amt.to_f32();
|
||||
s.top = amt.into();
|
||||
s
|
||||
}
|
||||
|
||||
pub fn bottom(amt: impl UiNum) -> Self {
|
||||
pub fn bottom(amt: impl Into<Len>) -> Self {
|
||||
let mut s = Self::ZERO;
|
||||
s.bottom = amt.to_f32();
|
||||
s.bottom = amt.into();
|
||||
s
|
||||
}
|
||||
|
||||
pub fn left(amt: impl UiNum) -> Self {
|
||||
pub fn left(amt: impl Into<Len>) -> Self {
|
||||
let mut s = Self::ZERO;
|
||||
s.left = amt.to_f32();
|
||||
s.left = amt.into();
|
||||
s
|
||||
}
|
||||
|
||||
pub fn right(amt: impl UiNum) -> Self {
|
||||
pub fn right(amt: impl Into<Len>) -> Self {
|
||||
let mut s = Self::ZERO;
|
||||
s.right = amt.to_f32();
|
||||
s.right = amt.into();
|
||||
s
|
||||
}
|
||||
|
||||
pub fn with_top(mut self, amt: impl UiNum) -> Self {
|
||||
self.top = amt.to_f32();
|
||||
pub fn with_top(mut self, amt: impl Into<Len>) -> Self {
|
||||
self.top = amt.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_bottom(mut self, amt: impl UiNum) -> Self {
|
||||
self.bottom = amt.to_f32();
|
||||
pub fn with_bottom(mut self, amt: impl Into<Len>) -> Self {
|
||||
self.bottom = amt.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_left(mut self, amt: impl UiNum) -> Self {
|
||||
self.left = amt.to_f32();
|
||||
pub fn with_left(mut self, amt: impl Into<Len>) -> Self {
|
||||
self.left = amt.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_right(mut self, amt: impl UiNum) -> Self {
|
||||
self.right = amt.to_f32();
|
||||
pub fn with_right(mut self, amt: impl Into<Len>) -> Self {
|
||||
self.right = amt.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: UiNum> From<T> for Padding {
|
||||
/// Covers both a bare number (`.pad(8)`, via `Len`'s own `From<N: UiNum>`
|
||||
/// blanket -- an `abs`/physical-pixel `Len`) and a `Len` directly
|
||||
/// (`.pad(dp(10))`) with the one impl, since `Len: Into<Len>` is the
|
||||
/// reflexive case of the same bound.
|
||||
impl<T: Into<Len>> From<T> for Padding {
|
||||
fn from(amt: T) -> Self {
|
||||
Self::uniform(amt.to_f32())
|
||||
Self::uniform(amt.into())
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
use crate::prelude::*;
|
||||
use crate::sense::{DragGesture, GestureOutcome};
|
||||
use std::time::Instant;
|
||||
|
||||
pub struct Scroll {
|
||||
inner: StrongWidget,
|
||||
@@ -7,6 +9,12 @@ pub struct Scroll {
|
||||
snap_end: bool,
|
||||
container_len: f32,
|
||||
content_len: f32,
|
||||
/// Touch panning, from the same `DragGesture` `List` is driven by
|
||||
/// (`transcript-ui::Selection::drag`) rather than a second copy of its
|
||||
/// wiring: arbitration, `DRAG_SLOP` and pointer capture all live in
|
||||
/// `sense.rs` and only what a committed pan *means* is decided here.
|
||||
/// See [`Self::drag`].
|
||||
gesture: DragGesture,
|
||||
}
|
||||
|
||||
impl Widget for Scroll {
|
||||
@@ -25,10 +33,20 @@ impl Widget for Scroll {
|
||||
// length itself (read below from what was actually drawn) is never
|
||||
// stale, so this self-corrects the next frame and never leaves the
|
||||
// scroll range wrong for long. See LAYOUT.md section 4.
|
||||
//
|
||||
// Every length here is resolved against the box this widget was
|
||||
// **offered** (`px_size`), never `output_size`: a `Scroll` is
|
||||
// routinely smaller than the window -- the composer's field is
|
||||
// capped at six lines by a `MaxSize` around it -- and measuring
|
||||
// the window instead would make the pan range, and so where the
|
||||
// content sits, a function of the screen rather than of the box.
|
||||
// (What the previous arithmetic here computed came to the same
|
||||
// number by a longer route, through a `within_len` against a
|
||||
// window-relative scalar; it read as if the window were the
|
||||
// container and cost a session working out that it was not.)
|
||||
let axis = self.axis;
|
||||
let output_len = painter.output_size().axis(axis);
|
||||
let container_len = painter.region().axis(axis).len();
|
||||
self.container_len = container_len.to_abs(output_len);
|
||||
let container_len = painter.px_size().axis(axis);
|
||||
self.container_len = container_len;
|
||||
|
||||
if self.snap_end {
|
||||
self.amt = self.content_len - self.container_len;
|
||||
@@ -41,12 +59,22 @@ impl Widget for Scroll {
|
||||
|
||||
let used = painter.widget_within(&self.inner, region);
|
||||
|
||||
// A child reporting `rel` means "this fraction of what I was
|
||||
// offered", and what it was offered is this scroll area -- so the
|
||||
// container, again, is what that resolves against.
|
||||
self.content_len = used
|
||||
.axis(axis)
|
||||
.apply_rest()
|
||||
.within_len(container_len)
|
||||
.to_abs(output_len);
|
||||
.apply_rest(painter.density())
|
||||
.to_abs(container_len);
|
||||
|
||||
// The **content's** size, not the container's. A parent that can
|
||||
// grow (the composer's bar) should hug the text until its own cap
|
||||
// stops it, and reporting the container instead would make this
|
||||
// widget's answer a function of the answer -- the bar is sized
|
||||
// from what is reported here, so it collapses to nothing and
|
||||
// never recovers. What keeps the content inside the offered box
|
||||
// is the mask a caller puts around it (`.scrollable().masked()`),
|
||||
// not this number.
|
||||
used
|
||||
}
|
||||
}
|
||||
@@ -60,6 +88,60 @@ impl Scroll {
|
||||
snap_end: true,
|
||||
container_len: 0.0,
|
||||
content_len: 0.0,
|
||||
gesture: DragGesture::on(axis),
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed one frame of a touch gesture over this scroll area through.
|
||||
/// Wired by `WidgetLike::scrollable`; a caller building a `Scroll` by
|
||||
/// hand registers the same senses and calls this.
|
||||
///
|
||||
/// `id` is this widget's own id, which `DragGesture` takes pointer
|
||||
/// capture on once the gesture commits -- so the rest of the drag
|
||||
/// reaches here even after the finger has left this area, and, just as
|
||||
/// importantly, stops reaching whatever is *inside* it. That is what
|
||||
/// resolves a vertical drag over a focused text field: the field sees
|
||||
/// the first few frames, iris::attr's `on_press` gives up its pending
|
||||
/// selection the moment they pass `DRAG_SLOP` vertically, and this
|
||||
/// takes the gesture over. Android's own `EditText` behaves the same
|
||||
/// way -- a vertical drag scrolls, and only a long press selects.
|
||||
///
|
||||
/// No fling: unlike `List`, `Scroll` has no per-frame tick to animate
|
||||
/// one with (`List::set_redraw_handle`/`tick_fling`), and the areas
|
||||
/// this wraps today -- a six-line composer, a diagnostics pane -- are
|
||||
/// at most a screenful, where Android does not fling either. The
|
||||
/// released velocity is deliberately dropped rather than approximated.
|
||||
pub fn drag(
|
||||
&mut self,
|
||||
render: &UiRenderState,
|
||||
id: WidgetId,
|
||||
sense: CursorSense,
|
||||
pos_window: Vec2,
|
||||
now: Instant,
|
||||
) {
|
||||
// `already_selected: false` -- a scroll area has no selection of
|
||||
// its own to extend, so a horizontal drag stays `Undecided` and a
|
||||
// vertical one past the slop pans, which is the whole contract
|
||||
// here. A caller that *does* own a selection (the transcript's
|
||||
// `Selection`) drives `DragGesture` itself instead.
|
||||
match self
|
||||
.gesture
|
||||
.handle(render, id, sense, pos_window, now, false)
|
||||
{
|
||||
// `scroll(dy)`, not `scroll(-dy)` -- `Selection::drag` passes
|
||||
// `-dy` to `List::scroll` because a `List`'s anchor offset and
|
||||
// this widget's `amt` run in *opposite* directions (offset is
|
||||
// where the anchored edge sits; `amt` is how far the content
|
||||
// has been pulled up past the top), even though `List::scroll`'s
|
||||
// own doc claims to mirror this one's convention. The rule that
|
||||
// holds for both, and the one to check a sign against, is that
|
||||
// the content follows the finger.
|
||||
GestureOutcome::Pan(dy) => self.scroll(dy),
|
||||
GestureOutcome::Undecided
|
||||
| GestureOutcome::Tapped
|
||||
| GestureOutcome::SelectStart
|
||||
| GestureOutcome::SelectExtend
|
||||
| GestureOutcome::Released(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,8 +152,179 @@ impl Scroll {
|
||||
self.snap_end = self.amt == len;
|
||||
}
|
||||
|
||||
/// How far the content has been pulled past the container's leading
|
||||
/// edge, in pixels -- 0 at the start of the content. Read-only, for a
|
||||
/// caller that needs to observe a pan (a test, a scroll indicator).
|
||||
pub fn amt(&self) -> f32 {
|
||||
self.amt
|
||||
}
|
||||
|
||||
pub fn scroll(&mut self, amt: f32) {
|
||||
self.amt -= amt;
|
||||
self.update_amt();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::sense::{CursorButton, DRAG_SLOP};
|
||||
use iris_core::UiData;
|
||||
use std::time::Duration;
|
||||
|
||||
/// A scroll area with 1000px of content in a 100px box, already
|
||||
/// settled somewhere in the middle so a drag has room in both
|
||||
/// directions.
|
||||
fn area() -> (UiData, Scroll, WidgetId) {
|
||||
let mut ui = UiData::default();
|
||||
let inner = ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any();
|
||||
let id = inner.id();
|
||||
let mut s = Scroll::new(inner, Axis::Y);
|
||||
s.content_len = 1000.0;
|
||||
s.container_len = 100.0;
|
||||
s.amt = 400.0;
|
||||
s.snap_end = false;
|
||||
(ui, s, id)
|
||||
}
|
||||
|
||||
fn press(
|
||||
s: &mut Scroll,
|
||||
render: &UiRenderState,
|
||||
id: WidgetId,
|
||||
sense: CursorSense,
|
||||
y: f32,
|
||||
t: Instant,
|
||||
) {
|
||||
s.drag(render, id, sense, Vec2::new(0.0, y), t);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vertical_finger_drag_pans_the_content_with_the_finger() {
|
||||
let (_ui, mut s, id) = area();
|
||||
let render = UiRenderState::new();
|
||||
let t = Instant::now();
|
||||
press(
|
||||
&mut s,
|
||||
&render,
|
||||
id,
|
||||
CursorSense::PressStart(CursorButton::Left),
|
||||
0.0,
|
||||
t,
|
||||
);
|
||||
// Finger down by well past the slop: the content follows it down,
|
||||
// which for this widget means *less* `amt`.
|
||||
press(
|
||||
&mut s,
|
||||
&render,
|
||||
id,
|
||||
CursorSense::Pressing(CursorButton::Left),
|
||||
DRAG_SLOP + 30.0,
|
||||
t + Duration::from_millis(20),
|
||||
);
|
||||
assert!(
|
||||
(s.amt - 370.0).abs() < 0.01,
|
||||
"expected the 30px past the slop to be applied downward, got amt={}",
|
||||
s.amt
|
||||
);
|
||||
// ...and the next frame's motion is a plain per-frame delta.
|
||||
press(
|
||||
&mut s,
|
||||
&render,
|
||||
id,
|
||||
CursorSense::Pressing(CursorButton::Left),
|
||||
DRAG_SLOP + 50.0,
|
||||
t + Duration::from_millis(40),
|
||||
);
|
||||
assert!((s.amt - 350.0).abs() < 0.01, "amt={}", s.amt);
|
||||
}
|
||||
|
||||
/// The half the change had no reason to touch: a press that never
|
||||
/// leaves the slop is a tap, and must move nothing at all -- otherwise
|
||||
/// every tap on a scrollable field nudges its text.
|
||||
#[test]
|
||||
fn a_press_that_stays_inside_the_slop_does_not_scroll() {
|
||||
let (_ui, mut s, id) = area();
|
||||
let render = UiRenderState::new();
|
||||
let t = Instant::now();
|
||||
press(
|
||||
&mut s,
|
||||
&render,
|
||||
id,
|
||||
CursorSense::PressStart(CursorButton::Left),
|
||||
0.0,
|
||||
t,
|
||||
);
|
||||
for (i, y) in [1.0, -2.0, DRAG_SLOP - 0.5].into_iter().enumerate() {
|
||||
press(
|
||||
&mut s,
|
||||
&render,
|
||||
id,
|
||||
CursorSense::Pressing(CursorButton::Left),
|
||||
y,
|
||||
t + Duration::from_millis(10 * (i as u64 + 1)),
|
||||
);
|
||||
}
|
||||
press(
|
||||
&mut s,
|
||||
&render,
|
||||
id,
|
||||
CursorSense::PressEnd(CursorButton::Left),
|
||||
DRAG_SLOP - 0.5,
|
||||
t + Duration::from_millis(50),
|
||||
);
|
||||
assert!(
|
||||
(s.amt - 400.0).abs() < 0.01,
|
||||
"a tap scrolled: amt={}",
|
||||
s.amt
|
||||
);
|
||||
}
|
||||
|
||||
/// A horizontal drag is not this widget's gesture: it must stay put
|
||||
/// rather than pick up the vertical noise in a sideways swipe.
|
||||
#[test]
|
||||
fn a_horizontal_drag_does_not_scroll() {
|
||||
let (_ui, mut s, id) = area();
|
||||
let render = UiRenderState::new();
|
||||
let t = Instant::now();
|
||||
s.drag(
|
||||
&render,
|
||||
id,
|
||||
CursorSense::PressStart(CursorButton::Left),
|
||||
Vec2::new(0.0, 0.0),
|
||||
t,
|
||||
);
|
||||
s.drag(
|
||||
&render,
|
||||
id,
|
||||
CursorSense::Pressing(CursorButton::Left),
|
||||
Vec2::new(120.0, 3.0),
|
||||
t + Duration::from_millis(20),
|
||||
);
|
||||
assert!((s.amt - 400.0).abs() < 0.01, "amt={}", s.amt);
|
||||
}
|
||||
|
||||
/// Panning stops at the ends of the content rather than running off,
|
||||
/// which is `update_amt`'s clamp -- checked through `drag` so the two
|
||||
/// cannot drift apart.
|
||||
#[test]
|
||||
fn a_pan_past_the_end_clamps_instead_of_running_off() {
|
||||
let (_ui, mut s, id) = area();
|
||||
let render = UiRenderState::new();
|
||||
let t = Instant::now();
|
||||
s.drag(
|
||||
&render,
|
||||
id,
|
||||
CursorSense::PressStart(CursorButton::Left),
|
||||
Vec2::new(0.0, 0.0),
|
||||
t,
|
||||
);
|
||||
s.drag(
|
||||
&render,
|
||||
id,
|
||||
CursorSense::Pressing(CursorButton::Left),
|
||||
Vec2::new(0.0, 5000.0),
|
||||
t + Duration::from_millis(20),
|
||||
);
|
||||
assert!((s.amt - 0.0).abs() < 0.01, "amt={}", s.amt);
|
||||
}
|
||||
}
|
||||
@@ -17,17 +17,21 @@ impl Widget for Sized {
|
||||
// learn its size, then moves it into place with a pure
|
||||
// translation; that translation is only valid if what got painted
|
||||
// is already the reported size, anchored the same way both times.
|
||||
let density = painter.density();
|
||||
let mut region = UiRegion::FULL;
|
||||
if let Some(x) = self.x {
|
||||
region.x = x.apply_rest().align(AxisAlign::Neg);
|
||||
region.x = x.apply_rest(density).align(AxisAlign::Neg);
|
||||
}
|
||||
if let Some(y) = self.y {
|
||||
region.y = y.apply_rest().align(AxisAlign::Neg);
|
||||
region.y = y.apply_rest(density).align(AxisAlign::Neg);
|
||||
}
|
||||
let used = painter.widget_within(&self.inner, region);
|
||||
// `fold_dp` on the way out: a declared size is a `Len` the caller
|
||||
// wrote (`.width(dp(48))`), and a *reported* one may not carry an
|
||||
// unresolved `dp` -- see `Len::fold_dp`.
|
||||
Size {
|
||||
x: self.x.unwrap_or(used.x),
|
||||
y: self.y.unwrap_or(used.y),
|
||||
x: self.x.map(|x| x.fold_dp(density)).unwrap_or(used.x),
|
||||
y: self.y.map(|y| y.fold_dp(density)).unwrap_or(used.y),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,18 @@ use std::marker::PhantomData;
|
||||
pub struct Span {
|
||||
pub children: Vec<StrongWidget>,
|
||||
pub dir: Dir,
|
||||
pub gap: f32,
|
||||
/// A `Len` (not a bare `f32`) so `dp(4)` resolves against the display's
|
||||
/// density the same way any other size in the tree does -- see
|
||||
/// `Len::dp`'s field doc. Only the `abs` component (folded from `dp` at
|
||||
/// draw time, `Widget::draw` below) is meaningful here; `rel`/`rest`
|
||||
/// were never supported for a gap and still are not.
|
||||
pub gap: Len,
|
||||
}
|
||||
|
||||
impl Widget for Span {
|
||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||
let axis = self.dir.axis;
|
||||
let gap = self.gap.apply_rest(painter.density()).abs;
|
||||
|
||||
// Phase 1: draw each child once, at the ambient (unmodified, full)
|
||||
// region a size-only query used to see before this migration, to
|
||||
@@ -25,7 +31,7 @@ impl Widget for Span {
|
||||
.map(|child| painter.widget(child).axis(axis))
|
||||
.collect();
|
||||
|
||||
let gap_total = self.gap * self.children.len().saturating_sub(1) as f32;
|
||||
let gap_total = gap * self.children.len().saturating_sub(1) as f32;
|
||||
let total = lens.iter().fold(Len::abs(gap_total), |s, &l| s + l);
|
||||
|
||||
// Phase 2: place each child for real, using the lengths just
|
||||
@@ -54,7 +60,7 @@ impl Widget for Span {
|
||||
child_region.flip(axis);
|
||||
}
|
||||
let used = painter.widget_within(child, child_region);
|
||||
start.abs += self.gap;
|
||||
start.abs += gap;
|
||||
|
||||
let ortho = used.axis(!axis);
|
||||
if ortho.rel > 0.0 || ortho.rest > 0.0 {
|
||||
@@ -82,12 +88,12 @@ impl Span {
|
||||
Self {
|
||||
children: Vec::new(),
|
||||
dir,
|
||||
gap: 0.0,
|
||||
gap: Len::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gap(mut self, gap: impl UiNum) -> Self {
|
||||
self.gap = gap.to_f32();
|
||||
pub fn gap(mut self, gap: impl Into<Len>) -> Self {
|
||||
self.gap = gap.into();
|
||||
self
|
||||
}
|
||||
|
||||
@@ -103,7 +109,7 @@ impl Span {
|
||||
pub struct SpanBuilder<State, const LEN: usize, Wa: WidgetArrLike<State, LEN, Tag>, Tag> {
|
||||
pub children: Wa,
|
||||
pub dir: Dir,
|
||||
pub gap: f32,
|
||||
pub gap: Len,
|
||||
_pd: PhantomData<(State, Tag)>,
|
||||
}
|
||||
|
||||
@@ -129,13 +135,13 @@ impl<State, const LEN: usize, Wa: WidgetArrLike<State, LEN, Tag>, Tag>
|
||||
Self {
|
||||
children,
|
||||
dir,
|
||||
gap: 0.0,
|
||||
gap: Len::ZERO,
|
||||
_pd: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gap(mut self, gap: impl UiNum) -> Self {
|
||||
self.gap = gap.to_f32();
|
||||
pub fn gap(mut self, gap: impl Into<Len>) -> Self {
|
||||
self.gap = gap.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,13 @@ use crate::prelude::*;
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Rect {
|
||||
pub color: UiColor,
|
||||
pub radius: f32,
|
||||
/// A `Len` rather than a raw `f32` so a corner can be written in `dp`
|
||||
/// and come out the same physical size on every display -- resolved
|
||||
/// against `Painter::density` in [`Rect::draw`], the same place every
|
||||
/// other `dp` is resolved. A plain number still works and still means
|
||||
/// physical pixels (`impl<N: UiNum> From<N> for Len`), which is what
|
||||
/// a hairline wants.
|
||||
pub radius: Len,
|
||||
pub thickness: f32,
|
||||
pub inner_radius: f32,
|
||||
}
|
||||
@@ -12,7 +18,7 @@ impl Rect {
|
||||
pub fn new(color: UiColor) -> Self {
|
||||
Self {
|
||||
color,
|
||||
radius: 0.0,
|
||||
radius: Len::ZERO,
|
||||
inner_radius: 0.0,
|
||||
thickness: 0.0,
|
||||
}
|
||||
@@ -21,8 +27,8 @@ impl Rect {
|
||||
self.color = color;
|
||||
self
|
||||
}
|
||||
pub fn radius(mut self, radius: impl UiNum) -> Self {
|
||||
self.radius = radius.to_f32();
|
||||
pub fn radius(mut self, radius: impl Into<Len>) -> Self {
|
||||
self.radius = radius.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -31,15 +37,40 @@ impl Widget for Rect {
|
||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||
painter.primitive(RectPrimitive {
|
||||
color: self.color,
|
||||
radius: self.radius,
|
||||
// `rel` has no meaning for a corner (a rect that fills its
|
||||
// parent has no length of its own to take a fraction of), so
|
||||
// only the `abs`/`dp` halves are folded.
|
||||
radius: self.radius.fold_dp(painter.density()).abs,
|
||||
thickness: self.thickness,
|
||||
inner_radius: self.inner_radius,
|
||||
});
|
||||
Size::REST // fills whatever it was given -- used == available
|
||||
}
|
||||
|
||||
/// **No** -- despite drawing one primitive and nothing else.
|
||||
///
|
||||
/// `is_size_independent` asks whether the widget's *content* is
|
||||
/// unaffected by how big a region it was given, so that
|
||||
/// `draw_inner` may keep the primitives it already has and rewrite
|
||||
/// their regions in place. A `Rect`'s content **is** its region: it
|
||||
/// returns `Size::REST` and fills whatever it was handed, so the fast
|
||||
/// path's `r.outside(&from).within(®ion)` remap has to reproduce
|
||||
/// the whole of `draw` -- and it does not, because a region carries
|
||||
/// `rel` and `abs` components that the round trip cannot recover
|
||||
/// separately.
|
||||
///
|
||||
/// What that looked like: a fenced code block's background
|
||||
/// (`transcript-ui`'s `BlockFrame::Verbatim`, a `Rect` behind a
|
||||
/// `Pad` in a `Stack`) kept the height of the *provisional* full-
|
||||
/// region draw `Span` does in its first phase, so one fence's panel
|
||||
/// covered every block below it -- and every row below that -- while
|
||||
/// the text itself was laid out correctly. Visible in
|
||||
/// `docs/bench/p1a-2026-09-06/`'s history and reproduced by this
|
||||
/// crate's `transcript` example. Answering `false` costs a redraw of
|
||||
/// one primitive when a rect is resized, which is what the fast path
|
||||
/// was saving.
|
||||
fn is_size_independent(&self) -> bool {
|
||||
true // content never depends on region size
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,15 @@ pub struct TextEdit {
|
||||
#[cfg_attr(target_os = "android", allow(dead_code))]
|
||||
history: Vec<(String, Option<Selection>)>,
|
||||
double_hit: Option<usize>,
|
||||
/// Where an in-flight press over this field began, while it is still
|
||||
/// undecided whether the gesture is a tap (focus/show the IME) or a
|
||||
/// drag (attr.rs's `Selector`/`Selectable`, Iris 2026-09-06: a swipe
|
||||
/// over the composer must not summon the keyboard). `None` both before
|
||||
/// any press and once the gesture has been decided either way --
|
||||
/// `attr.rs` is the only reader/writer, kept `pub(crate)` rather than
|
||||
/// behind an accessor since it is pure bookkeeping with no invariant
|
||||
/// beyond "some press is undecided," same shape as `double_hit` above.
|
||||
pub(crate) press_origin: Option<Vec2>,
|
||||
pub mode: EditMode,
|
||||
}
|
||||
|
||||
@@ -48,6 +57,7 @@ impl TextEdit {
|
||||
selection: None,
|
||||
history: Default::default(),
|
||||
double_hit: None,
|
||||
press_origin: None,
|
||||
mode,
|
||||
}
|
||||
}
|
||||
@@ -141,7 +151,8 @@ impl<'a> TextEditCtx<'a> {
|
||||
fn layout(&mut self) -> &Layout<UiColor> {
|
||||
let attrs = self.text.view.attrs.clone();
|
||||
let width = self.text.view.wrap_width();
|
||||
self.text.view.buf.shape(self.data, &attrs, width);
|
||||
let density = self.data.density;
|
||||
self.text.view.buf.shape(self.data, &attrs, width, density);
|
||||
self.text.view.buf.layout()
|
||||
}
|
||||
|
||||
@@ -167,6 +178,20 @@ impl<'a> TextEditCtx<'a> {
|
||||
self.text.selection = None;
|
||||
}
|
||||
|
||||
/// [`set`](Self::set) plus a fresh set of [`SpanStyle`]s in one call --
|
||||
/// what a streamed transcript row needs, since its markdown re-renders
|
||||
/// to a new string *and* a new span list on every delta and the two
|
||||
/// have to land together (a stale span list drawn against new text can
|
||||
/// point past its end). Used by `transcript-ui`'s incremental apply
|
||||
/// (RUST.md's "streaming still costs a full rebuild" fix) rather than
|
||||
/// tearing the row's widget down and rebuilding it from scratch.
|
||||
pub fn set_with_spans(&mut self, text: &str, spans: Vec<SpanStyle>) {
|
||||
let text = self.string(text);
|
||||
self.text.view.buf.set_text(text);
|
||||
self.text.view.buf.set_spans(spans);
|
||||
self.text.selection = None;
|
||||
}
|
||||
|
||||
pub fn motion(&mut self, motion: Motion, select: bool) {
|
||||
let Some(sel) = self.text.selection else {
|
||||
return;
|
||||
@@ -221,7 +246,21 @@ impl<'a> TextEditCtx<'a> {
|
||||
self.clear_span();
|
||||
let at = match self.text.selection {
|
||||
Some(sel) => sel.focus().index(),
|
||||
None => return,
|
||||
// No caret means nowhere to put the text, so this drops the
|
||||
// keystroke -- which is invisible, and was the whole of the
|
||||
// "typed text never appears" defect (see `select`'s comment).
|
||||
// A field the IME is talking to has been focused, and focusing
|
||||
// one places a caret, so reaching here is a bug in whoever
|
||||
// routed the input rather than something to recover from.
|
||||
None => {
|
||||
debug_assert!(
|
||||
false,
|
||||
"insert into a text field with no caret: '{}' was given input \
|
||||
without being focused, so the keystroke would be dropped silently",
|
||||
text,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let at = at.min(self.text.view.buf.text().len());
|
||||
self.text.view.buf.edit().insert_str(at, text);
|
||||
@@ -325,6 +364,29 @@ impl<'a> TextEditCtx<'a> {
|
||||
self.set_caret(index);
|
||||
}
|
||||
|
||||
/// The byte offset in the text that `pos` (in the same window-space
|
||||
/// coordinates a `CursorSense` reports, with `size` the region the
|
||||
/// event was measured against) lands on.
|
||||
///
|
||||
/// The one thing a caller outside this module needs to turn a tap into
|
||||
/// a *range* of the text -- which markdown link is under the finger,
|
||||
/// which inline-code chip was pressed. `layout()` is private because a
|
||||
/// caller holding a parley `Layout` could shape it against stale text;
|
||||
/// this hands back the answer rather than the layout, and does the
|
||||
/// same region-relative transform [`select`](Self::select) does, so
|
||||
/// the two cannot disagree about where a point is.
|
||||
///
|
||||
/// Parley clamps a point outside the laid-out text to the nearest
|
||||
/// cursor position, so a tap in the field's padding answers with the
|
||||
/// nearest offset rather than failing -- a caller wanting "was this
|
||||
/// actually *on* something" checks its own ranges, which is what
|
||||
/// makes a tap in the padding hit no link.
|
||||
pub fn byte_at(&mut self, pos: Vec2, size: Vec2) -> usize {
|
||||
let pos = pos - self.text.region().top_left().to_abs(size);
|
||||
let layout = self.layout();
|
||||
Selection::from_point(layout, pos.x, pos.y).focus().index()
|
||||
}
|
||||
|
||||
pub fn select_all(&mut self) {
|
||||
let len = self.text.view.buf.text().len();
|
||||
if len == 0 {
|
||||
@@ -343,14 +405,28 @@ impl<'a> TextEditCtx<'a> {
|
||||
|
||||
// The layout borrows `self`, so the whole decision is made in here and
|
||||
// only the answer escapes.
|
||||
//
|
||||
// **A press that reaches here has already been hit-tested to this
|
||||
// widget, so there is no "outside" to clear the selection for.**
|
||||
// This used to compare `pos` against the *laid-out text's* box and
|
||||
// set `selection = None` for anything beyond it -- but the laid-out
|
||||
// text is smaller than the field (padding, and for an empty field a
|
||||
// box of literally zero width), so tapping an **empty** composer
|
||||
// granted focus, opened the keyboard, and left `selection` at
|
||||
// `None` -- and `insert_str` returns early on `None`, so every
|
||||
// keystroke after that was silently dropped and nothing ever
|
||||
// appeared. That is RUST.md's P0 box item 2, "composed text never
|
||||
// becomes visible at all": the buffer was empty the whole time, and
|
||||
// Gboard's suggestion strip (its own composing state, not ours) is
|
||||
// what made it look otherwise. Parley's `from_point`/
|
||||
// `extend_to_point` already clamp a point outside the layout to the
|
||||
// nearest cursor position, which is what a tap in a field's padding
|
||||
// should do anyway. Losing focus is a separate path
|
||||
// (`TextEditCtx::deselect`, called from the backend's focus
|
||||
// handling), not this one.
|
||||
let outcome = {
|
||||
let layout = self.layout();
|
||||
let inside =
|
||||
pos.x >= 0.0 && pos.y >= 0.0 && pos.x <= layout.width() && pos.y <= layout.height();
|
||||
|
||||
if !inside {
|
||||
if drag { None } else { Some((None, None)) }
|
||||
} else if drag {
|
||||
if drag {
|
||||
prev_sel.map(|sel| (Some(sel.extend_to_point(layout, pos.x, pos.y)), prev_hit))
|
||||
} else {
|
||||
let hit = Selection::from_point(layout, pos.x, pos.y);
|
||||
@@ -644,6 +720,39 @@ mod tests {
|
||||
assert_eq!(t.selection.unwrap().focus().index(), 0);
|
||||
}
|
||||
|
||||
/// The defect itself: an empty field's laid-out text is a zero-sized
|
||||
/// box, so a tap anywhere in it used to land "outside" and clear the
|
||||
/// selection -- leaving a focused composer that silently swallowed
|
||||
/// every keystroke (RUST.md's P0 box item 2).
|
||||
#[test]
|
||||
fn tapping_an_empty_field_places_a_caret_so_typing_lands() {
|
||||
let (mut t, mut d) = edit("", EditMode::MultiLine);
|
||||
ctx(&mut t, &mut d).select(vec2(40.0, 20.0), vec2(1080.0, 2400.0), false, false);
|
||||
assert!(t.selection.is_some(), "a tap must leave a caret behind");
|
||||
ctx(&mut t, &mut d).insert("hi");
|
||||
assert_eq!(content(&t), "hi");
|
||||
}
|
||||
|
||||
/// The half the fix had no reason to touch: a field that *does* hold
|
||||
/// text, tapped past the end of it (a multi-line composer's padding
|
||||
/// below the last line) keeps a caret rather than losing the one it
|
||||
/// had, and the caret lands at the nearest position -- the end.
|
||||
#[test]
|
||||
fn tapping_past_the_end_of_the_text_clamps_to_the_end() {
|
||||
let (mut t, mut d) = edit("abc", EditMode::MultiLine);
|
||||
ctx(&mut t, &mut d).select(vec2(9000.0, 9000.0), vec2(1080.0, 2400.0), false, false);
|
||||
assert_eq!(t.selection.unwrap().focus().index(), 3);
|
||||
}
|
||||
|
||||
/// A drag still needs something to extend: with no previous selection
|
||||
/// there is nothing to drag from, and one must not be invented.
|
||||
#[test]
|
||||
fn dragging_without_a_previous_selection_selects_nothing() {
|
||||
let (mut t, mut d) = edit("abc", EditMode::MultiLine);
|
||||
ctx(&mut t, &mut d).select(vec2(10.0, 10.0), vec2(1080.0, 2400.0), true, false);
|
||||
assert!(t.selection.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_line_field_refuses_newlines() {
|
||||
let (mut t, mut d) = edit("", EditMode::SingleLine);
|
||||
@@ -683,6 +792,67 @@ mod tests {
|
||||
assert_eq!(content(&t), "に");
|
||||
}
|
||||
|
||||
/// `android/ime.rs`'s `set_composing_text` calls `replace` and expects
|
||||
/// the caret to land right after the inserted text, growing with it on
|
||||
/// every re-send -- the buffer-level half of RUST.md's P0 box ("doesn't
|
||||
/// enter it until I hit space, and also doesn't move cursor forward").
|
||||
#[test]
|
||||
fn composing_advances_the_caret_with_the_growing_text() {
|
||||
let (mut t, mut d) = edit("", EditMode::SingleLine);
|
||||
ctx(&mut t, &mut d).set_caret(0);
|
||||
ctx(&mut t, &mut d).replace(0, "h");
|
||||
assert_eq!(t.caret(), Some(1));
|
||||
ctx(&mut t, &mut d).replace(1, "hi");
|
||||
assert_eq!(content(&t), "hi");
|
||||
assert_eq!(t.caret(), Some(2));
|
||||
ctx(&mut t, &mut d).replace(2, "hit");
|
||||
assert_eq!(content(&t), "hit");
|
||||
assert_eq!(t.caret(), Some(3));
|
||||
}
|
||||
|
||||
/// The IME's `commitText` (`android_view::InputConnection::commit_text`'s
|
||||
/// default body): finish a composition in place, same as a real word
|
||||
/// boundary (a space) landing after Gboard's composing span.
|
||||
#[test]
|
||||
fn committing_composed_text_leaves_it_in_place_with_the_caret_after_it() {
|
||||
let (mut t, mut d) = edit("say ", EditMode::SingleLine);
|
||||
ctx(&mut t, &mut d).set_caret(4);
|
||||
ctx(&mut t, &mut d).replace(0, "hi");
|
||||
assert_eq!(content(&t), "say hi");
|
||||
// `finish_composing_text`/`commit_text` do not themselves touch the
|
||||
// buffer -- only the IME's own `compose_len` bookkeeping resets, in
|
||||
// `android/ime.rs`. Confirms the buffer already holds committed
|
||||
// text as plain, uncomposed content: a further `replace(0, " ")`
|
||||
// (the space that ends the word) appends rather than overwriting.
|
||||
ctx(&mut t, &mut d).replace(0, " ");
|
||||
assert_eq!(content(&t), "say hi ");
|
||||
assert_eq!(t.caret(), Some(7));
|
||||
}
|
||||
|
||||
/// `TextEditCtx::delete_byte_range` is `deleteSurroundingText`'s entry
|
||||
/// point once `android/ime.rs` has converted UTF-16 code units to
|
||||
/// bytes -- exercised directly here in bytes, since the UTF-16 math
|
||||
/// itself is `android/ime.rs`'s own `byte_to_utf16`/`utf16_to_byte`,
|
||||
/// outside this widget-only test module.
|
||||
#[test]
|
||||
fn delete_byte_range_removes_exactly_that_range() {
|
||||
let (mut t, mut d) = edit("hello world", EditMode::SingleLine);
|
||||
ctx(&mut t, &mut d).delete_byte_range(5, 11);
|
||||
assert_eq!(content(&t), "hello");
|
||||
assert_eq!(t.caret(), Some(5));
|
||||
}
|
||||
|
||||
/// `set_cursor_byte` is `setSelection`'s entry point -- collapses to a
|
||||
/// caret at the given byte offset regardless of any span that was there.
|
||||
#[test]
|
||||
fn set_cursor_byte_collapses_to_a_caret_there() {
|
||||
let (mut t, mut d) = edit("hello world", EditMode::SingleLine);
|
||||
ctx(&mut t, &mut d).select_all();
|
||||
ctx(&mut t, &mut d).set_cursor_byte(5);
|
||||
assert_eq!(t.selected_text(), None);
|
||||
assert_eq!(t.caret(), Some(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_moves_the_caret_and_shift_extends_a_span() {
|
||||
let (mut t, mut d) = edit("abc", EditMode::SingleLine);
|
||||
|
||||
@@ -60,8 +60,17 @@ impl TextView {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// The atlas generation is part of the cache key, not a separate
|
||||
// invalidation path: a `RenderedText` is only meaningful against the
|
||||
// atlas its glyphs were placed in, and a renderer rebuild clears
|
||||
// that atlas out from under every widget at once
|
||||
// (`GlyphAtlas::clear`). Without this the text drawn before the
|
||||
// rebuild is re-emitted with the old atlas's coordinates and comes
|
||||
// back as fragments of whatever now occupies them.
|
||||
let generation = painter.atlas_generation();
|
||||
if width == self.width
|
||||
&& let Some(tex) = &self.tex
|
||||
&& tex.generation == generation
|
||||
&& !self.attrs.changed
|
||||
&& !self.buf.changed
|
||||
{
|
||||
@@ -69,6 +78,12 @@ impl TextView {
|
||||
}
|
||||
self.width = width;
|
||||
let tex = painter.render_text(&mut self.buf, &self.attrs, width);
|
||||
log::debug!(
|
||||
"iris text render: chars={} width={width:?} glyphs={} size={:?}",
|
||||
self.buf.text().chars().count(),
|
||||
tex.glyphs.len(),
|
||||
tex.size,
|
||||
);
|
||||
self.tex = Some(tex.clone());
|
||||
self.attrs.changed = false;
|
||||
self.buf.changed = false;
|
||||
@@ -151,3 +166,55 @@ impl DerefMut for TextView {
|
||||
&mut self.attrs
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::layout_tests::TestRsc;
|
||||
use crate::prelude::*;
|
||||
|
||||
/// A renderer rebuild empties the glyph atlas under every widget at
|
||||
/// once (`iris_core::GlyphAtlas::clear`, called from
|
||||
/// `IrisViewPeer::surface_changed`'s new-renderer branch). Anything
|
||||
/// still holding a `RenderedText` from before then owns UV rectangles
|
||||
/// into a texture that no longer exists -- what Iris photographed on
|
||||
/// 2026-09-06 as every pre-resume glyph coming back as fragments while
|
||||
/// the text drawn after the resume was perfect.
|
||||
///
|
||||
/// The check is the atlas repopulating: `TextView::render`'s cache
|
||||
/// short-circuits before `TextData::place`, so without the generation
|
||||
/// in its key the second frame rasterises nothing and the atlas stays
|
||||
/// empty. (`Painter::glyphs`'s `debug_assert!` fires here too, which is
|
||||
/// the same finding from the submission side.)
|
||||
#[test]
|
||||
fn clearing_the_atlas_re_renders_cached_text_instead_of_reusing_it() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let root = wtext("hello there")
|
||||
.size(18)
|
||||
.color(UiColor::WHITE)
|
||||
.add_strong(&mut rsc)
|
||||
.any();
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((800.0, 600.0));
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
let rasterised = rsc.ui.text.atlas.glyph_count();
|
||||
assert!(rasterised > 0, "the first frame rasterised no glyphs");
|
||||
|
||||
// Exactly what the new-renderer branch does, in order: empty the
|
||||
// atlas, then redraw everything (`resize` is what marks the tree
|
||||
// for a full redraw, and a real `surface_changed` always calls it).
|
||||
rsc.ui.text.atlas.clear();
|
||||
assert_eq!(rsc.ui.text.atlas.glyph_count(), 0);
|
||||
render.resize((800.0, 600.0));
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
assert_eq!(
|
||||
rsc.ui.text.atlas.glyph_count(),
|
||||
rasterised,
|
||||
"the second frame re-emitted its cached glyphs instead of \
|
||||
re-rendering them against the fresh atlas"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
use crate::prelude::*;
|
||||
use std::time::Instant;
|
||||
|
||||
// these methods should "not require any context" (require unit) because they're in core
|
||||
widget_trait! {
|
||||
@@ -84,12 +85,35 @@ widget_trait! {
|
||||
}
|
||||
|
||||
fn scrollable(self) -> impl WidgetIdFn<Rsc, Scroll> where Rsc: HasEvents {
|
||||
self.scrollable_on(Axis::Y)
|
||||
}
|
||||
|
||||
// `scrollable` along `axis`. A code fence pans across its own long
|
||||
// lines exactly the way a transcript pans down its rows, so the two
|
||||
// are one function with the axis passed in rather than a second copy
|
||||
// -- `DragArbiter::on` is the other half. (A `///` doc comment here
|
||||
// is not accepted by `widget_trait!`, which parses its body itself.)
|
||||
fn scrollable_on(self, axis: Axis) -> impl WidgetIdFn<Rsc, Scroll> where Rsc: HasEvents {
|
||||
move |state| {
|
||||
Scroll::new(self.add_strong(state), Axis::Y)
|
||||
.on(CursorSense::Scroll, |ctx, rsc| {
|
||||
let delta = ctx.data.scroll_delta.y * 50.0;
|
||||
Scroll::new(self.add_strong(state), axis)
|
||||
.on(CursorSense::Scroll, move |ctx, rsc| {
|
||||
let delta = ctx.data.scroll_delta.axis(axis) * 50.0;
|
||||
ctx.widget(rsc).scroll(delta);
|
||||
})
|
||||
// A finger drag, through the same `DragGesture` the
|
||||
// transcript's `List` is panned by -- `Scroll::drag`'s doc
|
||||
// has the arbitration and why there is no fling. The wheel
|
||||
// above and this are the two inputs of one scroll, so they
|
||||
// are registered together rather than left to each caller.
|
||||
.on(
|
||||
CursorSense::click_or_drag() | CursorSense::unclick(),
|
||||
|ctx, rsc| {
|
||||
let id = ctx.widget.id();
|
||||
let (sense, pos) = (ctx.data.sense, ctx.data.cursor.pos);
|
||||
ctx.widget(rsc)
|
||||
.drag(ctx.data.render, id, sense, pos, Instant::now());
|
||||
},
|
||||
)
|
||||
.add(state)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
//! with `ui-trace record --do "tap 'Tools'"` on Android, to prove
|
||||
//! hold-the-edge expand).
|
||||
|
||||
use client_core::transcript_fold::{TranscriptItem, TranscriptRow as FoldedRow};
|
||||
use client_core::QuestionOption;
|
||||
use client_core::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
|
||||
use iris::prelude::*;
|
||||
|
||||
fn main() {
|
||||
@@ -43,6 +44,75 @@ fn msg(seq: u64, from_user: bool, text: &str) -> FoldedRow {
|
||||
})
|
||||
}
|
||||
|
||||
/// One tool call. `result` is `None` for a call with no result yet and
|
||||
/// `Some((output, failed))` for one that answered.
|
||||
fn tool_call(id: &str, tool: &str, input: &str, result: Option<(&str, bool)>) -> TranscriptItem {
|
||||
tool_call_in("run1", id, tool, input, result)
|
||||
}
|
||||
|
||||
/// The same, in a named run. Two runs in one transcript must not share a
|
||||
/// `run_id`: it is the row's identity in the list (`row::row_key`), and
|
||||
/// two rows under one key is the duplicate-key fault AGENTS.md's
|
||||
/// "Importing" section describes. Here it made two rows swap cached
|
||||
/// heights and draw at each other's boxes.
|
||||
fn tool_call_in(
|
||||
run: &str,
|
||||
id: &str,
|
||||
tool: &str,
|
||||
input: &str,
|
||||
result: Option<(&str, bool)>,
|
||||
) -> TranscriptItem {
|
||||
TranscriptItem::ToolRun {
|
||||
seq: 3,
|
||||
id: id.into(),
|
||||
run_id: run.into(),
|
||||
tool: tool.into(),
|
||||
input: input.into(),
|
||||
output: result.map(|(out, _)| out.to_string()).unwrap_or_default(),
|
||||
done: result.is_some(),
|
||||
failed: result.is_some_and(|(_, failed)| failed),
|
||||
asks: Vec::new(),
|
||||
images: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A call stopped on the reader: one unanswered permission question.
|
||||
fn asking(id: &str, tool: &str, input: &str) -> TranscriptItem {
|
||||
let mut call = tool_call_in("run2", id, tool, input, None);
|
||||
if let TranscriptItem::ToolRun { asks, .. } = &mut call {
|
||||
asks.push(QuestionCard {
|
||||
seq: 9,
|
||||
id: format!("{id}-q"),
|
||||
prompt: "Allow this command?".into(),
|
||||
header: None,
|
||||
options: vec![
|
||||
QuestionOption {
|
||||
label: "Allow".into(),
|
||||
description: None,
|
||||
preview: None,
|
||||
},
|
||||
QuestionOption {
|
||||
label: "Deny".into(),
|
||||
description: None,
|
||||
preview: None,
|
||||
},
|
||||
],
|
||||
multi_select: false,
|
||||
answers: Vec::new(),
|
||||
});
|
||||
}
|
||||
call
|
||||
}
|
||||
|
||||
/// Longer than the card's own cap, so the "Show all N lines" control is on
|
||||
/// screen in the expanded shot.
|
||||
fn long_output() -> String {
|
||||
(0..200)
|
||||
.map(|i| format!("test transcript_ui::case_{i} ... ok"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn synthetic_rows() -> Vec<FoldedRow> {
|
||||
vec and a fenced block:\n\n```rust\nfn main() {\n println!(\"hi\");\n}\n```",
|
||||
),
|
||||
// Every state a tool card has to draw, in one run (P1b): a call
|
||||
// that worked, one the tool reported as failed, one whose result
|
||||
// never arrived, and one still running. The last two look the same
|
||||
// in the events -- an empty output and `done: false` -- and are
|
||||
// told apart only by whether the session is still working, which
|
||||
// is what `TranscriptScreen::set_session_working` says.
|
||||
FoldedRow::Tools(vec![
|
||||
TranscriptItem::ToolRun {
|
||||
seq: 3,
|
||||
id: "t1".into(),
|
||||
run_id: "run1".into(),
|
||||
tool: "Read".into(),
|
||||
input: "{\"file\": \"src/main.rs\"}".into(),
|
||||
output: "fn main() {}\n".into(),
|
||||
done: true,
|
||||
asks: Vec::new(),
|
||||
images: Vec::new(),
|
||||
},
|
||||
TranscriptItem::ToolRun {
|
||||
seq: 4,
|
||||
id: "t2".into(),
|
||||
run_id: "run1".into(),
|
||||
tool: "Edit".into(),
|
||||
input: "{\"file\": \"src/main.rs\"}".into(),
|
||||
output: "ok".into(),
|
||||
done: true,
|
||||
asks: Vec::new(),
|
||||
images: Vec::new(),
|
||||
},
|
||||
TranscriptItem::ToolRun {
|
||||
seq: 5,
|
||||
id: "t3".into(),
|
||||
run_id: "run1".into(),
|
||||
tool: "Bash".into(),
|
||||
input: "cargo build".into(),
|
||||
output: "Compiling...\nFinished.".into(),
|
||||
done: true,
|
||||
asks: Vec::new(),
|
||||
images: Vec::new(),
|
||||
},
|
||||
tool_call(
|
||||
"t1",
|
||||
"Read",
|
||||
r#"{"file_path": "src/main.rs"}"#,
|
||||
Some(("fn main() {}\n", false)),
|
||||
),
|
||||
tool_call(
|
||||
"t2",
|
||||
"Bash",
|
||||
r#"{"command": "cargo build --release", "timeout": 480000, "description": "Build it"}"#,
|
||||
Some((
|
||||
"error: could not compile `iris`\nCaused by: linker not found",
|
||||
true,
|
||||
)),
|
||||
),
|
||||
tool_call("t3", "Grep", r#"{"pattern": "fn fold_event"}"#, None),
|
||||
]),
|
||||
// A lone call is a card too rather than a group of one -- and this
|
||||
// one carries the kilobyte output a collapsed card must not lay
|
||||
// out.
|
||||
FoldedRow::Single(tool_call(
|
||||
"t5",
|
||||
"Bash",
|
||||
r#"{"command": "cargo test -p transcript-ui -- --nocapture"}"#,
|
||||
Some((&long_output(), false)),
|
||||
)),
|
||||
msg(6, true, "Looks good, thanks!"),
|
||||
msg(
|
||||
7,
|
||||
false,
|
||||
"You're welcome. Let me know if you'd like anything else.",
|
||||
),
|
||||
// Every block kind `client_core::markdown_blocks` names, in one
|
||||
// row, so P1a's appearance can be looked at against the Compose
|
||||
// app's without a server (docs/RUST.md's P1a box). The heading,
|
||||
// paragraph, fence and table are the *same source* the bench
|
||||
// fixture carries (`app/bench-fixture/generate.py`), so the two
|
||||
// screenshots differ only in the renderer; the list and the quote
|
||||
// are extra, because the fixture has neither.
|
||||
msg(7, false, BLOCK_SAMPLER),
|
||||
]
|
||||
}
|
||||
|
||||
/// One of each markdown block, for the P1a screenshot pair. See
|
||||
/// [`synthetic_rows`].
|
||||
const BLOCK_SAMPLER: &str = "\
|
||||
## What changed
|
||||
|
||||
Iris **fold** render measure session window anchor context transcript \
|
||||
iris measure iris scroll call transcript layout *cursor* context, and a \
|
||||
[bench](https://example.com/bench) link.
|
||||
|
||||
```rust
|
||||
fn fold_event(items: Vec<Item>, seq: u64) -> Vec<Item> {
|
||||
// a comment worth keeping: this is the fold the app's own screen runs
|
||||
let mut out = items;
|
||||
out.push(Item::new(seq));
|
||||
out
|
||||
}
|
||||
```
|
||||
|
||||
| column | value |
|
||||
|---|---|
|
||||
| a | measure place draw tool call token context window anchor |
|
||||
|
||||
- one bullet
|
||||
- another, with `inline code`
|
||||
- nested one level
|
||||
1. first numbered
|
||||
2. second numbered
|
||||
|
||||
> A quoted line, to show the bar and the indent.
|
||||
";
|
||||
|
||||
impl DefaultAppState for Client {
|
||||
fn new(
|
||||
mut ui_state: DefaultUiState,
|
||||
@@ -117,6 +219,52 @@ impl DefaultAppState for Client {
|
||||
text: "clear".into(),
|
||||
}),
|
||||
);
|
||||
// A second run at the live end, so the *running* state is on
|
||||
// screen too. It cannot share a row with "no result": the two are
|
||||
// the same events and are told apart only by whether the session
|
||||
// is working, which is a property of the row rather than of the
|
||||
// call (`TranscriptScreen::set_session_working`).
|
||||
screen.push_row(
|
||||
rsc,
|
||||
&FoldedRow::Tools(vec![
|
||||
tool_call_in(
|
||||
"run2",
|
||||
"t6",
|
||||
"Read",
|
||||
r#"{"file_path": "docs/RUST.md"}"#,
|
||||
Some(("# Moving the app to Rust\n", false)),
|
||||
),
|
||||
tool_call_in(
|
||||
"run2",
|
||||
"t7",
|
||||
"Bash",
|
||||
r#"{"command": "cargo clippy --workspace --all-targets"}"#,
|
||||
Some(("error: unused variable `x`", true)),
|
||||
),
|
||||
tool_call_in("run2", "t8", "Glob", r#"{"pattern": "**/*.rs"}"#, None),
|
||||
// Waiting on a permission, so this card is drawn *open*
|
||||
// whatever the reader last chose -- the command is the
|
||||
// thing being decided, and a row saying only "Bash"
|
||||
// cannot be decided on. It is also how the expanded card
|
||||
// (input block, output block, timeout) gets into the
|
||||
// screenshot without a finger.
|
||||
asking(
|
||||
"t9",
|
||||
"Bash",
|
||||
r#"{"command": "rm -rf target", "timeout": 120000, "description": "Clear the build"}"#,
|
||||
),
|
||||
]),
|
||||
);
|
||||
screen.set_session_working(rsc, true);
|
||||
// The expanded picture has no other way to be looked at on a
|
||||
// machine with no display and no finger -- see `run-headless.sh`
|
||||
// and docs/RUST.md's P1b box.
|
||||
if std::env::var_os("IRIS_TOOLS_EXPANDED").is_some() {
|
||||
assert!(
|
||||
screen.expand_tail_tools(rsc, true),
|
||||
"the newest row must be the tool run this flag is about"
|
||||
);
|
||||
}
|
||||
Self { ui_state, screen }
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,58 @@
|
||||
//! measures whatever vertical space is left each frame -- nothing here
|
||||
//! computes a height by hand, and growing this field is exactly the
|
||||
//! O(1)-move-chain case LAYOUT.md and I3's benchmark already measured.
|
||||
//!
|
||||
//! **Rebuilt 2026-09-06** (Iris's phone report on the dc01f88 build: the
|
||||
//! grey bar drawn as a short, fixed strip with the typed text ~150px below
|
||||
//! it on black, and empty black between the bar and the keyboard). One
|
||||
//! widget now, top to bottom: an opaque background sized to its content
|
||||
//! (`.background`, the same `Stack` idiom the header row's `HEADER_SURFACE`
|
||||
//! already uses), the field inside `dp` padding and capped at
|
||||
//! [`MAX_LINES`] before it scrolls instead of growing forever, and an
|
||||
//! outer [`Pad`] whose `bottom` [`TranscriptScreen::set_bottom_inset`]
|
||||
//! rewrites in place whenever the keyboard opens/closes -- never rebuilt,
|
||||
//! since `field` is strongly owned inside this tree and this crate's
|
||||
//! widgets cannot be re-parented once added (this module's own comment
|
||||
//! below on why `build_composer` hands back a **weak** id).
|
||||
|
||||
use iris::prelude::*;
|
||||
|
||||
/// Caps the field's growth at roughly six lines of its own 18px text
|
||||
/// before it scrolls instead of consuming the whole screen -- an
|
||||
/// approximation (line-height and padding folded into one round `dp`
|
||||
/// number) rather than a value derived from the font's real metrics,
|
||||
/// which nothing in this crate exposes to a caller today.
|
||||
const MAX_LINES: f32 = 6.0;
|
||||
const APPROX_LINE_HEIGHT_DP: f32 = 24.0;
|
||||
const FIELD_PAD_DP: f32 = 12.0;
|
||||
|
||||
/// `field` is exposed so the caller can read its content on submit
|
||||
/// (`field.edit(rsc).text()`) and clear it afterward
|
||||
/// (`field.edit(rsc).set("")`).
|
||||
pub struct Composer {
|
||||
pub field: WeakWidget<TextEdit>,
|
||||
/// The bar's own outer padding -- only `bottom` is ever changed, by
|
||||
/// [`Self::set_bottom_inset`]. A `Pad` around the whole bar rather than
|
||||
/// a rebuilt tree, because `field` lives inside it and cannot be
|
||||
/// re-added to a new wrapper once it is strongly owned here.
|
||||
outer_pad: WeakWidget<Pad>,
|
||||
}
|
||||
|
||||
impl Composer {
|
||||
/// Called by the platform shell (Android's `on_insets_changed`, e.g.)
|
||||
/// whenever the space below the bar changes: the IME's own inset while
|
||||
/// it is open, the navigation-bar inset otherwise. Takes a plain
|
||||
/// `f32` in the caller's own physical-pixel units rather than an
|
||||
/// Android-specific insets type, so this crate stays usable from the
|
||||
/// winit backend too, which has no navigation bar to report.
|
||||
/// Rewrites the existing `Pad` in place (marking it dirty through the
|
||||
/// ordinary `Widgets::get_mut` path) instead of swapping in a new one,
|
||||
/// so the field's focus, selection and in-progress text are untouched.
|
||||
pub fn set_bottom_inset(&self, rsc: &mut impl UiRsc, inset: f32) {
|
||||
if let Some(pad) = rsc.ui_mut().widgets.get_mut(&self.outer_pad) {
|
||||
pad.padding.bottom = Len::abs(inset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the composer plus its own bar as a **weak** id -- the caller
|
||||
@@ -39,10 +83,31 @@ where
|
||||
.label("Message")
|
||||
.add(rsc);
|
||||
|
||||
let bar: WeakWidget = (field.pad(12).width(rest(1)),)
|
||||
.span(Dir::RIGHT)
|
||||
// One widget: an opaque bar sized to its own content (`.background`'s
|
||||
// `Stack{child: 1}`, the header row's own idiom) wrapping the padded,
|
||||
// height-capped field -- not a background rect and a field drawn as
|
||||
// two independent siblings, which is what let the two disagree on
|
||||
// where the bar actually was.
|
||||
// `.scrollable().masked()`: the finger pan (`Scroll::drag`) plus the
|
||||
// clip that keeps six lines' worth of a longer message inside the
|
||||
// bar. The mask is the caller's job rather than `Scroll`'s own,
|
||||
// because `Painter::set_mask` allows exactly one mask per widget and
|
||||
// a `Scroll` nested under another masked area would abort on the
|
||||
// second -- `.masked()` is the one mechanism for clipping and this is
|
||||
// one more use of it (tabs-ui's message area is the other).
|
||||
// Without it the overflow paints *above* the bar, over the
|
||||
// transcript: measured before this change at 58px of stray text for a
|
||||
// 475px message in a 417px box.
|
||||
let content = field
|
||||
.scrollable()
|
||||
.masked()
|
||||
.pad(dp(FIELD_PAD_DP))
|
||||
.max_height(dp(APPROX_LINE_HEIGHT_DP * MAX_LINES + FIELD_PAD_DP * 2.0))
|
||||
.width(rest(1))
|
||||
.background(rect(UiColor::new(40, 40, 46, 255)))
|
||||
.add(rsc);
|
||||
|
||||
(Composer { field }, bar)
|
||||
let outer_pad: WeakWidget<Pad> = content.pad(Padding::ZERO).add(rsc);
|
||||
|
||||
(Composer { field, outer_pad }, outer_pad)
|
||||
}
|
||||
@@ -1,14 +1,25 @@
|
||||
//! Markdown -> one plain string plus a `Vec<SpanStyle>`, for I5's row
|
||||
//! builder to hand to a single `TextEdit` (`row.rs`). This is the crate's
|
||||
//! answer to RUST.md's E2 finding against Masonry ("rich inline text --
|
||||
//! block-level yes, inline no, and both for the same reason": `TextArea`'s
|
||||
//! `StyleSet` is one style for the whole editor,
|
||||
//! One markdown **block** (`client_core::markdown_blocks::Block`) rendered
|
||||
//! for display: the plain text to draw, the [`SpanStyle`]s that style it,
|
||||
//! the links inside it, and the [`BlockFrame`] the row builder puts around
|
||||
//! it.
|
||||
//!
|
||||
//! This is the crate's answer to RUST.md's E2 finding against Masonry
|
||||
//! ("rich inline text -- block-level yes, inline no, and both for the same
|
||||
//! reason": `TextArea`'s `StyleSet` is one style for the whole editor,
|
||||
//! `masonry/src/widgets/text_area.rs:43-44`'s `// TODO: RichTextInput`
|
||||
//! beside it). iris's `SpanStyle` (`core/src/primitive/text.rs`, added for
|
||||
//! this box) is per-range, so bold/italic/inline-code/links/headings inside
|
||||
//! one wrapped paragraph render in their own style *and* the paragraph
|
||||
//! still wraps and selects as one buffer -- there is no second widget per
|
||||
//! span the way E2's block-level `Prose`-per-heading was.
|
||||
//! beside it). iris's `SpanStyle` (`core/src/primitive/text.rs`) is
|
||||
//! per-range, so bold/italic/inline-code/links inside one wrapped
|
||||
//! paragraph render in their own style *and* the paragraph still wraps and
|
||||
//! selects as one buffer.
|
||||
//!
|
||||
//! **Three widget shapes, not one per markdown feature** ([`BlockFrame`]).
|
||||
//! A heading, a paragraph and a list are all *text with spans*; a fence
|
||||
//! and a table are *verbatim text on a dark surface that pans sideways*;
|
||||
//! a quote is *text behind a coloured bar*. Everything else markdown can
|
||||
//! say is expressed in the spans, which cost no widgets and no layout
|
||||
//! nodes. `app/.../Markdown.kt`'s component table is the reference for the
|
||||
//! sizes and colours; docs/DECISIONS.md's 2026-09-06 entry records where
|
||||
//! this deliberately differs.
|
||||
//!
|
||||
//! **What this deliberately does not attempt**, each for a reason recorded
|
||||
//! here rather than silently dropped (see IRIS_TODO.md's dated entries for
|
||||
@@ -16,52 +27,190 @@
|
||||
//! - **No background chip behind inline code.** Drawing one needs the
|
||||
//! glyph run's own geometry (the way `TextEdit::draw`'s selection
|
||||
//! highlight uses `selection.geometry(layout)`,
|
||||
//! `iris/src/widget/text/edit.rs:99`), which is `TextEdit`-internal and
|
||||
//! not exposed to a caller building spans externally. `SpanStyle` gives
|
||||
//! the code range a monospace family and a dimmer text colour instead --
|
||||
//! visually distinct, just not chip-shaped.
|
||||
//! - **A link is styled (colour + underline) but not tappable.** Following
|
||||
//! it needs the same kind of per-range hit-testing a chip's background
|
||||
//! would (which byte range did the tap land in, then look up its URL),
|
||||
//! which is exactly the same missing primitive.
|
||||
//! - **Tables render as plain paragraphs of their cell text**, no columns.
|
||||
//! `pulldown_cmark::Tag::Table` is walked but not laid out -- a real grid
|
||||
//! needs its own widget, out of scope for a row builder.
|
||||
//! - **A fenced code block's language is not syntax-highlighted.**
|
||||
//! `client-core::highlight` exists and could feed per-token `SpanStyle`s,
|
||||
//! but wiring it in is real work belonging to whoever needs it next
|
||||
//! (IRIS_TODO.md).
|
||||
//! `iris/src/widget/text/edit.rs:99`), which is `TextEdit`-internal.
|
||||
//! `SpanStyle` gives the code range a monospace family and the
|
||||
//! palette's code colour instead -- visually distinct, just not
|
||||
//! chip-shaped.
|
||||
//! - **A list's indent is written in spaces**, not measured. Compose lays
|
||||
//! an item out as a marker column beside a text column, which keeps a
|
||||
//! wrapped second line aligned under the first; here the marker is part
|
||||
//! of the same buffer, so a wrapped line returns to the left margin.
|
||||
//! Doing better needs per-line indent in `TextAttrs`, which nothing else
|
||||
//! wants yet.
|
||||
//!
|
||||
//! A heading's `SpanStyle::font_size` override does not also raise its
|
||||
//! `line_height` (a buffer has one, set from the *base* font size in
|
||||
//! `TextAttrs`), so a heading's own line looks slightly tighter than a
|
||||
//! paragraph's -- visible, not incorrect, and not fixed here since it needs
|
||||
//! `SpanStyle` to carry line-height too, which nothing in this crate needed
|
||||
//! badly enough yet to justify.
|
||||
//! paragraph's -- visible, not incorrect, and not fixed here since it
|
||||
//! needs `SpanStyle` to carry line-height too.
|
||||
|
||||
use client_core::highlight::{self, Kind, Language};
|
||||
use client_core::markdown_blocks::{Block, BlockKind};
|
||||
use iris::prelude::*;
|
||||
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
|
||||
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
|
||||
use std::ops::Range;
|
||||
|
||||
// `UiColor` is `Color<u8>` (`core/src/lib.rs`), not the 0..1 float triples
|
||||
// its brighter/darker helpers might suggest -- these are plain 0..255 RGB.
|
||||
pub const CODE_COLOR: UiColor = UiColor::new(140, 217, 242, 255);
|
||||
pub const LINK_COLOR: UiColor = UiColor::new(140, 190, 255, 255);
|
||||
const STRIKETHROUGH_COLOR: UiColor = UiColor::new(150, 150, 150, 255);
|
||||
// Catppuccin Mocha, the same values `app/.../Theme.kt` maps onto
|
||||
// Material's roles, so a block drawn here and the same block drawn by the
|
||||
// Compose app are the same colour rather than nearly.
|
||||
const fn mocha(hex: u32) -> UiColor {
|
||||
UiColor::new(
|
||||
((hex >> 16) & 0xff) as u8,
|
||||
((hex >> 8) & 0xff) as u8,
|
||||
(hex & 0xff) as u8,
|
||||
255,
|
||||
)
|
||||
}
|
||||
|
||||
/// A block-level separator: two blocks never run into each other with no
|
||||
/// gap, but an empty `out` (the very first block) gets no leading blank.
|
||||
/// Body text: Mocha Text, the Compose app's `onSurface`.
|
||||
pub const TEXT_COLOR: UiColor = mocha(0xCDD6F4);
|
||||
/// Inline code, and a fence with no language to highlight it by.
|
||||
pub const CODE_COLOR: UiColor = mocha(0xCDD6F4);
|
||||
/// A link. "Blue is what a link is on every Catppuccin surface, and the
|
||||
/// one colour to leave alone" (`Theme.kt`'s `linkColor`).
|
||||
pub const LINK_COLOR: UiColor = mocha(0x89B4FA);
|
||||
/// A list's bullets and numbers: structure rather than words, so the
|
||||
/// items of a list can be counted without reading them (`listMarkerColor`).
|
||||
pub const MARKER_COLOR: UiColor = mocha(0xB4BEFE);
|
||||
/// What every verbatim thing in this app sits on -- Mocha Crust, one step
|
||||
/// *below* the page rather than above it (`Theme.kt`'s `rawSurface`).
|
||||
pub const VERBATIM_BACKGROUND: UiColor = mocha(0x11111B);
|
||||
/// A table's fill: Surface 0, the Compose app's `surfaceVariant`.
|
||||
pub const TABLE_BACKGROUND: UiColor = mocha(0x313244);
|
||||
/// A quote's bar and its text: the bar carries the structure, and the
|
||||
/// words step back one shade from body text so a quote reads as quoted
|
||||
/// without being hard to read.
|
||||
pub const QUOTE_BAR_COLOR: UiColor = mocha(0x585B70);
|
||||
pub const QUOTE_TEXT_COLOR: UiColor = mocha(0xA6ADC8);
|
||||
const STRIKETHROUGH_COLOR: UiColor = mocha(0x6C7086);
|
||||
|
||||
/// Catppuccin Mocha as the highlighter's palette -- the same mapping
|
||||
/// `Theme.kt`'s `catppuccinSyntax()` uses, so a `kotlin` fence is the same
|
||||
/// colours in both apps.
|
||||
fn syntax_color(kind: Kind) -> UiColor {
|
||||
match kind {
|
||||
Kind::Keyword => mocha(0xCBA6F7),
|
||||
Kind::String => mocha(0xA6E3A1),
|
||||
Kind::Literal => mocha(0xFAB387),
|
||||
Kind::Comment => mocha(0x6C7086),
|
||||
Kind::Metadata => mocha(0xF9E2AF),
|
||||
Kind::Punctuation => mocha(0xA6ADC8),
|
||||
Kind::Mark => mocha(0x89DCEB),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a row builder puts *around* a block's text widget. Three, not one
|
||||
/// per markdown feature -- see the module doc.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BlockFrame {
|
||||
/// Text and nothing else: a paragraph, a heading, a list, a rule.
|
||||
Plain,
|
||||
/// A dark rounded panel whose text does not wrap -- long lines pan
|
||||
/// sideways, the way `CodeFence.kt`'s `horizontalScroll` does. Carries
|
||||
/// its own fill, since a fence and a table are drawn on different
|
||||
/// ones.
|
||||
Verbatim { fill: UiColor },
|
||||
/// A coloured bar down the left edge and an indent past it.
|
||||
Quote,
|
||||
}
|
||||
|
||||
/// The frame a block kind is drawn in. Pure, and the *only* place the
|
||||
/// mapping is written: a new `BlockKind` shows up here as a compile error
|
||||
/// rather than silently taking prose's appearance.
|
||||
pub fn frame_of(kind: BlockKind) -> BlockFrame {
|
||||
match kind {
|
||||
BlockKind::Code => BlockFrame::Verbatim {
|
||||
fill: VERBATIM_BACKGROUND,
|
||||
},
|
||||
BlockKind::Table => BlockFrame::Verbatim {
|
||||
fill: TABLE_BACKGROUND,
|
||||
},
|
||||
BlockKind::Quote => BlockFrame::Quote,
|
||||
BlockKind::Paragraph | BlockKind::Heading | BlockKind::List | BlockKind::Other => {
|
||||
BlockFrame::Plain
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A tappable range of a block's text and where it points.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Link {
|
||||
/// Byte range into [`Rendered::text`].
|
||||
pub range: Range<usize>,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// One block, ready to draw. Not `Debug`: `SpanStyle` is not, and adding
|
||||
/// it there for this would be a change to iris for a test's benefit.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Rendered {
|
||||
pub text: String,
|
||||
pub spans: Vec<SpanStyle>,
|
||||
pub links: Vec<Link>,
|
||||
}
|
||||
|
||||
impl Rendered {
|
||||
/// The link `byte` falls inside, if any -- what a tap resolves
|
||||
/// through. Half-open, so the offset one past a link's last character
|
||||
/// (where a tap just after it lands) is *not* in it.
|
||||
pub fn link_at(&self, byte: usize) -> Option<&Link> {
|
||||
self.links.iter().find(|l| l.range.contains(&byte))
|
||||
}
|
||||
}
|
||||
|
||||
/// The heading ladder, in points at a 16pt body: it starts near the body
|
||||
/// text and descends, because these are headings inside a chat message
|
||||
/// rather than the top of a document. The numbers are Material's
|
||||
/// `headlineSmall`/`titleLarge`/`titleMedium`/`titleSmall`/`labelMedium`/
|
||||
/// `labelSmall`, which is what `Markdown.kt`'s `markdownTypography` picks
|
||||
/// -- kept as literals rather than derived from `base_size` so the two
|
||||
/// apps agree exactly.
|
||||
fn heading_size(level: HeadingLevel) -> f32 {
|
||||
match level {
|
||||
HeadingLevel::H1 => 24.0,
|
||||
HeadingLevel::H2 => 22.0,
|
||||
HeadingLevel::H3 => 16.0,
|
||||
HeadingLevel::H4 => 14.0,
|
||||
HeadingLevel::H5 => 12.0,
|
||||
HeadingLevel::H6 => 11.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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. Same three
|
||||
/// `MarkdownPieces.kt` uses.
|
||||
const BULLETS: [&str; 3] = ["\u{2022} ", "\u{25e6} ", "\u{25aa} "];
|
||||
|
||||
/// A block-level separator inside one block's own text (a list item's
|
||||
/// paragraphs, a quote's): two never run into each other with no gap, but
|
||||
/// an empty `out` gets no leading blank.
|
||||
fn ensure_blank_line(out: &mut String) {
|
||||
if !out.is_empty() && !out.ends_with("\n\n") {
|
||||
while out.ends_with('\n') {
|
||||
out.pop();
|
||||
}
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
fn heading_size(level: HeadingLevel) -> f32 {
|
||||
match level {
|
||||
HeadingLevel::H1 => 28.0,
|
||||
HeadingLevel::H2 => 24.0,
|
||||
HeadingLevel::H3 => 21.0,
|
||||
_ => 19.0,
|
||||
fn ensure_line(out: &mut String) {
|
||||
if !out.is_empty() && !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
/// One top-level block, rendered. `base_size` is the row's ordinary
|
||||
/// paragraph font size; a heading overrides it per span.
|
||||
pub fn render_block(block: &Block, base_size: f32) -> Rendered {
|
||||
match block.kind {
|
||||
// A table is the one block markdown states as a grid and iris has
|
||||
// no grid widget for. Rendered as padded monospace instead --
|
||||
// see [`table_text`].
|
||||
BlockKind::Table => table_text(&block.source),
|
||||
_ => render_markdown(&block.source, base_size),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,19 +218,27 @@ fn heading_size(level: HeadingLevel) -> f32 {
|
||||
/// style it. `base_size` is the row's ordinary paragraph font size, needed
|
||||
/// only so a heading's override is relative to it rather than a hardcoded
|
||||
/// absolute the caller cannot retune.
|
||||
pub fn render_markdown(src: &str, base_size: f32) -> (String, Vec<SpanStyle>) {
|
||||
let _ = base_size; // headings use fixed sizes today; kept for callers that may want relative sizing later
|
||||
pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
|
||||
let _ = base_size; // headings use the fixed Material ladder; see `heading_size`
|
||||
let mut out = String::new();
|
||||
let mut spans = Vec::new();
|
||||
let mut links = Vec::new();
|
||||
// Stack of start byte offsets for whatever inline/block styling is
|
||||
// currently open -- pulldown-cmark's `Start`/`End` events are always
|
||||
// balanced and each `End` already names its own kind (`TagEnd`), so a
|
||||
// plain offset stack (rather than a tree, or repeating the kind here
|
||||
// too) is enough.
|
||||
let mut open: Vec<usize> = Vec::new();
|
||||
let mut list_depth: u32 = 0;
|
||||
// too) is enough. A link's destination rides along beside its offset,
|
||||
// since `TagEnd::Link` does not carry it.
|
||||
let mut open: Vec<(usize, Option<String>)> = Vec::new();
|
||||
// One entry per open list: `Some(next number)` for an ordered list,
|
||||
// `None` for a bulleted one. Depth is this vector's length, which is
|
||||
// what picks the bullet glyph.
|
||||
let mut lists: Vec<Option<u64>> = Vec::new();
|
||||
// The language of the fence currently open, so `TagEnd::CodeBlock` can
|
||||
// highlight what was collected between the two.
|
||||
let mut fence_language: Option<Language> = None;
|
||||
|
||||
let parser = Parser::new_ext(src, Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES);
|
||||
let parser = Parser::new_ext(src, options());
|
||||
for event in parser {
|
||||
match event {
|
||||
Event::Start(tag) => match tag {
|
||||
@@ -89,16 +246,35 @@ pub fn render_markdown(src: &str, base_size: f32) -> (String, Vec<SpanStyle>) {
|
||||
| Tag::Emphasis
|
||||
| Tag::Strong
|
||||
| Tag::Strikethrough
|
||||
| Tag::Link { .. } => open.push(out.len()),
|
||||
Tag::CodeBlock(_) => {
|
||||
| Tag::Image { .. } => open.push((out.len(), None)),
|
||||
Tag::Link { dest_url, .. } => open.push((out.len(), Some(dest_url.to_string()))),
|
||||
Tag::CodeBlock(kind) => {
|
||||
fence_language = match &kind {
|
||||
CodeBlockKind::Fenced(info) => {
|
||||
// Only the first word: "rust,ignore" and
|
||||
// "console session" are both written.
|
||||
highlight::fence_language(info.split_whitespace().next())
|
||||
}
|
||||
CodeBlockKind::Indented => None,
|
||||
};
|
||||
ensure_blank_line(&mut out);
|
||||
open.push(out.len());
|
||||
open.push((out.len(), None));
|
||||
}
|
||||
Tag::Item => {
|
||||
out.push_str(&" ".repeat(list_depth.saturating_sub(1) as usize));
|
||||
out.push_str("\u{2022} ");
|
||||
ensure_line(&mut out);
|
||||
let depth = lists.len().max(1);
|
||||
out.push_str(&" ".repeat(depth - 1));
|
||||
let start = out.len();
|
||||
match lists.last_mut() {
|
||||
Some(Some(n)) => {
|
||||
out.push_str(&format!("{n}. "));
|
||||
*n += 1;
|
||||
}
|
||||
_ => out.push_str(BULLETS[(depth - 1) % BULLETS.len()]),
|
||||
}
|
||||
spans.push(SpanStyle::new(start..out.len()).color(MARKER_COLOR));
|
||||
}
|
||||
Tag::List(_) => list_depth += 1,
|
||||
Tag::List(first) => lists.push(first),
|
||||
Tag::Paragraph | Tag::BlockQuote(_) => ensure_blank_line(&mut out),
|
||||
_ => {}
|
||||
},
|
||||
@@ -113,11 +289,19 @@ pub fn render_markdown(src: &str, base_size: f32) -> (String, Vec<SpanStyle>) {
|
||||
| TagEnd::Strong
|
||||
| TagEnd::Strikethrough
|
||||
| TagEnd::Link
|
||||
| TagEnd::Image
|
||||
| TagEnd::CodeBlock),
|
||||
) => {
|
||||
let Some(start) = open.pop() else {
|
||||
let Some((start, dest)) = open.pop() else {
|
||||
continue;
|
||||
};
|
||||
if matches!(tag_end, TagEnd::CodeBlock) {
|
||||
// A fence's trailing newline is the fence marker's, not
|
||||
// the code's -- kept and it draws an empty last line.
|
||||
while out.ends_with('\n') {
|
||||
out.pop();
|
||||
}
|
||||
}
|
||||
let range = start..out.len();
|
||||
if range.is_empty() {
|
||||
continue;
|
||||
@@ -131,15 +315,27 @@ pub fn render_markdown(src: &str, base_size: f32) -> (String, Vec<SpanStyle>) {
|
||||
TagEnd::Strikethrough => {
|
||||
spans.push(SpanStyle::new(range).color(STRIKETHROUGH_COLOR));
|
||||
}
|
||||
TagEnd::Link => {
|
||||
spans.push(SpanStyle::new(range).color(LINK_COLOR).underline());
|
||||
// An image draws as its alt text until the port has a
|
||||
// transcript image widget (IRIS_TODO's "scaled
|
||||
// thumbnail"); marked as a link so it is at least
|
||||
// followable rather than silently inert.
|
||||
TagEnd::Link | TagEnd::Image => {
|
||||
spans.push(SpanStyle::new(range.clone()).color(LINK_COLOR).underline());
|
||||
if let Some(url) = dest {
|
||||
links.push(Link { range, url });
|
||||
}
|
||||
}
|
||||
TagEnd::CodeBlock => {
|
||||
spans.push(
|
||||
SpanStyle::new(range)
|
||||
SpanStyle::new(range.clone())
|
||||
.family(Family::Monospace)
|
||||
.color(CODE_COLOR),
|
||||
);
|
||||
// After the monospace span, so the per-token
|
||||
// colours win where they overlap it.
|
||||
if let Some(language) = fence_language.take() {
|
||||
highlight_into(&mut spans, &out, range, language);
|
||||
}
|
||||
}
|
||||
_ => unreachable!("filtered by the outer match arm"),
|
||||
}
|
||||
@@ -160,64 +356,432 @@ pub fn render_markdown(src: &str, base_size: f32) -> (String, Vec<SpanStyle>) {
|
||||
Event::SoftBreak => out.push(' '),
|
||||
Event::HardBreak => out.push('\n'),
|
||||
Event::Rule => {
|
||||
if !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
ensure_line(&mut out);
|
||||
out.push_str("\u{2500}\u{2500}\u{2500}\n");
|
||||
}
|
||||
Event::End(TagEnd::List(_)) => list_depth = list_depth.saturating_sub(1),
|
||||
Event::TaskListMarker(done) => {
|
||||
let start = out.len();
|
||||
out.push_str(if done { "[x] " } else { "[ ] " });
|
||||
spans.push(SpanStyle::new(start..out.len()).color(MARKER_COLOR));
|
||||
}
|
||||
Event::End(TagEnd::List(_)) => {
|
||||
lists.pop();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(out, spans)
|
||||
while out.ends_with('\n') {
|
||||
out.pop();
|
||||
}
|
||||
// A span left pointing past the text a later trim shortened would draw
|
||||
// against nothing; markdown that ends inside an open emphasis is
|
||||
// ordinary mid-stream input, not a defect.
|
||||
spans.retain(|s| s.range.end <= out.len());
|
||||
links.retain(|l| l.range.end <= out.len());
|
||||
Rendered {
|
||||
text: out,
|
||||
spans,
|
||||
links,
|
||||
}
|
||||
}
|
||||
|
||||
/// The same option set `client_core::markdown_blocks` splits with, so a
|
||||
/// block boundary there and the styling here cannot disagree about what
|
||||
/// the source means.
|
||||
fn options() -> Options {
|
||||
Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS
|
||||
}
|
||||
|
||||
/// `client_core::highlight`'s spans for the code at `range` inside `text`,
|
||||
/// appended to `spans`.
|
||||
///
|
||||
/// The highlighter indexes **chars** and `SpanStyle` indexes **bytes**
|
||||
/// (`highlight`'s module doc), so the offsets are walked once rather than
|
||||
/// converted per span -- a fence is scanned on every delta that lands in
|
||||
/// it, and it is the only block a delta re-renders.
|
||||
pub(crate) fn highlight_into(
|
||||
spans: &mut Vec<SpanStyle>,
|
||||
text: &str,
|
||||
range: Range<usize>,
|
||||
language: Language,
|
||||
) {
|
||||
let code = &text[range.clone()];
|
||||
// char index -> byte offset within `code`, plus the end, so a span's
|
||||
// `end` is always in range.
|
||||
let bytes: Vec<usize> = code
|
||||
.char_indices()
|
||||
.map(|(i, _)| i)
|
||||
.chain(std::iter::once(code.len()))
|
||||
.collect();
|
||||
for span in highlight::spans_of(code, language) {
|
||||
let (Some(&start), Some(&end)) = (bytes.get(span.start), bytes.get(span.end)) else {
|
||||
debug_assert!(
|
||||
false,
|
||||
"highlight span {}..{} outside {} chars of code",
|
||||
span.start,
|
||||
span.end,
|
||||
bytes.len() - 1
|
||||
);
|
||||
continue;
|
||||
};
|
||||
spans.push(
|
||||
SpanStyle::new(range.start + start..range.start + end)
|
||||
.family(Family::Monospace)
|
||||
.color(syntax_color(span.kind)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The widest a table column is allowed to get before its cells wrap
|
||||
/// inside it, in characters. Chosen the way `Markdown.kt`'s 136dp
|
||||
/// `tableCellWidth` was -- what fits three columns across a phone -- but
|
||||
/// counted in monospace characters, which is the unit a padded table has:
|
||||
/// three 28-character columns plus separators is about 90 characters,
|
||||
/// which is what a 16pt mono face gives on a 1080px phone before the
|
||||
/// sideways pan starts.
|
||||
const TABLE_MAX_COL: usize = 28;
|
||||
|
||||
/// A GFM table as **padded monospace columns**, with the header bold and a
|
||||
/// rule under it.
|
||||
///
|
||||
/// iris has no grid widget, and building one for the one block kind that
|
||||
/// needs it would be a widget per markdown feature -- what this crate's
|
||||
/// module doc says it will not do. A monospace face makes character counts
|
||||
/// and pixel widths the same thing, so padding each cell to its column's
|
||||
/// width *is* alignment, the column widths are measured from the cells,
|
||||
/// and the block reuses `BlockFrame::Verbatim`'s sideways pan for a table
|
||||
/// too wide to fit. docs/DECISIONS.md, 2026-09-06, has what this trades.
|
||||
pub fn table_text(src: &str) -> Rendered {
|
||||
let rows = table_cells(src);
|
||||
if rows.is_empty() {
|
||||
return Rendered::default();
|
||||
}
|
||||
let columns = rows.iter().map(Vec::len).max().unwrap_or(0);
|
||||
// Each cell wrapped to the cap first, so a column's width is the
|
||||
// widest *line* it will actually draw rather than the longest cell.
|
||||
let wrapped: Vec<Vec<Vec<String>>> = rows
|
||||
.iter()
|
||||
.map(|row| row.iter().map(|c| wrap_cell(c, TABLE_MAX_COL)).collect())
|
||||
.collect();
|
||||
let widths: Vec<usize> = (0..columns)
|
||||
.map(|c| {
|
||||
wrapped
|
||||
.iter()
|
||||
.filter_map(|row| row.get(c))
|
||||
.flat_map(|lines| lines.iter())
|
||||
.map(|l| l.chars().count())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut out = String::new();
|
||||
let mut spans = Vec::new();
|
||||
for (r, row) in wrapped.iter().enumerate() {
|
||||
let height = row.iter().map(Vec::len).max().unwrap_or(1);
|
||||
let start = out.len();
|
||||
for line in 0..height {
|
||||
if !out.is_empty() {
|
||||
out.push('\n');
|
||||
}
|
||||
for (c, width) in widths.iter().enumerate() {
|
||||
if c > 0 {
|
||||
out.push_str(" ");
|
||||
}
|
||||
let text = row.get(c).and_then(|l| l.get(line)).map(String::as_str);
|
||||
let text = text.unwrap_or("");
|
||||
out.push_str(text);
|
||||
// The last column is not padded: trailing spaces widen
|
||||
// the block's measured width for nothing.
|
||||
if c + 1 < widths.len() {
|
||||
for _ in text.chars().count()..*width {
|
||||
out.push(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if r == 0 {
|
||||
spans.push(SpanStyle::new(start..out.len()).bold());
|
||||
out.push('\n');
|
||||
let rule: usize = widths.iter().sum::<usize>() + 2 * widths.len().saturating_sub(1);
|
||||
let rule_start = out.len();
|
||||
out.extend(std::iter::repeat_n('\u{2500}', rule));
|
||||
spans.push(SpanStyle::new(rule_start..out.len()).color(QUOTE_BAR_COLOR));
|
||||
}
|
||||
}
|
||||
Rendered {
|
||||
text: out,
|
||||
spans,
|
||||
links: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The cells of a GFM table, row by row, as their plain text.
|
||||
fn table_cells(src: &str) -> Vec<Vec<String>> {
|
||||
let mut rows: Vec<Vec<String>> = Vec::new();
|
||||
let mut cell = String::new();
|
||||
let mut in_cell = false;
|
||||
for event in Parser::new_ext(src, options()) {
|
||||
match event {
|
||||
Event::Start(Tag::TableHead) | Event::Start(Tag::TableRow) => rows.push(Vec::new()),
|
||||
Event::Start(Tag::TableCell) => {
|
||||
cell.clear();
|
||||
in_cell = true;
|
||||
}
|
||||
Event::End(TagEnd::TableCell) => {
|
||||
in_cell = false;
|
||||
if let Some(row) = rows.last_mut() {
|
||||
row.push(cell.trim().to_string());
|
||||
}
|
||||
}
|
||||
Event::Text(text) | Event::Code(text) if in_cell => cell.push_str(&text),
|
||||
Event::SoftBreak | Event::HardBreak if in_cell => cell.push(' '),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
rows.retain(|r| !r.is_empty());
|
||||
rows
|
||||
}
|
||||
|
||||
/// `text` broken onto lines of at most `width` characters, at spaces where
|
||||
/// there are any. A word longer than the column is left over-long rather
|
||||
/// than cut mid-word: the column then widens for it, which is visible and
|
||||
/// correct, where cutting would silently lose characters.
|
||||
fn wrap_cell(text: &str, width: usize) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
let mut line = String::new();
|
||||
for word in text.split_whitespace() {
|
||||
let extra = if line.is_empty() { 0 } else { 1 };
|
||||
if !line.is_empty() && line.chars().count() + extra + word.chars().count() > width {
|
||||
lines.push(std::mem::take(&mut line));
|
||||
}
|
||||
if !line.is_empty() {
|
||||
line.push(' ');
|
||||
}
|
||||
line.push_str(word);
|
||||
}
|
||||
lines.push(line);
|
||||
lines
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use client_core::markdown_blocks::split_blocks;
|
||||
|
||||
fn block(src: &str) -> Rendered {
|
||||
let blocks = split_blocks(src);
|
||||
assert_eq!(blocks.len(), 1, "test wants exactly one block: {blocks:?}");
|
||||
render_block(&blocks[0], 16.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_paragraph_has_no_spans() {
|
||||
let (text, spans) = render_markdown("just some words", 16.0);
|
||||
assert_eq!(text, "just some words");
|
||||
assert!(spans.is_empty());
|
||||
let r = render_markdown("just some words", 16.0);
|
||||
assert_eq!(r.text, "just some words");
|
||||
assert!(r.spans.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bold_and_italic_produce_spans_over_the_right_range() {
|
||||
let (text, spans) = render_markdown("a **bold** and *italic* word", 16.0);
|
||||
assert_eq!(text, "a bold and italic word");
|
||||
let bold = spans.iter().find(|s| s.bold && !s.italic).unwrap();
|
||||
assert_eq!(&text[bold.range.clone()], "bold");
|
||||
let italic = spans.iter().find(|s| s.italic).unwrap();
|
||||
assert_eq!(&text[italic.range.clone()], "italic");
|
||||
let r = render_markdown("a **bold** and *italic* word", 16.0);
|
||||
assert_eq!(r.text, "a bold and italic word");
|
||||
let bold = r.spans.iter().find(|s| s.bold && !s.italic).unwrap();
|
||||
assert_eq!(&r.text[bold.range.clone()], "bold");
|
||||
let italic = r.spans.iter().find(|s| s.italic).unwrap();
|
||||
assert_eq!(&r.text[italic.range.clone()], "italic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_gets_a_bigger_font_size_span() {
|
||||
let (text, spans) = render_markdown("# A Title\n\nbody text", 16.0);
|
||||
assert!(text.starts_with("A Title"));
|
||||
let heading = spans.iter().find(|s| s.font_size.is_some()).unwrap();
|
||||
assert_eq!(&text[heading.range.clone()], "A Title");
|
||||
assert_eq!(heading.font_size, Some(28.0));
|
||||
let r = render_markdown("# A Title", 16.0);
|
||||
assert!(r.text.starts_with("A Title"));
|
||||
let heading = r.spans.iter().find(|s| s.font_size.is_some()).unwrap();
|
||||
assert_eq!(&r.text[heading.range.clone()], "A Title");
|
||||
assert_eq!(heading.font_size, Some(24.0));
|
||||
}
|
||||
|
||||
/// Every level draws at its own size, so two levels of nesting are
|
||||
/// never the same -- `Markdown.kt`'s reason for the ladder.
|
||||
#[test]
|
||||
fn every_heading_level_is_a_different_size() {
|
||||
let mut sizes = Vec::new();
|
||||
for level in 1..=6 {
|
||||
let src = format!("{} h", "#".repeat(level));
|
||||
let r = render_markdown(&src, 16.0);
|
||||
sizes.push(r.spans.iter().find_map(|s| s.font_size).unwrap());
|
||||
}
|
||||
let mut sorted = sizes.clone();
|
||||
sorted.sort_by(|a, b| b.partial_cmp(a).unwrap());
|
||||
sorted.dedup();
|
||||
assert_eq!(sizes, sorted, "the ladder must descend with no repeats");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_is_styled_and_keeps_its_visible_text() {
|
||||
let (text, spans) = render_markdown("see [the docs](https://example.com) for more", 16.0);
|
||||
assert!(text.contains("the docs"));
|
||||
fn a_link_keeps_its_text_and_its_url_and_can_be_hit() {
|
||||
let r = render_markdown("see [the docs](https://example.com) for more", 16.0);
|
||||
assert!(r.text.contains("the docs"));
|
||||
assert!(
|
||||
!text.contains("example.com"),
|
||||
!r.text.contains("example.com"),
|
||||
"the URL should not leak into the visible text"
|
||||
);
|
||||
let link = spans.iter().find(|s| s.underline).unwrap();
|
||||
assert_eq!(&text[link.range.clone()], "the docs");
|
||||
let link = r.spans.iter().find(|s| s.underline).unwrap();
|
||||
assert_eq!(&r.text[link.range.clone()], "the docs");
|
||||
let at = r.text.find("docs").unwrap();
|
||||
assert_eq!(r.link_at(at).unwrap().url, "https://example.com");
|
||||
assert!(r.link_at(0).is_none(), "the word 'see' is not the link");
|
||||
let past = r.text.find("for").unwrap();
|
||||
assert!(r.link_at(past).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fenced_code_block_is_monospaced() {
|
||||
let (text, spans) = render_markdown("before\n\n```\nlet x = 1;\n```\n\nafter", 16.0);
|
||||
let code = spans.iter().find(|s| s.family.is_some()).unwrap();
|
||||
assert!(text[code.range.clone()].contains("let x = 1;"));
|
||||
fn fenced_code_block_is_monospaced_and_highlighted_by_its_language() {
|
||||
let r = block("```rust\nlet x = 1; // note\n```");
|
||||
assert_eq!(r.text, "let x = 1; // note");
|
||||
let keyword = r
|
||||
.spans
|
||||
.iter()
|
||||
.find(|s| s.color == Some(syntax_color(Kind::Keyword)))
|
||||
.expect("a rust fence colours its keywords");
|
||||
assert_eq!(&r.text[keyword.range.clone()], "let");
|
||||
let comment = r
|
||||
.spans
|
||||
.iter()
|
||||
.find(|s| s.color == Some(syntax_color(Kind::Comment)))
|
||||
.unwrap();
|
||||
assert_eq!(&r.text[comment.range.clone()], "// note");
|
||||
assert!(r.spans.iter().all(|s| s.range.end <= r.text.len()));
|
||||
}
|
||||
|
||||
/// The half the change had no reason to touch: a fence in a language
|
||||
/// the highlighter has no rules for must be plain rather than
|
||||
/// coloured by the nearest language's (`CodeFence.kt`'s
|
||||
/// `fenceLanguage` doc).
|
||||
#[test]
|
||||
fn a_fence_in_an_unknown_language_is_monospace_and_uncoloured() {
|
||||
let r = block("```brainfuck\nlet x = 1;\n```");
|
||||
assert_eq!(r.text, "let x = 1;");
|
||||
assert_eq!(r.spans.len(), 1);
|
||||
// `Family` is not `Debug`, so this is `assert!` rather than
|
||||
// `assert_eq!`.
|
||||
assert!(r.spans[0].family == Some(Family::Monospace));
|
||||
assert_eq!(r.spans[0].color, Some(CODE_COLOR));
|
||||
}
|
||||
|
||||
/// Multi-byte characters are where a char-indexed highlighter and a
|
||||
/// byte-indexed span list disagree if the conversion is missing.
|
||||
#[test]
|
||||
fn highlight_spans_are_byte_offsets_even_with_multibyte_code() {
|
||||
let r = block("```rust\nlet s = \"café ☕\"; // é\n```");
|
||||
for span in &r.spans {
|
||||
assert!(
|
||||
r.text.is_char_boundary(span.range.start)
|
||||
&& r.text.is_char_boundary(span.range.end),
|
||||
"span {:?} is not on a char boundary of {:?}",
|
||||
span.range,
|
||||
r.text
|
||||
);
|
||||
}
|
||||
let string = r
|
||||
.spans
|
||||
.iter()
|
||||
.find(|s| s.color == Some(syntax_color(Kind::String)))
|
||||
.unwrap();
|
||||
assert_eq!(&r.text[string.range.clone()], "\"café ☕\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unterminated_fence_still_renders_what_arrived() {
|
||||
let r = block("```rust\nlet x = 1;");
|
||||
assert_eq!(r.text, "let x = 1;");
|
||||
assert!(
|
||||
r.spans
|
||||
.iter()
|
||||
.any(|s| s.color == Some(syntax_color(Kind::Keyword)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bulleted_list_gets_a_marker_per_item_and_indents_nesting() {
|
||||
let r = block("- one\n- two\n - deep");
|
||||
assert_eq!(r.text, "\u{2022} one\n\u{2022} two\n \u{25e6} deep");
|
||||
let markers: Vec<_> = r
|
||||
.spans
|
||||
.iter()
|
||||
.filter(|s| s.color == Some(MARKER_COLOR))
|
||||
.map(|s| r.text[s.range.clone()].to_string())
|
||||
.collect();
|
||||
assert_eq!(markers, ["\u{2022} ", "\u{2022} ", "\u{25e6} "]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_numbered_list_counts_from_the_number_it_was_written_with() {
|
||||
let r = block("3. three\n4. four");
|
||||
assert_eq!(r.text, "3. three\n4. four");
|
||||
let markers: Vec<_> = r
|
||||
.spans
|
||||
.iter()
|
||||
.filter(|s| s.color == Some(MARKER_COLOR))
|
||||
.map(|s| r.text[s.range.clone()].to_string())
|
||||
.collect();
|
||||
assert_eq!(markers, ["3. ", "4. "]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quote_is_its_text_and_takes_the_quote_frame() {
|
||||
let blocks = split_blocks("> quoted words\n> still quoted");
|
||||
assert_eq!(frame_of(blocks[0].kind), BlockFrame::Quote);
|
||||
let r = render_block(&blocks[0], 16.0);
|
||||
assert_eq!(r.text, "quoted words still quoted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_block_kind_maps_to_the_frame_it_is_drawn_in() {
|
||||
use BlockKind::*;
|
||||
assert_eq!(frame_of(Paragraph), BlockFrame::Plain);
|
||||
assert_eq!(frame_of(Heading), BlockFrame::Plain);
|
||||
assert_eq!(frame_of(List), BlockFrame::Plain);
|
||||
assert_eq!(frame_of(Other), BlockFrame::Plain);
|
||||
assert_eq!(frame_of(Quote), BlockFrame::Quote);
|
||||
assert!(matches!(frame_of(Code), BlockFrame::Verbatim { .. }));
|
||||
assert!(matches!(frame_of(Table), BlockFrame::Verbatim { .. }));
|
||||
assert_ne!(
|
||||
frame_of(Code),
|
||||
frame_of(Table),
|
||||
"a fence and a table sit on different fills"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_pads_its_columns_to_the_widest_cell() {
|
||||
let r = block("| a | bb |\n|---|---|\n| cccc | d |");
|
||||
let lines: Vec<&str> = r.text.lines().collect();
|
||||
assert_eq!(lines[0], "a bb");
|
||||
assert_eq!(lines[1], "\u{2500}".repeat(8));
|
||||
assert_eq!(lines[2], "cccc d");
|
||||
let bold = r.spans.iter().find(|s| s.bold).unwrap();
|
||||
assert_eq!(&r.text[bold.range.clone()], "a bb");
|
||||
}
|
||||
|
||||
/// The fixture's own table shape: a long cell wraps inside its column
|
||||
/// instead of making the row one enormous line.
|
||||
#[test]
|
||||
fn a_long_table_cell_wraps_inside_its_column() {
|
||||
let long = "one two three four five six seven eight nine ten eleven twelve";
|
||||
let r = block(&format!("| k | v |\n|---|---|\n| a | {long} |"));
|
||||
for line in r.text.lines() {
|
||||
assert!(
|
||||
line.chars().count() <= TABLE_MAX_COL + 1 + 2 + 1,
|
||||
"line too wide: {line:?}"
|
||||
);
|
||||
}
|
||||
assert!(r.text.contains("twelve"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_task_list_marks_its_boxes() {
|
||||
let r = block("- [x] done\n- [ ] not");
|
||||
assert!(r.text.contains("[x] done"));
|
||||
assert!(r.text.contains("[ ] not"));
|
||||
}
|
||||
}
|
||||