Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
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
|
how the cost of a layout node per link was attributed to the framework
|
||||||
rather than guessed at.
|
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
|
### Driving the UI
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ package com.example.aiapp
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.BatteryManager
|
import android.os.BatteryManager
|
||||||
import android.os.Process
|
import android.os.Process
|
||||||
import androidx.compose.animation.core.tween
|
import android.view.View
|
||||||
import androidx.compose.foundation.gestures.animateScrollBy
|
import androidx.compose.foundation.gestures.FlingBehavior
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
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 java.io.File
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -19,27 +23,84 @@ import kotlinx.coroutines.launch
|
|||||||
* here against [LazyListState] and [BenchFixture] directly. Only reachable from the `bench` build
|
* here against [LazyListState] and [BenchFixture] directly. Only reachable from the `bench` build
|
||||||
* (see [SessionSettingsDialog]'s `onRunBenchmark`), but compiled into every build for the reason
|
* (see [SessionSettingsDialog]'s `onRunBenchmark`), but compiled into every build for the reason
|
||||||
* [BenchFixture]'s doc comment gives.
|
* [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 {
|
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 CYCLES = 6
|
||||||
private const val SWIPE_PX = 900f
|
private const val SWIPE_PX = 900f
|
||||||
private const val SWIPE_MS = 200
|
private const val SWIPE_MS = 200
|
||||||
private const val SWIPE_PAUSE_MS = 500L
|
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. */
|
/** 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_EVENTS_PER_SEC = 20
|
||||||
private const val STREAM_SECONDS = 20
|
private const val STREAM_SECONDS = 20
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scrolls, then streams, then returns the extra report lines P0 asked for (CPU time, peak RSS,
|
* Type phase (v2): sentences built from long, multisyllabic words so the composer actually
|
||||||
* battery current) -- [FrameStats] and [DebugStats] are reset first, exactly as
|
* wraps across lines rather than fitting one, and long enough (600 chars) that the composer's
|
||||||
* `copyRenderReport` resets them, so the two accountings cover the same stretch of work.
|
* 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(
|
suspend fun run(
|
||||||
context: Context,
|
context: Context,
|
||||||
scope: CoroutineScope,
|
scope: CoroutineScope,
|
||||||
listState: LazyListState,
|
listState: LazyListState,
|
||||||
|
flingBehavior: FlingBehavior,
|
||||||
|
composerFocus: FocusRequester,
|
||||||
|
setComposerText: (String) -> Unit,
|
||||||
|
view: View,
|
||||||
): List<String> {
|
): List<String> {
|
||||||
FrameStats.reset()
|
FrameStats.reset()
|
||||||
DebugStats.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
|
val travel = runFlingPhase(listState, flingBehavior)
|
||||||
// content and two back, so a cycle returns to where it started and the whole loop measures
|
val sent = runStreamPhase()
|
||||||
// steady-state scrolling rather than travelling somewhere new each time.
|
runTypePhase(listState, composerFocus, setComposerText, view)
|
||||||
repeat(CYCLES) {
|
val keyboard = runKeyboardPhase(context, view)
|
||||||
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)
|
|
||||||
|
|
||||||
samplerJob.cancel()
|
samplerJob.cancel()
|
||||||
val cpuMs = Process.getElapsedCpuTime() - cpuStartMs
|
val cpuMs = Process.getElapsedCpuTime() - cpuStartMs
|
||||||
@@ -90,13 +127,158 @@ object BenchRun {
|
|||||||
val batteryLine = battery.finish()
|
val batteryLine = battery.finish()
|
||||||
|
|
||||||
return listOf(
|
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",
|
" process CPU time over this run: ${cpuMs}ms",
|
||||||
rssLine,
|
rssLine,
|
||||||
batteryLine,
|
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. */
|
/** VmHWM from /proc/self/status: the process's high-water mark, in kB, since it started. */
|
||||||
private fun peakRssLine(): String {
|
private fun peakRssLine(): String {
|
||||||
val kb =
|
val kb =
|
||||||
|
|||||||
@@ -134,6 +134,13 @@ fun debugReport(
|
|||||||
* render-report button reads exactly as it did before this existed.
|
* render-report button reads exactly as it did before this existed.
|
||||||
*/
|
*/
|
||||||
extra: List<String> = emptyList(),
|
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 {
|
): String = buildString {
|
||||||
appendLine("ai-app render report")
|
appendLine("ai-app render report")
|
||||||
appendLine(device)
|
appendLine(device)
|
||||||
@@ -148,6 +155,11 @@ fun debugReport(
|
|||||||
appendLine("transcript:")
|
appendLine("transcript:")
|
||||||
transcript.forEach { appendLine(it) }
|
transcript.forEach { appendLine(it) }
|
||||||
appendLine()
|
appendLine()
|
||||||
|
if (phaseFrames.isNotEmpty()) {
|
||||||
|
appendLine("per phase:")
|
||||||
|
phaseFrames.forEach { appendLine(it) }
|
||||||
|
appendLine()
|
||||||
|
}
|
||||||
appendLine("frames:")
|
appendLine("frames:")
|
||||||
frames.forEach { appendLine(it) }
|
frames.forEach { appendLine(it) }
|
||||||
appendLine()
|
appendLine()
|
||||||
|
|||||||
@@ -42,6 +42,21 @@ object FrameStats {
|
|||||||
private val gpu = ArrayList<Long>()
|
private val gpu = ArrayList<Long>()
|
||||||
private var since = System.currentTimeMillis()
|
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
|
@Synchronized
|
||||||
fun add(metrics: FrameMetrics) {
|
fun add(metrics: FrameMetrics) {
|
||||||
// The first frame after a window opens includes inflating it and is nobody's scroll.
|
// 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 {
|
listOf(total, waited, input, animation, layout, draw, sync, issue, swap, gpu).forEach {
|
||||||
it.clear()
|
it.clear()
|
||||||
}
|
}
|
||||||
|
phaseMarks.clear()
|
||||||
since = System.currentTimeMillis()
|
since = System.currentTimeMillis()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +111,38 @@ object FrameStats {
|
|||||||
) + if (gpu.isEmpty()) emptyList() else listOf(phase("gpu ", gpu))
|
) + 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. */
|
/** 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
|
@Synchronized fun drawPhase(): Pair<Long, Int> = draw.sum() to draw.size
|
||||||
|
|
||||||
|
|||||||
@@ -187,13 +187,20 @@ class MainActivity : ComponentActivity() {
|
|||||||
model = null,
|
model = null,
|
||||||
keepsOwnTranscript = false,
|
keepsOwnTranscript = false,
|
||||||
permissionMode = null,
|
permissionMode = null,
|
||||||
|
effort = null,
|
||||||
|
takesEffort = false,
|
||||||
imported = false,
|
imported = false,
|
||||||
notify = false,
|
notify = false,
|
||||||
|
autoResume = false,
|
||||||
|
autoResumeMessage = "",
|
||||||
|
resumeAt = null,
|
||||||
cwd = null,
|
cwd = null,
|
||||||
contextTokens = null,
|
contextTokens = null,
|
||||||
maxImageEdge = null,
|
maxImageEdge = null,
|
||||||
|
usageProvider = null,
|
||||||
status = "idle",
|
status = "idle",
|
||||||
lastActivity = 0.0,
|
lastActivity = 0.0,
|
||||||
|
subagents = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
// launchMode="singleTop": an enrollment scan, or a notification tapped while the app is open,
|
// 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.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.ScrollableDefaults
|
||||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
import androidx.compose.foundation.layout.Box
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.drawWithContent
|
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.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.TextRange
|
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
|
// `rememberSaveable`, and this screen restores by its own anchor instead -- two restores would
|
||||||
// fight over the first frame.
|
// fight over the first frame.
|
||||||
val listState = remember(address) { LazyListState() }
|
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
|
// 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
|
// 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.
|
// 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) },
|
FrameStats.drawPhase().let { (nanos, count) -> drawAccounting(nanos, count) },
|
||||||
crash = lastCrash(context),
|
crash = lastCrash(context),
|
||||||
extra = extra,
|
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)
|
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
|
// 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()
|
Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
val copyRenderReport = { buildAndCopyReport() }
|
val copyRenderReport = { buildAndCopyReport() }
|
||||||
// Bench build only: P0's scripted scroll-and-stream benchmark (BenchRun.kt), against the
|
// Bench build only: P0's scripted fling/stream/type/keyboard benchmark (BenchRun.kt), against
|
||||||
// fixture session opened below instead of a real server. Null everywhere else -- see
|
// the fixture session opened below instead of a real server. Null everywhere else -- see
|
||||||
// [SessionSettingsDialog]'s onRunBenchmark.
|
// [SessionSettingsDialog]'s onRunBenchmark.
|
||||||
val runBenchmark: (() -> Unit)? =
|
val runBenchmark: (() -> Unit)? =
|
||||||
if (BuildConfig.FIXTURE_MODE) {
|
if (BuildConfig.FIXTURE_MODE) {
|
||||||
{
|
{
|
||||||
settingsOpen = false
|
settingsOpen = false
|
||||||
scope.launch {
|
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)
|
buildAndCopyReport(extra)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1777,7 +1805,10 @@ fun SessionScreen(
|
|||||||
input = it
|
input = it
|
||||||
saveDraft(context, summary.id, it.text)
|
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
|
// No longer "(+image)": the images are on screen above this, and a
|
||||||
// placeholder saying so said it in words beside the thing itself.
|
// placeholder saying so said it in words beside the thing itself.
|
||||||
placeholder = { Text("Message") },
|
placeholder = { Text("Message") },
|
||||||
|
|||||||
@@ -112,6 +112,13 @@ pub enum TranscriptItem {
|
|||||||
ClearedNote {
|
ClearedNote {
|
||||||
seq: u64,
|
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 {
|
CompactedNote {
|
||||||
seq: u64,
|
seq: u64,
|
||||||
pre_tokens: Option<u64>,
|
pre_tokens: Option<u64>,
|
||||||
@@ -131,6 +138,7 @@ impl TranscriptItem {
|
|||||||
| Self::CommandRow { seq, .. }
|
| Self::CommandRow { seq, .. }
|
||||||
| Self::Note { seq, .. }
|
| Self::Note { seq, .. }
|
||||||
| Self::ClearedNote { seq }
|
| Self::ClearedNote { seq }
|
||||||
|
| Self::LimitNote { seq, .. }
|
||||||
| Self::CompactedNote { seq, .. } => *seq,
|
| Self::CompactedNote { seq, .. } => *seq,
|
||||||
Self::QuestionCard(card) => card.seq,
|
Self::QuestionCard(card) => card.seq,
|
||||||
}
|
}
|
||||||
@@ -525,6 +533,14 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
items.push(TranscriptItem::ClearedNote { seq });
|
items.push(TranscriptItem::ClearedNote { seq });
|
||||||
items
|
items
|
||||||
}
|
}
|
||||||
|
Event::LimitReached { resets_at } => {
|
||||||
|
let mut items = items.to_vec();
|
||||||
|
items.push(TranscriptItem::LimitNote {
|
||||||
|
seq,
|
||||||
|
resets_at: *resets_at,
|
||||||
|
});
|
||||||
|
items
|
||||||
|
}
|
||||||
Event::Compacted {
|
Event::Compacted {
|
||||||
pre_tokens,
|
pre_tokens,
|
||||||
post_tokens,
|
post_tokens,
|
||||||
|
|||||||
+20
-14
@@ -264,24 +264,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 |
|
| 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 | -- | -- | -- |
|
| 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
|
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
|
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,
|
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
|
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
|
driver, not in iris's layout/text/primitive code. This is consistent
|
||||||
with the earlier software-mode gap being mostly SwiftShader's CPU
|
with the earlier software-mode gap being mostly SwiftShader's CPU
|
||||||
rasterisation cost rather than an iris-specific slowness, but is not
|
rasterisation cost rather than an iris-specific slowness. **Still not
|
||||||
proof of it: a same-mode software `force-gles` run to isolate the
|
proof, and now closed as unanswerable rather than merely untaken**: a
|
||||||
backend crashed for an unrelated reason (SwiftShader's GL path reports
|
same-mode software `force-gles` run to isolate the backend was retried
|
||||||
itself as OpenGL ES 3.0, which has no compute shaders, and iris's device
|
2026-09-05 after fixing the compute-limit crash the first attempt hit,
|
||||||
request assumes them unconditionally) — real scope to fix, not done
|
and hit a second, structural wall instead — SwiftShader's ES 3.0 GL
|
||||||
here — and the two apps' frame populations still differ in kind the same
|
path has no storage-buffer capacity at all, and `shader.wgsl` reads
|
||||||
way the software-mode caveats describe. A real intermittent touch-
|
`var<storage>` buffers unconditionally, so reaching that path needs a
|
||||||
scroll dropout was also reproduced this pass (six consecutive swipes
|
shader rewrite, not a limits fix (RUST.md's I5 box, "The three
|
||||||
produced zero redraws while taps kept working; an identical retry then
|
remaining I5 verifications, closed 2026-09-05," item 2). The
|
||||||
succeeded) and is not explained. RUST.md's I5 box, "Where iris's frame
|
intermittent touch-scroll dropout this pass also reproduced is
|
||||||
time goes, 2026-09-05, the `-gpu host` pass," has the full account. The
|
root-caused and fixed as of the same date (a missed `ACTION_DOWN` on a
|
||||||
iris-vs-Masonry choice itself is still Iris's to make.
|
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.
|
||||||
+189
@@ -8,6 +8,66 @@ 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
|
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.
|
it helps judge the change without the session that made it. Newest first.
|
||||||
|
|
||||||
|
## 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)
|
## 2026-09-05: `AndroidAppState::platform_ready` (RUST.md's P0 box, iris half)
|
||||||
|
|
||||||
Added a second, optional lifecycle method to `iris::android::AndroidAppState`
|
Added a second, optional lifecycle method to `iris::android::AndroidAppState`
|
||||||
@@ -437,3 +497,132 @@ with a number instead of a guess (RUST.md's I5 box).
|
|||||||
blocked handing the frame to the driver," not a confirmed GPU-completion
|
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
|
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.
|
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()`. `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.
|
||||||
@@ -117,6 +117,39 @@ order and what "done" looks like. Tick and date them in place.
|
|||||||
screen wants the same thing (P1's own transcript rows already read
|
screen wants the same thing (P1's own transcript rows already read
|
||||||
their content from a `TextEdit` for the same reason).
|
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.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
- [x] **Benchmarks**, not unit tests, run on demand (2026-09-05; a
|
- [x] **Benchmarks**, not unit tests, run on demand (2026-09-05; a
|
||||||
@@ -385,3 +418,35 @@ do not duplicate it there.
|
|||||||
redundant. Decide after the layout change lands, by writing a button
|
redundant. Decide after the layout change lands, by writing a button
|
||||||
both ways and keeping the one that is shorter to explain; delete the
|
both ways and keeping the one that is shorter to explain; delete the
|
||||||
other rather than keeping two ways.
|
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.
|
||||||
@@ -861,6 +861,58 @@ unspecified rather than getting them wrong:
|
|||||||
conditions, so the remaining slack was accepted rather than chased
|
conditions, so the remaining slack was accepted rather than chased
|
||||||
further.
|
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
|
## For IRIS.md
|
||||||
|
|
||||||
When this lands, copy this entry into `IRIS.md` (newest first):
|
When this lands, copy this entry into `IRIS.md` (newest first):
|
||||||
|
|||||||
+900
-34
@@ -36,6 +36,35 @@ session spending an afternoon on them again.
|
|||||||
|
|
||||||
## Where things stand (2026-09-05)
|
## Where things stand (2026-09-05)
|
||||||
|
|
||||||
|
- **Streaming no longer costs a full rebuild** (P0's box, "Streaming no
|
||||||
|
longer costs a full rebuild" subsection): `iris::widget::List::
|
||||||
|
replace_back`/`clear` plus `transcript_ui::TranscriptScreen::apply`
|
||||||
|
replace the "refold + rebuild the whole ~3,200-row tree per event" path
|
||||||
|
in all three clients. Worst/p99 frame time in the streaming phase
|
||||||
|
dropped roughly 3x on this checkout's emulator (see the box for the
|
||||||
|
exact numbers and their caveats). Two new scripts,
|
||||||
|
`iris/android-app/build-apk.sh` and `iris/android-app/run-bench.sh`, now
|
||||||
|
do the build/install/tap/read-report cycle that used to be typed out by
|
||||||
|
hand each time.
|
||||||
|
- **The three items the dropout-fix pass left open are all closed,
|
||||||
|
2026-09-05** (the `ai-server` build break -- `event_model::
|
||||||
|
Event::LimitReached` -- was already fixed on `rustify` by the time this
|
||||||
|
pass started). Three clean `-gpu host` cold-boot `iris-scroll.sh` runs
|
||||||
|
all scrolled 24/24 swipes (checked directly via clustered `render():`
|
||||||
|
timestamps, not inferred from frame count), and the host-GPU table's
|
||||||
|
iris row is now a best-of-three. `EMU_GPU=software` + `force-gles`
|
||||||
|
still cannot produce a GLES number on this hardware, now for a third,
|
||||||
|
structural reason found this pass: SwiftShader's ES 3.0 GL path reports
|
||||||
|
zero storage-buffer capacity, and `shader.wgsl` reads four
|
||||||
|
`var<storage>` buffers unconditionally -- reaching that path needs a
|
||||||
|
shader rewrite, not a limits fix, so the SwiftShader-Vulkan-vs-GLES
|
||||||
|
question is closed as unanswerable on this hardware rather than
|
||||||
|
answered with a number. A fresh cold-boot `run-bench.sh` reading for
|
||||||
|
P0's bench build (`frames=690 janky%=62.03 p50=19.6ms worst=62.5ms`) is
|
||||||
|
in line with or better than the P0 box's three warm-AVD readings, so
|
||||||
|
that box's "needs a clean cold boot" caveat is resolved too. Full
|
||||||
|
account in I5's box, "The three remaining I5 verifications, closed
|
||||||
|
2026-09-05."
|
||||||
- **The intermittent touch-scroll dropout is root-caused and fixed,
|
- **The intermittent touch-scroll dropout is root-caused and fixed,
|
||||||
2026-09-05.** Not the previously-suspected coalesced first
|
2026-09-05.** Not the previously-suspected coalesced first
|
||||||
`ACTION_MOVE` (ruled out) -- a gesture's `ACTION_DOWN` can land on a
|
`ACTION_MOVE` (ruled out) -- a gesture's `ACTION_DOWN` can land on a
|
||||||
@@ -49,16 +78,12 @@ session spending an afternoon on them again.
|
|||||||
tests (three in `iris/src/sense.rs`'s `drag_arbiter_tests`, one in
|
tests (three in `iris/src/sense.rs`'s `drag_arbiter_tests`, one in
|
||||||
`transcript-ui`'s `selection::tests`, the latter failing on the
|
`transcript-ui`'s `selection::tests`, the latter failing on the
|
||||||
pre-fix code). See this box's own "Touch-scroll dropout root-caused,
|
pre-fix code). See this box's own "Touch-scroll dropout root-caused,
|
||||||
2026-09-05" subsection for the trace and what could and could not be
|
2026-09-05" subsection for the trace. **The aggregate verification an
|
||||||
re-verified this pass -- **this checkout's emulator turned out to be
|
earlier pass could not complete (peer-emulator interference) is now
|
||||||
concurrently in use by another session's P0 benchmark work partway
|
done, 2026-09-05**: three separate cold-`-gpu host`-boot
|
||||||
through verification** (its sandbox server was restarted, wiping this
|
`iris-scroll.sh` runs each scrolled all 24/24 swipes, confirmed by
|
||||||
pass's test session, and its Compose `bench` app took window focus),
|
clustered `render():` timestamps rather than frame count alone -- see
|
||||||
so the "run iris-scroll.sh three times cleanly" and "re-take the
|
this same subsection's "Update, 2026-09-05" paragraph.
|
||||||
host-GPU FrameReport row" pass conditions could not be completed
|
|
||||||
end-to-end this pass. The fix itself is verified by direct, targeted
|
|
||||||
logcat traces taken before that interference began, not by the
|
|
||||||
aggregate script.
|
|
||||||
- **iris no longer requests compute-shader limits it never uses,
|
- **iris no longer requests compute-shader limits it never uses,
|
||||||
2026-09-05.** `adapter.request_device`'s `Limits::default()` asks for
|
2026-09-05.** `adapter.request_device`'s `Limits::default()` asks for
|
||||||
desktop-tier compute limits unconditionally even though nothing in
|
desktop-tier compute limits unconditionally even though nothing in
|
||||||
@@ -68,12 +93,15 @@ session spending an afternoon on them again.
|
|||||||
ES 3.0, no compute at all). New shared `iris_core::device_limits()`
|
ES 3.0, no compute at all). New shared `iris_core::device_limits()`
|
||||||
zeros exactly the six compute fields; `rigs/gpu-probe`'s own mirrored
|
zeros exactly the six compute fields; `rigs/gpu-probe`'s own mirrored
|
||||||
limits were updated and confirm `IRIS DEVICE: ok` on this VM's own
|
limits were updated and confirm `IRIS DEVICE: ok` on this VM's own
|
||||||
Vulkan and GL adapters. **The specific SwiftShader-ES-3.0 crash this
|
Vulkan and GL adapters. **Verified on-device 2026-09-05**: a cold
|
||||||
fixes was not re-verified on-device this pass** -- the cold boot needed
|
`EMU_GPU=software` boot no longer aborts on the compute-limit request
|
||||||
would have force-restarted this checkout's emulator while another
|
this fix targeted -- adapter selection now succeeds -- but device
|
||||||
session had its own app focused on it, so it was left rather than
|
creation still aborts, on a different, unfixed limit
|
||||||
disrupted. See this box's "Fixed, 2026-09-05, later the same day"
|
(`max_storage_buffer_binding_size`, SwiftShader ES 3.0 has no SSBOs
|
||||||
subsection (under the software-mode crash it fixes) and `DECISIONS.md`.
|
either); see this box's "The three remaining I5 verifications, closed
|
||||||
|
2026-09-05" subsection, item 2. See this box's "Fixed, 2026-09-05,
|
||||||
|
later the same day" subsection (under the software-mode crash it fixes)
|
||||||
|
and `DECISIONS.md`.
|
||||||
- **Decided 2026-09-05: iris over Masonry**, by Iris, from the host-GPU
|
- **Decided 2026-09-05: iris over Masonry**, by Iris, from the host-GPU
|
||||||
numbers in I5's box and E1/E2's findings. See the Recommendation's item
|
numbers in I5's box and E1/E2's findings. See the Recommendation's item
|
||||||
3 and `DECISIONS.md`. Next: the remaining screens and the app on iris —
|
3 and `DECISIONS.md`. Next: the remaining screens and the app on iris —
|
||||||
@@ -1515,20 +1543,23 @@ accepted.
|
|||||||
the bare REST fetch (no live stream yet) looked perfect on its own,
|
the bare REST fetch (no live stream yet) looked perfect on its own,
|
||||||
and only *resuming* a stream after it exposed the seam.
|
and only *resuming* a stream after it exposed the seam.
|
||||||
|
|
||||||
**Deliberately left simple, and why** (`app.rs`'s module doc has the
|
**Deliberately left simple at the time, later fixed** (`app.rs`'s
|
||||||
full account): every incoming SSE event refolds the session's whole
|
module doc has the full account): every incoming SSE event used to
|
||||||
item list and rebuilds the entire right-hand widget tree from
|
refold the session's whole item list and rebuild the entire
|
||||||
scratch, rather than reaching for `TranscriptScreen::push_row`'s
|
right-hand widget tree from scratch on every event, rather than
|
||||||
incremental append -- `push_row` can only add a new row, and a
|
reaching for `TranscriptScreen::push_row`'s incremental append --
|
||||||
streaming reply is exactly a row whose text keeps changing after it
|
`push_row` could only add a new row, and a streaming reply is
|
||||||
first appears. Fine at the size a desktop session's conversation
|
exactly a row whose text keeps changing after it first appears.
|
||||||
is; wrong for a long, fast-streaming one, and the real fix needs
|
Fine at the size a desktop session's conversation is; wrong for a
|
||||||
`transcript-ui` to expose updating a row already on screen, which it
|
long, fast-streaming one -- fixed below (this same box's "Streaming
|
||||||
does not yet. The composer's in-progress text is saved and restored
|
no longer costs a full rebuild" entry) by giving `transcript-ui` a
|
||||||
across a rebuild so a reply streaming in while the reader is typing
|
`TranscriptScreen::apply` that updates a row already on screen
|
||||||
a followup doesn't erase it. No history paging (I3's job, reused
|
instead of rebuilding every row around it. `rebuild_transcript`
|
||||||
as-is if this becomes permanent) and no scroll-position preservation
|
still runs the whole tree once, for a freshly loaded/selected
|
||||||
across a rebuild -- both named rather than silently missing.
|
session and for `apply`'s own rare full-rebuild fallback. No history
|
||||||
|
paging (I3's job, reused as-is if this becomes permanent) and no
|
||||||
|
scroll-position preservation across a rebuild -- both still named
|
||||||
|
rather than silently missing, and neither depends on the fix below.
|
||||||
Background network I/O runs on plain `std::thread`s reporting back
|
Background network I/O runs on plain `std::thread`s reporting back
|
||||||
through winit's `EventLoopProxy<AppEvent>` rather than iris's own
|
through winit's `EventLoopProxy<AppEvent>` rather than iris's own
|
||||||
`Tasks`/`task_on`, because `Tasks` only requests a redraw once after
|
`Tasks`/`task_on`, because `Tasks` only requests a redraw once after
|
||||||
@@ -3135,7 +3166,7 @@ silently on real hardware.
|
|||||||
| app | build | GPU mode | frames | janky % | p50 | p90 | p99 | worst | cpu p50 | gpu-wait p50 |
|
| 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 | -- | -- | -- |
|
| 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
|
**Under real GPU rendering, iris's median frame is faster than
|
||||||
Compose's, not 2-3x slower** -- the opposite shape from the
|
Compose's, not 2-3x slower** -- the opposite shape from the
|
||||||
@@ -3179,7 +3210,27 @@ silently on real hardware.
|
|||||||
Practical effect on the table above: the 62-frame iris run was the
|
Practical effect on the table above: the 62-frame iris run was the
|
||||||
one attempt this pass that worked on the first try, so it stands as
|
one attempt this pass that worked on the first try, so it stands as
|
||||||
the number, but a next pass should budget for retries rather than
|
the number, but a next pass should budget for retries rather than
|
||||||
treating a single `iris-scroll.sh` invocation as reliable.
|
treating a single `iris-scroll.sh` invocation as reliable. **Update,
|
||||||
|
2026-09-05, the dropout fix (`e692429`, this box's own "Touch-scroll
|
||||||
|
dropout root-caused" subsection) verified against this table**: with
|
||||||
|
the fix in, three separate `iris-scroll.sh` invocations against a
|
||||||
|
fresh cold `-gpu host` boot each scrolled all 24/24 swipes, no
|
||||||
|
retries needed --
|
||||||
|
frames=450 janky%=48.44 p50=16.3ms p90=22.7ms p99=26.2ms worst=29.7ms cpu_p50=1.0ms gpu_wait_p50=13.1ms
|
||||||
|
frames=462 janky%=50.22 p50=16.9ms p90=25.0ms p99=45.4ms worst=59.0ms cpu_p50=1.3ms gpu_wait_p50=13.9ms
|
||||||
|
frames=439 janky%=46.24 p50=15.7ms p90=23.3ms p99=31.2ms worst=57.4ms cpu_p50=1.2ms gpu_wait_p50=13.2ms
|
||||||
|
Per-swipe coverage was checked directly, not inferred from the frame
|
||||||
|
count alone: a continuous `adb logcat -v time -s iris-android-app:D`
|
||||||
|
capture started before each of the second and third runs (the first
|
||||||
|
run's capture was taken with `logcat -d` after the fact and lost
|
||||||
|
earlier lines to the ring buffer, so it is corroborating rather than
|
||||||
|
direct) shows `render():` timestamps clustering into exactly 24
|
||||||
|
groups per run, one per swipe, each with 22-42 render calls and no
|
||||||
|
gap over 0.3s inside a cluster -- i.e. every one of the 24 swipes
|
||||||
|
produced real redraw activity in all three runs, closing this table's
|
||||||
|
open verification. The third run (lowest janky% and p50) is the
|
||||||
|
"best of three" row now in the table above, replacing the earlier
|
||||||
|
single-attempt 62-frame reading.
|
||||||
|
|
||||||
**Redundant-work check (no optimising, as instructed), host GPU,
|
**Redundant-work check (no optimising, as instructed), host GPU,
|
||||||
`force-gles`.** (1) **Idle redraw: zero**, confirmed fresh this pass
|
`force-gles`.** (1) **Idle redraw: zero**, confirmed fresh this pass
|
||||||
@@ -3581,8 +3632,12 @@ device.
|
|||||||
400 fixture events replay at 20/s through `fold_event` -- the same
|
400 fixture events replay at 20/s through `fold_event` -- the same
|
||||||
fold path a live SSE frame takes in `transcript_client.rs`'s own
|
fold path a live SSE frame takes in `transcript_client.rs`'s own
|
||||||
`apply_event` -- each one triggering `rebuild_transcript`'s full
|
`apply_event` -- each one triggering `rebuild_transcript`'s full
|
||||||
`transcript_ui::build_tree` rebuild, same tradeoff as
|
`transcript_ui::build_tree` rebuild at the time this box was
|
||||||
`TranscriptClient`/`desktop-app`. A battery sampler runs
|
written, same tradeoff as `TranscriptClient`/`desktop-app`. **Fixed
|
||||||
|
2026-09-05, later the same day**: all three now call
|
||||||
|
`TranscriptScreen::apply` instead -- see this same section's
|
||||||
|
"Streaming no longer costs a full rebuild" entry below for the
|
||||||
|
before/after numbers. A battery sampler runs
|
||||||
concurrently on its own `tokio::spawn`d task (not through
|
concurrently on its own `tokio::spawn`d task (not through
|
||||||
`ctx.update`, since a JNI battery read needs no widget-tree access),
|
`ctx.update`, since a JNI battery read needs no widget-tree access),
|
||||||
attaching whichever thread it runs on via a stored `JavaVM` --
|
attaching whichever thread it runs on via a stored `JavaVM` --
|
||||||
@@ -3715,6 +3770,817 @@ device.
|
|||||||
branch was using (`iris/core/src/render/mod.rs`,
|
branch was using (`iris/core/src/render/mod.rs`,
|
||||||
`iris/src/android/render.rs`, `iris/src/default/render.rs`).
|
`iris/src/android/render.rs`, `iris/src/default/render.rs`).
|
||||||
|
|
||||||
|
**Streaming no longer costs a full rebuild, 2026-09-05.** The gap
|
||||||
|
named above and in E4/I5 (`push_row` can only append; a streaming
|
||||||
|
reply is a row that keeps *changing* after it appears) is closed:
|
||||||
|
`iris::widget::List` gained `replace_back` (swap the last row's
|
||||||
|
widget in place, same slot, so a pinned-to-newest list stays pinned
|
||||||
|
and an off-screen replace moves nothing on screen -- two new unit
|
||||||
|
tests, `replacing_the_last_row_stays_pinned_to_the_bottom` and
|
||||||
|
`replacing_the_last_row_out_of_view_does_not_move_visible_rows` in
|
||||||
|
`iris/src/widget/list.rs`) and `clear` (drop every row, the fallback
|
||||||
|
path). `transcript_ui::TranscriptScreen::apply(rsc, old_items,
|
||||||
|
new_items)` diffs `group_tool_runs(old)`/`group_tool_runs(new)`
|
||||||
|
(pure bookkeeping, no widget built doing it) and picks the cheapest
|
||||||
|
update: unchanged (no-op), pure append (`push_row`, same as before),
|
||||||
|
the common streaming case -- only the last row's content changed --
|
||||||
|
rebuilds just that one row and swaps it in with `replace_back`, or
|
||||||
|
(rare: `group_tool_runs` regrouping a row before the tail) a full
|
||||||
|
`List::clear` rebuild, counted by `TranscriptScreen::take_rebuilds()`.
|
||||||
|
Seven new unit tests in `transcript-ui/src/lib.rs`'s `diff_tests`
|
||||||
|
cover all three cases directly against synthetic `Vec<FoldedRow>`s
|
||||||
|
(no widget/Rsc needed for the decision itself). `bench_client.rs`,
|
||||||
|
`transcript_client.rs` and `desktop-app/app.rs` all call `apply` now
|
||||||
|
instead of rebuilding per event; `IRIS.md`'s 2026-09-05 entry has
|
||||||
|
the full API account. `TextEditCtx` also gained `set_with_spans`
|
||||||
|
(`iris/src/widget/text/edit.rs`) -- `set()` plus a fresh span list
|
||||||
|
in one call, since a streamed row's re-rendered markdown needs both
|
||||||
|
to land together.
|
||||||
|
|
||||||
|
**Two new scripts, `iris/android-app/build-apk.sh` and
|
||||||
|
`iris/android-app/run-bench.sh`**, written this pass after repeating
|
||||||
|
the ANDROID_HOME/NDK-export/`cargo ndk`/Gradle-release/keystore/
|
||||||
|
apksigner incantation by hand one too many times. `build-apk.sh
|
||||||
|
[debug|release] [--abi arm64-v8a|x86_64] [--features "..."]` builds
|
||||||
|
the cdylib and the APK and verifies it (badging, and signing for a
|
||||||
|
release build); `run-bench.sh [--apk PATH]` installs on this
|
||||||
|
checkout's own emulator (`emu serial`), taps "Run benchmark" by
|
||||||
|
label (no coordinates), polls logcat for the report line, and prints
|
||||||
|
it. Used for everything below and for the redelivery at the end of
|
||||||
|
this box.
|
||||||
|
|
||||||
|
**Numbers, this checkout's AVD (`ai-app-2`), release, x86_64,
|
||||||
|
`force-gles`, via `run-bench.sh` -- three separate runs, same warm
|
||||||
|
AVD (not a fresh cold boot each time):**
|
||||||
|
|
||||||
|
frames=690 janky%=78.26 p50=26.9ms p90=60.3ms p99=103.4ms worst=130.1ms cpu_p50=7.4ms gpu_wait_p50=15.7ms
|
||||||
|
frames=691 janky%=84.95 p50=28.3ms p90=60.9ms p99=95.2ms worst=120.7ms cpu_p50=5.4ms gpu_wait_p50=18.2ms
|
||||||
|
frames=691 janky%=58.32 p50=18.9ms p90=40.3ms p99=75.6ms worst=101.4ms cpu_p50=3.5ms gpu_wait_p50=12.5ms
|
||||||
|
|
||||||
|
Against this same box's earlier iris-half reading (full rebuild per
|
||||||
|
event, a *different*, freshly-booted x86_64 AVD, host GPU):
|
||||||
|
`frames=372 janky%=56.99 p50=19.5ms p90=219.5ms p99=284.5ms
|
||||||
|
worst=369.3ms cpu_p50=0.4ms gpu_wait_p50=13.9ms`. The tail is what
|
||||||
|
moved: `worst` dropped from 369.3ms to 101–130ms and `p99` from
|
||||||
|
284.5ms to 76–103ms across all three post-fix runs, consistent with
|
||||||
|
removing the periodic full-tree-rebuild stall during the
|
||||||
|
20-events/second streaming phase. `p50`/`cpu_p50` are *not* a clean
|
||||||
|
comparison -- these three runs share one already-warm AVD instance
|
||||||
|
rather than each getting its own fresh cold boot the way the earlier
|
||||||
|
reading did, and `cpu_p50` in particular is noisy run to run (3.5 to
|
||||||
|
7.4ms here) in a way a controlled A/B would need to separate from
|
||||||
|
the code change itself. **What a future pass should do for a clean
|
||||||
|
number**: two fresh cold boots of the same AVD, one per build,
|
||||||
|
`run-bench.sh` on each, nothing else running.
|
||||||
|
|
||||||
|
**The three remaining I5 verifications, closed 2026-09-05.** The
|
||||||
|
`server`/`event_model` drift the previous pass hit (`no variant
|
||||||
|
named 'LimitReached'`) was already fixed upstream on `rustify` by
|
||||||
|
the time this pass started (commit `c07d544`, "carry main's
|
||||||
|
`LimitReached` event") -- `cargo build --release` under `server/`
|
||||||
|
is clean, `./ui-sandbox.sh start` builds and runs. All three items
|
||||||
|
this left open:
|
||||||
|
|
||||||
|
1. **Three clean `iris-scroll.sh` runs against a cold `-gpu host`
|
||||||
|
boot, all 24/24 swipes scrolling in every run** -- results and
|
||||||
|
the per-swipe verification method are recorded just above, in
|
||||||
|
this same "Touch-scroll dropout root-caused" subsection's
|
||||||
|
"Update, 2026-09-05" paragraph, and the host-GPU table above now
|
||||||
|
carries the best-of-three row.
|
||||||
|
2. **`EMU_GPU=software` + `force-gles`, cold boot -- still cannot
|
||||||
|
isolate SwiftShader-Vulkan from SwiftShader-GL, now for a third
|
||||||
|
and structural reason.** Same release build
|
||||||
|
(`transcript-screen force-gles`), fresh cold boot under
|
||||||
|
`EMU_GPU=software`. The earlier compute-limit abort this same
|
||||||
|
box's "Fixed, 2026-09-05, later the same day" paragraph resolved
|
||||||
|
(`iris_core::device_limits()` zeroing `max_compute_*`) no longer
|
||||||
|
fires -- adapter selection now succeeds and picks up
|
||||||
|
SwiftShader's ES 3.0 GL path -- but device creation aborts on a
|
||||||
|
*different* limit immediately after:
|
||||||
|
Abort message: 'Could not get device!: RequestDeviceError { inner: Core(LimitsExceeded(
|
||||||
|
FailedLimit { name: "max_storage_buffer_binding_size", requested: 134217728, allowed: 0 } )) }'
|
||||||
|
i.e. SwiftShader's ES 3.0 reports zero storage-buffer capacity at
|
||||||
|
all -- SSBOs are an ES 3.1+ feature, the same generation gap the
|
||||||
|
compute-limit failure came from, and exactly the trap this box's
|
||||||
|
own "Fixed" paragraph flagged when it rejected
|
||||||
|
`Limits::downlevel_webgl2_defaults()` for zeroing
|
||||||
|
`max_storage_buffers_per_shader_stage` while `shader.wgsl`'s
|
||||||
|
vertex stage reads four `var<storage>` buffers unconditionally.
|
||||||
|
**Closing the open question, one sentence**: whether
|
||||||
|
SwiftShader-Vulkan or GLES-in-general explains the ~80-150ms
|
||||||
|
software-mode numbers cannot be answered on this hardware at
|
||||||
|
all, because `shader.wgsl`'s storage-buffer reads make a GLES
|
||||||
|
path on downlevel (ES 3.0) SwiftShader structurally unreachable
|
||||||
|
rather than merely unmeasured -- reaching it is a shader rewrite
|
||||||
|
(moving those reads off `var<storage>`), which is real scope, not
|
||||||
|
a measurement task, and was not attempted here.
|
||||||
|
3. **P0's bench build, cold-boot `run-bench.sh` number**: same
|
||||||
|
`-gpu host` cold boot as item 1 (re-cold-booted after the
|
||||||
|
`EMU_GPU=software` boot above), `./build-apk.sh release --abi
|
||||||
|
x86_64 --features "transcript-screen force-gles bench"`,
|
||||||
|
`./run-bench.sh`:
|
||||||
|
frames=690 janky%=62.03 p50=19.6ms p90=42.4ms p99=56.5ms worst=62.5ms cpu_p50=4.4ms gpu_wait_p50=12.6ms
|
||||||
|
scroll: 6 cycles (24 swipes), streamed 400/400 fixture events
|
||||||
|
process CPU time over this run: 15394ms
|
||||||
|
peak RSS: 164348kB
|
||||||
|
battery current: mean 900000µA over 21 samples (min 900000, max 900000, the emulator's fixed mocked-charger reading, not a real battery -- see P0's own box)
|
||||||
|
Against the P0 box's own three same-warm-AVD readings
|
||||||
|
(`frames=690/691/691`, `p50` 18.9-28.3ms, `worst` 101-130ms),
|
||||||
|
this cold-boot run's `worst` (62.5ms) and `p99` (56.5ms) are
|
||||||
|
*lower* than any of the three warm-AVD runs, and its `p50`
|
||||||
|
(19.6ms) sits inside their range -- so the P0 box's caveat that
|
||||||
|
the warm-AVD numbers might be inflated by AVD staleness does not
|
||||||
|
hold up under a fresh cold boot; if anything this run is cleaner.
|
||||||
|
`cpu_p50` (4.4ms) is within the 3.5-7.4ms noise band the P0 box
|
||||||
|
already flagged as run-to-run noisy on a shared warm AVD.
|
||||||
|
|
||||||
|
**Redelivered, 2026-09-05.** `./build-apk.sh release --abi
|
||||||
|
arm64-v8a` (arm64-only jniLibs; an earlier step in this same pass
|
||||||
|
had left an x86_64 slice in there from the emulator testing above,
|
||||||
|
removed before this build so the delivered APK matches P0's
|
||||||
|
original arm64-only shape) -- `aapt2 dump badging` confirms
|
||||||
|
`native-code: 'arm64-v8a'` and the same
|
||||||
|
`dev.iris.android.demo.bench` id, `apksigner verify` the same
|
||||||
|
`CN=ai-app` cert as before. Copied over
|
||||||
|
`~/host/bench/iris-bench-arm64.apk`; `~/host/bench/README.md` gained
|
||||||
|
a one-line build-date/commit note so Iris can tell which build she
|
||||||
|
has.
|
||||||
|
|
||||||
|
**iris bench crash on the phone, 2026-09-06.** The delivered APK
|
||||||
|
(`dev.iris.android.demo.bench`, arm64, release) aborted on Iris's own
|
||||||
|
phone (a Pixel, GrapheneOS, Mali GPU) on the very first
|
||||||
|
`surface_changed`: `AndroidRenderer::new` -> `UiRenderNode::new` ->
|
||||||
|
`create_bind_group_layout` -> wgpu's `default_error_handler` panics
|
||||||
|
with `wgpu error: Validation Error`, and Android's crash report
|
||||||
|
truncated the message right there, so the actual validation failure
|
||||||
|
was unknown. Ran fine on the emulator's Vulkan (SwiftShader) and GLES
|
||||||
|
(`force-gles`/virgl) and on the desktop GPU. Nobody on this session
|
||||||
|
has the phone or `adb` access to it; this pass worked from the crash
|
||||||
|
report alone plus reading wgpu-core's own validation source
|
||||||
|
(`wgpu-core-28.0.0/src/{binding_model,device/resource}.rs`, the
|
||||||
|
version this workspace pins).
|
||||||
|
|
||||||
|
**1. Diagnostic, not guesswork -- what actually happens is now
|
||||||
|
visible.** `iris_core::UiRenderNode::new` (`core/src/render/mod.rs`)
|
||||||
|
wraps every `create_bind_group_layout`/pipeline call in three nested
|
||||||
|
wgpu error scopes (one per `ErrorFilter`: `OutOfMemory`,
|
||||||
|
`Validation`, `Internal`), pops them in reverse once creation is
|
||||||
|
done, and returns `Result<Self, String>` -- the `String` is wgpu's
|
||||||
|
own `Display` text for whichever scope caught something, which is
|
||||||
|
already wgpu-core's `format_error` output (`"Validation Error\n\n
|
||||||
|
Caused by:\n ..."`, confirmed by reading
|
||||||
|
`wgpu-28.0.0/src/backend/wgpu_core.rs`'s `format_error` -- the exact
|
||||||
|
text the panic would have printed, just no longer thrown away).
|
||||||
|
`android::render::AndroidRenderer::new` turns a failure into a full
|
||||||
|
report: the adapter's name/backend/driver
|
||||||
|
(`Adapter::get_info`), the limits bind-group-layout validation
|
||||||
|
checks a storage/texture binding against
|
||||||
|
(`max_storage_buffers_per_shader_stage`,
|
||||||
|
`max_sampled_textures_per_shader_stage`, `max_bind_groups`,
|
||||||
|
`max_bindings_per_bind_group`, `max_storage_buffer_binding_size`,
|
||||||
|
`min_storage_buffer_offset_alignment`), and
|
||||||
|
`DownlevelCapabilities.flags` (`Adapter::get_downlevel_capabilities`)
|
||||||
|
-- then wgpu's own error text. `android::view::IrisViewPeer::
|
||||||
|
surface_changed` logs it as one logcat line (`iris renderer init
|
||||||
|
failed: ...`, newlines replaced with ` | `) and shows the full
|
||||||
|
multi-line text on screen: a new `IrisView.showRendererError(String)`
|
||||||
|
(an ordinary instance method Rust calls into via JNI, not a `native`
|
||||||
|
one -- the direction is Rust reaching into Java, the opposite of
|
||||||
|
every `native fn` this view already declares) swaps the activity's
|
||||||
|
whole content for a plain, selectable, scrollable `TextView`, opening
|
||||||
|
with "Copy this text and send it to Iris" (UI_RULES.md: a failure is
|
||||||
|
reported where it happened and says what to do next). Desktop's
|
||||||
|
`UiRenderer::new` keeps panicking on failure (no on-screen fallback
|
||||||
|
exists there) but now with wgpu's full chain as the message, since it
|
||||||
|
no longer relies on wgpu's own handler getting there first.
|
||||||
|
|
||||||
|
**A real, separate reentrancy bug turned up while testing this, and
|
||||||
|
is fixed alongside it.** Calling `Activity::setContentView` directly
|
||||||
|
from inside `surface_changed` deadlocked -- not literally, but hit
|
||||||
|
Rust's `RefCell already borrowed` abort: `setContentView` tears the
|
||||||
|
old view hierarchy down synchronously, which fires `IrisView`'s own
|
||||||
|
`onFocusChanged` *before* `setContentView` returns, straight back
|
||||||
|
into the same `IrisViewPeer` through `on_focus_changed` while
|
||||||
|
android-view's own dispatch (`with_peer` in its `view.rs`) still
|
||||||
|
holds this peer's `RefCell` borrow for the `surface_changed` call in
|
||||||
|
progress. Found by deliberately inducing a validation error (see
|
||||||
|
below) and watching it abort a different way than the crash this
|
||||||
|
pass was fixing. Fixed by moving the Java call into
|
||||||
|
`ctx.push_dynamic_deferred_callback`, which android-view already
|
||||||
|
runs only after dropping the borrow (confirmed by reading
|
||||||
|
`with_peer`'s body) -- the same mechanism `raise_if_enabled` (this
|
||||||
|
file's AccessKit push) already relies on for the identical reason.
|
||||||
|
Left as a comment at the call site rather than only here, since the
|
||||||
|
next thing that reaches into Java from inside a `ViewPeer` callback
|
||||||
|
needs the same warning.
|
||||||
|
|
||||||
|
**2. The audit -- every bind-group-layout entry, checked against
|
||||||
|
wgpu-core's actual validation, not guessed.** `CreateBindGroupLayoutError`
|
||||||
|
(`wgpu-core::binding_model`) has seven variants; the ones a static,
|
||||||
|
no-`count`, no-feature layout like this crate's can hit are
|
||||||
|
`Entry { error: MissingDownlevelFlags(_) }` and
|
||||||
|
`Entry { error: MissingFeatures(_) }`. Walked every entry in
|
||||||
|
`uniform_layout`, `primitive_layout`, `rsc_layout`, `masks_layout`
|
||||||
|
(all four in `UiRenderNode::new`):
|
||||||
|
- `uniform_layout` (group 0): one uniform buffer, `VERTEX|FRAGMENT`.
|
||||||
|
Uniform buffers need no downlevel flag or feature at any
|
||||||
|
visibility. Not it.
|
||||||
|
- `primitive_layout` (group 1, `rects`/`glyphs`): two storage
|
||||||
|
buffers, both `FRAGMENT`-only (confirmed against `shader.wgsl`:
|
||||||
|
`rects`/`glyphs` are read only in `fs_main`). `FRAGMENT`-visible
|
||||||
|
storage buffers need `DownlevelFlags::FRAGMENT_STORAGE`, which
|
||||||
|
every backend in wgpu-hal grants unconditionally for a
|
||||||
|
non-write-only binding (`ty: Storage { read_only: true }` here).
|
||||||
|
Not it.
|
||||||
|
- `rsc_layout` (group 2, atlas/image/sampler): a `D2Array` texture,
|
||||||
|
a `D2` texture, a `NonFiltering` sampler, all `FRAGMENT`, no
|
||||||
|
`count`. `Bt::Texture`'s only feature requirement
|
||||||
|
(`TEXTURE_BINDING_ARRAY`) gates on `count.is_some()`, which none
|
||||||
|
of these set. Not it.
|
||||||
|
- `masks_layout` (group 3, `masks`/`move_offsets`): `masks` is
|
||||||
|
`FRAGMENT`-only (read only in `fs_main`'s mask lookup). But
|
||||||
|
`move_offsets` is `VERTEX | FRAGMENT` -- `shader.wgsl`'s
|
||||||
|
`resolve_move` is called from both `vs_main` (a primitive's own
|
||||||
|
corners) and `fs_main` (a mask's move chain) -- and it is a
|
||||||
|
storage buffer, which is exactly what
|
||||||
|
`wgpu-core/src/device/resource.rs` gates on
|
||||||
|
`DownlevelFlags::VERTEX_STORAGE` whenever `visibility` contains
|
||||||
|
`VERTEX` (confirmed by reading that check directly, not inferring
|
||||||
|
it from the flag's name). **This is the one entry among all four
|
||||||
|
layouts whose validity is device-dependent rather than static.**
|
||||||
|
|
||||||
|
**Named hypothesis: the delivered build forced GLES, and GLES's
|
||||||
|
`VERTEX_STORAGE` is not unconditional the way Vulkan's is.**
|
||||||
|
Read `wgpu-hal-28.0.0`'s two backends' own downlevel-flag
|
||||||
|
construction (`vulkan/adapter.rs`, `gles/adapter.rs`):
|
||||||
|
- **Vulkan** grants `Df::VERTEX_STORAGE` unconditionally for any
|
||||||
|
Vulkan 1.0 device, alongside `COMPUTE_SHADERS`/`FRAGMENT_STORAGE`
|
||||||
|
and others in one unconditional `Df::empty() | ... ` -- there is
|
||||||
|
no `.set(VERTEX_STORAGE, <device check>)` call anywhere in that
|
||||||
|
file. This is why the emulator's SwiftShader-Vulkan run and the
|
||||||
|
desktop's real Vulkan both pass: **on Vulkan, this exact layout
|
||||||
|
cannot fail this check, on any conforming device.**
|
||||||
|
- **GLES** computes it explicitly:
|
||||||
|
`downlevel_flags.set(VERTEX_STORAGE, max_storage_block_size != 0
|
||||||
|
&& max_storage_buffers_per_shader_stage != 0 &&
|
||||||
|
(vertex_shader_storage_blocks != 0 || vertex_ssbo_false_zero))` --
|
||||||
|
i.e. it depends on the driver actually reporting a nonzero
|
||||||
|
`GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS`. This is a known-weak spot
|
||||||
|
on Android GLES drivers specifically (vertex-stage SSBO support
|
||||||
|
lags fragment-stage support even on ES 3.1+ hardware), and this
|
||||||
|
exact document already found the adjacent failure mode once this
|
||||||
|
pass: SwiftShader's ES 3.0 GL path reports zero storage-buffer
|
||||||
|
capacity at all (`max_storage_buffer_binding_size: 0`, this box's
|
||||||
|
item 2 under "The three remaining I5 verifications"). A phone
|
||||||
|
that negotiates a GLES context with no (or driver-buggy)
|
||||||
|
vertex-stage SSBO support hits precisely this: `masks_layout`'s
|
||||||
|
`move_offsets` entry, `MissingDownlevelFlags(VERTEX_STORAGE)`.
|
||||||
|
|
||||||
|
**And the delivered build does force GLES.** `~/repos/
|
||||||
|
ai-app-bench`'s own README (committed alongside the P0 APKs)
|
||||||
|
says so directly: "`force-gles` matches I5's own ... finding: the
|
||||||
|
default Vulkan backend has no adapter under a plain `-gpu host`
|
||||||
|
boot on this AVD" -- true and reasoned correctly *for this VM's
|
||||||
|
emulator*, but `build-apk.sh`'s default `FEATURES` applied the
|
||||||
|
same flag to every arm64 build regardless of target, so the exact
|
||||||
|
same cfg-locked GLES-only path that was a deliberate, documented
|
||||||
|
emulator workaround shipped to a real Mali phone with no way to
|
||||||
|
turn it back to Vulkan short of a rebuild. `force-gles`'s own doc
|
||||||
|
comment (`iris/Cargo.toml`) only ever talks about the emulator
|
||||||
|
("the same build can be measured against SwiftShader's software
|
||||||
|
Vulkan ICD ... or virgl's GLES path") -- real hardware was never
|
||||||
|
the case it was written for.
|
||||||
|
|
||||||
|
**What would confirm or kill this, from the on-screen report
|
||||||
|
alone**: `backend: Gl` (confirms GLES was in fact what ran) and
|
||||||
|
`downlevel flags: DownlevelFlags(...)` *not* containing
|
||||||
|
`VERTEX_STORAGE` in the list. If a future report instead shows
|
||||||
|
`backend: Vulkan` with `VERTEX_STORAGE` present, this hypothesis is
|
||||||
|
wrong and the "Caused by" chain in that same report names the real
|
||||||
|
one directly -- which is the entire point of doing (1) first.
|
||||||
|
|
||||||
|
**Fix applied**: `build-apk.sh`'s default `--features` dropped
|
||||||
|
`force-gles` (now `"transcript-screen bench"`, was
|
||||||
|
`"transcript-screen force-gles bench"`), with a comment explaining
|
||||||
|
why and telling a future emulator-isolation run to pass it back
|
||||||
|
explicitly. This is a build/delivery fix, not a shader rewrite --
|
||||||
|
the shader itself is untouched, since moving `move_offsets` off a
|
||||||
|
storage buffer is real scope this document already declined once
|
||||||
|
this pass for the adjacent SwiftShader-ES-3.0 finding, and the
|
||||||
|
actual defect here is that a debug-only backend override reached a
|
||||||
|
real device, not that the shader's design is wrong. The rebuilt
|
||||||
|
arm64 APK (below) uses the default backend, i.e. Vulkan on a real
|
||||||
|
phone -- if it still fails, (1)'s on-screen report is what comes
|
||||||
|
back this time, not a truncated abort.
|
||||||
|
|
||||||
|
**Verified, this checkout's emulator, cold `emu up`
|
||||||
|
(`ai-app-2`):**
|
||||||
|
- GLES (`force-gles`, matching every prior P0 GLES reading's
|
||||||
|
backend): `run-bench.sh` end to end, no crash, report unchanged
|
||||||
|
in shape from the pre-fix readings above
|
||||||
|
(`frames=691 janky%=58.90 p50=19.2ms p90=43.7ms p99=62.4ms
|
||||||
|
worst=69.3ms cpu_p50=4.7ms gpu_wait_p50=12.6ms`, 24/24 swipes,
|
||||||
|
400/400 streamed events) -- the diagnostic wrapper adds no
|
||||||
|
measurable cost or behaviour change on the success path.
|
||||||
|
- **Induced failure, confirmed the fix works end to end**: added a
|
||||||
|
temporary `count: Some(NonZeroU32::new(2))` to `uniform_layout`'s
|
||||||
|
one entry (an artificial `ArrayUnsupported`/`MissingFeatures`,
|
||||||
|
chosen because it is guaranteed to fail on every backend rather
|
||||||
|
than depending on this VM's flaky adapter enumeration), rebuilt,
|
||||||
|
installed, launched: logcat showed the full one-line report
|
||||||
|
(adapter `Android Emulator OpenGL ES Translator (virgl ...)`,
|
||||||
|
every named limit, the downlevel flags, and wgpu's "Caused by"
|
||||||
|
chain naming `Binding 0 entry is invalid` / the missing
|
||||||
|
`BUFFER_BINDING_ARRAY` feature) and `ui-trace elements` confirmed
|
||||||
|
a `TextView` labelled with that exact report text was on screen
|
||||||
|
-- process alive, no abort. This is also what caught the
|
||||||
|
reentrancy bug above (the first attempt aborted a different way,
|
||||||
|
`RefCell already borrowed`, fixed, then reproduced clean). The
|
||||||
|
temporary `count: Some(...)` was reverted before anything else.
|
||||||
|
- **Vulkan (no `force-gles`) could not be re-verified this pass**:
|
||||||
|
this cold `emu up` enumerates zero Vulkan adapters
|
||||||
|
(`wgpu_core::instance: enabled backend 'Vulkan' has no adapters`,
|
||||||
|
the *unrelated*, already-panicking `.expect("Could not get
|
||||||
|
adapter!")` path this fix does not touch) even with the default
|
||||||
|
`-gpu host` boot the same README quoted above once relied on --
|
||||||
|
matching this document's own prior notes that Vulkan
|
||||||
|
availability on this VM's emulator is flaky across cold boots,
|
||||||
|
not something this pass's diff caused (confirmed by checking the
|
||||||
|
panic message is byte-for-byte the pre-existing
|
||||||
|
`RequestAdapterError` shape, not a new one). Not chased further:
|
||||||
|
it is orthogonal to the crash this pass fixes, and the real test
|
||||||
|
of "does Vulkan work" is the phone itself, not this VM.
|
||||||
|
|
||||||
|
**Checks, all clean**: `cargo fmt --all -- --check` and
|
||||||
|
`cargo clippy --workspace --all-targets` (iris workspace, zero
|
||||||
|
warnings beyond the pre-existing wgpu/winit future-incompat
|
||||||
|
notice), `cargo test --workspace` (unchanged counts, all passing --
|
||||||
|
nothing here touched logic under test), `cargo ndk -t x86_64 -P 26
|
||||||
|
clippy --features "transcript-screen force-gles bench" --lib -- -D
|
||||||
|
warnings` (clean), `cargo ndk -t arm64-v8a -P 26 build --release
|
||||||
|
--features "transcript-screen bench"` (clean, arm64-only
|
||||||
|
`jniLibs`).
|
||||||
|
|
||||||
|
**Redelivered, 2026-09-06.** New arm64 APK (Vulkan, no
|
||||||
|
`force-gles`), same `dev.iris.android.demo.bench` id, same
|
||||||
|
`CN=ai-app` signing cert, copied to `~/host/bench/
|
||||||
|
iris-bench-arm64.apk` and `~/repos/ai-app-bench/iris/build/outputs/
|
||||||
|
apk/release/iris-bench-arm64.apk`; that repo's own README gained a
|
||||||
|
dated entry explaining both changes (the diagnostic and the
|
||||||
|
`force-gles` removal) so Iris can tell this build apart from the
|
||||||
|
one that crashed. **Not done this pass**: confirming the fix on
|
||||||
|
Iris's actual phone -- nobody on this session has it or `adb`
|
||||||
|
access to it, so this is read from the crash report and wgpu's
|
||||||
|
source, verified as far as this VM's tooling reaches, and handed
|
||||||
|
back with a diagnostic that will say the real story on the next
|
||||||
|
run either way.
|
||||||
|
|
||||||
|
**Benchmark v2 (2026-09-06), asked for by Iris after using the
|
||||||
|
Compose build on her phone**: "it doesn't fling like I typically do
|
||||||
|
when scrolling up to find old messages. It should travel way faster
|
||||||
|
which is better for stress testing. You may also want to add typing
|
||||||
|
in the textbox as well and seeing how performant wrapping & pushing
|
||||||
|
the transcript up are, and also keyboard performance if possible."
|
||||||
|
**This is the one spec** -- written once here so both apps' "Run
|
||||||
|
benchmark" implement the identical four phases; a change to a
|
||||||
|
constant below has to be made in both `app/`'s `BenchRun.kt` and
|
||||||
|
`iris/`'s bench client, together, or the two reports stop measuring
|
||||||
|
the same thing while still looking like they do.
|
||||||
|
|
||||||
|
1. **fling.** Starting pinned at the newest end
|
||||||
|
(`listState.scrollToItem(0)` / iris's equivalent), 8 flings away
|
||||||
|
from it (toward older messages) through the list's own real fling
|
||||||
|
path -- Compose: `LazyListState.scroll { with(flingBehavior) {
|
||||||
|
performFling(velocity) } }` using the screen's actual
|
||||||
|
`FlingBehavior` (`ScrollableDefaults.flingBehavior()`, since
|
||||||
|
`TranscriptList`'s `LazyColumn` never overrides it -- **not**
|
||||||
|
`animateScrollBy`, which can only ever cover the fixed distance
|
||||||
|
and time it is given and was Iris's complaint) -- each fling's
|
||||||
|
`initialVelocity = 12,000 px/s`. That number is well above a
|
||||||
|
moderate tween-swipe's implied speed (v1's `SWIPE_PX`/`SWIPE_MS`
|
||||||
|
is roughly 4,500 px/s) and is meant to be a hard, fast flick for
|
||||||
|
stress-testing, per Iris's ask. After each fling, wait for
|
||||||
|
`isScrollInProgress` to clear (cap 3s; `performFling` already
|
||||||
|
suspends until its own decay ends, this is belt-and-suspenders)
|
||||||
|
plus 300ms between flings. Then 8 more flings back toward the
|
||||||
|
newest end (`-12,000 px/s`). Record the list's first visible
|
||||||
|
index/offset at the start, after the 8 outward flings, and at the
|
||||||
|
end, so the two apps' *travel* can be compared directly rather
|
||||||
|
than just their frame times.
|
||||||
|
2. **stream. Unchanged from v1**: 400 tail events at 20/s (20
|
||||||
|
seconds), pinned to the newest end before it starts (the same
|
||||||
|
"Jump to latest" pin `stream-bench.sh` does).
|
||||||
|
3. **type.** Pin to the newest end, focus the composer, show the IME
|
||||||
|
if the platform allows it, then insert this **exact 600-character
|
||||||
|
string** one character per 50ms through the composer's real
|
||||||
|
`TextFieldValue` state (Compose: the same `input` state
|
||||||
|
`onValueChange` writes; iris: whatever holds the composer's text
|
||||||
|
today), then delete it the same way, one character per 50ms.
|
||||||
|
Chosen for long, multisyllabic words specifically so the composer
|
||||||
|
wraps across lines and the transcript above it is pushed upward
|
||||||
|
by a growing box, which is what Iris asked to see measured:
|
||||||
|
|
||||||
|
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!!!
|
||||||
|
|
||||||
|
Report whether the IME was actually open during typing (it should
|
||||||
|
be, from this phase's own show-IME step -- see phase 4 for what
|
||||||
|
to say if the platform refuses to show it at all).
|
||||||
|
4. **keyboard.** Show the IME (`WindowInsetsControllerCompat.show
|
||||||
|
(WindowInsetsCompat.Type.ime())` against the window/view; iris's
|
||||||
|
equivalent through its own shell), wait 1s, hide it, wait 1s;
|
||||||
|
five cycles. Confirm each show/hide with the platform's own
|
||||||
|
answer (Compose: `ViewCompat.getRootWindowInsets(view)
|
||||||
|
?.isVisible(WindowInsetsCompat.Type.ime())`, i.e. the same
|
||||||
|
`WindowInsets.isImeVisible` fact `SessionScreen`'s composer-inset
|
||||||
|
bug fix already relies on) rather than assuming the request
|
||||||
|
worked -- if it is never confirmed shown even once, the report's
|
||||||
|
`keyboard:` line has to say "**keyboard: could not be shown**"
|
||||||
|
in words (UI_RULES: never present an inferred value as a
|
||||||
|
measured one, and design the unknown/failed state before the
|
||||||
|
answer's).
|
||||||
|
|
||||||
|
**Frame accounting**: one recorder, not two. Mark each phase's start
|
||||||
|
in the existing per-frame recorder (Compose: `FrameStats.markPhase
|
||||||
|
(name)`, a list of `(name, frameIndexAtStart, wallClockAtStart)`
|
||||||
|
alongside the existing `total`/`waited`/... arrays) and slice the
|
||||||
|
same `FrameMetrics` samples by phase afterward
|
||||||
|
(`FrameStats.phaseLines`) rather than running a second listener.
|
||||||
|
|
||||||
|
**Report shape**: a `per phase:` block appears once any phase marks
|
||||||
|
exist (empty/absent on an ordinary "Copy" press, which never marks a
|
||||||
|
phase), one entry per phase: frame count, the phase's wall-clock
|
||||||
|
duration, late count/percent (against the same refresh-rate budget
|
||||||
|
the whole-run section uses), p50/p90/p99, and the worst single
|
||||||
|
frame. Then **every existing whole-run section stays, unchanged in
|
||||||
|
shape** -- `frames:`, `where the draw phase went:`, `work since this
|
||||||
|
was last copied:` -- because that is what the emulator-baseline and
|
||||||
|
phone-baseline numbers already on record in this file were read
|
||||||
|
against, and a report that dropped or renamed one of those lines
|
||||||
|
would silently stop being comparable to them. Finally `bench:` gains
|
||||||
|
new lines beside the existing CPU/RSS/battery ones: the fling
|
||||||
|
phase's total travel (start/outward/end index+offset), the typed
|
||||||
|
character count, and the keyboard phase's shown/hidden-confirmed
|
||||||
|
counts (or the "could not be shown" line).
|
||||||
|
|
||||||
|
**Compose half: done, 2026-09-06.** `FrameStats.markPhase`/
|
||||||
|
`phaseLines` (`app/androidApp/src/main/kotlin/com/example/aiapp/
|
||||||
|
FrameStats.kt`), `debugReport`'s new `phaseFrames` parameter
|
||||||
|
(`DebugStats.kt`), and `BenchRun.kt`'s four-phase `run` (fling via
|
||||||
|
`ScrollableDefaults.flingBehavior()` captured in `SessionScreen` and
|
||||||
|
passed down since it needs a `@Composable` call site; type via a new
|
||||||
|
`composerFocus: FocusRequester` attached to the composer's
|
||||||
|
`OutlinedTextField` plus a `setComposerText` callback that writes
|
||||||
|
`input` the same way a keystroke does; keyboard via
|
||||||
|
`WindowInsetsControllerCompat` against `LocalView.current`) are all
|
||||||
|
in. `BenchRun.TYPE_TEXT` is the exact 600-character constant quoted
|
||||||
|
above (verified `.length == 600`). A pre-existing, unrelated break
|
||||||
|
in `MainActivity.kt`'s `benchSessionSummary()` (missing several
|
||||||
|
`SessionSummary` constructor arguments added by a change this pass
|
||||||
|
did not otherwise touch -- confirmed pre-existing by reproducing the
|
||||||
|
same compile failure after stashing this pass's own diff) was fixed
|
||||||
|
alongside this, since it blocked `compileBenchKotlin` outright and
|
||||||
|
is in this session's own `app/` scope.
|
||||||
|
|
||||||
|
Checks all clean: `ktfmtFormat`, `compileDebugKotlin`,
|
||||||
|
`compileBenchKotlin`, `lintDebug`, `lintBench` (both "No issues
|
||||||
|
found"), `testDebugUnitTest`. `grep -n "tap [0-9]" app/*.sh` still
|
||||||
|
has its one pre-existing, unrelated hit.
|
||||||
|
|
||||||
|
**Compose bench v2, emulator smoke run, 2026-09-05** (this
|
||||||
|
checkout's AVD, cold `emu up`, `ui-trace` tap-by-label throughout --
|
||||||
|
the dialog needed a swipe to reach "Run benchmark" below the fold,
|
||||||
|
report read back over `adb logcat`):
|
||||||
|
|
||||||
|
ai-app render report
|
||||||
|
device: sdk_gphone64_x86_64 (Google), Android 16
|
||||||
|
build: release
|
||||||
|
|
||||||
|
transcript:
|
||||||
|
124 events, 26 rows, 58 units loaded
|
||||||
|
viewport 1714px, 2 units visible
|
||||||
|
on screen: the list's own 0px, AssistantMsg 18732px
|
||||||
|
0 tool calls and 0 groups open
|
||||||
|
|
||||||
|
per phase:
|
||||||
|
fling: 1620 frames over 32.3s
|
||||||
|
late: 1537 (94.9%)
|
||||||
|
total p50 20.5ms p90 29.2ms p99 45.9ms
|
||||||
|
worst 61.8ms
|
||||||
|
stream: 1079 frames over 20.6s
|
||||||
|
late: 1037 (96.1%)
|
||||||
|
total p50 21.0ms p90 33.5ms p99 39.3ms
|
||||||
|
worst 51.2ms
|
||||||
|
type: 3568 frames over 61.4s
|
||||||
|
late: 3536 (99.1%)
|
||||||
|
total p50 23.8ms p90 32.1ms p99 38.5ms
|
||||||
|
worst 50.3ms
|
||||||
|
keyboard: 215 frames over 10.0s
|
||||||
|
late: 212 (98.6%)
|
||||||
|
total p50 21.3ms p90 37.6ms p99 48.4ms
|
||||||
|
worst 50.2ms
|
||||||
|
|
||||||
|
frames:
|
||||||
|
6482 frames over 124.3s at 60Hz (16.7ms budget)
|
||||||
|
late: 6322 (97.5%)
|
||||||
|
total p50 21.7ms p90 33.1ms p99 45.3ms
|
||||||
|
gpu p50 17.4ms p90 27.0ms p99 30.6ms
|
||||||
|
|
||||||
|
where the draw phase went:
|
||||||
|
draw phase 1.27ms per frame, of which:
|
||||||
|
the transcript: 0.16ms (measure 0.09, place 0.07, record 0.00)
|
||||||
|
everything else: 1.10ms (87%)
|
||||||
|
|
||||||
|
bench:
|
||||||
|
fling: 8 flings out + 8 back at 12000px/s, travel start=idx=0/off=0px outward=idx=218/off=73px 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: 61192ms
|
||||||
|
peak RSS: 195716kB
|
||||||
|
battery current: mean 900000µA over 125 samples (min 900000, max 900000)
|
||||||
|
|
||||||
|
Read this the same way the v1 emulator smoke run above is read: it
|
||||||
|
proves the harness runs end to end and produces every field this
|
||||||
|
spec asked for, not a phone number -- software rasterisation, and
|
||||||
|
the fixed 900mA battery reading is the emulator's mocked charger
|
||||||
|
again. Two things worth carrying forward: the **fling phase reached
|
||||||
|
index 218** in 8 flings (against v1's `animateScrollBy` loop, which
|
||||||
|
never moved past a handful of indices in the same 8-swipe count),
|
||||||
|
which is the direct evidence the new fling travels "way faster" as
|
||||||
|
asked; and **the emulator's software keyboard toggled and was
|
||||||
|
confirmed by `isImeVisible` all 10 times**, so phase 4 is not a
|
||||||
|
guaranteed "could not be shown" on every platform, only where the
|
||||||
|
IME genuinely refuses. `frames:`'s 6,482-frame, 124.3s total matches
|
||||||
|
the sum of the four phase durations (32.3+20.6+61.4+10.0 ≈ 124.3s),
|
||||||
|
confirming the phase marks partition the whole run rather than
|
||||||
|
overlapping or dropping frames between them.
|
||||||
|
|
||||||
|
**Iris's first real phone report, 2026-09-06** (the redelivered,
|
||||||
|
no-`force-gles` APK above): no crash. Two screenshots, before any
|
||||||
|
touch: headings/links/code/table all render correctly. Four defects
|
||||||
|
found and worked this pass:
|
||||||
|
|
||||||
|
1. **Every glyph disappears on the first tap or scroll; rectangles
|
||||||
|
stay drawn** (the keyboard case is the same thing -- a tap on the
|
||||||
|
composer). **Not root-caused this pass.** Audited `GpuTextures`'
|
||||||
|
atlas-grow/patch path, `ArrBuf`'s resize-on-length-change
|
||||||
|
contract, and the masks/move_offsets/rsc bind-group rebuild logic
|
||||||
|
in `core/src/render/mod.rs` against wgpu's queue-ordering
|
||||||
|
contract -- everything read as spec-correct (a `queue.write_texture`/
|
||||||
|
`write_buffer` issued before a later `queue.submit` is guaranteed
|
||||||
|
visible to it on the same queue, and a dropped `Buffer`/`Texture`/
|
||||||
|
`BindGroup` still in flight is kept alive by wgpu's own tracker).
|
||||||
|
No violation found by static reading; reproducing needs either
|
||||||
|
the phone or a Mali driver trace, neither available this pass.
|
||||||
|
Instrumented for the next report instead: `Device::
|
||||||
|
on_uncaptured_error` is now installed on the Android device
|
||||||
|
(`WgpuErrorLog`, `android::render::AndroidRenderer`), and
|
||||||
|
`IrisViewPeer::render` logs masks/moves-resized, atlas
|
||||||
|
pages-grown and image bind-group creates for the first 10 frames
|
||||||
|
after every `surface_changed` -- exactly the window this bug
|
||||||
|
lands in. The bench screen's new "Diagnostics" button (below)
|
||||||
|
surfaces the error log and adapter identity on demand.
|
||||||
|
2. **Bold words render as blank gaps of the correct advance width**
|
||||||
|
(regular, links, inline code render fine). Fixed by bundling Noto
|
||||||
|
Sans/Noto Sans Mono (regular/bold/italic/bold-italic, static
|
||||||
|
cuts, OFL) into `iris-core` and registering them ahead of the
|
||||||
|
platform's own fonts -- `core/src/primitive/text.rs`'s
|
||||||
|
`TextData::register_bundled_fonts`. Named hypothesis, not
|
||||||
|
confirmed on the phone: the system "Roboto" on a modern Android
|
||||||
|
device is the variable "Roboto Flex," and this crate's glyph
|
||||||
|
path (`TextData::place`) does not apply `Synthesis`/variable-axis
|
||||||
|
correction at all -- a bundled *static* per-style face sidesteps
|
||||||
|
the question rather than answering it. `TextData::font_diagnostics`
|
||||||
|
reports what got resolved; logged once at startup and shown on
|
||||||
|
the Diagnostics page.
|
||||||
|
3. **Text far too small** -- iris had no device-pixel-ratio handling
|
||||||
|
on *either* platform before this pass (grepped for `scale_factor`
|
||||||
|
across the whole crate: zero hits). `DisplayMetrics.density`
|
||||||
|
(Android) / `Window::scale_factor()` (desktop) now divides every
|
||||||
|
physical-pixel number (window size, touch coordinates, the
|
||||||
|
shader's window uniform) down to logical units before it reaches
|
||||||
|
layout, so a `font_size: 16.0` is 16 dp rather than 16 raw device
|
||||||
|
pixels on a ~3x-density phone. Cost a second, real bug found only
|
||||||
|
by measuring on this checkout's emulator after the first fix
|
||||||
|
landed: `android::view::IrisViewPeer::surface_changed`'s call
|
||||||
|
into `UiRenderState::resize` (the layout engine's own notion of
|
||||||
|
the canvas, which every widget's absolute `PixelRegion` is
|
||||||
|
computed against) was still being handed raw physical
|
||||||
|
`width`/`height`, while `AndroidRenderer`'s side of the same
|
||||||
|
resize had already switched to logical -- splitting layout and
|
||||||
|
the shader into two different units. A fixed-size widget (the
|
||||||
|
bench screen's `.height(56)` button row) exposed it at ~40
|
||||||
|
physical px against the ~147px `56 * content_scale` predicts;
|
||||||
|
a proportional (`rest(n)`) size hid it by adapting to whichever
|
||||||
|
total it was given. Both are logical now. **Not fully verified**:
|
||||||
|
a fresh-install emulator screenshot after both fixes shows
|
||||||
|
visibly larger, readable text (`docs/bench/` has neither
|
||||||
|
screenshot committed -- see AGENTS.md on transcripts/screenshots
|
||||||
|
not going in this repo -- but the before/after is described in
|
||||||
|
the commit), and the button row's own height still isn't
|
||||||
|
obviously matching `56 * content_scale` on this run -- worth a
|
||||||
|
second look with `ui-trace show --field box` once there's time,
|
||||||
|
but not a blocker for the magnitude of the original bug (3x too
|
||||||
|
small).
|
||||||
|
4. **Status-bar inset not applied** -- confirmed nothing in this
|
||||||
|
app ever read `insets().top` at all (`android/insets.rs` has
|
||||||
|
carried `Insets.top` since it was written; nothing consumed it).
|
||||||
|
Fixed with a new, generic hook: `AndroidAppState::
|
||||||
|
on_insets_changed(rsc, LogicalInsets)`, called from `render()`
|
||||||
|
exactly when `AndroidUiState::insets()` changes, in logical units
|
||||||
|
matching everything else `content_scale` now divides.
|
||||||
|
`BenchClient::on_insets_changed` rebuilds the root tree with
|
||||||
|
`Padding::top(insets.top)` on the button row -- rebuilding the
|
||||||
|
whole tree rather than one `WidgetPtr` slot's content, because
|
||||||
|
the first attempt (a `Pad` dropped into an unrelated `WidgetPtr`
|
||||||
|
slot with no height override of its own) did not propagate the
|
||||||
|
wrapped span's fixed height correctly, which is what surfaced
|
||||||
|
finding 3's `UiRenderState::resize` bug in the first place.
|
||||||
|
Verified via `ui-trace show --field box`: the button row's top
|
||||||
|
(150 physical px) sits 8px below `statusBarBackground`'s bottom
|
||||||
|
edge (142px) on this checkout's emulator.
|
||||||
|
|
||||||
|
**A named `Diagnostics` control now exists** (RUST.md's own earlier
|
||||||
|
ask): a third button on the bench screen's top row, filling the
|
||||||
|
existing benchmark-report `TextEdit` with adapter identity/backend/
|
||||||
|
driver, font resolution, the atlas's live view count, every
|
||||||
|
uncaptured wgpu error since surface creation, and the frame report
|
||||||
|
-- `android::render::AndroidRenderer::diagnostics_report`. Uses the
|
||||||
|
existing "Copy report" button/clipboard path rather than a second
|
||||||
|
one.
|
||||||
|
|
||||||
|
**Verified this pass, this checkout's emulator** (`EMU_GPU` default,
|
||||||
|
`--features force-gles` -- this cold `emu up` again enumerated zero
|
||||||
|
Vulkan adapters, the same pre-existing flakiness earlier boxes
|
||||||
|
documented, not something this pass's diff caused): `cargo fmt --all
|
||||||
|
-- --check`, `cargo clippy --workspace --all-targets` (zero warnings
|
||||||
|
beyond the pre-existing wgpu future-incompat notice), `cargo test
|
||||||
|
--workspace` (all passing, unchanged pure-logic counts), `cargo ndk
|
||||||
|
-t x86_64 -P 26 check` clean, `./run-bench.sh` end to end
|
||||||
|
(`frames=691`, 24/24 swipes, 400/400 streamed events, no crash),
|
||||||
|
fresh-install screenshots and `ui-trace` box readouts for the four
|
||||||
|
items above. **Not verified this pass**: the actual phone (no
|
||||||
|
access), and item 1's root cause (needs either the phone's next
|
||||||
|
Diagnostics-page report or a Mali trace).
|
||||||
|
|
||||||
|
**Recorded but not fixed this pass** (a follow-up agent takes these,
|
||||||
|
to avoid colliding with this pass's `bench_client.rs`/`view.rs`
|
||||||
|
changes) -- see `IRIS_TODO.md`'s "From the phone, 2026-09-06":
|
||||||
|
swiping has no momentum (stops exactly where the finger releases,
|
||||||
|
unlike Compose's fling), and scrolling down sometimes jitters the
|
||||||
|
text.
|
||||||
|
|
||||||
|
**Redelivered, 2026-09-06, later the same day.** New arm64 APK
|
||||||
|
(Vulkan, no `force-gles`, bundled fonts, content-scale fix,
|
||||||
|
Diagnostics control), same `dev.iris.android.demo.bench` id, same
|
||||||
|
`CN=ai-app` signing cert, copied to `~/host/bench/
|
||||||
|
iris-bench-arm64.apk` and `~/repos/ai-app-bench/iris/build/outputs/
|
||||||
|
apk/release/iris-bench-arm64.apk`; that repo's own README gained a
|
||||||
|
dated entry. Still not confirmed on Iris's actual phone.
|
||||||
|
|
||||||
|
**Redelivered again, 2026-09-06, a later pass.** Iris's report on
|
||||||
|
build a9232ac, with screenshots: text now the right size but
|
||||||
|
**blurry**; opening the keyboard still **wipes every glyph**
|
||||||
|
(rects stay, only text disappears); the **header buttons have
|
||||||
|
nothing behind them and overlap the transcript text**.
|
||||||
|
|
||||||
|
**1. The keyboard wipe.** Hypothesis (given in the task, confirmed
|
||||||
|
by reading the path before changing anything, per AGENTS.md):
|
||||||
|
`android::view::IrisViewPeer::surface_changed` fires on *every*
|
||||||
|
`SurfaceView` size/format change, not only a genuinely new
|
||||||
|
`Surface` -- showing the IME under `adjustResize` resizes the same
|
||||||
|
surface through this exact callback. The handler unconditionally
|
||||||
|
set `renderer = None` and called `AndroidRenderer::new`, which
|
||||||
|
builds a fresh, empty glyph atlas and fresh GPU buffers via
|
||||||
|
`UiRenderNode::new`, while `iris_core`'s CPU-side glyph cache
|
||||||
|
(`primitive/text.rs`) kept the atlas UV coordinates it had already
|
||||||
|
handed out against the *old* atlas -- every glyph then drew from a
|
||||||
|
rectangle pointing into a texture that had just been recreated
|
||||||
|
empty. Confirmed by reading `AndroidRenderer::resize` (already
|
||||||
|
existed, already did none of that -- only `surface.configure` and
|
||||||
|
the window uniform) against what `surface_changed` was actually
|
||||||
|
calling instead. **Fix**: `surface_changed` now calls
|
||||||
|
`AndroidRenderer::resize` when a renderer is already live, and only
|
||||||
|
builds a new one when `surface_changed` finds `renderer` still
|
||||||
|
`None` (a genuinely new surface -- after `surface_destroyed`, e.g.
|
||||||
|
backgrounding). Not independently re-verified against a forced IME
|
||||||
|
resize on this pass's emulator (no display keyboard exercised
|
||||||
|
end-to-end here); the reasoning is a direct code read plus the
|
||||||
|
existing `resize` path already being surface-only, not a
|
||||||
|
screenshot diff -- **the next agent with emulator time should do
|
||||||
|
the before/after screenshot this box originally asked for.**
|
||||||
|
|
||||||
|
**2. The blur.** Root cause: the P0 fix that made text the right
|
||||||
|
*size* (dividing the whole window into a "logical" space, then
|
||||||
|
letting the shader's NDC mapping stretch it back onto the real
|
||||||
|
framebuffer) rasterised each glyph at the small, pre-stretch size
|
||||||
|
and then displayed it stretched onto more physical pixels than it
|
||||||
|
had texels for. **Fix, and the density-independent length unit
|
||||||
|
Iris asked for the same day (IRIS_TODO.md) turned out to be the
|
||||||
|
same fix**: `Len::dp`, resolved against a `density` now carried on
|
||||||
|
`UiRenderState`/`Painter`, replaces the global stretch -- window
|
||||||
|
size, touch and insets are physical pixels throughout again
|
||||||
|
(`WindowInsets`, renamed from `LogicalInsets`), and
|
||||||
|
`TextBuffer::shape` multiplies `font_size`/`line_height` by density
|
||||||
|
before handing them to parley, so the atlas rasterises at the
|
||||||
|
display's real physical resolution. Full design in docs/LAYOUT.md's
|
||||||
|
"Density: `Len::dp`" section and the public-API summary in
|
||||||
|
docs/IRIS.md's 2026-09-06 entry.
|
||||||
|
|
||||||
|
**3. The header.** Only each button's own `rect(...)` painted
|
||||||
|
anything, so the gaps between/around them and the status-bar strip
|
||||||
|
above showed `CLEAR_COLOR` (black) one layer back, and the row's
|
||||||
|
reserved height was three `abs` (now-physical-pixel) button boxes
|
||||||
|
-- smaller than the dp-correct size the transcript below uses,
|
||||||
|
which is what read as "overlap" once the two disagreed. Fixed with
|
||||||
|
a `HEADER_SURFACE` rect stacked behind the whole row and every
|
||||||
|
header size moved onto `dp(...)`.
|
||||||
|
|
||||||
|
**4. Keyboard diagnostics, so Iris can report back even if a
|
||||||
|
keyboard-triggered regression persists.** `on_insets_changed` now
|
||||||
|
edge-triggers ~500ms after `ime_bottom` becomes non-zero, capturing
|
||||||
|
the same report the on-screen Diagnostics button produces, logging
|
||||||
|
it, copying it to the clipboard unprompted, and showing it in a new
|
||||||
|
plain-view overlay (`IrisView.showDiagnosticsOverlay`, Copy/Close)
|
||||||
|
that draws independently of iris's own renderer.
|
||||||
|
|
||||||
|
**Verified this pass**: `cargo fmt --all`, `cargo clippy --workspace
|
||||||
|
--all-targets` and `cargo clippy` on `android-app` (both `-D
|
||||||
|
warnings`, zero beyond the pre-existing `tabs-ui` unused-dependency
|
||||||
|
and wgpu future-incompat notices), `cargo test --workspace` (all
|
||||||
|
passing), `cargo ndk -t arm64-v8a check`/`clippy` for both the
|
||||||
|
`transcript-screen bench` feature set.
|
||||||
|
|
||||||
|
**Then run on this checkout's own emulator** (x86_64 debug,
|
||||||
|
`--features "transcript-screen force-gles bench"` -- this AVD has no
|
||||||
|
Vulkan adapter under a plain `-gpu host` boot, matching every prior
|
||||||
|
emulator finding in this file): `run-bench.sh` end to end, no crash,
|
||||||
|
`frames=534 janky%=79.03 ... cpu_p50=1.3ms`, 24/24 swipes, 400/400
|
||||||
|
streamed events -- unchanged in shape from prior readings, so the
|
||||||
|
diff cost nothing on the success path. **Header background**:
|
||||||
|
screenshot confirms the `HEADER_SURFACE` panel now sits behind all
|
||||||
|
three buttons (`/tmp/bench-after-run.png` this pass). **Keyboard
|
||||||
|
wipe**: forced a real `surface_changed` two ways -- `adb shell wm
|
||||||
|
size 1080x1900` (screenshot before/after, text intact) and actually
|
||||||
|
opening the soft keyboard via `settings put secure
|
||||||
|
show_ime_with_hard_keyboard 1` + tapping the message field
|
||||||
|
(ui-trace confirmed a real resize, elements moved -547px; keyboard
|
||||||
|
visible in the screenshot, text still fully rendered, not wiped).
|
||||||
|
Both are real evidence the reuse-renderer fix works, though neither
|
||||||
|
is the literal before/after diff this box originally asked for --
|
||||||
|
**still worth a deliberate side-by-side screenshot pair in a future
|
||||||
|
pass.**
|
||||||
|
|
||||||
|
**Found during this same verification, not fixed, needs a follow-up
|
||||||
|
pass**: after the keyboard-triggered resize, the top button row
|
||||||
|
appeared to render a **second time**, well below its real position,
|
||||||
|
inside the transcript's scroll area (same colours/text, unmistakably
|
||||||
|
the same three buttons) -- and a tap aimed at the composer's
|
||||||
|
"Message" field landed on "Run benchmark" instead (a second
|
||||||
|
benchmark run started, visible in logcat as two `iris bench report:`
|
||||||
|
lines from one session). Only seen after a resize with the keyboard
|
||||||
|
genuinely open; the plain `wm size` resize screenshot pair did not
|
||||||
|
show it, nor did the fresh-install screenshot before either resize.
|
||||||
|
**Not root-caused this pass** -- time ran out before isolating
|
||||||
|
whether this is the `Span::DOWN` two-phase draw (LAYOUT.md's
|
||||||
|
provisional-then-real placement) leaving a phase-1 primitive
|
||||||
|
retained somewhere it should have been moved from, something
|
||||||
|
specific to the keyboard's `on_insets_changed` rebuild racing a
|
||||||
|
redraw, or unrelated to this pass's changes entirely (not verified
|
||||||
|
against a build predating this session's commits, so do not treat
|
||||||
|
"caused by this pass" as established -- MACHINE.md's pinned rule
|
||||||
|
about not attributing without measuring applies here too). Also
|
||||||
|
noteworthy: `capture_keyboard_diagnostics` never fired in this
|
||||||
|
session (no "iris keyboard diagnostics" log line) despite the
|
||||||
|
keyboard visibly opening -- `on_insets_changed`'s `ime_bottom` may
|
||||||
|
not be populated the way expected on this emulator/API level, or
|
||||||
|
the duplicate-row state above interfered; **also needs a follow-up
|
||||||
|
pass** before relying on the auto-capture on a real phone.
|
||||||
|
|
||||||
|
**Not verified this pass**: anything on Iris's real phone, the
|
||||||
|
two-density crispness check IRIS_TODO.md's unit item asks for, and
|
||||||
|
the two open items just above.
|
||||||
|
|
||||||
|
**Fling and jitter, 2026-09-06.** The two `IRIS_TODO.md` "From the
|
||||||
|
phone" items this box's own text names as follow-ups are fixed --
|
||||||
|
`List::fling`/`VelocityTracker`/`FlingCalculator` (IRIS.md's
|
||||||
|
2026-09-06 entry) and the `DragArbiter` slop-release jump (fixed by
|
||||||
|
applying only the excess past `DRAG_SLOP` on the crossing frame,
|
||||||
|
not the whole pre-threshold drag) -- both wired through
|
||||||
|
`Selection::drag`'s release path, both covered by new unit tests in
|
||||||
|
`iris/src/sense.rs` and `iris/src/widget/list.rs`. **Root-caused by
|
||||||
|
reading `DragArbiter::update` and testing it directly, not by an
|
||||||
|
emulator trace** -- this pass did not open an emulator, so the
|
||||||
|
"trace the list's offset per frame" verification this box's own
|
||||||
|
todo asked for is still open, as is a feel-check of the fling on
|
||||||
|
real touch input. **Benchmark v2's four-phase spec (fling/stream/
|
||||||
|
type/keyboard) in `bench_client.rs` was not attempted this pass** --
|
||||||
|
wiring a real IME show/hide and refresh-rate read through
|
||||||
|
`bench_jni.rs`, and `FrameReport`'s per-phase accounting, is real
|
||||||
|
scope on its own and was left rather than shipped half-verified;
|
||||||
|
the Compose half above is already done and is the reference shape
|
||||||
|
for whoever picks this up. No redelivery this pass.
|
||||||
|
|
||||||
- [ ] **P1 — session screen parity.** History paging backward (with the
|
- [ ] **P1 — session screen parity.** History paging backward (with the
|
||||||
page-boundary healing `client-core` does not have yet, below),
|
page-boundary healing `client-core` does not have yet, below),
|
||||||
`TranscriptSource`-backed cache/server stitching, jump-to-latest,
|
`TranscriptSource`-backed cache/server stitching, jump-to-latest,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
```
|
||||||
@@ -305,6 +305,25 @@ pub enum Event {
|
|||||||
/// it, which is why this is written down rather than left to be inferred
|
/// it, which is why this is written down rather than left to be inferred
|
||||||
/// from a second example that does not exist.
|
/// from a second example that does not exist.
|
||||||
Cleared,
|
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 {
|
Error {
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+1
@@ -1757,6 +1757,7 @@ dependencies = [
|
|||||||
"fxhash",
|
"fxhash",
|
||||||
"image",
|
"image",
|
||||||
"parley",
|
"parley",
|
||||||
|
"pollster",
|
||||||
"swash",
|
"swash",
|
||||||
"wgpu",
|
"wgpu",
|
||||||
]
|
]
|
||||||
|
|||||||
Generated
+1
@@ -1785,6 +1785,7 @@ dependencies = [
|
|||||||
"fxhash",
|
"fxhash",
|
||||||
"image",
|
"image",
|
||||||
"parley",
|
"parley",
|
||||||
|
"pollster",
|
||||||
"swash",
|
"swash",
|
||||||
"wgpu",
|
"wgpu",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
package dev.iris.android.demo;
|
package dev.iris.android.demo;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.ClipData;
|
||||||
|
import android.content.ClipboardManager;
|
||||||
import android.content.Context;
|
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;
|
import org.linebender.android.rustview.RustView;
|
||||||
|
|
||||||
@@ -33,4 +44,112 @@ public final class IrisView extends RustView {
|
|||||||
unregisterInsetsNative(mViewPeer);
|
unregisterInsetsNative(mViewPeer);
|
||||||
super.onDetachedFromWindow();
|
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));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Executable
+90
@@ -0,0 +1,90 @@
|
|||||||
|
#!/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"
|
||||||
|
|
||||||
|
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"
|
||||||
Executable
+64
@@ -0,0 +1,64 @@
|
|||||||
|
#!/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 (24 swipes + a 20s streaming phase) but device speed varies.
|
||||||
|
i=0
|
||||||
|
while [ "$i" -lt 90 ]; do
|
||||||
|
LINE=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null | grep "iris bench report:" || true)
|
||||||
|
if [ -n "$LINE" ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
i=$((i + 1))
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
if [ -z "$LINE" ]; then
|
||||||
|
echo "run-bench.sh: no report after 90s -- check logcat by hand" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
adb -s "$SERIAL" logcat -d -s iris-android-app:I | grep -A 6 "iris bench report:"
|
||||||
@@ -4,17 +4,21 @@
|
|||||||
//! `transcript-ui`'s real screen with no server -- a frame-time comparison
|
//! `transcript-ui`'s real screen with no server -- a frame-time comparison
|
||||||
//! that measures the renderer rather than the data or the network.
|
//! that measures the renderer rather than the data or the network.
|
||||||
//!
|
//!
|
||||||
//! **Reuses `transcript_client.rs`'s shape** (folded items, a full
|
//! **Reuses `transcript_client.rs`'s shape** (folded items, the same
|
||||||
//! `transcript_ui::build_tree` rebuild per event) with the network half
|
//! `TranscriptScreen::apply` incremental update on every event) with the
|
||||||
//! replaced by the checked-in fixture, embedded with `include_str!` --
|
//! network half replaced by the checked-in fixture, embedded with
|
||||||
//! `app/bench-fixture/assets/transcript.jsonl`, 1,915,760 bytes, generated
|
//! `include_str!` -- `app/bench-fixture/assets/transcript.jsonl`,
|
||||||
//! by `app/bench-fixture/generate.py` and never a real transcript (that
|
//! 1,915,760 bytes, generated by `app/bench-fixture/generate.py` and never
|
||||||
//! file's own README). The first 3,200 lines are the opening backlog,
|
//! a real transcript (that file's own README). The first 3,200 lines are
|
||||||
//! folded once through `client_core::transcript_fold::fold_page` exactly
|
//! the opening backlog, folded once through
|
||||||
//! as a real `/transcript` page would be; the remaining ~400 are the
|
//! `client_core::transcript_fold::fold_page` exactly as a real
|
||||||
//! streaming tail, replayed one at a time through `fold_event` -- the same
|
//! `/transcript` page would be (then a full `transcript_ui::build_tree`,
|
||||||
//! fold path a live SSE reply arrives on -- by the "Run benchmark"
|
//! same as any first load); the remaining ~400 are the streaming tail,
|
||||||
//! control below.
|
//! 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 crate::bench_jni::PlatformHandle;
|
||||||
use android_view::jni::{JavaVM, objects::GlobalRef};
|
use android_view::jni::{JavaVM, objects::GlobalRef};
|
||||||
@@ -54,6 +58,12 @@ pub struct BenchClient {
|
|||||||
ui_state: AndroidUiState,
|
ui_state: AndroidUiState,
|
||||||
content: WeakWidget<WidgetPtr>,
|
content: WeakWidget<WidgetPtr>,
|
||||||
report_display: WeakWidget<TextEdit>,
|
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>,
|
screen: Option<transcript_ui::TranscriptScreen>,
|
||||||
items: Vec<TranscriptItem>,
|
items: Vec<TranscriptItem>,
|
||||||
/// The events not yet streamed -- consumed by `start_benchmark`'s own
|
/// The events not yet streamed -- consumed by `start_benchmark`'s own
|
||||||
@@ -64,6 +74,13 @@ pub struct BenchClient {
|
|||||||
platform: Option<Arc<PlatformHandle>>,
|
platform: Option<Arc<PlatformHandle>>,
|
||||||
last_report: Option<String>,
|
last_report: Option<String>,
|
||||||
running: bool,
|
running: bool,
|
||||||
|
/// 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HasAndroidUiState for BenchClient {
|
impl HasAndroidUiState for BenchClient {
|
||||||
@@ -168,27 +185,48 @@ impl AndroidAppState for BenchClient {
|
|||||||
.label("Benchmark report")
|
.label("Benchmark report")
|
||||||
.add(rsc);
|
.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);
|
||||||
let tree = (
|
let tree = (
|
||||||
controls,
|
top_bar,
|
||||||
content.height(rest(2)),
|
content.height(rest(2)),
|
||||||
report_display.height(rest(1)).pad(8),
|
report_display.height(rest(1)).pad(dp(8)),
|
||||||
)
|
)
|
||||||
.span(Dir::DOWN)
|
.span(Dir::DOWN)
|
||||||
.add_strong(rsc)
|
.add_strong(rsc)
|
||||||
.any();
|
.any();
|
||||||
ui_state.set_root(tree);
|
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 {
|
let mut client = Self {
|
||||||
ui_state,
|
ui_state,
|
||||||
content,
|
content,
|
||||||
report_display,
|
report_display,
|
||||||
|
top_bar,
|
||||||
screen: None,
|
screen: None,
|
||||||
items: Vec::new(),
|
items: Vec::new(),
|
||||||
stream_tail: Vec::new(),
|
stream_tail: Vec::new(),
|
||||||
platform: None,
|
platform: None,
|
||||||
last_report: None,
|
last_report: None,
|
||||||
running: false,
|
running: false,
|
||||||
|
keyboard_was_visible: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let (backlog, stream_tail) = parse_fixture();
|
let (backlog, stream_tail) = parse_fixture();
|
||||||
@@ -212,11 +250,84 @@ impl AndroidAppState for BenchClient {
|
|||||||
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>, _render: &mut UiRenderState) -> bool {
|
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>, _render: &mut UiRenderState) -> bool {
|
||||||
false
|
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.
|
||||||
|
///
|
||||||
|
/// **Also the trigger for the keyboard diagnostics capture** (RUST.md's
|
||||||
|
/// P0 box): the IME resizing the surface is exactly the case the
|
||||||
|
/// 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,
|
||||||
|
) {
|
||||||
|
let controls = bench_controls(rsc, insets.top);
|
||||||
|
(self.top_bar)(rsc).set(controls);
|
||||||
|
|
||||||
|
let ime_visible = insets.ime_bottom > 0.0;
|
||||||
|
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>;
|
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))
|
let run_rect = rect(Color::rgb(40, 70, 40))
|
||||||
.on(
|
.on(
|
||||||
CursorSense::click(),
|
CursorSense::click(),
|
||||||
@@ -230,7 +341,7 @@ fn bench_controls(rsc: &mut Rsc) -> WeakWidget {
|
|||||||
wtext("Run benchmark").size(18).text_align(Align::CENTER),
|
wtext("Run benchmark").size(18).text_align(Align::CENTER),
|
||||||
)
|
)
|
||||||
.stack()
|
.stack()
|
||||||
.pad(8)
|
.pad(dp(8))
|
||||||
.add(rsc);
|
.add(rsc);
|
||||||
|
|
||||||
let copy_rect = rect(Color::rgb(50, 50, 60))
|
let copy_rect = rect(Color::rgb(50, 50, 60))
|
||||||
@@ -246,10 +357,33 @@ fn bench_controls(rsc: &mut Rsc) -> WeakWidget {
|
|||||||
wtext("Copy report").size(18).text_align(Align::CENTER),
|
wtext("Copy report").size(18).text_align(Align::CENTER),
|
||||||
)
|
)
|
||||||
.stack()
|
.stack()
|
||||||
.pad(8)
|
.pad(dp(8))
|
||||||
.add(rsc);
|
.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 {
|
impl BenchClient {
|
||||||
@@ -266,6 +400,56 @@ impl BenchClient {
|
|||||||
self.screen = Some(screen);
|
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 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(),
|
||||||
|
};
|
||||||
|
let report = match &self.android_state().renderer {
|
||||||
|
Some(renderer) => renderer.diagnostics_report(&font, &frame_report),
|
||||||
|
None => "iris diagnostics: no renderer yet (no surface)".to_string(),
|
||||||
|
};
|
||||||
|
self.report_display.edit(rsc).set(&report);
|
||||||
|
self.last_report = Some(report);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The keyboard's own diagnostics capture -- see `on_insets_changed`'s
|
||||||
|
/// doc comment. Reuses `show_diagnostics`'s exact report (so it is the
|
||||||
|
/// same text the on-screen `Diagnostics` button produces, plus the
|
||||||
|
/// per-frame log `FrameReport` already keeps around the resize --
|
||||||
|
/// `frame_report.report()` above covers "the frames around the
|
||||||
|
/// resize" without a second accounting mechanism), then does three
|
||||||
|
/// things the button does not: logs it (so a `logcat` pull gets it
|
||||||
|
/// even if nothing on screen does), copies it to the clipboard
|
||||||
|
/// unprompted, and shows it in the shell's plain overlay view, which
|
||||||
|
/// draws independently of iris's own renderer -- the whole point,
|
||||||
|
/// since the renderer is exactly what might be in the wiped state
|
||||||
|
/// this exists to report on.
|
||||||
|
fn capture_keyboard_diagnostics(&mut self, rsc: &mut Rsc) {
|
||||||
|
self.show_diagnostics(rsc);
|
||||||
|
let Some(report) = self.last_report.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
log::info!("iris keyboard diagnostics:\n{report}");
|
||||||
|
let Some(platform) = &self.platform else {
|
||||||
|
log::info!("iris keyboard diagnostics: no platform handle, can't reach the shell");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if platform.copy_to_clipboard("iris keyboard diagnostics", &report) {
|
||||||
|
log::info!("iris keyboard diagnostics: copied to clipboard");
|
||||||
|
} else {
|
||||||
|
log::info!("iris keyboard diagnostics: clipboard copy failed");
|
||||||
|
}
|
||||||
|
platform.show_diagnostics_overlay(&report);
|
||||||
|
}
|
||||||
|
|
||||||
fn copy_report(&mut self) {
|
fn copy_report(&mut self) {
|
||||||
let Some(report) = &self.last_report else {
|
let Some(report) = &self.last_report else {
|
||||||
log::info!("iris bench report: nothing to copy -- run the benchmark first");
|
log::info!("iris bench report: nothing to copy -- run the benchmark first");
|
||||||
@@ -344,8 +528,15 @@ impl BenchClient {
|
|||||||
let mut sent = 0usize;
|
let mut sent = 0usize;
|
||||||
for event in stream_tail.into_iter().take(total) {
|
for event in stream_tail.into_iter().take(total) {
|
||||||
ctx.update(move |state: &mut BenchClient, rsc| {
|
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||||
|
let old_items = state.items.clone();
|
||||||
state.items = fold_event(&state.items, &event);
|
state.items = fold_event(&state.items, &event);
|
||||||
state.rebuild_transcript(rsc);
|
match &state.screen {
|
||||||
|
// The path P0 asked to measure: update only the
|
||||||
|
// row(s) that changed instead of rebuilding all
|
||||||
|
// ~3,200 of them per event.
|
||||||
|
Some(screen) => screen.apply(rsc, &old_items, &state.items),
|
||||||
|
None => state.rebuild_transcript(rsc),
|
||||||
|
}
|
||||||
});
|
});
|
||||||
redraw.request_redraw();
|
redraw.request_redraw();
|
||||||
sent += 1;
|
sent += 1;
|
||||||
|
|||||||
@@ -131,4 +131,31 @@ impl PlatformHandle {
|
|||||||
.ok()?;
|
.ok()?;
|
||||||
Some(())
|
Some(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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** --
|
//! **Reuses `iris/desktop-app`'s `app.rs` shape almost exactly** --
|
||||||
//! `fold_event`/`group_tool_runs`/`fold_page`/`raw_seq` from
|
//! `fold_event`/`group_tool_runs`/`fold_page`/`raw_seq` from
|
||||||
//! `client_core::transcript_fold`, a `generation` counter guarding against
|
//! `client_core::transcript_fold`, a `generation` counter guarding against
|
||||||
//! a stale background response, and a full rebuild of the widget tree on
|
//! a stale background response. What differs is only the redraw
|
||||||
//! every event (same tradeoff, same reason: `push_row` cannot update a row
|
//! mechanism: android-view has no `winit::EventLoopProxy`, so this uses
|
||||||
//! already on screen, and this rig's conversations are small). What
|
//! `iris::task::Tasks::redraw_handle` (new, added alongside this box) to
|
||||||
//! differs is only the redraw mechanism: android-view has no
|
//! request a frame after each `TaskCtx::update` instead of relying on
|
||||||
//! `winit::EventLoopProxy`, so this uses `iris::task::Tasks::redraw_handle`
|
//! `Tasks::spawn`'s single end-of-future redraw -- see that method's own
|
||||||
//! (new, added alongside this box) to request a frame after each
|
//! doc for why.
|
||||||
//! `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::api::{ApiClient, UreqTransport};
|
||||||
use client_core::event_stream::{StreamItem, follow_session_events};
|
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) {
|
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.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) {
|
fn send_message(&mut self, session_id: String, text: String) {
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ edition.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
wgpu = { workspace = true }
|
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 }
|
bytemuck ={ workspace = true }
|
||||||
image = { workspace = true }
|
image = { workspace = true }
|
||||||
parley = { workspace = true }
|
parley = { workspace = true }
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
pub struct Len {
|
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,
|
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 rel: f32,
|
||||||
pub rest: 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 {
|
UiVec2 {
|
||||||
x: self.x.apply_rest(),
|
x: self.x.apply_rest(density),
|
||||||
y: self.y.apply_rest(),
|
y: self.y.apply_rest(density),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,26 +122,43 @@ impl Size {
|
|||||||
impl Len {
|
impl Len {
|
||||||
pub const ZERO: Self = Self {
|
pub const ZERO: Self = Self {
|
||||||
abs: 0.0,
|
abs: 0.0,
|
||||||
|
dp: 0.0,
|
||||||
rel: 0.0,
|
rel: 0.0,
|
||||||
rest: 0.0,
|
rest: 0.0,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const REST: Self = Self {
|
pub const REST: Self = Self {
|
||||||
abs: 0.0,
|
abs: 0.0,
|
||||||
|
dp: 0.0,
|
||||||
rel: 0.0,
|
rel: 0.0,
|
||||||
rest: 1.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 {
|
UiScalar {
|
||||||
rel: self.rel + if self.rest > 0.0 { 1.0 } else { 0.0 },
|
rel: self.rel + if self.rest > 0.0 { 1.0 } else { 0.0 },
|
||||||
abs: self.abs,
|
abs: self.abs + self.dp * density,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn abs(abs: impl UiNum) -> Self {
|
pub fn abs(abs: impl UiNum) -> Self {
|
||||||
Self {
|
Self {
|
||||||
abs: abs.to_f32(),
|
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,
|
rel: 0.0,
|
||||||
rest: 0.0,
|
rest: 0.0,
|
||||||
}
|
}
|
||||||
@@ -125,6 +166,7 @@ impl Len {
|
|||||||
pub fn rel(rel: impl UiNum) -> Self {
|
pub fn rel(rel: impl UiNum) -> Self {
|
||||||
Self {
|
Self {
|
||||||
abs: 0.0,
|
abs: 0.0,
|
||||||
|
dp: 0.0,
|
||||||
rel: rel.to_f32(),
|
rel: rel.to_f32(),
|
||||||
rest: 0.0,
|
rest: 0.0,
|
||||||
}
|
}
|
||||||
@@ -132,6 +174,7 @@ impl Len {
|
|||||||
pub fn rest(ratio: impl UiNum) -> Self {
|
pub fn rest(ratio: impl UiNum) -> Self {
|
||||||
Self {
|
Self {
|
||||||
abs: 0.0,
|
abs: 0.0,
|
||||||
|
dp: 0.0,
|
||||||
rel: 0.0,
|
rel: 0.0,
|
||||||
rest: ratio.to_f32(),
|
rest: ratio.to_f32(),
|
||||||
}
|
}
|
||||||
@@ -144,6 +187,15 @@ pub mod len_fns {
|
|||||||
pub fn abs(abs: impl UiNum) -> Len {
|
pub fn abs(abs: impl UiNum) -> Len {
|
||||||
Len {
|
Len {
|
||||||
abs: abs.to_f32(),
|
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,
|
rel: 0.0,
|
||||||
rest: 0.0,
|
rest: 0.0,
|
||||||
}
|
}
|
||||||
@@ -151,6 +203,7 @@ pub mod len_fns {
|
|||||||
pub fn rel(rel: impl UiNum) -> Len {
|
pub fn rel(rel: impl UiNum) -> Len {
|
||||||
Len {
|
Len {
|
||||||
abs: 0.0,
|
abs: 0.0,
|
||||||
|
dp: 0.0,
|
||||||
rel: rel.to_f32(),
|
rel: rel.to_f32(),
|
||||||
rest: 0.0,
|
rest: 0.0,
|
||||||
}
|
}
|
||||||
@@ -158,14 +211,15 @@ pub mod len_fns {
|
|||||||
pub fn rest(ratio: impl UiNum) -> Len {
|
pub fn rest(ratio: impl UiNum) -> Len {
|
||||||
Len {
|
Len {
|
||||||
abs: 0.0,
|
abs: 0.0,
|
||||||
|
dp: 0.0,
|
||||||
rel: 0.0,
|
rel: 0.0,
|
||||||
rest: ratio.to_f32(),
|
rest: ratio.to_f32(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl_op!(Len Add add; abs rel rest);
|
impl_op!(Len Add add; abs dp rel rest);
|
||||||
impl_op!(Len Sub sub; abs rel rest);
|
impl_op!(Len Sub sub; abs dp rel rest);
|
||||||
|
|
||||||
impl_op!(Size Add add; x y);
|
impl_op!(Size Add add; x y);
|
||||||
impl_op!(Size Sub sub; x y);
|
impl_op!(Size Sub sub; x y);
|
||||||
@@ -187,6 +241,9 @@ impl std::fmt::Display for Len {
|
|||||||
if self.abs != 0.0 {
|
if self.abs != 0.0 {
|
||||||
write!(f, "{} abs;", self.abs)?;
|
write!(f, "{} abs;", self.abs)?;
|
||||||
}
|
}
|
||||||
|
if self.dp != 0.0 {
|
||||||
|
write!(f, "{} dp;", self.dp)?;
|
||||||
|
}
|
||||||
if self.rel != 0.0 {
|
if self.rel != 0.0 {
|
||||||
write!(f, "{} rel;", self.rel)?;
|
write!(f, "{} rel;", self.rel)?;
|
||||||
}
|
}
|
||||||
|
|||||||
+251
-11
@@ -2,14 +2,63 @@ use crate::{Align, GlyphAtlas, GlyphKey, PlacedGlyph, RegionAlign, Textures, UiC
|
|||||||
use parley::{
|
use parley::{
|
||||||
Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, FontStyle, FontWeight,
|
Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, FontStyle, FontWeight,
|
||||||
GenericFamily, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty,
|
GenericFamily, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty,
|
||||||
|
fontique::{Blob, FamilyId},
|
||||||
};
|
};
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
|
use std::sync::Arc;
|
||||||
use swash::{
|
use swash::{
|
||||||
FontRef,
|
FontRef,
|
||||||
scale::{Render, ScaleContext, Source, StrikeWith},
|
scale::{Render, ScaleContext, Source, StrikeWith},
|
||||||
zeno::{Format, Vector},
|
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
|
/// Everything text needs that outlives one string: the font collection, the
|
||||||
/// layout scratch space, the glyph rasteriser and the atlas they fill.
|
/// layout scratch space, the glyph rasteriser and the atlas they fill.
|
||||||
pub struct TextData {
|
pub struct TextData {
|
||||||
@@ -17,15 +66,182 @@ pub struct TextData {
|
|||||||
pub layout_cx: LayoutContext<UiColor>,
|
pub layout_cx: LayoutContext<UiColor>,
|
||||||
scale_cx: ScaleContext,
|
scale_cx: ScaleContext,
|
||||||
pub atlas: GlyphAtlas,
|
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 {
|
impl Default for TextData {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
let mut data = Self {
|
||||||
font_cx: FontContext::new(),
|
font_cx: FontContext::new(),
|
||||||
layout_cx: LayoutContext::new(),
|
layout_cx: LayoutContext::new(),
|
||||||
scale_cx: ScaleContext::new(),
|
scale_cx: ScaleContext::new(),
|
||||||
atlas: GlyphAtlas::default(),
|
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`
|
/// `set_spans` forces `shaped` to `None` directly, the same way `edit`
|
||||||
/// does, since spans change far less often than a naive equality check
|
/// does, since spans change far less often than a naive equality check
|
||||||
/// on the whole `Vec` would cost to compute every frame.
|
/// on the whole `Vec` would cost to compute every frame.
|
||||||
shaped: Option<(TextAttrs, Option<f32>)>,
|
shaped: Option<(TextAttrs, Option<f32>, f32)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TextBuffer {
|
impl TextBuffer {
|
||||||
@@ -215,19 +431,42 @@ impl TextBuffer {
|
|||||||
Vec2::new(self.layout.width(), self.layout.height())
|
Vec2::new(self.layout.width(), self.layout.height())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lay the text out, unless it is already laid out for these attributes and
|
/// Lay the text out, unless it is already laid out for these
|
||||||
/// this width.
|
/// attributes, this width and this density.
|
||||||
pub fn shape(&mut self, data: &mut TextData, attrs: &TextAttrs, width: Option<f32>) {
|
///
|
||||||
if self.shaped.as_ref() == Some(&(attrs.clone(), width)) {
|
/// **`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;
|
return;
|
||||||
}
|
}
|
||||||
let mut builder = data
|
let mut builder = data
|
||||||
.layout_cx
|
.layout_cx
|
||||||
.ranged_builder(&mut data.font_cx, &self.text, 1.0, true);
|
.ranged_builder(&mut data.font_cx, &self.text, 1.0, true);
|
||||||
builder.push_default(StyleProperty::FontFamily(attrs.family.family()));
|
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(
|
builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute(
|
||||||
attrs.line_height,
|
attrs.line_height * density,
|
||||||
)));
|
)));
|
||||||
builder.push_default(StyleProperty::Brush(attrs.color));
|
builder.push_default(StyleProperty::Brush(attrs.color));
|
||||||
for span in &self.spans {
|
for span in &self.spans {
|
||||||
@@ -239,7 +478,7 @@ impl TextBuffer {
|
|||||||
builder.push(StyleProperty::FontFamily(family.family()), range.clone());
|
builder.push(StyleProperty::FontFamily(family.family()), range.clone());
|
||||||
}
|
}
|
||||||
if let Some(size) = span.font_size {
|
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 {
|
if span.bold {
|
||||||
builder.push(StyleProperty::FontWeight(FontWeight::BOLD), range.clone());
|
builder.push(StyleProperty::FontWeight(FontWeight::BOLD), range.clone());
|
||||||
@@ -255,7 +494,7 @@ impl TextBuffer {
|
|||||||
self.layout.break_all_lines(width);
|
self.layout.break_all_lines(width);
|
||||||
self.layout
|
self.layout
|
||||||
.align(Alignment::Start, AlignmentOptions::default());
|
.align(Alignment::Start, AlignmentOptions::default());
|
||||||
self.shaped = Some((attrs.clone(), width));
|
self.shaped = Some((attrs.clone(), width, density));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,8 +611,9 @@ impl TextData {
|
|||||||
attrs: &TextAttrs,
|
attrs: &TextAttrs,
|
||||||
width: Option<f32>,
|
width: Option<f32>,
|
||||||
textures: &mut Textures,
|
textures: &mut Textures,
|
||||||
|
density: f32,
|
||||||
) -> RenderedText {
|
) -> RenderedText {
|
||||||
buffer.shape(self, attrs, width);
|
buffer.shape(self, attrs, width, density);
|
||||||
let glyphs = self.place(buffer, textures);
|
let glyphs = self.place(buffer, textures);
|
||||||
RenderedText {
|
RenderedText {
|
||||||
glyphs: std::sync::Arc::new(glyphs),
|
glyphs: std::sync::Arc::new(glyphs),
|
||||||
|
|||||||
+150
-18
@@ -4,6 +4,7 @@ use crate::{
|
|||||||
util::{HashMap, Vec2},
|
util::{HashMap, Vec2},
|
||||||
};
|
};
|
||||||
use data::WindowUniform;
|
use data::WindowUniform;
|
||||||
|
use pollster::FutureExt;
|
||||||
use wgpu::{
|
use wgpu::{
|
||||||
util::{BufferInitDescriptor, DeviceExt},
|
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 {
|
pub struct UiRenderNode {
|
||||||
uniform_group: BindGroup,
|
uniform_group: BindGroup,
|
||||||
primitive_layout: BindGroupLayout,
|
primitive_layout: BindGroupLayout,
|
||||||
@@ -152,7 +204,7 @@ impl UiRenderNode {
|
|||||||
queue: &Queue,
|
queue: &Queue,
|
||||||
ui: &mut UiData,
|
ui: &mut UiData,
|
||||||
ui_render: &mut UiRenderState,
|
ui_render: &mut UiRenderState,
|
||||||
) {
|
) -> FrameUpdateStats {
|
||||||
self.active.clear();
|
self.active.clear();
|
||||||
for (i, primitives) in ui_render.layers.iter_mut() {
|
for (i, primitives) in ui_render.layers.iter_mut() {
|
||||||
self.active.push(i);
|
self.active.push(i);
|
||||||
@@ -236,6 +288,10 @@ impl UiRenderNode {
|
|||||||
if rebuild_main {
|
if rebuild_main {
|
||||||
self.rsc_group = Self::rsc_group(device, &self.rsc_layout, &self.textures);
|
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
|
/// 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));
|
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 {
|
let shader = device.create_shader_module(ShaderModuleDescriptor {
|
||||||
label: Some("UI Shape Shader"),
|
label: Some("UI Shape Shader"),
|
||||||
source: ShaderSource::Wgsl(SHAPE_SHADER.into()),
|
source: ShaderSource::Wgsl(SHAPE_SHADER.into()),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Seeded from the surface's own size, not `WindowUniform::default()`
|
// Seeded from the caller's own reported size, not
|
||||||
// (0, 0): the vertex shader divides by `window.dim` to reach clip
|
// `WindowUniform::default()` (0, 0): the vertex shader divides by
|
||||||
// space, so a window this buffer disagrees with means every
|
// `window.dim` to reach clip space, so a window this buffer
|
||||||
// primitive's position is NaN/Inf and is dropped before
|
// disagrees with means every primitive's position is NaN/Inf and is
|
||||||
// rasterization -- the clear colour still reaches the screen (the
|
// dropped before rasterization -- the clear colour still reaches
|
||||||
// pass runs regardless) while nothing drawn on top of it ever does.
|
// the screen (the pass runs regardless) while nothing drawn on top
|
||||||
// winit's backend gets away with the old default because winit
|
// of it ever does. winit's backend gets away with the old default
|
||||||
// fires an initial `WindowEvent::Resized` that calls `resize()`
|
// because winit fires an initial `WindowEvent::Resized` that calls
|
||||||
// before the first frame; android-view has no such automatic
|
// `resize()` before the first frame; android-view has no such
|
||||||
// event, so `AndroidRenderer::new` built a node whose window buffer
|
// automatic event, so `AndroidRenderer::new` built a node whose
|
||||||
// was never corrected -- this is I2's "nothing draws" bug (RUST.md).
|
// window buffer was never corrected -- this is I2's "nothing draws"
|
||||||
let window_uniform = WindowUniform {
|
// bug (RUST.md).
|
||||||
width: config.width as f32,
|
//
|
||||||
height: config.height as f32,
|
// **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 {
|
let window_buffer = device.create_buffer_init(&BufferInitDescriptor {
|
||||||
label: Some("window"),
|
label: Some("window"),
|
||||||
@@ -373,7 +472,18 @@ impl UiRenderNode {
|
|||||||
cache: None,
|
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,
|
uniform_group,
|
||||||
primitive_layout,
|
primitive_layout,
|
||||||
rsc_layout,
|
rsc_layout,
|
||||||
@@ -387,7 +497,7 @@ impl UiRenderNode {
|
|||||||
move_offsets,
|
move_offsets,
|
||||||
masks_layout,
|
masks_layout,
|
||||||
masks_group,
|
masks_group,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bind_group_0(
|
fn bind_group_0(
|
||||||
@@ -554,4 +664,26 @@ impl UiRenderNode {
|
|||||||
pub fn take_image_bind_group_creates(&mut self) -> u64 {
|
pub fn take_image_bind_group_creates(&mut self) -> u64 {
|
||||||
self.textures.take_bind_group_creates()
|
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,
|
||||||
}
|
}
|
||||||
@@ -67,6 +67,12 @@ pub struct GpuTextures {
|
|||||||
/// unchanging image list is zero, the same way `UiRenderState`'s
|
/// unchanging image list is zero, the same way `UiRenderState`'s
|
||||||
/// `draw_count`/`region_mut_count` prove the layout side.
|
/// `draw_count`/`region_mut_count` prove the layout side.
|
||||||
bind_group_creates: u64,
|
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 {
|
impl GpuTextures {
|
||||||
@@ -226,6 +232,7 @@ impl GpuTextures {
|
|||||||
/// array's view, which invalidates every bind group that referenced it,
|
/// array's view, which invalidates every bind group that referenced it,
|
||||||
/// so this also rebuilds all of them before returning.
|
/// so this also rebuilds all of them before returning.
|
||||||
fn grow_array(&mut self, rsc_layout: &BindGroupLayout) {
|
fn grow_array(&mut self, rsc_layout: &BindGroupLayout) {
|
||||||
|
self.pages_grown += 1;
|
||||||
let new_capacity = self.array_capacity * 2;
|
let new_capacity = self.array_capacity * 2;
|
||||||
let new_texture = Self::create_array_texture(&self.device, new_capacity);
|
let new_texture = Self::create_array_texture(&self.device, new_capacity);
|
||||||
if self.page_count > 0 {
|
if self.page_count > 0 {
|
||||||
@@ -392,6 +399,7 @@ impl GpuTextures {
|
|||||||
sampler,
|
sampler,
|
||||||
null_view,
|
null_view,
|
||||||
bind_group_creates: 0,
|
bind_group_creates: 0,
|
||||||
|
pages_grown: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,6 +410,12 @@ impl GpuTextures {
|
|||||||
std::mem::take(&mut self.bind_group_creates)
|
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 {
|
pub fn array_view(&self) -> &TextureView {
|
||||||
&self.array_view
|
&self.array_view
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,8 +165,10 @@ impl<'a> Painter<'a> {
|
|||||||
attrs: &TextAttrs,
|
attrs: &TextAttrs,
|
||||||
width: Option<f32>,
|
width: Option<f32>,
|
||||||
) -> RenderedText {
|
) -> RenderedText {
|
||||||
|
let density = self.state.density;
|
||||||
let ui = self.rsc.ui_mut();
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draw a laid-out string: one quad per glyph, all sampling the atlas.
|
/// Draw a laid-out string: one quad per glyph, all sampling the atlas.
|
||||||
@@ -210,6 +212,12 @@ impl<'a> Painter<'a> {
|
|||||||
self.state.output_size
|
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 {
|
pub fn px_size(&mut self) -> Vec2 {
|
||||||
self.region.size().to_abs(self.state.output_size)
|
self.region.size().to_abs(self.state.output_size)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ pub struct UiRenderState {
|
|||||||
pub active: HashMap<WidgetId, ActiveData>,
|
pub active: HashMap<WidgetId, ActiveData>,
|
||||||
pub layers: PrimitiveLayers,
|
pub layers: PrimitiveLayers,
|
||||||
pub(super) output_size: Vec2,
|
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>,
|
old_root: Option<WidgetId>,
|
||||||
resized: bool,
|
resized: bool,
|
||||||
@@ -35,6 +41,7 @@ impl UiRenderState {
|
|||||||
active: Default::default(),
|
active: Default::default(),
|
||||||
layers: Default::default(),
|
layers: Default::default(),
|
||||||
output_size: Vec2::ZERO,
|
output_size: Vec2::ZERO,
|
||||||
|
density: 1.0,
|
||||||
old_root: None,
|
old_root: None,
|
||||||
resized: false,
|
resized: false,
|
||||||
draw_started: Default::default(),
|
draw_started: Default::default(),
|
||||||
@@ -60,6 +67,20 @@ impl UiRenderState {
|
|||||||
self.resized = true;
|
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) {
|
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
|
// safety mechanism for memory leaks; might wanna return a result instead so user can
|
||||||
// decide whether to panic or not
|
// decide whether to panic or not
|
||||||
@@ -311,7 +332,7 @@ impl UiRenderState {
|
|||||||
};
|
};
|
||||||
let from = active
|
let from = active
|
||||||
.size
|
.size
|
||||||
.to_uivec2()
|
.to_uivec2(self.density)
|
||||||
.align(RegionAlign::TOP_LEFT)
|
.align(RegionAlign::TOP_LEFT)
|
||||||
.within(&active.region);
|
.within(&active.region);
|
||||||
let slot = active.move_slot;
|
let slot = active.move_slot;
|
||||||
|
|||||||
+18
-18
@@ -15,23 +15,19 @@
|
|||||||
//! +-----------+--------------------------------------+
|
//! +-----------+--------------------------------------+
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! **Deliberately left simple, and why**: every incoming SSE event refolds
|
//! **Incoming SSE events go through `TranscriptScreen::apply`**, not a
|
||||||
//! the *entire* transcript (`client_core::transcript_fold::fold_event` is
|
//! full rebuild: `client_core::transcript_fold::fold_event` folds the new
|
||||||
//! already `O(items)` and a desktop session's conversation is small) and
|
//! item list as before, then `apply` updates only the row(s) that actually
|
||||||
//! rebuilds the whole right-hand widget tree from scratch, rather than
|
//! changed (almost always the one still-open assistant message a delta
|
||||||
//! reaching for `TranscriptScreen::push_row`'s incremental append.
|
//! landed in) instead of rebuilding the whole right-hand widget tree from
|
||||||
//! `push_row` cannot update a row already on screen -- only append a new
|
//! scratch. `rebuild_transcript` still runs the whole tree once, for a
|
||||||
//! one -- and a streaming assistant reply is exactly a row whose *text*
|
//! freshly loaded/selected session and for `apply`'s own rare
|
||||||
//! keeps changing after it first appears (see `transcript-ui`'s own doc on
|
//! full-rebuild fallback (a `group_tool_runs` regroup touching a row
|
||||||
//! `fold_event` folding deltas into one growing item). A full rebuild
|
//! before the tail). The composer's in-progress text survives a rebuild
|
||||||
//! shows that growth correctly at the cost of redrawing everything each
|
//! (`rebuild_transcript`'s `in_progress` local) since the user typing a
|
||||||
//! time; fine for this proof, wrong for a long, fast-streaming transcript
|
//! followup while a reply streams in is the one case a naive rebuild
|
||||||
//! -- the incremental path that fixes it needs `transcript-ui` to expose
|
//! would otherwise lose data on -- `apply`'s own path never touches the
|
||||||
//! updating a row in place, which it does not yet. The composer's
|
//! composer at all, so this only matters on the fallback.
|
||||||
//! 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.
|
|
||||||
//!
|
//!
|
||||||
//! Background network I/O (`client_core::api`/`event_stream`, both
|
//! Background network I/O (`client_core::api`/`event_stream`, both
|
||||||
//! blocking by design -- see `client-core`'s `Cargo.toml`) runs on plain
|
//! blocking by design -- see `client-core`'s `Cargo.toml`) runs on plain
|
||||||
@@ -209,8 +205,12 @@ impl DefaultAppState for Client {
|
|||||||
event,
|
event,
|
||||||
} => {
|
} => {
|
||||||
if self.current(&session_id, generation) {
|
if self.current(&session_id, generation) {
|
||||||
|
let old_items = self.items.clone();
|
||||||
self.items = fold_event(&self.items, &event);
|
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 {
|
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);
|
let mut span = Span::empty(Dir::DOWN);
|
||||||
span.push(text);
|
span.push(text);
|
||||||
span.push(img);
|
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 {
|
} else {
|
||||||
wtext(row_text(i))
|
wtext(row_text(i))
|
||||||
.wrap(true)
|
.wrap(true)
|
||||||
.color(text_color)
|
.color(text_color)
|
||||||
.pad(8.0)
|
.pad(dp(8.0))
|
||||||
.background(rect(tint))
|
.background(rect(tint))
|
||||||
.add_strong(rsc)
|
.add_strong(rsc)
|
||||||
.any()
|
.any()
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ mod view;
|
|||||||
pub use insets::Insets;
|
pub use insets::Insets;
|
||||||
pub use render::AndroidRenderer;
|
pub use render::AndroidRenderer;
|
||||||
pub use view::{
|
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
|
/// Registers the extra native methods this backend needs beyond what
|
||||||
|
|||||||
+238
-7
@@ -48,10 +48,65 @@ pub struct AndroidRenderer {
|
|||||||
config: SurfaceConfiguration,
|
config: SurfaceConfiguration,
|
||||||
encoder: CommandEncoder,
|
encoder: CommandEncoder,
|
||||||
pub ui: UiRenderNode,
|
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 {
|
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
|
// `force-gles` (RUST.md's I5 "Where iris's frame time goes") swaps
|
||||||
// the software-Vulkan (SwiftShader) path for GLES/virgl on the same
|
// the software-Vulkan (SwiftShader) path for GLES/virgl on the same
|
||||||
// build, to isolate whether the backend itself explains the frame
|
// build, to isolate whether the backend itself explains the frame
|
||||||
@@ -84,6 +139,18 @@ impl AndroidRenderer {
|
|||||||
.block_on()
|
.block_on()
|
||||||
.expect("Could not get adapter!");
|
.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
|
// Same request as the winit backend's `UiRenderer::new` -- no
|
||||||
// binding-array features, see TEXTURES.md's "Recommended shape".
|
// binding-array features, see TEXTURES.md's "Recommended shape".
|
||||||
// `iris_core::device_limits()` is shared between the two backends;
|
// `iris_core::device_limits()` is shared between the two backends;
|
||||||
@@ -96,6 +163,30 @@ impl AndroidRenderer {
|
|||||||
.block_on()
|
.block_on()
|
||||||
.expect("Could not get device!");
|
.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_caps = surface.get_capabilities(&adapter);
|
||||||
let surface_format = surface_caps
|
let surface_format = surface_caps
|
||||||
.formats
|
.formats
|
||||||
@@ -117,16 +208,121 @@ impl AndroidRenderer {
|
|||||||
surface.configure(&device, &config);
|
surface.configure(&device, &config);
|
||||||
|
|
||||||
let encoder = Self::create_encoder(&device);
|
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,
|
surface,
|
||||||
device,
|
device,
|
||||||
queue,
|
queue,
|
||||||
config,
|
config,
|
||||||
encoder,
|
encoder,
|
||||||
ui,
|
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 {
|
fn create_encoder(device: &Device) -> CommandEncoder {
|
||||||
@@ -135,8 +331,31 @@ impl AndroidRenderer {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update(&mut self, ui: &mut UiData, render: &mut UiRenderState) {
|
/// Returns what changed this frame -- see `FrameDiagnostics`'s doc
|
||||||
self.ui.update(&self.device, &self.queue, ui, render);
|
/// 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
|
/// Draws and presents one frame, returning the time spent in
|
||||||
@@ -179,15 +398,27 @@ impl AndroidRenderer {
|
|||||||
submit_start.elapsed()
|
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 {
|
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) {
|
pub fn resize(&mut self, width: u32, height: u32) {
|
||||||
self.config.width = width;
|
self.config.width = width;
|
||||||
self.config.height = height;
|
self.config.height = height;
|
||||||
self.surface.configure(&self.device, &self.config);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+261
-15
@@ -4,7 +4,11 @@ use accesskit_android::Adapter as AccessAdapter;
|
|||||||
use android_view::{
|
use android_view::{
|
||||||
AccessibilityNodeInfo, AccessibilityNodeProvider, Bundle, CallbackCtx, Context,
|
AccessibilityNodeInfo, AccessibilityNodeProvider, Bundle, CallbackCtx, Context,
|
||||||
InputConnection, KeyEvent, MotionEvent, Rect, View, ViewPeer,
|
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},
|
ndk::event::{Keycode, MotionAction},
|
||||||
};
|
};
|
||||||
// `marker::Sized` explicitly: `crate::prelude::*` below also brings in the
|
// `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
|
/// `Option` because a `SurfaceView`'s surface does not outlive backgrounding
|
||||||
/// the way a winit `Window` does -- `surfaceDestroyed`/`surfaceCreated` can
|
/// the way a winit `Window` does -- `surfaceDestroyed`/`surfaceCreated` can
|
||||||
/// happen any number of times over the life of one `IrisViewPeer`.
|
/// 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 struct AndroidUiState {
|
||||||
pub root: Option<StrongWidget>,
|
pub root: Option<StrongWidget>,
|
||||||
pub renderer: Option<AndroidRenderer>,
|
pub renderer: Option<AndroidRenderer>,
|
||||||
@@ -62,10 +70,41 @@ pub struct AndroidUiState {
|
|||||||
/// because `dumpsys gfxinfo` cannot see a `SurfaceView`'s own
|
/// because `dumpsys gfxinfo` cannot see a `SurfaceView`'s own
|
||||||
/// GPU-drawn frames at all. See `iris_core::FrameReport`'s own doc.
|
/// GPU-drawn frames at all. See `iris_core::FrameReport`'s own doc.
|
||||||
pub frame_report: FrameReport,
|
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 {
|
impl AndroidUiState {
|
||||||
fn new(shared: Rc<RefCell<Shared>>) -> Self {
|
fn new(shared: Rc<RefCell<Shared>>, content_scale: f32) -> Self {
|
||||||
Self {
|
Self {
|
||||||
root: None,
|
root: None,
|
||||||
renderer: None,
|
renderer: None,
|
||||||
@@ -78,6 +117,8 @@ impl AndroidUiState {
|
|||||||
access_adapter: Default::default(),
|
access_adapter: Default::default(),
|
||||||
access: AccessTree::new(),
|
access: AccessTree::new(),
|
||||||
frame_report: FrameReport::new(),
|
frame_report: FrameReport::new(),
|
||||||
|
content_scale,
|
||||||
|
last_insets: Insets::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +161,48 @@ pub trait AndroidAppState: HasAndroidUiState {
|
|||||||
/// storing them has no effect on that mechanism.
|
/// storing them has no effect on that mechanism.
|
||||||
#[allow(unused_variables)]
|
#[allow(unused_variables)]
|
||||||
fn platform_ready(&mut self, rsc: &mut AndroidRsc<Self>, vm: JavaVM, view: GlobalRef) {}
|
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
|
/// The android-view analogue of `default::DefaultRsc` -- identical in
|
||||||
@@ -266,10 +349,23 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
|||||||
/// these in until that is root-caused; removing them loses the exact
|
/// these in until that is root-caused; removing them loses the exact
|
||||||
/// evidence a `logcat` capture needs to reproduce the state.
|
/// evidence a `logcat` capture needs to reproduce the state.
|
||||||
fn render(&mut self, ctx: &mut CallbackCtx) {
|
fn render(&mut self, ctx: &mut CallbackCtx) {
|
||||||
let ui_state = self.state.android_state();
|
if self.state.android_state().renderer.is_none() {
|
||||||
if ui_state.renderer.is_none() {
|
|
||||||
return;
|
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);
|
||||||
|
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!(
|
log::debug!(
|
||||||
"render(): root={:?} widgets={} active={} root_px={:?} out_size={:?}",
|
"render(): root={:?} widgets={} active={} root_px={:?} out_size={:?}",
|
||||||
ui_state.root.is_some(),
|
ui_state.root.is_some(),
|
||||||
@@ -294,7 +390,27 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
|||||||
let Some(renderer) = &mut ui_state.renderer else {
|
let Some(renderer) = &mut ui_state.renderer else {
|
||||||
return;
|
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();
|
let submit_to_present = renderer.draw();
|
||||||
self.state
|
self.state
|
||||||
.android_state_mut()
|
.android_state_mut()
|
||||||
@@ -337,6 +453,31 @@ fn show_soft_input<'local>(env: &mut JNIEnv<'local>, view: &View<'local>) {
|
|||||||
imm.show_soft_input(env, view, 0);
|
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> {
|
impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
||||||
fn on_key_down<'local>(
|
fn on_key_down<'local>(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -379,6 +520,8 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
|||||||
) -> bool {
|
) -> bool {
|
||||||
self.drain_tasks();
|
self.drain_tasks();
|
||||||
let action = event.action_masked(&mut ctx.env);
|
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 x = event.x(&mut ctx.env);
|
||||||
let y = event.y(&mut ctx.env);
|
let y = event.y(&mut ctx.env);
|
||||||
let ui_state = self.state.android_state_mut();
|
let ui_state = self.state.android_state_mut();
|
||||||
@@ -431,7 +574,6 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
|||||||
height: i32,
|
height: i32,
|
||||||
) {
|
) {
|
||||||
self.drain_tasks();
|
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
|
// The layout engine's own notion of the canvas size is separate
|
||||||
// from the wgpu surface's -- winit's backend sets it from
|
// from the wgpu surface's -- winit's backend sets it from
|
||||||
// `WindowEvent::Resized`, and there is no equivalent automatic
|
// `WindowEvent::Resized`, and there is no equivalent automatic
|
||||||
@@ -440,13 +582,101 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
|||||||
// nothing but the clear colour: the widget tree laid out against
|
// nothing but the clear colour: the widget tree laid out against
|
||||||
// whatever size `UiRenderState::new` starts at instead of the
|
// whatever size `UiRenderState::new` starts at instead of the
|
||||||
// surface's real one.
|
// surface's real one.
|
||||||
self.render.resize((width as u32, height as u32));
|
//
|
||||||
// Drop the old renderer (and the surface it owns) before building
|
// **Physical pixels, matching `AndroidRenderer`'s own
|
||||||
// one from the new window -- see `AndroidRenderer`'s doc comment.
|
// `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();
|
||||||
|
if already_live {
|
||||||
let ui_state = self.state.android_state_mut();
|
let ui_state = self.state.android_state_mut();
|
||||||
ui_state.renderer = None;
|
ui_state
|
||||||
ui_state.renderer = Some(AndroidRenderer::new(window, width as u32, height as u32));
|
.renderer
|
||||||
|
.as_mut()
|
||||||
|
.expect("checked Some above")
|
||||||
|
.resize(width as u32, height as u32);
|
||||||
self.render(ctx);
|
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) => {
|
||||||
|
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>(
|
fn surface_destroyed<'local>(
|
||||||
@@ -557,10 +787,19 @@ impl<State: AndroidAppState> AccessibilityNodeProvider for IrisViewPeer<State> {
|
|||||||
/// `register_view_class`, which wants a plain function pointer) -- see
|
/// `register_view_class`, which wants a plain function pointer) -- see
|
||||||
/// `iris/android-app/src/lib.rs`.
|
/// `iris/android-app/src/lib.rs`.
|
||||||
pub fn new_peer<'local, State: AndroidAppState>(
|
pub fn new_peer<'local, State: AndroidAppState>(
|
||||||
env: JNIEnv<'local>,
|
mut env: JNIEnv<'local>,
|
||||||
view: View<'local>,
|
view: View<'local>,
|
||||||
_context: Context<'local>,
|
context: Context<'local>,
|
||||||
) -> android_view::jni::sys::jlong {
|
) -> 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 vm = env.get_java_vm().unwrap();
|
||||||
let global_view = env.new_global_ref(&view.0).unwrap();
|
let global_view = env.new_global_ref(&view.0).unwrap();
|
||||||
let redraw: Arc<dyn RequestRedraw> = Arc::new(AndroidRedrawHandle::new(vm, global_view));
|
let redraw: Arc<dyn RequestRedraw> = Arc::new(AndroidRedrawHandle::new(vm, global_view));
|
||||||
@@ -572,15 +811,22 @@ pub fn new_peer<'local, State: AndroidAppState>(
|
|||||||
state: Default::default(),
|
state: Default::default(),
|
||||||
_state: PhantomData,
|
_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 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 mut state = State::new(ui_state, &mut rsc);
|
||||||
let platform_vm = env.get_java_vm().unwrap();
|
let platform_vm = env.get_java_vm().unwrap();
|
||||||
let platform_view = env.new_global_ref(&view.0).unwrap();
|
let platform_view = env.new_global_ref(&view.0).unwrap();
|
||||||
state.platform_ready(&mut rsc, platform_vm, platform_view);
|
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 {
|
let peer = IrisViewPeer {
|
||||||
rsc,
|
rsc,
|
||||||
render: UiRenderState::new(),
|
render,
|
||||||
state,
|
state,
|
||||||
task_recv,
|
task_recv,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,10 +11,15 @@ pub struct Input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
match event {
|
||||||
WindowEvent::CursorMoved { position, .. } => {
|
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;
|
self.cursor.exists = true;
|
||||||
}
|
}
|
||||||
WindowEvent::MouseInput { state, button, .. } => {
|
WindowEvent::MouseInput { state, button, .. } => {
|
||||||
@@ -30,7 +35,9 @@ impl Input {
|
|||||||
WindowEvent::MouseWheel { delta, .. } => {
|
WindowEvent::MouseWheel { delta, .. } => {
|
||||||
let mut delta = match *delta {
|
let mut delta = match *delta {
|
||||||
MouseScrollDelta::LineDelta(x, y) => Vec2::new(x, y),
|
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 {
|
if delta.x == 0.0 && self.modifiers.shift {
|
||||||
delta.x = delta.y;
|
delta.x = delta.y;
|
||||||
@@ -68,8 +75,13 @@ impl Input {
|
|||||||
|
|
||||||
impl DefaultUiState {
|
impl DefaultUiState {
|
||||||
pub fn window_size(&self) -> Vec2 {
|
pub fn window_size(&self) -> Vec2 {
|
||||||
let size = self.renderer.window().inner_size();
|
let window = self.renderer.window();
|
||||||
(size.width, size.height).into()
|
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 {
|
pub fn cursor_state(&self) -> &CursorState {
|
||||||
|
|||||||
@@ -246,7 +246,8 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
|||||||
ui_state
|
ui_state
|
||||||
.access_adapter
|
.access_adapter
|
||||||
.process_event(&ui_state.window, &event);
|
.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 cursor_state = ui_state.cursor_state().clone();
|
||||||
let old = ui_state.focus;
|
let old = ui_state.focus;
|
||||||
if cursor_state.buttons.left.is_start() {
|
if cursor_state.buttons.left.is_start() {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::task::RequestRedraw;
|
use crate::task::RequestRedraw;
|
||||||
use iris_core::{UiData, UiRenderNode, UiRenderState};
|
use iris_core::{UiData, UiRenderNode, UiRenderState, util::Vec2};
|
||||||
use pollster::FutureExt;
|
use pollster::FutureExt;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use wgpu::*;
|
use wgpu::*;
|
||||||
@@ -66,7 +66,13 @@ impl UiRenderer {
|
|||||||
self.config.width = size.width;
|
self.config.width = size.width;
|
||||||
self.config.height = size.height;
|
self.config.height = size.height;
|
||||||
self.surface.configure(&self.device, &self.config);
|
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 {
|
fn create_encoder(device: &Device) -> CommandEncoder {
|
||||||
@@ -141,7 +147,28 @@ impl UiRenderer {
|
|||||||
|
|
||||||
let encoder = Self::create_encoder(&device);
|
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 {
|
Self {
|
||||||
surface,
|
surface,
|
||||||
|
|||||||
+427
-4
@@ -1,5 +1,6 @@
|
|||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use std::{
|
use std::{
|
||||||
|
collections::VecDeque,
|
||||||
ops::{BitOr, Deref, DerefMut},
|
ops::{BitOr, Deref, DerefMut},
|
||||||
rc::Rc,
|
rc::Rc,
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
@@ -491,7 +492,25 @@ impl DragArbiter {
|
|||||||
} else if dy.abs() > DRAG_SLOP && dy.abs() >= dx.abs() {
|
} else if dy.abs() > DRAG_SLOP && dy.abs() >= dx.abs() {
|
||||||
self.state = ArbiterState::Panning;
|
self.state = ArbiterState::Panning;
|
||||||
self.last = pos;
|
self.last = pos;
|
||||||
DragOutcome::Pan(dy)
|
// `dy` 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(dy - DRAG_SLOP.copysign(dy))
|
||||||
} else if now.duration_since(self.origin_at) >= LONG_PRESS
|
} else if now.duration_since(self.origin_at) >= LONG_PRESS
|
||||||
&& dx.abs() <= DRAG_SLOP
|
&& dx.abs() <= DRAG_SLOP
|
||||||
&& dy.abs() <= DRAG_SLOP
|
&& dy.abs() <= DRAG_SLOP
|
||||||
@@ -510,6 +529,390 @@ impl DragArbiter {
|
|||||||
pub fn release(&mut self) {
|
pub fn release(&mut self) {
|
||||||
self.state = ArbiterState::Idle;
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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) {
|
||||||
|
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 {
|
||||||
|
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 {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
@@ -534,9 +937,13 @@ mod drag_arbiter_tests {
|
|||||||
fn a_vertical_drag_pans_immediately() {
|
fn a_vertical_drag_pans_immediately() {
|
||||||
let mut a = DragArbiter::new();
|
let mut a = DragArbiter::new();
|
||||||
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
|
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!(
|
assert_eq!(
|
||||||
a.update(Vec2::new(0.0, 20.0), t(10)),
|
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.
|
// Subsequent frames keep panning, by the delta since last frame.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -545,6 +952,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]
|
#[test]
|
||||||
fn a_horizontal_drag_with_nothing_selected_does_not_select() {
|
fn a_horizontal_drag_with_nothing_selected_does_not_select() {
|
||||||
let mut a = DragArbiter::new();
|
let mut a = DragArbiter::new();
|
||||||
@@ -603,7 +1026,7 @@ mod drag_arbiter_tests {
|
|||||||
a.press_start(Vec2::new(0.0, 0.0), t(0), true);
|
a.press_start(Vec2::new(0.0, 0.0), t(0), true);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
a.update(Vec2::new(0.0, 20.0), t(10)),
|
a.update(Vec2::new(0.0, 20.0), t(10)),
|
||||||
DragOutcome::Pan(20.0)
|
DragOutcome::Pan(12.0)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,7 +1069,7 @@ mod drag_arbiter_tests {
|
|||||||
a.press_start(Vec2::new(0.0, 700.0), t(0), false);
|
a.press_start(Vec2::new(0.0, 700.0), t(0), false);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
a.update(Vec2::new(0.0, 720.0), t(10)),
|
a.update(Vec2::new(0.0, 720.0), t(10)),
|
||||||
DragOutcome::Pan(20.0)
|
DragOutcome::Pan(12.0)
|
||||||
);
|
);
|
||||||
assert!(!a.is_idle());
|
assert!(!a.is_idle());
|
||||||
}
|
}
|
||||||
|
|||||||
+432
-8
@@ -102,7 +102,7 @@
|
|||||||
|
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use iris_core::util::HashMap;
|
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
|
/// 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
|
/// 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
|
/// row is evicted (`pop_front`/`pop_back`) so this cannot grow past
|
||||||
/// however many rows are currently loaded.
|
/// however many rows are currently loaded.
|
||||||
heights: HashMap<RowKey, f32>,
|
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 {
|
impl List {
|
||||||
@@ -226,6 +259,10 @@ impl List {
|
|||||||
snap_end: true,
|
snap_end: true,
|
||||||
viewport_len: 0.0,
|
viewport_len: 0.0,
|
||||||
last_viewport_len: 0.0,
|
last_viewport_len: 0.0,
|
||||||
|
fling: None,
|
||||||
|
redraw: None,
|
||||||
|
at_start: false,
|
||||||
|
at_end: false,
|
||||||
pending_tap: None,
|
pending_tap: None,
|
||||||
extents: HashMap::default(),
|
extents: HashMap::default(),
|
||||||
heights: HashMap::default(),
|
heights: HashMap::default(),
|
||||||
@@ -317,6 +354,40 @@ impl List {
|
|||||||
self.extents.clear();
|
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
|
/// Move the anchor's edge by `amt` pixels. Positive moves later
|
||||||
/// content into view (mirrors `Scroll::scroll`'s sign convention).
|
/// content into view (mirrors `Scroll::scroll`'s sign convention).
|
||||||
/// Deliberately unclamped -- see the module doc's "what is not
|
/// Deliberately unclamped -- see the module doc's "what is not
|
||||||
@@ -327,6 +398,97 @@ 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) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
/// Snap to the newest content (last item, or the `more_after`
|
/// Snap to the newest content (last item, or the `more_after`
|
||||||
/// sentinel if set), bottom-aligned to the viewport. O(1).
|
/// sentinel if set), bottom-aligned to the viewport. O(1).
|
||||||
pub fn jump_to_end(&mut self) {
|
pub fn jump_to_end(&mut self) {
|
||||||
@@ -572,9 +734,10 @@ impl List {
|
|||||||
let axis = self.axis;
|
let axis = self.axis;
|
||||||
let output_len = painter.output_size().axis(axis);
|
let output_len = painter.output_size().axis(axis);
|
||||||
let container_len = painter.region().axis(axis).len();
|
let container_len = painter.region().axis(axis).len();
|
||||||
|
let density = painter.density();
|
||||||
let resolve = move |used: Size| -> f32 {
|
let resolve = move |used: Size| -> f32 {
|
||||||
used.axis(axis)
|
used.axis(axis)
|
||||||
.apply_rest()
|
.apply_rest(density)
|
||||||
.within_len(container_len)
|
.within_len(container_len)
|
||||||
.to_abs(output_len)
|
.to_abs(output_len)
|
||||||
};
|
};
|
||||||
@@ -690,26 +853,33 @@ impl Widget for List {
|
|||||||
};
|
};
|
||||||
let (mut top, mut bottom) = self.place(painter, anchor.slot, placement);
|
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 {
|
while top > 0.0 {
|
||||||
let Some(prev) = self.prev_slot(idx) else {
|
let Some(prev) = self.prev_slot(idx_top) else {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
let (t, _) = self.place(painter, prev, Placement::Bottom(top));
|
let (t, _) = self.place(painter, prev, Placement::Bottom(top));
|
||||||
top = t;
|
top = t;
|
||||||
idx = prev;
|
idx_top = prev;
|
||||||
}
|
}
|
||||||
|
|
||||||
idx = anchor.slot;
|
let mut idx_bottom = anchor.slot;
|
||||||
while bottom < self.viewport_len {
|
while bottom < self.viewport_len {
|
||||||
let Some(next) = self.next_slot(idx) else {
|
let Some(next) = self.next_slot(idx_bottom) else {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
let (_, b) = self.place(painter, next, Placement::Top(bottom));
|
let (_, b) = self.place(painter, next, Placement::Top(bottom));
|
||||||
bottom = b;
|
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();
|
self.update_snap_end();
|
||||||
Size::REST
|
Size::REST
|
||||||
}
|
}
|
||||||
@@ -1032,4 +1202,258 @@ mod tests {
|
|||||||
assert!(moves <= 12, "n={n}: expected O(visible) moves, got {moves}");
|
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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -17,14 +17,15 @@ impl Widget for Aligned {
|
|||||||
// already-resolved region double-applies that composition and is
|
// already-resolved region double-applies that composition and is
|
||||||
// wrong for any widget nested below the root.
|
// wrong for any widget nested below the root.
|
||||||
let used = painter.widget(&self.inner);
|
let used = painter.widget(&self.inner);
|
||||||
|
let density = painter.density();
|
||||||
let region = match self.align.tuple() {
|
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) => {
|
(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)
|
UiRegion::new(x, UiSpan::FULL)
|
||||||
}
|
}
|
||||||
(None, Some(y)) => {
|
(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)
|
UiRegion::new(UiSpan::FULL, y)
|
||||||
}
|
}
|
||||||
(None, None) => UiRegion::FULL,
|
(None, None) => UiRegion::FULL,
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ pub struct MaxSize {
|
|||||||
impl MaxSize {
|
impl MaxSize {
|
||||||
/// Caps a reported length at `max`, comparing in pixels since `Len`'s
|
/// Caps a reported length at `max`, comparing in pixels since `Len`'s
|
||||||
/// rel/abs/rest components are not otherwise comparable.
|
/// 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 {
|
let Some(max) = max else {
|
||||||
return len;
|
return len;
|
||||||
};
|
};
|
||||||
let len_px = len.apply_rest().to_abs(output);
|
let len_px = len.apply_rest(density).to_abs(output);
|
||||||
let max_px = max.apply_rest().to_abs(output);
|
let max_px = max.apply_rest(density).to_abs(output);
|
||||||
if len_px > max_px { max } else { len }
|
if len_px > max_px { max } else { len }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,11 +24,11 @@ impl MaxSize {
|
|||||||
/// start, if it does not. Needed so the child is never painted bigger
|
/// start, if it does not. Needed so the child is never painted bigger
|
||||||
/// than the size this widget reports for it -- see the identical
|
/// than the size this widget reports for it -- see the identical
|
||||||
/// requirement noted on `Sized::draw`.
|
/// 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 {
|
let Some(max) = max else {
|
||||||
return UiSpan::FULL;
|
return UiSpan::FULL;
|
||||||
};
|
};
|
||||||
let max_scalar = max.apply_rest();
|
let max_scalar = max.apply_rest(density);
|
||||||
let max_px = max_scalar.to_abs(output);
|
let max_px = max_scalar.to_abs(output);
|
||||||
if offered_px > max_px {
|
if offered_px > max_px {
|
||||||
max_scalar.align(AxisAlign::Neg)
|
max_scalar.align(AxisAlign::Neg)
|
||||||
@@ -41,15 +41,16 @@ impl MaxSize {
|
|||||||
impl Widget for MaxSize {
|
impl Widget for MaxSize {
|
||||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||||
let output = painter.output_size();
|
let output = painter.output_size();
|
||||||
|
let density = painter.density();
|
||||||
let offered = painter.px_size();
|
let offered = painter.px_size();
|
||||||
let region = UiRegion {
|
let region = UiRegion {
|
||||||
x: Self::clamp_region(offered.x, self.x, output.x),
|
x: Self::clamp_region(offered.x, self.x, output.x, density),
|
||||||
y: Self::clamp_region(offered.y, self.y, output.y),
|
y: Self::clamp_region(offered.y, self.y, output.y, density),
|
||||||
};
|
};
|
||||||
let used = painter.widget_within(&self.inner, region);
|
let used = painter.widget_within(&self.inner, region);
|
||||||
Size {
|
Size {
|
||||||
x: Self::clamp(used.x, self.x, output.x),
|
x: Self::clamp(used.x, self.x, output.x, density),
|
||||||
y: Self::clamp(used.y, self.y, output.y),
|
y: Self::clamp(used.y, self.y, output.y, density),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,9 +7,12 @@ pub struct Pad {
|
|||||||
|
|
||||||
impl Widget for Pad {
|
impl Widget for Pad {
|
||||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||||
let used = painter.widget_within(&self.inner, self.padding.region());
|
let density = painter.density();
|
||||||
let width = self.padding.left + self.padding.right;
|
let used = painter.widget_within(&self.inner, self.padding.region(density));
|
||||||
let height = self.padding.top + self.padding.bottom;
|
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 {
|
Size {
|
||||||
x: used.x + Len::abs(width),
|
x: used.x + Len::abs(width),
|
||||||
y: used.y + Len::abs(height),
|
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 struct Padding {
|
||||||
pub left: f32,
|
pub left: Len,
|
||||||
pub right: f32,
|
pub right: Len,
|
||||||
pub top: f32,
|
pub top: Len,
|
||||||
pub bottom: f32,
|
pub bottom: Len,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Padding {
|
impl Padding {
|
||||||
pub const ZERO: Self = Self {
|
pub const ZERO: Self = Self {
|
||||||
left: 0.0,
|
left: Len::ZERO,
|
||||||
right: 0.0,
|
right: Len::ZERO,
|
||||||
top: 0.0,
|
top: Len::ZERO,
|
||||||
bottom: 0.0,
|
bottom: Len::ZERO,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn uniform(amt: impl UiNum) -> Self {
|
pub fn uniform(amt: impl Into<Len>) -> Self {
|
||||||
let amt = amt.to_f32();
|
let amt = amt.into();
|
||||||
Self {
|
Self {
|
||||||
left: amt,
|
left: amt,
|
||||||
right: amt,
|
right: amt,
|
||||||
@@ -41,80 +50,84 @@ impl Padding {
|
|||||||
bottom: amt,
|
bottom: amt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn region(&self) -> UiRegion {
|
pub fn region(&self, density: f32) -> UiRegion {
|
||||||
let mut region = UiRegion::FULL;
|
let mut region = UiRegion::FULL;
|
||||||
region.x.start.abs += self.left;
|
region.x.start.abs += self.left.apply_rest(density).abs;
|
||||||
region.y.start.abs += self.top;
|
region.y.start.abs += self.top.apply_rest(density).abs;
|
||||||
region.x.end.abs -= self.right;
|
region.x.end.abs -= self.right.apply_rest(density).abs;
|
||||||
region.y.end.abs -= self.bottom;
|
region.y.end.abs -= self.bottom.apply_rest(density).abs;
|
||||||
region
|
region
|
||||||
}
|
}
|
||||||
pub fn x(amt: impl UiNum) -> Self {
|
pub fn x(amt: impl Into<Len>) -> Self {
|
||||||
let amt = amt.to_f32();
|
let amt = amt.into();
|
||||||
Self {
|
Self {
|
||||||
left: amt,
|
left: amt,
|
||||||
right: amt,
|
right: amt,
|
||||||
top: 0.0,
|
top: Len::ZERO,
|
||||||
bottom: 0.0,
|
bottom: Len::ZERO,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn y(amt: impl UiNum) -> Self {
|
pub fn y(amt: impl Into<Len>) -> Self {
|
||||||
let amt = amt.to_f32();
|
let amt = amt.into();
|
||||||
Self {
|
Self {
|
||||||
left: 0.0,
|
left: Len::ZERO,
|
||||||
right: 0.0,
|
right: Len::ZERO,
|
||||||
top: amt,
|
top: amt,
|
||||||
bottom: amt,
|
bottom: amt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn top(amt: impl UiNum) -> Self {
|
pub fn top(amt: impl Into<Len>) -> Self {
|
||||||
let mut s = Self::ZERO;
|
let mut s = Self::ZERO;
|
||||||
s.top = amt.to_f32();
|
s.top = amt.into();
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn bottom(amt: impl UiNum) -> Self {
|
pub fn bottom(amt: impl Into<Len>) -> Self {
|
||||||
let mut s = Self::ZERO;
|
let mut s = Self::ZERO;
|
||||||
s.bottom = amt.to_f32();
|
s.bottom = amt.into();
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn left(amt: impl UiNum) -> Self {
|
pub fn left(amt: impl Into<Len>) -> Self {
|
||||||
let mut s = Self::ZERO;
|
let mut s = Self::ZERO;
|
||||||
s.left = amt.to_f32();
|
s.left = amt.into();
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn right(amt: impl UiNum) -> Self {
|
pub fn right(amt: impl Into<Len>) -> Self {
|
||||||
let mut s = Self::ZERO;
|
let mut s = Self::ZERO;
|
||||||
s.right = amt.to_f32();
|
s.right = amt.into();
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_top(mut self, amt: impl UiNum) -> Self {
|
pub fn with_top(mut self, amt: impl Into<Len>) -> Self {
|
||||||
self.top = amt.to_f32();
|
self.top = amt.into();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_bottom(mut self, amt: impl UiNum) -> Self {
|
pub fn with_bottom(mut self, amt: impl Into<Len>) -> Self {
|
||||||
self.bottom = amt.to_f32();
|
self.bottom = amt.into();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_left(mut self, amt: impl UiNum) -> Self {
|
pub fn with_left(mut self, amt: impl Into<Len>) -> Self {
|
||||||
self.left = amt.to_f32();
|
self.left = amt.into();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_right(mut self, amt: impl UiNum) -> Self {
|
pub fn with_right(mut self, amt: impl Into<Len>) -> Self {
|
||||||
self.right = amt.to_f32();
|
self.right = amt.into();
|
||||||
self
|
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 {
|
fn from(amt: T) -> Self {
|
||||||
Self::uniform(amt.to_f32())
|
Self::uniform(amt.into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,7 +43,7 @@ impl Widget for Scroll {
|
|||||||
|
|
||||||
self.content_len = used
|
self.content_len = used
|
||||||
.axis(axis)
|
.axis(axis)
|
||||||
.apply_rest()
|
.apply_rest(painter.density())
|
||||||
.within_len(container_len)
|
.within_len(container_len)
|
||||||
.to_abs(output_len);
|
.to_abs(output_len);
|
||||||
|
|
||||||
|
|||||||
@@ -17,12 +17,13 @@ impl Widget for Sized {
|
|||||||
// learn its size, then moves it into place with a pure
|
// learn its size, then moves it into place with a pure
|
||||||
// translation; that translation is only valid if what got painted
|
// translation; that translation is only valid if what got painted
|
||||||
// is already the reported size, anchored the same way both times.
|
// is already the reported size, anchored the same way both times.
|
||||||
|
let density = painter.density();
|
||||||
let mut region = UiRegion::FULL;
|
let mut region = UiRegion::FULL;
|
||||||
if let Some(x) = self.x {
|
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 {
|
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);
|
let used = painter.widget_within(&self.inner, region);
|
||||||
Size {
|
Size {
|
||||||
|
|||||||
@@ -4,12 +4,18 @@ use std::marker::PhantomData;
|
|||||||
pub struct Span {
|
pub struct Span {
|
||||||
pub children: Vec<StrongWidget>,
|
pub children: Vec<StrongWidget>,
|
||||||
pub dir: Dir,
|
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 {
|
impl Widget for Span {
|
||||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||||
let axis = self.dir.axis;
|
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)
|
// Phase 1: draw each child once, at the ambient (unmodified, full)
|
||||||
// region a size-only query used to see before this migration, to
|
// 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))
|
.map(|child| painter.widget(child).axis(axis))
|
||||||
.collect();
|
.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);
|
let total = lens.iter().fold(Len::abs(gap_total), |s, &l| s + l);
|
||||||
|
|
||||||
// Phase 2: place each child for real, using the lengths just
|
// Phase 2: place each child for real, using the lengths just
|
||||||
@@ -54,7 +60,7 @@ impl Widget for Span {
|
|||||||
child_region.flip(axis);
|
child_region.flip(axis);
|
||||||
}
|
}
|
||||||
let used = painter.widget_within(child, child_region);
|
let used = painter.widget_within(child, child_region);
|
||||||
start.abs += self.gap;
|
start.abs += gap;
|
||||||
|
|
||||||
let ortho = used.axis(!axis);
|
let ortho = used.axis(!axis);
|
||||||
if ortho.rel > 0.0 || ortho.rest > 0.0 {
|
if ortho.rel > 0.0 || ortho.rest > 0.0 {
|
||||||
@@ -82,12 +88,12 @@ impl Span {
|
|||||||
Self {
|
Self {
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
dir,
|
dir,
|
||||||
gap: 0.0,
|
gap: Len::ZERO,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn gap(mut self, gap: impl UiNum) -> Self {
|
pub fn gap(mut self, gap: impl Into<Len>) -> Self {
|
||||||
self.gap = gap.to_f32();
|
self.gap = gap.into();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +109,7 @@ impl Span {
|
|||||||
pub struct SpanBuilder<State, const LEN: usize, Wa: WidgetArrLike<State, LEN, Tag>, Tag> {
|
pub struct SpanBuilder<State, const LEN: usize, Wa: WidgetArrLike<State, LEN, Tag>, Tag> {
|
||||||
pub children: Wa,
|
pub children: Wa,
|
||||||
pub dir: Dir,
|
pub dir: Dir,
|
||||||
pub gap: f32,
|
pub gap: Len,
|
||||||
_pd: PhantomData<(State, Tag)>,
|
_pd: PhantomData<(State, Tag)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,13 +135,13 @@ impl<State, const LEN: usize, Wa: WidgetArrLike<State, LEN, Tag>, Tag>
|
|||||||
Self {
|
Self {
|
||||||
children,
|
children,
|
||||||
dir,
|
dir,
|
||||||
gap: 0.0,
|
gap: Len::ZERO,
|
||||||
_pd: PhantomData,
|
_pd: PhantomData,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn gap(mut self, gap: impl UiNum) -> Self {
|
pub fn gap(mut self, gap: impl Into<Len>) -> Self {
|
||||||
self.gap = gap.to_f32();
|
self.gap = gap.into();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,7 +141,8 @@ impl<'a> TextEditCtx<'a> {
|
|||||||
fn layout(&mut self) -> &Layout<UiColor> {
|
fn layout(&mut self) -> &Layout<UiColor> {
|
||||||
let attrs = self.text.view.attrs.clone();
|
let attrs = self.text.view.attrs.clone();
|
||||||
let width = self.text.view.wrap_width();
|
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()
|
self.text.view.buf.layout()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +168,20 @@ impl<'a> TextEditCtx<'a> {
|
|||||||
self.text.selection = None;
|
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) {
|
pub fn motion(&mut self, motion: Motion, select: bool) {
|
||||||
let Some(sel) = self.text.selection else {
|
let Some(sel) = self.text.selection else {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ where
|
|||||||
.label("Message")
|
.label("Message")
|
||||||
.add(rsc);
|
.add(rsc);
|
||||||
|
|
||||||
let bar: WeakWidget = (field.pad(12).width(rest(1)),)
|
let bar: WeakWidget = (field.pad(dp(12)).width(rest(1)),)
|
||||||
.span(Dir::RIGHT)
|
.span(Dir::RIGHT)
|
||||||
.background(rect(UiColor::new(40, 40, 46, 255)))
|
.background(rect(UiColor::new(40, 40, 46, 255)))
|
||||||
.add(rsc);
|
.add(rsc);
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ pub struct TranscriptScreen {
|
|||||||
pub list: WeakWidget<List>,
|
pub list: WeakWidget<List>,
|
||||||
pub composer: composer::Composer,
|
pub composer: composer::Composer,
|
||||||
selection: Rc<RefCell<Selection>>,
|
selection: Rc<RefCell<Selection>>,
|
||||||
|
/// How many times [`Self::apply`] has fallen back to a full rebuild --
|
||||||
|
/// `Cell` rather than requiring `&mut self`, matching every other
|
||||||
|
/// method here (the real state lives behind `list`/`selection`'s own
|
||||||
|
/// interior mutability, per `push_row`'s existing `&self`). Drained by
|
||||||
|
/// [`Self::take_rebuilds`].
|
||||||
|
rebuilds: std::cell::Cell<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TranscriptScreen {
|
impl TranscriptScreen {
|
||||||
@@ -75,6 +81,93 @@ impl TranscriptScreen {
|
|||||||
(self.list)(rsc).push_back(ListRow::new(key, widget));
|
(self.list)(rsc).push_back(ListRow::new(key, widget));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply the effect of one more folded event without rebuilding the
|
||||||
|
/// whole screen -- RUST.md's "streaming still costs a full rebuild"
|
||||||
|
/// fix. `old`/`new` are `client_core::transcript_fold::fold_event`'s
|
||||||
|
/// own before/after item lists (never grouped into rows -- that
|
||||||
|
/// happens here, over both, so the common tail cases can be told
|
||||||
|
/// apart; `group_tool_runs` is pure bookkeeping over already-folded
|
||||||
|
/// items, no widget is built doing it).
|
||||||
|
///
|
||||||
|
/// Three cases, cheapest first:
|
||||||
|
/// - **nothing changed**: no-op.
|
||||||
|
/// - **pure append** (a still-open reply's row now closed and stable,
|
||||||
|
/// a new tool call, a new message): every new row is `push_back`ed,
|
||||||
|
/// same cost as [`Self::push_row`].
|
||||||
|
/// - **only the last row's content changed** (the common case: a delta
|
||||||
|
/// folded into a still-open assistant message): that one row is
|
||||||
|
/// rebuilt (`row::build_row`, the same path a fresh row goes
|
||||||
|
/// through) and swapped in with [`List::replace_back`] -- every
|
||||||
|
/// other row is untouched, so nothing else redraws or moves. Any
|
||||||
|
/// further new rows are appended after it, for the (also common)
|
||||||
|
/// case of a delta that both finishes the open reply and starts the
|
||||||
|
/// next row in the same event.
|
||||||
|
///
|
||||||
|
/// Anything else -- a row *before* the tail changed, which only
|
||||||
|
/// happens when `group_tool_runs` regroups already-seen items (a tool
|
||||||
|
/// run's calls that used to be separate rows join once the run closes)
|
||||||
|
/// -- falls back to a full rebuild: every row is dropped
|
||||||
|
/// (`List::clear`) and rebuilt from `new`. Counted in
|
||||||
|
/// [`Self::take_rebuilds`] so a caller (a report, a test) can see how
|
||||||
|
/// often the fallback actually fires rather than assuming it never
|
||||||
|
/// does.
|
||||||
|
pub fn apply<Rsc: HasEvents>(
|
||||||
|
&self,
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
old: &[client_core::transcript_fold::TranscriptItem],
|
||||||
|
new: &[client_core::transcript_fold::TranscriptItem],
|
||||||
|
) where
|
||||||
|
Rsc::State: FocusHost,
|
||||||
|
{
|
||||||
|
use client_core::transcript_fold::group_tool_runs;
|
||||||
|
|
||||||
|
let old_rows = group_tool_runs(old);
|
||||||
|
let new_rows = group_tool_runs(new);
|
||||||
|
|
||||||
|
match diff_rows(&old_rows, &new_rows) {
|
||||||
|
RowDiff::Unchanged => {}
|
||||||
|
RowDiff::Appended { common } => {
|
||||||
|
// Pure append: every already-drawn row is byte-for-byte the
|
||||||
|
// same `FoldedRow` it was last time.
|
||||||
|
for row in &new_rows[common..] {
|
||||||
|
self.push_row(rsc, row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RowDiff::ReplaceLast { common } => {
|
||||||
|
// Only the tail row's content changed -- rebuild that one
|
||||||
|
// row and swap it in place, keeping every row before it
|
||||||
|
// untouched.
|
||||||
|
let old_key = row::row_key(&old_rows[common].key());
|
||||||
|
let (new_key, widget) =
|
||||||
|
row::build_row(rsc, self.list, self.selection.clone(), &new_rows[common]);
|
||||||
|
if new_key != old_key {
|
||||||
|
self.selection.borrow_mut().unregister(old_key);
|
||||||
|
}
|
||||||
|
let evicted = (self.list)(rsc).replace_back(ListRow::new(new_key, widget));
|
||||||
|
drop(evicted); // frees the old row's widget, same as a pop would
|
||||||
|
for row in &new_rows[common + 1..] {
|
||||||
|
self.push_row(rsc, row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RowDiff::Rebuild => {
|
||||||
|
// A row before the tail changed (a regroup) -- nothing
|
||||||
|
// short of a full rebuild expresses that.
|
||||||
|
self.rebuilds.set(self.rebuilds.get() + 1);
|
||||||
|
(self.list)(rsc).clear();
|
||||||
|
for row in &new_rows {
|
||||||
|
self.push_row(rsc, row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many times [`Self::apply`] has fallen back to a full rebuild
|
||||||
|
/// since the last call, reset to 0 by reading it -- the same
|
||||||
|
/// take-and-reset shape `AccessTree::take_rebuilds` already uses (I4).
|
||||||
|
pub fn take_rebuilds(&self) -> usize {
|
||||||
|
self.rebuilds.replace(0)
|
||||||
|
}
|
||||||
|
|
||||||
/// The concatenated text of whatever is currently selected across one
|
/// The concatenated text of whatever is currently selected across one
|
||||||
/// or more rows, `None` if nothing is -- what a copy command reads.
|
/// or more rows, `None` if nothing is -- what a copy command reads.
|
||||||
pub fn selected_text(&self, rsc: &mut impl UiRsc) -> Option<String> {
|
pub fn selected_text(&self, rsc: &mut impl UiRsc) -> Option<String> {
|
||||||
@@ -139,7 +232,148 @@ where
|
|||||||
list,
|
list,
|
||||||
composer,
|
composer,
|
||||||
selection,
|
selection,
|
||||||
|
rebuilds: std::cell::Cell::new(0),
|
||||||
},
|
},
|
||||||
tree,
|
tree,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What changed at the tail between two folded row lists -- the decision
|
||||||
|
/// [`TranscriptScreen::apply`] acts on. Kept as its own pure function, no
|
||||||
|
/// widget and no `Rsc`, so the three cases can be tested directly against
|
||||||
|
/// synthetic `Vec<FoldedRow>`s (below) rather than needing a full widget
|
||||||
|
/// harness to exercise logic that never touches one.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum RowDiff {
|
||||||
|
/// `old` and `new` are the same length and every row is identical.
|
||||||
|
Unchanged,
|
||||||
|
/// Rows `[common..]` of `new` are new; everything before `common` is
|
||||||
|
/// byte-for-byte the same `FoldedRow` `old` already had.
|
||||||
|
Appended { common: usize },
|
||||||
|
/// Row `common` is the only one whose content differs; anything past
|
||||||
|
/// it in `new` is a pure append after the replacement.
|
||||||
|
ReplaceLast { common: usize },
|
||||||
|
/// A row *before* the tail differs -- only `group_tool_runs` regrouping
|
||||||
|
/// an earlier run does this, and nothing short of a full rebuild
|
||||||
|
/// expresses it.
|
||||||
|
Rebuild,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diff_rows(old: &[FoldedRow], new: &[FoldedRow]) -> RowDiff {
|
||||||
|
let common = old
|
||||||
|
.iter()
|
||||||
|
.zip(new.iter())
|
||||||
|
.take_while(|(a, b)| a == b)
|
||||||
|
.count();
|
||||||
|
|
||||||
|
if common == old.len() && common == new.len() {
|
||||||
|
RowDiff::Unchanged
|
||||||
|
} else if common == old.len() {
|
||||||
|
RowDiff::Appended { common }
|
||||||
|
} else if !old.is_empty() && common == old.len() - 1 && common < new.len() {
|
||||||
|
// The `common < new.len()` guard is what tells "the tail row's
|
||||||
|
// content changed" apart from "the tail row was removed and
|
||||||
|
// nothing replaced it" (a shrinking list) -- the latter has
|
||||||
|
// nothing at `new[common]` to rebuild into place.
|
||||||
|
RowDiff::ReplaceLast { common }
|
||||||
|
} else {
|
||||||
|
RowDiff::Rebuild
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod diff_tests {
|
||||||
|
use super::*;
|
||||||
|
use client_core::transcript_fold::TranscriptItem;
|
||||||
|
|
||||||
|
fn user(seq: u64, text: &str) -> FoldedRow {
|
||||||
|
FoldedRow::Single(TranscriptItem::UserMsg {
|
||||||
|
seq,
|
||||||
|
text: text.to_string(),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assistant(seq: u64, text: &str, settled: bool) -> FoldedRow {
|
||||||
|
FoldedRow::Single(TranscriptItem::AssistantMsg {
|
||||||
|
seq,
|
||||||
|
text: text.to_string(),
|
||||||
|
settled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool(seq: u64, run_id: &str) -> TranscriptItem {
|
||||||
|
TranscriptItem::ToolRun {
|
||||||
|
seq,
|
||||||
|
id: format!("id{seq}"),
|
||||||
|
run_id: run_id.to_string(),
|
||||||
|
tool: "grep".to_string(),
|
||||||
|
input: "x".to_string(),
|
||||||
|
output: String::new(),
|
||||||
|
done: false,
|
||||||
|
asks: Vec::new(),
|
||||||
|
images: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identical_lists_are_unchanged() {
|
||||||
|
let rows = vec![user(1, "hi"), assistant(2, "hello", true)];
|
||||||
|
assert_eq!(diff_rows(&rows, &rows.clone()), RowDiff::Unchanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_list_growing_by_one_is_an_append_from_zero() {
|
||||||
|
let old: Vec<FoldedRow> = Vec::new();
|
||||||
|
let new = vec![user(1, "hi")];
|
||||||
|
assert_eq!(diff_rows(&old, &new), RowDiff::Appended { common: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_new_message_after_a_settled_reply_is_a_pure_append() {
|
||||||
|
// The row that used to be the tail (a now-closed assistant
|
||||||
|
// message) is unchanged; a new user message is appended after it
|
||||||
|
// -- the transition every reply's *last* delta makes once the
|
||||||
|
// next turn starts.
|
||||||
|
let old = vec![user(1, "hi"), assistant(2, "hello", true)];
|
||||||
|
let new = vec![user(1, "hi"), assistant(2, "hello", true), user(3, "and?")];
|
||||||
|
assert_eq!(diff_rows(&old, &new), RowDiff::Appended { common: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_delta_into_the_open_reply_is_a_last_row_replace() {
|
||||||
|
// The common streaming case: the assistant message's key (its
|
||||||
|
// first delta's seq) never changes, only its text grows.
|
||||||
|
let old = vec![user(1, "hi"), assistant(2, "hel", false)];
|
||||||
|
let new = vec![user(1, "hi"), assistant(2, "hello", false)];
|
||||||
|
assert_eq!(diff_rows(&old, &new), RowDiff::ReplaceLast { common: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_delta_that_both_settles_the_reply_and_starts_the_next_row_is_still_a_replace() {
|
||||||
|
// `ReplaceLast` only claims the row it names; `apply` appends
|
||||||
|
// whatever comes after it separately -- this just confirms the
|
||||||
|
// diff still recognises the replace even with a trailing append.
|
||||||
|
let old = vec![user(1, "hi"), assistant(2, "hel", false)];
|
||||||
|
let new = vec![user(1, "hi"), assistant(2, "hello", true), user(3, "and?")];
|
||||||
|
assert_eq!(diff_rows(&old, &new), RowDiff::ReplaceLast { common: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tool_run_closing_and_joining_an_earlier_call_is_a_regroup_fallback() {
|
||||||
|
// Two separate `Single` rows for the same run id become one
|
||||||
|
// `Tools` row once `group_tool_runs` sees them adjacent -- that
|
||||||
|
// changes row 0, not just the tail, so nothing short of a full
|
||||||
|
// rebuild expresses it.
|
||||||
|
let old = vec![FoldedRow::Single(tool(1, "run-a")), user(2, "meanwhile")];
|
||||||
|
let new = vec![FoldedRow::Tools(vec![tool(1, "run-a"), tool(3, "run-a")])];
|
||||||
|
assert_eq!(diff_rows(&old, &new), RowDiff::Rebuild);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shrinking_the_list_is_a_rebuild() {
|
||||||
|
let old = vec![user(1, "hi"), assistant(2, "hello", true)];
|
||||||
|
let new = vec![user(1, "hi")];
|
||||||
|
assert_eq!(diff_rows(&old, &new), RowDiff::Rebuild);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,15 @@ fn item_content(item: &TranscriptItem) -> (Option<&str>, String) {
|
|||||||
TranscriptItem::PeerNote { from, text, .. } => (Some(from.as_str()), text.clone()),
|
TranscriptItem::PeerNote { from, text, .. } => (Some(from.as_str()), text.clone()),
|
||||||
TranscriptItem::Note { text, .. } => (None, text.clone()),
|
TranscriptItem::Note { text, .. } => (None, text.clone()),
|
||||||
TranscriptItem::ClearedNote { .. } => (None, "_Context cleared._".to_string()),
|
TranscriptItem::ClearedNote { .. } => (None, "_Context cleared._".to_string()),
|
||||||
|
// Epoch seconds as-is until the port has a relative-time formatter
|
||||||
|
// (P1); the Compose `LimitRow` draws it as a countdown.
|
||||||
|
TranscriptItem::LimitNote { resets_at, .. } => (
|
||||||
|
None,
|
||||||
|
match resets_at {
|
||||||
|
Some(at) => format!("_Usage limit reached; resets at {at:.0} (epoch seconds)._"),
|
||||||
|
None => "_Usage limit reached._".to_string(),
|
||||||
|
},
|
||||||
|
),
|
||||||
TranscriptItem::CompactedNote {
|
TranscriptItem::CompactedNote {
|
||||||
pre_tokens,
|
pre_tokens,
|
||||||
post_tokens,
|
post_tokens,
|
||||||
@@ -165,8 +174,8 @@ where
|
|||||||
|
|
||||||
(header, field.width(rest(1)))
|
(header, field.width(rest(1)))
|
||||||
.span(Dir::DOWN)
|
.span(Dir::DOWN)
|
||||||
.gap(4)
|
.gap(dp(4))
|
||||||
.pad(10)
|
.pad(dp(10))
|
||||||
.add_strong(rsc)
|
.add_strong(rsc)
|
||||||
.any()
|
.any()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ pub struct Selection {
|
|||||||
/// pan wanting the same touch gesture). See `drag` below, and
|
/// pan wanting the same touch gesture). See `drag` below, and
|
||||||
/// `iris::sense::DragArbiter`'s own doc for the decision itself.
|
/// `iris::sense::DragArbiter`'s own doc for the decision itself.
|
||||||
arbiter: DragArbiter,
|
arbiter: DragArbiter,
|
||||||
|
/// Tracks the last ~100ms of this gesture's pan deltas (in the same
|
||||||
|
/// signed units `list.scroll` takes), so a release that turns out to
|
||||||
|
/// have been panning can hand `List::fling` a realistic initial
|
||||||
|
/// velocity instead of one frame's noisy last delta --
|
||||||
|
/// IRIS_TODO.md's "swiping has no momentum."
|
||||||
|
velocity: VelocityTracker,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Selection {
|
impl Default for Selection {
|
||||||
@@ -55,6 +61,7 @@ impl Selection {
|
|||||||
rows: BTreeMap::new(),
|
rows: BTreeMap::new(),
|
||||||
anchor: None,
|
anchor: None,
|
||||||
arbiter: DragArbiter::new(),
|
arbiter: DragArbiter::new(),
|
||||||
|
velocity: VelocityTracker::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,9 +185,21 @@ impl Selection {
|
|||||||
CursorSense::PressStart(_) => {
|
CursorSense::PressStart(_) => {
|
||||||
let already_selected = self.has_selection(ui);
|
let already_selected = self.has_selection(ui);
|
||||||
self.arbiter.press_start(pos_window, now, already_selected);
|
self.arbiter.press_start(pos_window, now, already_selected);
|
||||||
|
self.velocity.reset();
|
||||||
|
// A fresh touch-down cancels any fling still coasting from
|
||||||
|
// the previous gesture -- `List::fling`'s own doc, and
|
||||||
|
// Android's `Scroller::abortAnimation` for the same reason.
|
||||||
|
list(ui).cancel_fling();
|
||||||
self.arbiter.update(pos_window, now)
|
self.arbiter.update(pos_window, now)
|
||||||
}
|
}
|
||||||
CursorSense::PressEnd(_) => {
|
CursorSense::PressEnd(_) => {
|
||||||
|
// A fling only ever follows a pan -- never a selection
|
||||||
|
// that happened to end with the finger still moving, and
|
||||||
|
// never a tap/long-press that never left `Undecided`.
|
||||||
|
if self.arbiter.is_panning() {
|
||||||
|
let v = self.velocity.velocity();
|
||||||
|
list(ui).fling(v);
|
||||||
|
}
|
||||||
self.arbiter.release();
|
self.arbiter.release();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -198,13 +217,19 @@ impl Selection {
|
|||||||
_ if self.arbiter.is_idle() => {
|
_ if self.arbiter.is_idle() => {
|
||||||
let already_selected = self.has_selection(ui);
|
let already_selected = self.has_selection(ui);
|
||||||
self.arbiter.press_start(pos_window, now, already_selected);
|
self.arbiter.press_start(pos_window, now, already_selected);
|
||||||
|
self.velocity.reset();
|
||||||
|
list(ui).cancel_fling();
|
||||||
self.arbiter.update(pos_window, now)
|
self.arbiter.update(pos_window, now)
|
||||||
}
|
}
|
||||||
_ => self.arbiter.update(pos_window, now),
|
_ => self.arbiter.update(pos_window, now),
|
||||||
};
|
};
|
||||||
match outcome {
|
match outcome {
|
||||||
DragOutcome::Undecided => {}
|
DragOutcome::Undecided => {}
|
||||||
DragOutcome::Pan(dy) => list(ui).scroll(-dy),
|
DragOutcome::Pan(dy) => {
|
||||||
|
let amt = -dy;
|
||||||
|
self.velocity.add_sample(amt, now);
|
||||||
|
list(ui).scroll(amt);
|
||||||
|
}
|
||||||
DragOutcome::SelectStart => {
|
DragOutcome::SelectStart => {
|
||||||
// Grep-able on "iris selection" the way the frame report is
|
// Grep-able on "iris selection" the way the frame report is
|
||||||
// on "iris frame report" -- selection has no accessibility
|
// on "iris frame report" -- selection has no accessibility
|
||||||
|
|||||||
Reference in new issue
Block a user