Author SHA1 Message Date
iris 27ca5b2349 Merge remote-tracking branch 'origin/rustify' into worktree-agent-a9002910a315fe719 2026-09-06 02:03:04 -04:00
irisandClaude Fable 5.1 20b12255e1 iris/android: composing text sync, tap-vs-swipe focus, composer rebuild, atlas reset on app-switch
Four fixes from Iris's phone report on the dc01f88 build, plus her same-day
follow-up on swipe-vs-tap:

- android/ime.rs: InputConnection now calls InputMethodManager.updateSelection
  after every edit (new update_ime_selection, called from after_input) -- Gboard
  was holding keystrokes back with nothing telling it the app's selection/
  composing region had moved, which read as "doesn't enter it until I hit
  space, doesn't move the caret". New unit tests in widget/text/edit.rs cover
  the buffer-level composing/commit/delete/selection operations directly.

- attr.rs: Selector/Selectable rewritten around a shared on_press dispatcher
  over PressStart/Pressing/PressEnd instead of click_or_drag(), so a field
  that isn't already focused only grants focus (and requests the IME) on a
  completed tap -- press and release with no frame past DRAG_SLOP. A drag
  is never consumed, so whatever is behind the field still sees it. New
  FocusHost::is_focused (both platform impls) and TextEdit::press_origin
  back this. Verified on the emulator: dumpsys input_method's mInputShown
  stays false after a swipe over the composer, true after a tap.

- iris_core: GlyphAtlas::clear()/Textures::reset(), called together from
  android/view.rs's surface_changed exactly when a genuinely new renderer is
  built (app-switch, not the keyboard-resize path that already reuses the
  renderer) -- both CPU-side caches otherwise kept pointing at the old,
  destroyed device's textures. Verified on the emulator: home, reopen, every
  glyph still on screen.

- transcript-ui/composer.rs: rebuilt as one widget (unchanged Stack{rect,
  span} idiom, capped at ~6 lines via MaxSize + .scrollable(), wrapped in one
  Pad whose bottom Composer::set_bottom_inset rewrites in place so the bar
  sits on the IME or nav-bar inset with no rebuild -- rebuilding would drop
  focus/selection/in-progress text). Wired from bench_client.rs's existing
  on_insets_changed.

A second, deeper bug found while verifying the composing fix is NOT fixed
this pass: composed text never becomes visible at all. A new layout_tests.rs
test proves the widget tree's own region math is correct across a keyboard
resize, ruling that out; RUST.md's P0 box has the full writeup and what to
check next (UiRenderState::redraw's single-widget path, or something
force-gles-specific -- this AVD has no Vulkan adapter to rule that out with).

cargo fmt/clippy/test --workspace and cargo ndk clippy all clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 02:03:00 -04:00
irisandClaude Fable 5.1 71a3fae655 IRIS_TODO.md: streaming re-lays out the whole message, from the phone's bench v2
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 01:35:26 -04:00
irisandClaude Fable 5.1 c3984da623 docs/bench: iris bench v2 report from Iris's phone, verbatim, with her observations
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 01:34:39 -04:00
irisandClaude Fable 5.1 2e3f4ada38 Merge iris fling/jitter fix + Benchmark v2 + header/ime follow-ups into rustify
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 01:23:50 -04:00
irisandClaude Fable 5.1 03c6be80a3 iris android-app: header-duplicate investigation, ime-inset fix for keyboard confirmation
Two follow-ups after the keyboard/dp/header pass, both requested against
the P0 box:

(a) The header row rendering a second time inside the transcript area
after a keyboard-triggered resize: reproduced reliably (tap the composer,
screenshot after the keyboard opens). Ruled out one concrete hypothesis --
on_insets_changed rebuilding top_bar on every ime_bottom change, unrelated
to the header's own status-bar padding -- with a guard (last_top_pad) that
reproduced the identical duplicate afterward, so repeated rebuilding is
not the cause. Kept the guard as a real (if insufficient) fix for needless
rebuilds. Not root-caused: Span's two-phase provisional/real draw and the
redraw_all-vs-redraw_updates split are the two live suspects, but pinning
which one (or something else) produces the duplicate needs instrumenting
draw_inner directly or the phone. Full writeup in RUST.md's P0 box.

(b) Why on_insets_changed's ime_bottom never confirmed the keyboard being
shown, on either the auto-diagnostics or the new bench keyboard phase:
MainActivity.java uses windowSoftInputMode="adjustResize", under which
WindowInsets.Type.ime()'s own inset amount is defined to read zero (the
window already resized to avoid the overlap that inset would describe) --
the same trap AGENTS.md already names for the Compose side. Fixed to read
insets.isVisible(ime()) instead, a boolean unaffected by resize-vs-pan.
This alone did not make the callback re-fire on this emulator, which
still shows no insets callback after the initial one at attach -- named
but unconfirmed hypothesis: a non-edge-to-edge Activity may not get insets
redelivered for a pure IME toggle handled via resize, needing an edge-to-
edge opt-in this pass did not attempt given the risk to adjustResize's
own behavior.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 01:23:36 -04:00
iris 4afc453faa Merge remote-tracking branch 'origin/rustify' into worktree-agent-a16b22e34539b810e
# Conflicts:
#	iris/android-app/src/bench_client.rs
#	iris/android-app/src/bench_jni.rs
2026-09-06 01:05:18 -04:00
irisandClaude Fable 5.1 1aab61bf26 iris android-app: Benchmark v2 -- fling, type and keyboard phases
Implements RUST.md's "Benchmark v2" spec in bench_client.rs: fling (8 out
+ 8 back at 12,000px/s through List::fling, waits for !is_scrolling()
capped 3s, reports travel as row index + offset via List's new
anchor_position_display), stream (unchanged), type (the 600-char P0
constant, one char per 50ms into the composer's real TextEdit via .set(),
then deleted), and keyboard (5 show/hide cycles via bench_jni.rs's new
InputMethodManager calls, confirmed from on_insets_changed's real
ime_bottom transitions rather than assumed from the JNI call returning).

FrameReport gained mark_phase/phase_stats/late_at_hz (iris/core) so the
report can show a per-phase block (frames, late%, p50/p90/p99, worst)
against the display's real refresh rate (bench_jni's new
refresh_rate_hz), matching the shape docs/bench/compose-phone-v2 uses.
RING_CAPACITY bumped 4096->16384 since a full v2 run is ~3,000+ frames.

Found and fixed a real deadlock while wiring this up: read_from_state
(a new helper that gets a value back out of a spawned task's ctx.update,
which has no return channel of its own) only worked for its first call in
a chain, because nothing called redraw.request_redraw() after enqueueing
later ones -- nothing then drains the task channel to run them. Every
call now triggers its own redraw.

Verified end to end on this checkout's x86_64 emulator (force-gles, cold
boot): fling/stream/type all report populated phase blocks; keyboard's
show never got a real on_insets_changed confirmation this run (see
follow-up work). Full report and travel numbers go in RUST.md's P0 box
next.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 01:02:04 -04:00
iris dc01f88d75 Merge branch 'worktree-agent-a1ff0294b6c29127e' into tmp-merge 2026-09-06 00:54:21 -04:00
iris c589a75fa0 Merge remote-tracking branch 'origin/rustify' into worktree-agent-a1ff0294b6c29127e
# Conflicts:
#	docs/RUST.md
2026-09-06 00:54:06 -04:00
irisandClaude Fable 5.1 4b62cc642e docs/RUST.md: emulator verification results for the keyboard/dp/header fixes
run-bench.sh end to end clean (24/24 swipes, 400/400 events); header
background confirmed by screenshot; the keyboard wipe fix confirmed two
ways (a forced wm size resize and an actual soft-keyboard open, both real
surface_changed triggers, text intact both times).

Also records two things found during this verification and not fixed:
the top button row appears to render a second time, out of place, after
a keyboard-triggered resize, and a tap aimed at the field below can land
on it instead -- and the keyboard diagnostics auto-capture never fired in
this session. Neither is root-caused; explicitly not attributed to this
pass's changes without more evidence, per the standing rule against
blaming ambient failures on your own code without measuring first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:51:27 -04:00
irisandClaude Fable 5.1 80c2eadec9 docs: record the keyboard-wipe fix, the dp unit and the header fix
docs/IRIS.md's 2026-09-06 entry (public API), docs/LAYOUT.md's "Density:
Len::dp" design section, IRIS_TODO.md's density-unit item ticked, and
docs/RUST.md's P0 box gets the investigation: the keyboard-wipe
hypothesis and confirmation, the blur root cause and why the dp unit
turned out to be the same fix, the header cause, and what remains
unverified (an emulator screenshot of the keyboard fix, and Iris's real
phone).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:40:59 -04:00
irisandClaude Fable 5.1 0b587629e6 iris/android-app bench: auto-capture diagnostics when the keyboard opens
So Iris can get a report off the phone even if the keyboard wipe (or
some other keyboard-triggered regression) is still present on whatever
build she is holding, independent of whether the on-screen Diagnostics
button itself is drawing.

on_insets_changed edge-triggers on ime_bottom becoming non-zero, waits
KEYBOARD_DIAGNOSTICS_DELAY_MS (500ms, long enough for the resize and a
couple of frames to settle) via a spawned task, then
capture_keyboard_diagnostics reuses show_diagnostics's exact report text,
logs it, copies it to the clipboard unprompted, and shows it through a
new PlatformHandle::show_diagnostics_overlay call into
IrisView.showDiagnosticsOverlay -- a plain TextView + Copy/Close panel
added over the existing IrisView (not replacing it, unlike
showRendererError's one-way trip) so it draws independently of whatever
iris's own renderer is doing, and Close returns to the still-running
session underneath.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:39:14 -04:00
irisandClaude Fable 5.1 3163256d2c iris/android-app: opaque header background, header sizes onto dp
Iris's phone report (build a9232ac): "the header buttons have nothing
behind them and overlap the transcript text." Only each button's own
rect painted anything, so the gaps between and around them (and the
status-bar strip above) showed CLEAR_COLOR (black) one layer back, and
the row's reserved height was three abs (physical-pixel) button boxes --
smaller, on a dense phone, than the dp-correct size the transcript below
now uses post the previous two commits, which is what reads as overlap
once the two disagree.

Fixed with a HEADER_SURFACE rect stacked behind the whole button row
(not just behind each button), and every non-text size in the header
(button padding, row height, the report field's padding) moved from a
bare number to dp(...), so the row's reserved height in the outer
Span::DOWN matches what is actually painted. The list/report field
already sit below the header in that same Span::DOWN, not behind it --
no stacking change needed there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:35:45 -04:00
irisandClaude Fable 5.1 6102e0d4d9 iris: a dp length unit, resolved against density; crisp glyphs at physical size
Iris asked for this 2026-09-06 (IRIS_TODO.md, "a third length kind beside
relative and pixels ... a unit resolved against the display's density at
layout time"): before this, a Len was abs (physical pixels) or rel/rest
(a fraction of the parent), and the only way to make a design size look
the same physical size on a denser display was a single global multiply
applied after layout -- which the previous commit found is also what
made text blurry.

Len gains a `dp` field, resolved against a `density: f32` (physical
pixels per dp) now carried on UiRenderState/Painter
(`UiRenderState::set_density`/`density()`, `Painter::density()`) and
threaded through every `apply_rest`/`to_uivec2` call site. `len_fns::dp`
/ `Len::dp` construct one, exactly parallel to the existing `abs`/`rel`/
`rest`. A bare number is unaffected (still `abs`, physical pixels) --
`dp` is opt-in.

Text: `TextBuffer::shape` now takes `density` and multiplies
`font_size`/`line_height` (and any span override) by it before handing
them to parley, so the size that reaches the shaper and the rasteriser
(`TextData::place`) is the display's real physical size -- the atlas
holds a bitmap at the resolution it is actually shown at, instead of a
low-resolution one stretched afterward. `GlyphKey.size` already keys on
the resolved `font_size`, so a cache entry is naturally per physical size
with no further change. `TextData` also carries its own `density` copy
for `TextEditCtx::layout` (cursor movement/hit-testing), which shapes
text from an input callback with no `Painter` to read it from.

`Span::gap` and `Padding`'s four sides move from bare `f32` to `Len`, so
`.gap(dp(4))`/`.pad(dp(10))` work the same way any other size does; a
bare number still means physical pixels, unchanged.

Migrated transcript-ui's non-text sizes (row gap/padding, composer
padding) and one example to the new unit, per IRIS_TODO.md's "done when"
list. Android's own density (`DisplayMetrics.density`) is wired to both
copies in `new_peer`; the winit backend has no per-monitor density wired
up yet and stays at the default (1.0).

docs/IRIS.md, docs/LAYOUT.md and IRIS_TODO.md updated next.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:35:38 -04:00
irisandClaude Fable 5.1 f0da383e28 iris/android: reuse the renderer across a surface resize, fix the keyboard glyph wipe
Hypothesis confirmed by reading the path end to end before changing
anything: 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 dropped AndroidRenderer and rebuilt it via
AndroidRenderer::new, which allocates a brand-new, empty glyph atlas and
fresh GPU buffers, while iris_core's CPU-side glyph cache kept the UV
coordinates it had already handed out against the *old* atlas -- so every
glyph drew from a rectangle pointing into a texture that had just been
recreated empty. Rects never go through the atlas, so they kept drawing:
exactly Iris's report ("rectangles stay; only text disappears").

Fixed by reusing the existing AndroidRenderer (device, atlas, buffers,
bind groups) and only reconfiguring the surface + window uniform via its
existing resize() when a renderer is already live; AndroidRenderer::new
now runs only when surface_changed finds `renderer` already None (a
genuinely new surface, e.g. after surface_destroyed/backgrounding).

While in this path, removed the global logical/physical scale stopgap
(dividing window size, touch coordinates and insets by content_scale)
that the P0 "text too small" fix had added: it is what made text blurry
next (a glyph rasterised small then stretched by the NDC mapping onto the
real physical framebuffer). Window size, touch and insets are physical
pixels throughout now, matching AndroidRenderer's own swapchain
resolution; density is resolved per-length instead (next commit).
LogicalInsets renamed to WindowInsets to match.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:35:22 -04:00
irisandClaude Fable 5.1 2d3695a1d3 Merge iris fling/jitter fix into rustify
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:20:39 -04:00
irisandClaude Fable 5.1 f06ee259b4 iris: List::fling with Android's spline physics, and fix the drag-slop scroll jitter
Adds VelocityTracker and a port of AOSP SplineOverScroller's fling curve
(FlingCalculator, cited at the definition) to iris::sense, and wires
List::fling/is_scrolling/cancel_fling/tick_fling through
Selection::drag's release path -- a pan's release now decelerates instead
of stopping dead on the finger lifting, matching IRIS_TODO.md's "swiping
has no momentum" ask. Clamped at the loaded content's start/end and
cancelled by the next touch-down.

Also fixes the scroll jitter DragArbiter's slop release caused: crossing
DRAG_SLOP applied the whole pre-threshold drag (measured from press_start)
in one step, since nothing pans while a gesture might still resolve to a
selection. Now only the excess past DRAG_SLOP is applied on that frame,
the same way Android's own touch handling consumes touch slop rather than
replaying it.

Root-caused by reading DragArbiter's state machine and covered by new
unit tests (fling distance against the closed-form spline result within
1%, cancel-on-touch, start/end clamp, the slop-crossing regression); no
emulator was used this pass, so an on-device trace/feel-check is still
open, and Benchmark v2's four-phase bench_client.rs spec was not
attempted. docs/IRIS.md, docs/IRIS_TODO.md and docs/RUST.md's P0 box
record what's done and what's left.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:20:24 -04:00
irisandClaude Fable 5.1 560a74caf8 docs: record the phone-report fixes, follow-ups and the bundled-font API
RUST.md's P0 box gets Iris's first real-phone report (no crash) and the
four defects it found (glyph-wipe-on-first-touch, missing bold glyphs,
text far too small, status-bar inset not applied), what was fixed and
how it was verified on the emulator, and what's still open (item 1's
root cause, and the top-row height anomaly noted in the last commit).

IRIS_TODO.md gets a new "From the phone, 2026-09-06" section for the two
items explicitly deferred to a follow-up agent: no scroll momentum/fling,
and occasional jitter scrolling down.

IRIS.md gets the public-API entry for TextData's bundled fonts/
font_diagnostics, UiRenderNode::new/resize's new window_size parameter,
AndroidUiState::content_scale, AndroidAppState::on_insets_changed, and
iris_core::WgpuErrorLog.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:59:40 -04:00
irisandClaude Fable 5.1 fd7e17523d iris/android: fix layout/shader unit mismatch left by the density-scale commit
surface_changed's self.render.resize(...) -- UiRenderState::output_size,
what every widget's absolute PixelRegion (a fixed .height(56), notably)
is computed against -- was still being handed raw physical width/height
after the previous commit switched AndroidRenderer's own size()/resize()/
new() to logical (physical / content_scale) for the shader's window
uniform. That split layout and the shader into two different units:
layout placed a "56"-unit row inside a ~2219-physical-unit-tall canvas
(an absolute box, still exactly 56 units), the shader then divided that
same 56 by a ~845-unit *logical* window dimension -- found on the
emulator by measuring a fresh install's top button row at ~40 physical
px against the ~147px `56 * content_scale` predicts. Proportional
(rest(n)) sizes hid the mismatch by adapting to whichever total they were
given; only fixed sizes exposed it. Now divides by content_scale here
too, matching every other call site.

Verified on this checkout's emulator (EMU_GPU default, force-gles):
run-bench.sh completes end to end (frames=691, 24/24 swipes streamed
400/400 events) and a fresh-install screenshot shows visibly larger
text than before this and the previous commit, with the top row's own
sizing still worth a closer look on a real device -- see RUST.md's P0
box for what remains unverified there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:57:24 -04:00
irisandClaude Fable 5.1 c7682297fa docs/bench: Compose bench v2 report from Iris's phone, verbatim
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:49:02 -04:00
irisandClaude Fable 5.1 27511302f2 iris/android-app: Diagnostics control, top-bar status-bar padding, cargo fmt
Adds a third "Diagnostics" button to the bench screen's top row, filling
the existing benchmark-report TextEdit (so the existing "Copy report"
button and clipboard path work on it unchanged) with adapter identity,
font resolution, atlas view count, wgpu errors seen so far and the frame
report -- RUST.md's P0 box, "a named Diagnostics control ... copy this
and send it to Iris." Logs the same font-resolution summary once at
startup too.

Wires BenchClient::on_insets_changed (the new AndroidAppState hook) to
rebuild the top button row with Padding::top(insets.top), through a
WidgetPtr slot (top_bar) so it can be swapped once the status-bar inset
is known -- fixes RUST.md's P0 box, "the status-bar inset is not
applied," where the two top buttons sat directly under the status bar
because nothing in this file read insets().top at all.

cargo fmt --all across the touched files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:44:43 -04:00
irisandClaude Fable 5.1 184a6c5b33 IRIS_TODO.md: a density-independent length unit, asked for by Iris
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:43:16 -04:00
irisandClaude Fable 5.1 5b2ca039f1 docs/RUST.md: bench v2 spec and the emulator smoke run
Iris's ask (2026-09-06): the fling should travel much faster for
stress-testing, plus typing and keyboard phases. Written once into the
P0 box so the iris agent implements the identical four-phase spec --
constants, ordering and report shape -- rather than a second one that
looks the same but isn't.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:41:03 -04:00
irisandClaude Fable 5.1 a8d24553d5 app: bench v2 -- a real fling, typing and keyboard phases
Iris's ask after using the Compose bench build on her phone: the old
scroll phase used animateScrollBy, which can only ever cover the fixed
distance/time it's given, so it never flings the way a real fast swipe
does. BenchRun.run now has four phases: fling (8 flings out + 8 back
through the list's own FlingBehavior at 12,000px/s), stream (unchanged),
type (600 fixed characters into the real composer TextFieldValue, then
deleted, to exercise wrapping and the transcript being pushed upward),
and keyboard (five show/hide cycles via WindowInsetsControllerCompat,
each confirmed by isImeVisible rather than assumed).

FrameStats.markPhase/phaseLines slice the same FrameMetrics recording
by phase rather than running a second recorder; debugReport gains a
phaseFrames section ahead of the existing whole-run frames/accounting/
work sections, which are otherwise unchanged.

Also fixes a pre-existing, unrelated break in MainActivity.kt's
benchSessionSummary() -- missing several SessionSummary constructor
arguments from an earlier change -- since it blocked compileBenchKotlin
outright.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:40:59 -04:00
irisandClaude Fable 5.1 3b80a88f3b iris/android: content_scale (density), per-frame diagnostics, insets hook
Threads DisplayMetrics.density (read once in new_peer, via the Context
android-view already hands the JNI entry point) through AndroidUiState
as content_scale, and divides by it everywhere a raw device-pixel number
used to reach layout unscaled: AndroidRenderer::size()/resize()/new() now
report logical (physical / density) dimensions to UiRenderNode and to
UiRenderState's own root-layout size, and on_touch_event divides the
incoming MotionEvent coordinates the same way, so touch and layout agree
on units again. This is the fix for RUST.md's P0 box, "text is far too
small" -- a font_size: 16.0 was 16 raw device pixels on a ~3x-density
phone, identical to the desktop fix in the previous commit.

Installs Device::on_uncaptured_error on the Android device (wgpu's
default handler is an unconditional panic outside UiRenderNode::new's
own error scopes) into a new iris_core::WgpuErrorLog, and adds
AndroidRenderer::diagnostics_report() combining adapter identity, font
resolution, atlas view count and the error log into one string for a
future Diagnostics screen. render() now logs a one-line diagnostic
(masks/moves resized, atlas pages grown, image bind-group creates, wgpu
error count) for the first 10 frames after each surface_changed -- the
window RUST.md's P0 box says the glyph-wipe-on-first-touch happens in.

Adds AndroidAppState::on_insets_changed(rsc, LogicalInsets), called from
render() exactly when AndroidUiState::insets() changes (once at startup
for the status bar, again on rotation/IME) -- nothing previously read
insets().top at all, which is why RUST.md's P0 box found the bench
screen's top buttons sitting under the status bar.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:40:30 -04:00
irisandClaude Fable 5.1 d8e6bc6e9b iris: bundle Noto Sans for text rendering, apply density scale on both backends
Bundles Noto Sans/Noto Sans Mono (regular/bold/italic/bold-italic, OFL
licensed) into iris-core and registers them ahead of the platform's own
fonts in the SansSerif/Monospace generic-family fallback lists, so text
no longer depends on the platform's font enumeration succeeding or
resolving weight/style correctly. Iris's phone report showed bold spans
rendering as blank gaps of the correct advance width -- the glyph simply
wasn't rasterised -- while the emulator's system fonts happened to
resolve every style; a bundled static-per-style family removes that
platform-dependent step entirely. TextData::font_diagnostics() reports
what was found/resolved, for the startup log and the Diagnostics page.

Also applies a content/device-pixel scale that neither backend had
before: UiRenderNode::new/resize now take the window size explicitly
(logical units) rather than deriving it from the surface's physical
config, so a 16.0 font size is 16 logical units rather than 16 raw
device pixels. Wired on desktop via window.scale_factor() (input events,
window_size, and the render node's own seed); the Android side (density
via DisplayMetrics, touch coordinates, layout root size) is the next
commit.

Also adds WgpuErrorLog and a per-frame atlas-grow counter
(GpuTextures::take_pages_grown), both plumbing for the Android
diagnostics page in the next commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:36:29 -04:00
irisandClaude Fable 5.1 b887a96765 docs/bench: iris's first phone report, before the phone fixes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:26:09 -04:00
irisandClaude Fable 5.1 2aaa3733c3 docs/bench: the Compose P0 report from Iris's phone, verbatim
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:20:05 -04:00
irisandClaude Fable 5.1 46246ea511 iris: turn the phone bind-group-layout crash into a diagnostic, drop force-gles from phone builds
UiRenderNode::new used to let a wgpu validation error reach the default
uncaptured-error handler and panic, which is what aborted the P0 bench APK
on Iris's phone in AndroidRenderer::new with only "wgpu error: Validation
Error" surviving into the truncated crash report. It now wraps creation in
wgpu error scopes and returns Result<Self, String>; the Android backend
turns a failure into the adapter's identity, the limits/downlevel flags a
layout validates against, and wgpu's own error chain, logged as one logcat
line and shown on screen (IrisView.showRendererError) instead of crashing.

Auditing every bind-group-layout entry against wgpu-core's own validation
source names the likely cause: masks_layout's move_offsets storage buffer
is visible to the vertex stage, which Vulkan grants unconditionally but
GLES gates on the driver's own vertex-stage SSBO support -- and the
delivered APK was built with force-gles, a flag meant only to force the
*emulator* onto GLES for one frame-time measurement, that build-apk.sh's
default feature list applied to every arm64 build regardless of target.
Its default no longer includes force-gles.

Testing the diagnostic (by inducing an artificial validation error) also
found and fixed a real reentrancy bug: calling Activity.setContentView
synchronously from inside a ViewPeer callback re-enters the same peer's
RefCell borrow through onFocusChanged, aborting with "RefCell already
borrowed". Deferred through the same push_dynamic_deferred_callback
mechanism raise_if_enabled already uses.

Full audit, verification, and the named hypothesis are in RUST.md's P0
box ("iris bench crash on the phone, 2026-09-06"); the API change is in
IRIS.md. Nobody on this session has the phone, so this is unconfirmed
against real hardware -- the point of (1) is that the next run says so
either way.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:05:07 -04:00
irisandClaude Fable 5.1 a27fbdb029 docs: close I5's three blocked verifications (24/24-swipe, backend isolation, cold-boot bench)
Ran three clean iris-scroll.sh passes on a cold -gpu host boot (all
24/24 swipes confirmed scrolling via clustered render() timestamps, not
inferred from frame count) and retook the host-GPU table's iris row as
a best-of-three. EMU_GPU=software + force-gles still cannot produce a
GLES number on this hardware -- after the earlier compute-limit crash
was fixed, device creation now aborts on max_storage_buffer_binding_size
instead (SwiftShader ES 3.0 has no SSBOs, and shader.wgsl reads four
var<storage> buffers unconditionally), so the SwiftShader-Vulkan-vs-GLES
question is closed as structurally unanswerable rather than answered.
A fresh cold-boot run-bench.sh reading for P0's bench build is in line
with the earlier warm-AVD readings, closing that box's own caveat too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:39:27 -04:00
irisandClaude Fable 5.1 c07d544aeb event-model, client-core, transcript-ui: carry main's LimitReached event
The merge that brought main into rustify added Event::LimitReached to the
server's drivers, but on this branch the enum lives in event-model, which
the merge left without it, so ai-server (and ui-sandbox.sh) did not build.
Definition copied from main's driver.rs; the fold mirrors TranscriptItems.kt's
LimitNote; the iris row shows the epoch until P1 brings a time formatter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:18:38 -04:00
irisandClaude Fable 5.1 46d3a6fd41 docs: record the streaming-rebuild fix, its numbers, and the new scripts
RUST.md's P0 box gets the fix, the before/after streaming-phase numbers
(with their caveats), the build-apk.sh/run-bench.sh scripts, and what the
dropout-fix pass's three remaining verifications are blocked on (the
sandbox ai-server currently fails to build, unrelated to this change).
IRIS.md gets the List::replace_back/clear and TranscriptScreen::apply
API entries. AGENTS.md's rigs section gets one sentence on each script.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:14:35 -04:00
irisandClaude Fable 5.1 5655fa8093 iris-android-app: build-apk.sh and run-bench.sh
Wraps the cargo-ndk/Gradle/keystore/apksigner build and the
install/tap-by-label/read-report cycle that P0's work had been retyping
by hand, so it stops costing time and mistakes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:14:35 -04:00
irisandClaude Fable 5.1 b3b1d47dd6 iris: streaming a transcript event no longer rebuilds the whole screen
Every client (bench_client, transcript_client, desktop-app) refolded and
rebuilt the ~3,200-row widget tree from scratch per SSE event, which is
the streaming-phase cost the P0 benchmark gate would otherwise measure
against a Compose app that updates one row. iris::widget::List gains
replace_back (swap the last row's widget in place, keeping its slot so a
pinned list stays pinned) and clear (the full-rebuild fallback);
transcript_ui::TranscriptScreen::apply diffs the folded row lists and
picks the cheapest update -- unchanged, append, replace-the-last-row, or
(rare regroup) a full rebuild, counted. TextEditCtx::set_with_spans lets a
row's text and span list land together on a streamed update.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:14:27 -04:00
iris 50fe4828a2 Merge branch 'worktree-agent-a27094a7db775552a' into tmp-merge 2026-09-05 21:37:12 -04:00
iris 800da46188 Merge remote-tracking branch 'origin/rustify' into worktree-agent-a27094a7db775552a
# Conflicts:
#	docs/IRIS.md
2026-09-05 21:37:04 -04:00
irisandClaude Fable 5.1 00767eed4d docs: P0's iris half done -- bench feature, emulator smoke run, APK
RUST.md's P0 box gets the iris-half account: the fixture, the scroll/stream
mechanism, the report fields, build commands (all clean), packaging (no
cargo xtask apk yet, so a new Gradle release build type on top of cargo
ndk), and the emulator smoke run's report next to Compose's own. Used a
second, differently-named AVD rather than contend with the session already
on this checkout's own emulator.

DECISIONS.md's P0 entry gets a matching summary bullet. IRIS.md records
AndroidAppState::platform_ready. IRIS_TODO.md notes the one gap found:
no read-only selectable text primitive, so the bench report's TextEdit
picks up a keyboard on tap it has nothing to type into.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 21:35:51 -04:00
irisandClaude Fable 5.1 683db4908a iris-android-app: a bench feature, P0's iris half
A third AndroidAppState (BenchClient) on top of transcript-screen: embeds
app/bench-fixture/assets/transcript.jsonl with include_str! (no server, no
enrollment), folds the first 3,200 lines through client_core's real
fold_page as the opening backlog, and holds the rest back as a streaming
tail. "Run benchmark" resets FrameReport, animates the same 24-swipe/
6-cycle scroll BenchRun.kt drives (List::scroll in ~60Hz steps, since iris
has no built-in tween), then replays the tail at 20/s through fold_event --
the same fold path a live SSE reply takes -- and shows a report in a
selectable TextEdit. "Copy report" puts it on the clipboard.

The report adds process CPU time (libc::getrusage), peak RSS (/proc/self/
status's VmHWM) and battery current (BatteryManager.getIntProperty via
direct JNI, bench_jni.rs's PlatformHandle) to FrameStats's existing
frames/janky%/percentiles/CPU-GPU-split line -- "unavailable" rather than a
fabricated number wherever the platform can't answer.

build.rs now exits early under the bench feature before requiring a live
server's host/port/token/CA: BenchClient never calls build_transport().
app/build.gradle gains a signed `release` build type (previously only
debug) so the cdylib cargo ndk builds can be packaged for a phone, the same
key app/build-apk.sh generates.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 21:35:44 -04:00
irisandClaude Fable 5.1 8d23a20792 iris: AndroidAppState::platform_ready, a JavaVM+View handle for later JNI calls
Default no-op lifecycle hook, called once from new_peer right after new.
P0's bench build needs to call BatteryManager/ClipboardManager through the
view's own Context from a background thread as well as the UI thread, and
neither a JavaVM nor a GlobalRef to the view was reachable from
AndroidAppState::new before this. Existing implementors (Client,
TranscriptClient) are unaffected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 21:35:33 -04:00
iris 88631f5e8b Merge remote-tracking branch 'origin/rustify' into worktree-agent-a27094a7db775552a
# Conflicts:
#	AGENTS.md
#	server/src/session/driver.rs
2026-09-05 21:10:19 -04:00
irisandClaude Fable 5.1 cf10b17c5b End a subagent on its own end_turn, not the parent's tool_result, and give the expander a touch-sized row
The Agent tool runs subagents in the background, so the parent's result
arrives at launch while the subagent works on for minutes; finishing on it
read a running agent as finished with a transcript cut off at launch. A
subagent now ends on its own message_delta end_turn, and a later line for a
finished one reopens it, since a background agent can be messaged again.

The card's expander row was only the chevron's height, so a tap for it
landed on the first subcard; it is the platform's 48dp minimum now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 14:44:00 -04:00
irisandClaude Fable 5.1 9fa09b0af1 Show a session's subagents as subcards, each with a read-only transcript
A subagent is a second transcript owned by a session, in the same event
model, with no process and no controls. The claude translator routes lines
carrying parent_tool_use_id to a per-subagent translator and transcript
under <session>/subagents/<tool_use_id>; three routes expose the list, a
transcript page and the SSE stream. Echo grows /subagent [n] as the rig.

On the phone a card with subagents ends in a chevron expander, collapsed by
default, opening to outlined subcards styled like dev-updater's components;
a subcard opens SessionScreen in read-only form, addressed through
TranscriptAddress so paging, cache and stream are shared.

Design in SUBAGENTS.md; choices awaiting review in DECISIONS.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 13:41:15 -04:00
iris eff5c8b0c0 Let the machine's own CLI refresh an expired token, and retry once
A 401 from the usage endpoint means the stored access token has expired.
Refreshing it here is not an option: Anthropic's OAuth rotates the refresh
token, so a second refresher invalidates the CLI's copy and forces a
re-login on a machine that usually has a live session on it. So run the CLI
there instead and re-read what it wrote.

`doctor` rather than `auth status`: probed against 2.1.258 with an invalid
token, `auth status` answers loggedIn:true from the file alone and never
reaches the network. The same probe showed a failed refresh blanks both
tokens, which is why this stays on the 401 path.

Also gives ProviderConfig one program() so the CLI's default path is not
written down twice.
2026-09-05 12:07:34 -04:00
iris 7b63330aaa Say when a usage 401 is an expired login, not an unreachable endpoint
A 401 is the endpoint answering and refusing the stored OAuth token, which
Claude Code refreshes as it runs -- so a machine whose CLI has been idle
hands us a stale one. Reporting it as "usage endpoint unreachable" pointed
at the network instead of at the one thing that fixes it.
2026-09-05 11:58:17 -04:00
irisandClaude Opus 5 6bdec6e785 Let a session resume itself when its usage limit lifts
Off by default and per session: it spends quota the moment quota exists,
with nobody watching, which is not a thing a default may decide. Switched
on from the session settings dialog, with the message it sends editable
("continue" unless something else is typed).

Running out of quota becomes a state rather than an error. The Claude
driver recognises its dialect's sentence -- `Claude AI usage limit
reached|1788546972` -- and reports `LimitReached` with the reset time it
gave; nothing above a driver matches on a string. The transcript draws it
as a divider, like a clear or a compaction.

The schedule is a plan to *ask*, never a plan to send. Both reset times
available are untrustworthy in the direction that matters -- the dialect's
is written when the turn fails, the endpoint's moves when the window does
-- so the wait ends in a question to the usage meter, and only `ok` with
no window at 100% sends anything. A window still spent reschedules to its
own reset time, which is what makes a limit that lifts late wait longer
and one that lifts early resume sooner. A meter that cannot be asked is a
longer wait too, never a send. A day after the limit was hit the wait
gives up and says so in the transcript, so a machine that can never be
asked is not retried for ever.

The schedule is persisted on the session: a five-hour window outlasts a
backend restart, and a wait forgotten across one never comes back.

Driven end to end with echo, never a real account: `/limit [minutes]`
reports the same event a real driver does and `/usage` sets what the meter
answers, deliberately separate so the two can disagree. The wait moved
from the dialect's two minutes to the meter's seven when the meter changed
its mind, and the message went out on the first check after the meter came
back under the limit.

Also makes the settings dialog scrollable, which these two controls made
necessary: at a 1.5x system font it clipped the last of them with nothing
on screen to say so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 05:21:43 -04:00
irisandClaude Opus 5 4821a02bd3 Default thinking level for new sessions, and move the rigs out of AGENTS.md
`Config::default_effort` is what a session starts at when nothing chose one,
applied in `spawn_session` rather than filled in by the spawn screen so it
holds for an import and a bare API call too. It is set by the spawn screen's
own picker, whose label says so: one control, where new sessions are made,
rather than a settings page for a single value. Not on a provider, because
providers are discovered and the next rediscovery would erase it; not on the
phone, because a second device would then spawn at a level nobody there
chose. `GET`/`POST /defaults` carry it as a struct, so the permission mode --
still hardcoded to `auto` on the spawn screen -- can move there later without
a second route.

Only drivers that read a level are given one: an echo session was storing a
`--effort` it never passes to anything, which is a config file answering a
question about itself wrongly.

Separately, `AGENTS.md` is 35 KB sent with every request in this repo, and 12
KB of it was rigs and reference measurements that only matter once you are
running one. Those are the `ai-app-rigs` skill now -- the same text, still the
only copy, read when the work touches it. 35,198 -> 20,813 chars.

Verified on the emulator against the sandbox: the spawn screen pre-fills from
the server, picking `low` spawned a session at `low` and left `/defaults` set
to it, and an echo session spawned afterwards took no level at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 21:42:05 -04:00
irisandClaude Opus 5 1ff662c7c3 Let a session choose how hard it thinks
Output is about an eighth of what a session costs and thinking is nearly
all of it -- prose is ~1.5% of output tokens, measured over 27,015 requests
of this account's own transcripts -- so the level is the largest saving
available short of shortening the conversation itself.

Shaped like the working directory rather than like the model: the CLI's
only two setting control requests are `set_model` and `set_permission_mode`
(checked against the 2.1.258 binary), so `--effort` is read when the process
launches and cannot be asked of a running one. `set_session_effort` records
the level and stops the process; the next message or Start launches one that
has it. That is also why the picker is in the session settings dialog beside
Move, and not on the bar beside the model and the mode, which take effect
mid-turn.

`None` is a level in its own right -- the CLI's own default -- so the picker
can return to it, and a blank is normalized to it at the boundary rather
than stored as a level the CLI would reject.

Offered only where it means something: `DriverKind::takes_effort` reports
the capability and the phone leaves the row out entirely, rather than the
session-type branch this app does not have anywhere else. A llama session
would otherwise get a control whose only effect is stopping its process.

Verified on the emulator against the sandbox's fake CLI: the picker sets it,
the server reports it, and an echo session's dialog is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 21:23:58 -04:00
irisandClaude Opus 5 e4f0935f98 Keep the second auth test under a subscriber, so the tripwire is not flaky
`gates_every_route_and_never_logs_the_token` failed about one full-suite
run in ten, on the assertion that a rejection *was* logged. Its sibling
ends with an unauthenticated request of its own, made with no subscriber
on that thread -- and tracing caches a callsite's interest process-wide
the first time it is reached, so whichever test got there first decided
whether the warning would ever be recorded.

That is the rule already written at the top of "Things that have bitten",
applied to one member of a set: the combined gating+logging test exists
because of it, and the enrollment test added later did not get it.
Twenty runs clean since.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:58:11 -04:00
iris 3c0214ece8 Merge branch 'main' of git.arirex.me:iris/ai-app
# Conflicts:
#	AGENTS.md
#	PLAN.md
#	app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt
#	app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt
#	server/src/config.rs
#	server/src/main.rs
#	server/src/routes.rs
#	server/src/session/echo.rs
#	server/src/session/llama.rs
#	server/src/session/transport.rs
#	server/src/ssh.rs
#	server/src/usage.rs
2026-09-04 17:56:50 -04:00
irisandClaude Opus 5 127b25e60a Meter a session by its provider, and let llama.cpp run over ssh
The rate-limit bar answered a question about an account, and picked the
answer by machine. One machine runs echo, the Claude CLI and a local
model side by side, so every echo session on it drew the CLI's five-hour
window: a quota that session cannot spend and could never run down. A
session now names its meter (`usageProvider`, from
`DriverKind::usage_provider`, which `usage::providers_for` reads too so
the two lists cannot disagree), and the phone matches on machine *and*
provider. Nothing meters echo or llama, and nothing at all is drawn --
including while the first fetch is out, since "checking" under a session
that turns out to meter nothing is a row the screen then withdraws.

Echo gets a meter it can be *told* about instead: `/usage 42`,
`/usage 95 20`, `/usage 42 never`, `/usage notloggedin`,
`/usage unreachable`, `/usage failed`, `/usage off`. Those states cost
real quota to arrange, which is why none of them had been looked at.

And llama.cpp runs wherever a setup says, which was the last of phase 5.
`Transport::reserve_port` is the second half of what a transport is --
"run this" plus "reach this port" -- returning the port the server binds
there and the port that reaches it here, and `Launch::reaching` puts the
`-L` tunnel on the connection that already carries the command. Three
things that came out of building it:

- A forwarded launch gets a pty and every other one keeps `-T`. Killing
  the ssh client ends a CLI by closing the stdin it reads; llama-server
  never reads its stdin, so the same kill left it running on the far
  machine with the model loaded -- one orphan per stopped session.
- The model is looked for on the machine that will serve it, at that
  machine's own models directory, so `GET /setups/{id}/models` is what
  the spawn screen offers rather than the backend's own downloads.
- The readiness poll watches the process, not only the port: a model
  that will not load exits in a second and would otherwise have been
  reported as "gave up after 300s". The failure carries the log's tail.

Exercised end to end against this VM over ssh to itself: spawn, load,
answer, outlive a backend restart, be adopted, answer again, and stop --
with both the ssh client and the far llama-server gone afterwards. The
local path, the Claude bar and the spawn screen checked on the emulator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:45:32 -04:00
112 changed files with 12788 additions and 610 deletions

No files matched your search

+244
View File
@@ -0,0 +1,244 @@
---
name: ai-app-rigs
description: ai-app's test rigs, harness scripts and reference measurements - ui-sandbox.sh, debug-transcript.sh, transcript-bench.sh, stream-bench.sh, trace-draw.sh, the /usage fixture vocabulary, the fake CLI, the rule that no UI-driving script may tap a coordinate, how to test llama.cpp and ssh on this machine, how importing behaves, and the scroll/stream/explorer numbers not worth re-measuring. Read before running or writing a benchmark, driving the app's UI from a script, exercising the session lifecycle, testing a llama or remote session, or touching the import screen.
---
# ai-app: rigs, harnesses and measurements
Moved out of `AGENTS.md` on 2026-09-04 so it is read when it is relevant
rather than sent with every request in this repo -- it was 12 KB of the 35 KB
that file cost on every one. Unchanged in the move, and still the only copy.
## The rigs
Each exists because something was invisible without it.
- **`app/ui-sandbox.sh`** — a second `ai-server` with its own `$HOME`, config
and data directory, holding eight invented Claude Code transcripts and a
`claude` that is two lines of shell. **That isolation is the point**: the
import screen lists whatever is in `~/.claude/projects`, which in this VM is
real agent transcripts, so exercising *delete* against the ordinary server
deletes somebody's conversation and exercising *import* starts a real
`--resume` on the owner's account.
Its port and root derive from the checkout's name, so two checkouts'
sandboxes cannot reach each other, and its token is generated once into
`~/.config/ai-app/sandbox-token` and carried across restarts along with any
the enrolment flow appended — so the emulator app is enrolled **once** (the
start banner prints the command) and stays enrolled. It shares the real TLS
certificates, because the installed APK pins that CA.
Driving verbs, so none of this is re-derived per session:
`./ui-sandbox.sh spawn [title]` (an echo session, prints its id),
`./ui-sandbox.sh send SID text|@file`, and
`./ui-sandbox.sh api /path [curl args]`.
`./ui-sandbox.sh keep` restarts the server without wiping the sessions and
enrolment already there — for when the fixture under test was expensive to
build; plain `start` wipes them, which is right for the list-screen
fixtures and wrong for that.
It passes `--delay` by default, and `AI_SANDBOX_BIG_MB` puts one large
transcript among the small ones while `AI_SANDBOX_SPAWN_DELAY` makes the
fake CLI slow to start. Both exist because operations that finish in
milliseconds have states on the way that nothing can observe, and an
unobservable state is one where broken and working look identical.
It also builds a fixture tree at the sandbox home's `~/files` for the
explorer, holding the states otherwise only reachable by finding a real
machine in one: an empty directory, a name with a tab and one with an
apostrophe, a binary file, one over `FILE_LIMIT`, one `chmod 000`, a
symlink to a directory and a broken one, a source file per language, and
the three sizes the limits were measured against (`edit-32k.rs`,
`edit-128k.rs`, `big-source.rs`). Point a session at it with
`./ui-sandbox.sh api /sessions/<id>/cwd -X POST -H 'content-type: application/json' -d '{"cwd":"~/files"}'`.
The explorer's 409 is produced by editing the file on the machine
(`printf … > file`) between pressing the pencil and pressing save.
- **`app/debug-transcript.sh`** — a real conversation on the emulator. The
echo driver is the right rig for most things and the wrong one for anything
whose cost scales with what was actually written: a real reply is longer,
is real markdown, and carries tool calls whose input and output are
kilobytes. Two faults were invisible until a real transcript was loaded — a
page of history landing mid-fling threw the reader back to the newest end,
and parsing one real reply took 51ms against 4.6ms for a synthetic one.
`-b` takes the biggest conversation on the machine rather than the newest,
which is what a scrolling test wants; `--stop` takes it down.
It copies the transcript into `/tmp` and gives the server a `HOME` of its
own, so the import can only see the copy — importing spawns `claude
--resume`, and against the real file that is a second CLI writing to a
conversation somebody may still be in. **A transcript never goes in this
repository**: they hold whatever was said, read and written in that
session, and `~/repos` is shared with the host besides.
- **`/usage` in an echo session puts up an invented meter**, which is how the
rate-limit screens' states are reached without spending quota: `/usage 42`,
`/usage 95 20` (minutes left), `/usage 42 never` (the between-blocks window
with no reset time), `/usage 42 unreadable`, `/usage notloggedin`,
`/usage unreachable`, `/usage failed`, `/usage off`. The vocabulary is
`usage::Fixture`'s, since those are its states. With none set an echo
session meters nothing, which is the ordinary case and draws no bar.
- **A fake CLI exercises the process lifecycle without a token.** Point a
`claude_cli` provider's `command` at a two-line script — `#!/bin/sh` and
`cat > /dev/null` — and it behaves the way the lifecycle code cares about:
it holds the fifo open, records a real pid, writes nothing, and dies on a
signal. So adopt, stop, restart and start are all drivable without a real
`--resume` and without spending a turn on somebody's account. Reach for
this when what is under test is *whether a process is running*, and for
`debug-transcript.sh` when it is *what the transcript draws*.
- **`app/transcript-bench.sh`** is the standard scroll measurement: it opens
the first session (or `-k` keeps the current screen), scrolls a fixed
gesture loop, and prints the app's render report — the same one the in-app
copy button produces, whose `on screen:` line names what the viewport was
holding. Compare two runs with the same gestures; the emulator's absolute
frame times transfer nothing, the report's accounting does. Run it either
side of any change under `Markdown*.kt`, `Transcript*.kt` or
`SessionScreen.kt`'s list, and put the report in the commit. The numbers
that move first are the worst `record: one block`, the reparse mean while
streaming, and the draw phase's accounting line.
- **`app/stream-bench.sh [-k] FILE`** is that measurement for a reply still
arriving. It taps "Jump to latest" so the list is pinned to the newest end,
resets the report, sends FILE, waits for the transcript to stop growing,
and prints. Both of those are corrections to a first version that measured
nothing: a transcript parked further back never redraws while a reply
streams into it, and a session is idle at *both* ends of a turn, so polling
for idle answers before the turn has started.
- **`app/trace-draw.sh`** names what a scrolling frame spends inside the
framework, from `atrace` text output with no trace processor needed. It is
how the cost of a layout node per link was attributed to the framework
rather than guessed at.
### Driving the UI
**No script that drives this app's UI presses a coordinate.** Every control
is found by the name it already carries for assistive technology —
`ui-trace record --do "tap 'Session settings'"` — which resolves the label
against the screen at the moment of the gesture and fails the whole run when
it is not there. `app/bench-lib.sh` is what the bench scripts share for it. A
coordinate is a position measured once by hand, and anything that moves the
control makes the tap land on whatever now sits there — the bench then
reports a number that was never measured, which reads exactly like a result.
Both bench scripts pressed the render report at `tap 723 205` until that
button moved into the session settings dialog on 2026-09-03. The check that
none has crept back:
grep -n "tap [0-9]" app/*.sh
Swipes are still coordinates, deliberately: a gesture across a scrolling area
is a distance rather than a control.
**Two traps in the emulator bench loop**, each of which cost a run.
`adb shell pm clear` removes the enrolment and the notification permission
along with the saved anchors, so the next run measures a permission dialog —
re-enrol with the command `ui-sandbox.sh` prints, and
`pm grant … POST_NOTIFICATIONS`. And a saved scroll anchor is per session id,
so the only way two builds start a scroll from the same place is a *fresh
session for each*.
**The emulator is `~/repos/emulator-tools`' business, not this repo's.**
`emu up` creates and boots the AVD named after this checkout — whatever `emu
name` prints, never a name typed out here, since this file is the same in
every clone. `run-android.sh` is that plus a build and an install. The `adb`
on `PATH` after sourcing `android-env.sh` is that repo's wrapper, which fills
in `-s` from the same rule. Gradle does not go through it, so a Gradle init
script from `emulator-tools` runs `emu check` before `installDebug`,
`uninstallDebug` and `connectedAndroidTest` and fails rather than fanning out
to every attached device; when it refuses, say which device you mean at the
moment you use it — `ANDROID_SERIAL=$(emu serial) ./gradlew …`.
### Testing llama.cpp and ssh here
**Both are set up here as of 2026-09-04** and need nothing typed. The
prebuilt CPU llama.cpp lives outside the repo at `~/.local/opt/llama.cpp`
(the 15 MB `ubuntu-x64` release asset) and is symlinked as
`/usr/local/bin/llama-server`, which is what makes **discovery find it over
ssh**: `~/.local/bin` is not on the PATH a non-interactive ssh session gets.
It resolves its own libraries through `$ORIGIN`, so no `LD_LIBRARY_PATH` is
needed. One model is downloaded — `unsloth/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf`,
639 MB under `~/.local/share/ai-app/models` — and answers at usable speed on
this VM's 8 cores. **Do not test with a 2-bit quant**: the
IQ2_XXS of that model produces fluent nonsense, which reads exactly like a
broken driver — `llama-cli` produces the same from the file directly, which
is how to tell the two apart in a hurry.
There is no second machine, so **ssh this VM to itself**. That is set up
too: the key is `~/.config/ai-app/ssh-self` (its public half is in
`~/.ssh/authorized_keys`, labelled removable), and the real config carries a
setup called **"this vm over ssh"** — `bob@127.0.0.1` with that
`identityFile` plus
`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=/tmp/ai-app-known-hosts"]`
so it touches nothing real — offering `claude-cli` and `llama-cpp`. It is the
whole rig for "does a remote llama session work", since the far machine is
this one and the model file is the same file. For a throwaway setup of your
own, point a provider's `command` at something harmless like `/bin/echo`
rather than at `claude`: the transport is what is under test, the process
exiting immediately is the signal, and it costs no tokens. The remote login
shell here is **fish**; the
remote script and `ssh.rs`'s POSIX quoting happen to mean the same thing in
both, but that is luck rather than design, and a shell that is neither is the
thing to suspect first if a remote spawn ever mangles an argument.
## Importing
The import list reports each session's **size as well as its line count**,
because the two disagree in the way that matters: these transcripts embed
screenshots as base64, so one line can be a megabyte. On this machine a 69 MB
session has 3,427 lines and a 44 MB one has 6,792 — nothing about a line
count tells you what continuing a session will cost. Shown, not warned about;
importing a large session is a choice somebody is entitled to make.
**Never import a Claude Code session that is open in a terminal.** The app
refuses it — see PLAN.md for the incident that made that a refusal rather
than a warning.
**One Claude Code session id can name two files, and the listing offers it
once.** Resuming from a different working directory makes the CLI write a
second transcript with the same id under that directory's project folder — an
ordinary state of a machine, not corruption. Everything downstream addresses
a session by id, and the phone keyed its list on it, so two rows sharing one
**closed the app** on a Compose duplicate-key throw. `parse_listing` keeps
the copy with the most lines, because the other is usually a few-hundred-byte
stub and is often the *newer* of the two, so recency is the wrong key.
Deleting removes every copy rather than the first, or the row came back after
a delete that reported success. The phone's half is `uniqueItems`, which
every list keyed on a server-chosen id goes through: a repeat there must
never be able to close the app, whatever produced it.
**Deleting a session offers to take the machine's own transcript with it**
`DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the
confirmation, and only where the driver keeps a record of its own
(`keepsOwnTranscript`, which today means Claude Code). Off by default,
because leaving that copy is what makes an ordinary delete recoverable — and
the dialog's paragraph is rewritten when it is on rather than appended to,
since the sentence promising the conversation "should still be there to
import again" is exactly the one the switch makes false. The server deletes
the machine's copy *first*, so a machine it cannot reach leaves the session
where it was instead of half-deleted.
## Measurements worth not re-taking
- **What the transcript screen costs to scroll.** Taken 2026-08-30 on the GPU
emulator against a real imported transcript with the server at
`--delay 120`. Settled and flinging fast, both into fresh history and back
through rows already drawn: **5.25.9% janky frames, 99th percentile
2932ms, 02 slow UI-thread frames.** The stock Settings app on the same
device is 3.3% and 38ms, so this is at the platform floor. The number that
is *not* at the floor is the first few seconds after opening a session,
where every row on the way is being composed for the first time; that is
inherent to a lazy list and it is why a measurement taken before the screen
settles reads three times worse. **Settle first, then reset `gfxinfo`.**
- **The reset path is not reachable by reopening a session.** Measured
2026-09-04 against a session streaming at 20 events a second: reopening one
with an anchor 1,800 events back connects **87119 events behind**, well
under `CATCH_UP_LIMIT`'s 200, because the restore is two requests — the
opening page, then one span covering the whole distance. To exercise the
reset at all you have to lower `CATCH_UP_LIMIT` in a throwaway build; at 5
the app takes the reset on a live connection, clears, refills and carries
on without reconnecting.
- **The session screen's stream survives backgrounding here** — 20 seconds at
the launcher while 415 events were produced brought no reconnect at all,
which is not what the comment above that loop expects, and is most likely
this emulator being headless rather than the phone's behaviour.
- **Reopening a cached session costs one request for one event** (the probe),
and scrolling the whole conversation back costs nothing more; a cold open
of the same 500-event session is two pages, 100 events. Measured
2026-09-04 on the emulator against the sandbox.
- **Reading is cheap and editing is not.** The viewer handles a 1 MiB,
28,000-line file because it draws one row per line; the editor is one
`BasicTextField`, which costs two seconds a frame at 128 kB and stops the
app at 1 MiB, so `EDIT_LIMIT` caps it at 32 kB with the reason said on
screen. If you make the editor faster, that number is what to move.
EXPLORER.md's "What the measurements said" has the rest.
+7
View File
@@ -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
+44
View File
@@ -0,0 +1,44 @@
# Decisions awaiting review
Choices made while working autonomously, for Bryan to keep or change. Each
says what was picked and why; the detail is in the design doc it names.
Delete an entry once it has been looked at.
## Subagent views (2026-09-05, `SUBAGENTS.md`)
Made on my own judgement, limited blast radius:
1. **A subagent is a transcript, not a session.** It has no process,
controls or settings; it is addressed as `/sessions/{id}/subagents/{sub}`
and stored under the session's directory, so deleting the session takes
it. Alternative rejected: registering it as a session of its own, which
would give it a card in the main list and a driver that can do nothing.
2. **Read-only view is the session screen minus its controls**, rather than
a second, simpler transcript screen. Keeps paging, caching, selection
and rendering in one place. Cost: a `readOnly` mode threaded through
`SessionScreen`.
3. **The list only carries a count.** Each session row says how many
subagents it has; their titles and statuses are fetched when the card is
expanded. Keeps `GET /sessions` from reading every subagent transcript.
Consequence: an expanded card's statuses refresh with the list, not live.
4. **Expanded/collapsed is remembered per session on the phone**, not on
the server. Collapsed by default, per the transcript convention that new
things arrive collapsed.
5. **Subagents of imported sessions are not shown.** The import path still
skips `isSidechain` records; the CLI's own `subagents/agent-*.jsonl` files
are not read. Only subagents run while this backend was watching exist.
6. **Echo grows `/subagent [n]`** as the test rig, so nothing here needs a
paid turn to exercise.
Deferred, because they reach further than this feature:
- **Live status on the list.** Whether the session list should follow a
stream at all (it refreshes on demand today) decides whether subagent
status can ever be live there. Not changed.
- **Nested subagents.** A subagent's own Task calls are shown as tool calls
in its transcript and are not given transcripts of their own. Supporting
that is the same mechanism one level down, but the UI would need nested
expanders.
- **The subagent status row says "context unknown".** Nothing measures a
subagent's context; the row could leave it out rather than admit it.
+141
View File
@@ -0,0 +1,141 @@
# Subagents
A session's subagents -- the helpers a Claude Code session starts through its
Task tool -- each get a transcript of their own, listed under the session's
card and readable in the same transcript view the session has. Designed
2026-09-05; the decisions Bryan has not yet reviewed are in `DECISIONS.md`.
## What a subagent is here
**A subagent is a second transcript owned by a session, in the same event
model, with no process and no controls.** It is not a session: it cannot be
messaged, stopped or started, and it has no setup, model or usage of its
own. Everything it shares with a session -- the transcript file format, the
paging routes, the SSE stream, the phone's cache and rendering -- is reused
by addressing, not by copying.
The CLI reports a subagent's messages on the parent's own stream-json
output, each carrying `parent_tool_use_id` = the id of the Task `tool_use`
that started it. Before this the translator dropped those lines
(`subagent_events_are_not_duplicated_into_the_transcript`); now it routes
them to that subagent's own translator and transcript. The parent's
transcript still shows only the Task call itself.
## Storage
Under the session directory:
```
<session>/subagents/<tool_use_id>/meta.json {title, created}
<session>/subagents/<tool_use_id>/transcript.jsonl same SeqEvent lines as the session's
```
The id is the Task tool_use id (`toolu_…`), which is unique, stable across a
backend restart, and already the key everything on the parent side uses.
Only ids matching `[A-Za-z0-9_-]+` are ever created or looked up, since the
id becomes a path.
The transcript's sequence numbers are its own, starting at 1. `Transcript`,
`read_window`, `catch_up` and `read_after` work on it unchanged.
Its path out: deleting the session deletes its directory, subagents included.
There is no separate delete.
## Lifecycle, as events in the subagent's transcript
1. Created on the first child line for an unseen parent id (or, when the
parent Task call was seen, at that call). First lines written:
`Status Running`, then `UserMessage { text: <the Task's prompt> }` when
the prompt is known -- it genuinely is the subagent's first user turn.
2. Every child line is translated by that subagent's own `Translator`
(one per subagent: tool ids are unique but streaming deltas are by
content-block index, and parallel subagents interleave).
3. **The parent's `tool_result` never finishes a subagent.** The Task tool
runs in the background by default: the `tool_result` -- "Async agent
launched..." -- arrives the moment it *starts*, while the subagent goes
on working for however long its own turn takes, sometimes minutes. What
ends it is its own turn ending: the raw API's `message_delta` on its
stream carrying `stop_reason: "end_turn"` (a `stop_reason` of `tool_use`
is the model about to call one, not an end), or a `result` line for its
own turn if a future CLI version ever sends one. Either maps to
`Status Exited`; the subagent's vocabulary has no `Idle`, so the
equivalent event `dispatch` produces for an ordinary session is dropped
rather than written. A shipped version of this finished on the
`tool_result` instead, which read a running background agent as
"finished" with its transcript truncated at the moment it launched.
4. **A child line for a subagent that already finished reopens it**
(`Status Running`) rather than being dropped: a background Task can be
sent another message long after its first turn ended, and that is
exactly what a further line for it means. Same transcript, same child
`Translator`, just picking back up.
5. When the parent session's process exits (`Status Exited` on the
session), every subagent still `Running` gets `Status Exited` too: its
process was the parent's.
A subagent that was mid-flight when the backend restarted keeps working:
the registry reopens the existing transcript on the next child line, and
the file continues its sequence -- the same reopening #4 describes, whether
what closed it was a restart or its own `end_turn`. If its turn ended while
the backend was down nothing recorded that until the next line arrives, so
its last status stays `Running`, which the list reports as **unknown**
rather than as running (see the wire shape) until then.
Title: the Task call's `description` input, then ` (<subagent_type>)` when
one is given; falling back to the tool's name when the child arrives before
(or without) the parent call being seen.
## Server layout
- `session/subagent.rs` -- the registry: `Subagents` (per session, in
`Shared`), `Subagent` (its `Transcript` behind a mutex plus a
`broadcast::Sender<SeqEvent>`), `record(id, event)`, `start(id, title,
prompt)`, `finish(id)`, `reopen(id)`, `finish_all()`, `list()` from disk. Drivers get an
`Arc<Subagents>` beside their `EventSink`; llama ignores it.
- `session/claude/translate.rs` -- routes child lines by parent id, holds
one child `Translator` per subagent, remembers pending Task calls'
description/prompt/subagent_type.
- `session/echo.rs` -- `/subagent [n]`: the test rig. Starts *n* (default 1)
subagents at once, each named "helper k". Each writes the prompt as its
user message, streams a few words of text, runs one `Bash` tool call, then
finishes about three seconds after starting, and the parent's Task calls
end when their subagent does. Three seconds so the running state can be
seen on the phone.
- `routes.rs` -- three routes, in the doc table.
## Wire shape
```
GET /sessions/{id} SessionInfo gains `subagents: N` (count, 0 when none)
GET /sessions same field on each row
GET /sessions/{id}/subagents [{id, title, status, created, lastActivity}], oldest first
GET /sessions/{id}/subagents/{sub}/transcript exactly the session transcript's query and answer
GET /sessions/{id}/subagents/{sub}/events?after=N exactly the session events stream
```
`status` is the transcript's last `Status` event, serialised like a session's
(`running`, `exited`), except that a subagent whose session is not itself
running cannot be running: the list answers `unknown` for that one. The
phone words these as *running*, *finished* and *unknown* on the subcard.
The count on `SessionInfo` is a directory listing, so the list stays cheap.
The per-subagent status is only read when the list route is asked for.
## Phone
- `SessionSummary.subagents: Int`. A card with a non-zero count ends in an
expander row -- a full-width `Chevron(Pointing.Down)` row that flips to
`Pointing.Up` -- collapsed by default. Expanding fetches
`/sessions/{id}/subagents` and draws one `OutlinedCard` per subagent,
indented inside the session card, the way dev-updater draws a project's
components: title, then the status word and a relative time. The
expansion state is per session id and survives a refresh of the list.
- Tapping a subcard opens `Screen.Subagent`, which is `SessionScreen` in
**read-only** form: the same transcript, paging, cache, selection,
images and status row, with the composer, the process button, the model
picker, the files button, the settings cog and the usage bar left out.
The header shows the subagent's title with the session's title beneath
it. Back returns to the list.
- Addressing: `fetchTranscript`, `EventStream`, `TranscriptSource` and the
cache take a transcript address rather than a session id --
`sessions/{id}` or `sessions/{id}/subagents/{sub}` -- so the cache nests a
subagent's copy under its session's and the same code serves both.
@@ -142,6 +142,20 @@ data class SessionSummary(
val keepsOwnTranscript: Boolean, val keepsOwnTranscript: Boolean,
/** How much the session asks before acting; null when it was never set. */ /** How much the session asks before acting; null when it was never set. */
val permissionMode: String?, val permissionMode: String?,
/**
* How hard the model thinks, or null for the CLI's own default.
*
* Null is a level somebody can choose, not only one to start in -- see [EFFORT_LEVELS]. It is
* reported rather than assumed for the same reason [permissionMode] is.
*/
val effort: String?,
/**
* Whether a thinking level does anything here -- a Claude CLI session, not a llama or echo one.
*
* Asked of the server rather than worked out from the provider's name, because this is a
* property of the driver's *kind* and the phone only has the name.
*/
val takesEffort: Boolean,
/** /**
* Whether this continues a session the machine already had, which changes what deleting means. * Whether this continues a session the machine already had, which changes what deleting means.
*/ */
@@ -153,6 +167,24 @@ data class SessionSummary(
* itself from a default is one you can turn off while believing you are reading it. * itself from a default is one you can turn off while believing you are reading it.
*/ */
val notify: Boolean, val notify: Boolean,
/**
* Whether this session sends itself a message once its account's usage limit lifts, and what
* that message says.
*
* The message is what the server would actually send, with its own default already filled in,
* so the field shows the words rather than an empty box standing for them.
*/
val autoResume: Boolean,
val autoResumeMessage: String,
/**
* When the server next intends to check whether the limit has lifted, in epoch seconds, or null
* when nothing is waiting.
*
* A time to *ask*, not a time to resume: the server checks the meter at that moment and waits
* again if the limit is still on. Worded that way wherever it is shown, because a promise this
* app cannot keep is worse than no time at all.
*/
val resumeAt: Double?,
/** /**
* The directory the session works in, or null where it was never given one. * The directory the session works in, or null where it was never given one.
* *
@@ -178,8 +210,27 @@ data class SessionSummary(
* server because that is where a provider's kind is known. * server because that is where a provider's kind is known.
*/ */
val maxImageEdge: Int?, val maxImageEdge: Int?,
/**
* Which of `GET /usage`'s snapshots is about this session, and null where nothing meters it.
*
* The rate-limit bar answers a question about an *account*, and what decides which account --
* if any -- is the provider this session runs, not the machine it runs on. Pairing by machine
* alone drew the Claude CLI's five-hour window under every echo session on a machine that also
* has the CLI: a quota that session cannot spend and could never run down. Decided by the
* server for the same reason [maxImageEdge] is -- it is a fact about the provider's kind, and
* this app has only its name.
*/
val usageProvider: String?,
val status: String, val status: String,
val lastActivity: Double, val lastActivity: Double,
/**
* How many subagents this session has, however their own status now reads.
*
* A directory listing on the server rather than a status read per subagent, so the list stays
* cheap; the per-subagent state is only fetched when the card is expanded. Zero on a server
* that predates subagents, so this app still opens against one.
*/
val subagents: Int,
) )
private fun parseSession(session: JSONObject) = private fun parseSession(session: JSONObject) =
@@ -192,14 +243,24 @@ private fun parseSession(session: JSONObject) =
title = session.getString("title"), title = session.getString("title"),
model = session.optString("model").ifEmpty { null }, model = session.optString("model").ifEmpty { null },
permissionMode = session.optString("permissionMode").ifEmpty { null }, permissionMode = session.optString("permissionMode").ifEmpty { null },
effort = session.optString("effort").ifEmpty { null },
takesEffort = session.optBoolean("takesEffort", false),
imported = session.optBoolean("imported", false), imported = session.optBoolean("imported", false),
notify = session.optBoolean("notify", true), notify = session.optBoolean("notify", true),
autoResume = session.optBoolean("autoResume", false),
// The server sends its own default rather than nothing, so an empty answer means an older
// server -- and this app's word for it is the same word.
autoResumeMessage =
session.optString("autoResumeMessage").ifEmpty { DEFAULT_RESUME_MESSAGE },
resumeAt = if (session.has("resumeAt")) session.getDouble("resumeAt") else null,
cwd = session.optString("cwd").ifEmpty { null }, cwd = session.optString("cwd").ifEmpty { null },
contextTokens = contextTokens =
if (session.has("contextTokens")) session.getLong("contextTokens") else null, if (session.has("contextTokens")) session.getLong("contextTokens") else null,
maxImageEdge = session.optInt("maxImageEdge", 0).takeIf { it > 0 }, maxImageEdge = session.optInt("maxImageEdge", 0).takeIf { it > 0 },
usageProvider = session.optString("usageProvider").ifEmpty { null },
status = session.getString("status"), status = session.getString("status"),
lastActivity = session.getDouble("lastActivity"), lastActivity = session.getDouble("lastActivity"),
subagents = session.optInt("subagents", 0),
) )
fun fetchSessions(settings: ServerSettings): List<SessionSummary> = fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
@@ -215,6 +276,35 @@ fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
fun fetchSession(settings: ServerSettings, sessionId: String): SessionSummary = fun fetchSession(settings: ServerSettings, sessionId: String): SessionSummary =
requestFromServer(settings, "/sessions/$sessionId") { parseSession(it.jsonObject()) } requestFromServer(settings, "/sessions/$sessionId") { parseSession(it.jsonObject()) }
/**
* One row of `GET /sessions/{id}/subagents`, oldest first.
*
* A subagent is a second transcript owned by a session -- no process, no controls of its own -- so
* this carries only what a card needs to draw and to open it; see SUBAGENTS.md. [status] is
* "running", "exited" or "unknown": a subagent whose session is not itself running cannot be
* running, and the list says so rather than reporting a state that cannot hold.
*/
data class SubagentSummary(
val id: String,
val title: String,
val status: String,
val created: Double,
val lastActivity: Double,
)
fun fetchSubagents(settings: ServerSettings, sessionId: String): List<SubagentSummary> =
requestFromServer(settings, "/sessions/$sessionId/subagents") {
it.jsonObjects { row ->
SubagentSummary(
id = row.getString("id"),
title = row.getString("title"),
status = row.getString("status"),
created = row.getDouble("created"),
lastActivity = row.getDouble("lastActivity"),
)
}
}
// What the server offers, so the spawn screen has no hardcoded lists: a setup added to the server's // What the server offers, so the spawn screen has no hardcoded lists: a setup added to the server's
// config.ron appears here with no app rebuild. // config.ron appears here with no app rebuild.
// //
@@ -375,6 +465,12 @@ data class SshDetails(
* Where files attached from here land on that machine; null for the session's own directory. * Where files attached from here land on that machine; null for the session's own directory.
*/ */
val attachmentsDir: String? = null, val attachmentsDir: String? = null,
/**
* Where that machine keeps its GGUF models; null for the same place the backend keeps its own
* (`~/.local/share/ai-app/models`, read on that machine). A llama.cpp session serves the file
* from the machine it runs on, so this is where its models are looked for and listed.
*/
val modelsDir: String? = null,
) )
private fun SshDetails.toJson() = private fun SshDetails.toJson() =
@@ -382,6 +478,7 @@ private fun SshDetails.toJson() =
if (port != null) put("port", port) if (port != null) put("port", port)
if (!identityFile.isNullOrBlank()) put("identityFile", identityFile) if (!identityFile.isNullOrBlank()) put("identityFile", identityFile)
if (!attachmentsDir.isNullOrBlank()) put("attachmentsDir", attachmentsDir) if (!attachmentsDir.isNullOrBlank()) put("attachmentsDir", attachmentsDir)
if (!modelsDir.isNullOrBlank()) put("modelsDir", modelsDir)
} }
/** What a machine turns out to have, without saving anything. */ /** What a machine turns out to have, without saving anything. */
@@ -450,6 +547,8 @@ fun spawnSession(
model: String? = null, model: String? = null,
cwd: String? = null, cwd: String? = null,
permissionMode: String? = null, permissionMode: String? = null,
/** Null for whatever the server's default is; see [fetchDefaultEffort]. */
effort: String? = null,
params: Map<String, String> = emptyMap(), params: Map<String, String> = emptyMap(),
/** Continue this Claude Code session instead of starting an empty one. */ /** Continue this Claude Code session instead of starting an empty one. */
import: String? = null, import: String? = null,
@@ -467,6 +566,7 @@ fun spawnSession(
if (!model.isNullOrBlank()) put("model", model) if (!model.isNullOrBlank()) put("model", model)
if (!cwd.isNullOrBlank()) put("cwd", cwd) if (!cwd.isNullOrBlank()) put("cwd", cwd)
if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode) if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode)
if (!effort.isNullOrBlank()) put("effort", effort)
if (!import.isNullOrBlank()) put("import", import) if (!import.isNullOrBlank()) put("import", import)
if (params.isNotEmpty()) { if (params.isNotEmpty()) {
put("params", JSONObject(params.toMap<String, Any>())) put("params", JSONObject(params.toMap<String, Any>()))
@@ -906,7 +1006,7 @@ fun startImport(
*/ */
fun fetchTranscript( fun fetchTranscript(
settings: ServerSettings, settings: ServerSettings,
sessionId: String, address: TranscriptAddress,
before: Long? = null, before: Long? = null,
limit: Int = 80, limit: Int = 80,
// Count [limit] in rows, not events, joining a reply's streamed deltas into one -- so a page of // Count [limit] in rows, not events, joining a reply's streamed deltas into one -- so a page of
@@ -925,7 +1025,7 @@ fun fetchTranscript(
if (coalesce) append("&coalesce=true") if (coalesce) append("&coalesce=true")
if (after != null) append("&after=").append(after) if (after != null) append("&after=").append(after)
} }
return requestFromServer(settings, "/sessions/$sessionId/transcript$query") { connection -> return requestFromServer(settings, "/${address.urlPath}/transcript$query") { connection ->
val body = JSONArray(connection.inputStream.bufferedReader().readText()) val body = JSONArray(connection.inputStream.bufferedReader().readText())
// The text as well as the event: the transcript cache stores the one and the fold needs the // The text as well as the event: the transcript cache stores the one and the fold needs the
// other, and they have to be the same line. // other, and they have to be the same line.
@@ -973,6 +1073,56 @@ fun setSessionModel(settings: ServerSettings, sessionId: String, model: String)
*/ */
val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan") val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
/**
* What a new session's thinking level is when nothing chose one, or null for the CLI's own.
*
* Held by the server rather than by this phone, because a second device would otherwise spawn
* sessions at a level the first one's owner never picked.
*/
fun fetchDefaultEffort(settings: ServerSettings): String? =
requestFromServer(settings, "/defaults") {
it.jsonObject().optString("effort").ifEmpty { null }
}
/** Sets what new sessions start at. Nothing already running changes. */
fun setDefaultEffort(settings: ServerSettings, level: String?) {
requestFromServer(
settings,
"/defaults",
method = "POST",
jsonBody = JSONObject().put("effort", level ?: JSONObject.NULL).toString(),
) {}
}
/**
* How hard the model thinks, as `claude --effort` takes them, cheapest first.
*
* Not offered alongside the model and the permission mode on the session's own bar, because it does
* not behave like them: the CLI has a control request for those two and none for this (checked
* against 2.1.258), so a level is settled when the process is launched. Changing it therefore stops
* the process, which is what the working directory beside it in this dialog does, and why it is
* here rather than on a bar whose other controls take effect mid-turn.
*/
val EFFORT_LEVELS = listOf("low", "medium", "high", "xhigh", "max")
/** What the picker shows, and sends as null, for a session that has chosen no level. */
const val DEFAULT_EFFORT = "default"
/**
* Records how hard a session thinks and **stops its process**, since the level is read when the
* process is launched. The next message, or Start, runs one that has it.
*
* [level] is null for the CLI's own default.
*/
fun setSessionEffort(settings: ServerSettings, sessionId: String, level: String?) {
requestFromServer(
settings,
"/sessions/$sessionId/effort",
method = "POST",
jsonBody = JSONObject().put("effort", level ?: JSONObject.NULL).toString(),
) {}
}
/** Switches how much a running session asks before acting, also in place. */ /** Switches how much a running session asks before acting, also in place. */
fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode: String) { fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode: String) {
requestFromServer( requestFromServer(
@@ -984,6 +1134,36 @@ fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode:
} }
/** Turns this session's notifications on or off. Stored on the backend -- see `SessionConfig`. */ /** Turns this session's notifications on or off. Stored on the backend -- see `SessionConfig`. */
/**
* What an auto-resume says when nothing else was typed. Mirrors the server's own default, so a
* cleared field shows the word that would actually be sent instead of going blank.
*/
const val DEFAULT_RESUME_MESSAGE = "continue"
/**
* Turns auto-resume on or off and sets what it would say, in one request because they are one
* decision -- see the server's `/sessions/{id}/auto-resume`.
*/
fun setSessionAutoResume(
settings: ServerSettings,
sessionId: String,
autoResume: Boolean,
message: String?,
) {
requestFromServer(
settings,
"/sessions/$sessionId/auto-resume",
method = "POST",
jsonBody =
JSONObject()
.put("autoResume", autoResume)
// Empty means the server's default rather than a session poked with nothing to
// read, which is the same rule the server applies to the field.
.put("message", message?.trim()?.ifEmpty { null } ?: JSONObject.NULL)
.toString(),
) {}
}
fun setSessionNotify(settings: ServerSettings, sessionId: String, notify: Boolean) { fun setSessionNotify(settings: ServerSettings, sessionId: String, notify: Boolean) {
requestFromServer( requestFromServer(
settings, settings,
@@ -1074,6 +1254,26 @@ private fun parseDownload(o: JSONObject) =
error = if (o.has("error")) o.getString("error") else null, error = if (o.has("error")) o.getString("error") else null,
) )
/**
* The models on one machine, which is the list a llama.cpp session there can choose from.
*
* Not [fetchModels], which is what the *backend* has downloaded. A session serves its model from
* the machine it runs on, so for a machine reached over ssh those are two different lists -- and
* offering the backend's would name files that are not there, turning a choice that cannot work
* into a session that fails when it tries to load one.
*/
fun fetchSetupModels(settings: ServerSettings, setupId: String): List<LocalModel> =
requestFromServer(settings, "/setups/${setupId.urlEncoded()}/models") { connection ->
JSONArray(connection.inputStream.bufferedReader().readText()).mapObjects { m ->
LocalModel(
key = m.getString("key"),
repo = m.getString("repo"),
file = m.getString("file"),
bytes = m.getLong("bytes"),
)
}
}
fun fetchModels(settings: ServerSettings): Models = fun fetchModels(settings: ServerSettings): Models =
requestFromServer(settings, "/models") { connection -> requestFromServer(settings, "/models") { connection ->
val body = JSONObject(connection.inputStream.bufferedReader().readText()) val body = JSONObject(connection.inputStream.bufferedReader().readText())
@@ -1,7 +1,9 @@
package com.example.aiapp package com.example.aiapp
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@@ -34,7 +36,23 @@ import kotlinx.coroutines.withContext
* session, spawning one, and settings. * session, spawning one, and settings.
*/ */
private sealed class Screen { private sealed class Screen {
data object Main : Screen() /**
* The session list, with a subagent's own transcript over it when [subagent] is set.
*
* A layer on this screen rather than a screen of its own, for the same reason [Session.files]
* is: [SessionListScreen] owns which cards are expanded and what each expansion fetched, kept
* in `remember`, and a subagent is opened from a card's expander. As a sibling `Screen` it was
* disposed and recreated on every return, which lost that state -- an expanded card collapsed
* itself the moment its own subagent's view was closed.
*/
data class Main(val subagent: SubagentTarget? = null) : Screen()
/**
* One subagent's own transcript, read-only. See [SessionScreen]'s `subagent` parameter and
* SUBAGENTS.md's "Phone". Closing it returns to [Main] under it, not to [Session]: a subagent
* is opened from the session list's card rather than from inside the session it belongs to.
*/
data class SubagentTarget(val summary: SessionSummary, val subagent: SubagentSummary)
/** /**
* One session, with the file explorer over it when [files] is set. * One session, with the file explorer over it when [files] is set.
@@ -81,7 +99,7 @@ fun AppRoot(
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) } var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) }
var screen by remember { mutableStateOf<Screen>(Screen.Main) } var screen by remember { mutableStateOf<Screen>(Screen.Main()) }
// A notification tap this could not follow, and why. Null both before one is asked for and // A notification tap this could not follow, and why. Null both before one is asked for and
// after one succeeds, since success is a screen rather than a message. // after one succeeds, since success is a screen rather than a message.
var failedOpen by remember { mutableStateOf<FailedOpen?>(null) } var failedOpen by remember { mutableStateOf<FailedOpen?>(null) }
@@ -96,7 +114,7 @@ fun AppRoot(
share = shareRequest share = shareRequest
// A session already open takes it. Otherwise the list is where the choice is made, // A session already open takes it. Otherwise the list is where the choice is made,
// whatever screen was showing: Spawn and Settings have nowhere to put a file. // whatever screen was showing: Spawn and Settings have nowhere to put a file.
if (screen !is Screen.Session) screen = Screen.Main if (screen !is Screen.Session) screen = Screen.Main()
} }
} }
@@ -123,7 +141,7 @@ fun AppRoot(
existing = null, existing = null,
onSaved = { saved -> onSaved = { saved ->
settings = saved settings = saved
screen = Screen.Main screen = Screen.Main()
}, },
onBack = null, onBack = null,
) )
@@ -136,7 +154,7 @@ fun AppRoot(
// shows, so it always refetches. // shows, so it always refetches.
val goToMain = { val goToMain = {
reloadToken++ reloadToken++
screen = Screen.Main screen = Screen.Main()
} }
if (screen !is Screen.Main) { if (screen !is Screen.Main) {
BackHandler(onBack = goToMain) BackHandler(onBack = goToMain)
@@ -185,6 +203,9 @@ fun AppRoot(
reloadToken = reloadToken, reloadToken = reloadToken,
share = share, share = share,
onOpen = { screen = Screen.Session(it) }, onOpen = { screen = Screen.Session(it) },
onOpenSubagent = { summary, subagent ->
screen = here.copy(subagent = Screen.SubagentTarget(summary, subagent))
},
onSpawn = { screen = Screen.Spawn }, onSpawn = { screen = Screen.Spawn },
onImported = { imported -> onImported = { imported ->
reloadToken++ reloadToken++
@@ -192,6 +213,27 @@ fun AppRoot(
}, },
onSettings = { screen = Screen.Settings }, onSettings = { screen = Screen.Settings },
) )
// Its own back handler is registered after MainScreen's, so it is the one the
// platform asks first while a subagent is open -- the same rule the files
// explorer's handler follows over its session, below.
here.subagent?.let { target ->
BackHandler { screen = here.copy(subagent = null) }
// Its own opaque background: this screen was always the sole content under
// the theme's own Surface before, so it never had to paint one -- stacked over
// the list here, the space between its own cards let the list underneath show
// through without this. The same fix FilesScreen needed over its session.
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
key(target.summary.id, target.subagent.id) {
SessionScreen(
settings = current,
summary = target.summary,
onBack = { screen = here.copy(subagent = null) },
onFiles = {},
subagent = target.subagent,
)
}
}
}
} }
is Screen.Session -> is Screen.Session ->
// Keyed on the id, because a different session is a different screen rather than this // Keyed on the id, because a different session is a different screen rather than this
@@ -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()
@@ -12,6 +12,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
/** /**
* A line across the transcript saying what left the session's context. * A line across the transcript saying what left the session's context.
@@ -47,3 +51,40 @@ fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier)
fun ClearedRow(modifier: Modifier = Modifier) { fun ClearedRow(modifier: Modifier = Modifier) {
TranscriptDivider("Context cleared", clearedColor, modifier) TranscriptDivider("Context cleared", clearedColor, modifier)
} }
/**
* The mark running out of quota leaves.
*
* The same red the usage bar takes when a window is spent, because it is the same fact in a second
* place: colour by consequence, so "there is nothing left to spend" is learned once.
*
* A time rather than a countdown. The row is folded once and never re-measured, so a span would go
* stale on screen the moment it was drawn; and this is when the *account* said it would reset,
* which is not a promise about when the session picks back up. A limit the session was told no
* reset time for says nothing about one -- that state has its own words rather than a plausible
* number.
*/
@Composable
fun LimitRow(item: TranscriptItem.LimitNote, modifier: Modifier = Modifier) {
TranscriptDivider(limitSummary(item.resetsAt, ZoneId.systemDefault()), overLimitColor, modifier)
}
/**
* What the row says. Split out so the wording is testable without a screen, since the two states it
* has to keep apart -- a reset time that arrived and one that never did -- are exactly the pair
* that reads the same when it goes wrong.
*
* [zone] is a parameter rather than read here so a test says the same thing wherever it runs.
*/
fun limitSummary(resetsAt: Double?, zone: ZoneId): String {
val at = resetsAt?.let {
try {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
.withZone(zone)
.format(Instant.ofEpochSecond(it.toLong()))
} catch (_: Exception) {
null
}
}
return if (at == null) "Usage limit reached" else "Usage limit reached • resets $at"
}
@@ -14,7 +14,7 @@ private const val RESET_EVENT = "reset"
* mean. [close] from any thread ends it, and the caller owns reconnecting -- with the last seq it * mean. [close] from any thread ends it, and the caller owns reconnecting -- with the last seq it
* saw as the new cursor. * saw as the new cursor.
*/ */
class EventStream(settings: ServerSettings, private val sessionId: String) { class EventStream(settings: ServerSettings, private val address: TranscriptAddress) {
private val stream = Sse(settings) private val stream = Sse(settings)
fun close() = stream.close() fun close() = stream.close()
@@ -35,7 +35,7 @@ class EventStream(settings: ServerSettings, private val sessionId: String) {
// one and the screen folds the other, and they have to be the same line. // one and the screen folds the other, and they have to be the same line.
onEvent: (raw: String, event: SeqEvent) -> Unit, onEvent: (raw: String, event: SeqEvent) -> Unit,
) { ) {
stream.run("/sessions/$sessionId/events?after=$after", onOpen) { name, data -> stream.run("/${address.urlPath}/events?after=$after", onOpen) { name, data ->
// A named frame carries no payload and a data frame has no name. // A named frame carries no payload and a data frame has no name.
if (name == RESET_EVENT) onReset() if (name == RESET_EVENT) onReset()
else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data)) else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data))
@@ -157,6 +157,18 @@ sealed class SessionEvent {
*/ */
data object Cleared : SessionEvent() data object Cleared : SessionEvent()
/**
* The session stopped because its account's usage limit was reached.
*
* Its own event rather than an [Error] carrying the CLI's sentence, because it is a state
* rather than something that went wrong -- and because the raw sentence is `Claude AI usage
* limit reached|1788546972`, which is not readable by the person it is shown to.
*
* [resetsAt] is epoch seconds and null where the session was told nothing. Only the server acts
* on it; what this draws it as is a time, not a countdown, because nothing here re-measures it.
*/
data class LimitReached(val resetsAt: Double?) : SessionEvent()
data class Error(val message: String) : SessionEvent() data class Error(val message: String) : SessionEvent()
/** /**
@@ -261,6 +273,10 @@ fun parseSeqEvent(json: String): SeqEvent {
trigger = body.optString("trigger").ifEmpty { null }, trigger = body.optString("trigger").ifEmpty { null },
) )
"cleared" -> SessionEvent.Cleared "cleared" -> SessionEvent.Cleared
"limitReached" ->
SessionEvent.LimitReached(
if (body.has("resetsAt")) body.getDouble("resetsAt") else null
)
"error" -> SessionEvent.Error(body.getString("message")) "error" -> SessionEvent.Error(body.getString("message"))
else -> SessionEvent.Unknown(type) else -> SessionEvent.Unknown(type)
} }
@@ -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,
@@ -48,6 +48,8 @@ fun MainScreen(
/** What another app shared in and no session has taken yet; see [ShareRequest]. */ /** What another app shared in and no session has taken yet; see [ShareRequest]. */
share: ShareRequest? = null, share: ShareRequest? = null,
onOpen: (SessionSummary) -> Unit, onOpen: (SessionSummary) -> Unit,
/** Opens one session's subagent, from the expander under its card. */
onOpenSubagent: (SessionSummary, SubagentSummary) -> Unit,
onSpawn: () -> Unit, onSpawn: () -> Unit,
onImported: (SessionSummary) -> Unit, onImported: (SessionSummary) -> Unit,
onSettings: () -> Unit, onSettings: () -> Unit,
@@ -139,6 +141,7 @@ fun MainScreen(
settings = settings, settings = settings,
reloadToken = token, reloadToken = token,
onOpen = onOpen, onOpen = onOpen,
onOpenSubagent = onOpenSubagent,
onSpawn = onSpawn, onSpawn = onSpawn,
) )
MainTab.Import -> MainTab.Import ->
@@ -1,7 +1,9 @@
package com.example.aiapp package com.example.aiapp
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -9,6 +11,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -17,6 +20,7 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -30,6 +34,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -47,12 +53,40 @@ fun SessionListScreen(
settings: ServerSettings, settings: ServerSettings,
reloadToken: Int, reloadToken: Int,
onOpen: (SessionSummary) -> Unit, onOpen: (SessionSummary) -> Unit,
/** Opens one session's subagent, from the expander under its card. */
onOpenSubagent: (SessionSummary, SubagentSummary) -> Unit,
onSpawn: () -> Unit, onSpawn: () -> Unit,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) } var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) }
var confirmingDelete by remember { mutableStateOf<SessionSummary?>(null) } var confirmingDelete by remember { mutableStateOf<SessionSummary?>(null) }
// Which session cards are expanded to show their subagents, and what each expansion fetched.
// Ids rather than a flag on the row for the same reason `deleting` is: the rows are rebuilt
// from
// whatever the server last said, and this belongs to the reader's own choice, which survives a
// refresh.
var expandedSessions by remember { mutableStateOf(setOf<String>()) }
var subagentLoads by remember {
mutableStateOf(mapOf<String, LoadState<List<SubagentSummary>>>())
}
fun loadSubagents(sessionId: String) {
subagentLoads = subagentLoads + (sessionId to LoadState.Loading)
scope.launch {
subagentLoads =
subagentLoads +
(sessionId to
try {
LoadState.Loaded(
withContext(Dispatchers.IO) { fetchSubagents(settings, sessionId) }
)
} catch (e: ApiException) {
LoadState.failed(e)
})
}
}
// Failures that belong to one session rather than to the list, keyed by its id and shown on its // Failures that belong to one session rather than to the list, keyed by its id and shown on its
// own card. The two scopes are decided by whether the server answered: it answered and refused, // own card. The two scopes are decided by whether the server answered: it answered and refused,
// so this says nothing about the other rows. // so this says nothing about the other rows.
@@ -84,6 +118,14 @@ fun SessionListScreen(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
transcriptCache.retainOnly(loaded.value.map { it.id }.toSet()) transcriptCache.retainOnly(loaded.value.map { it.id }.toSet())
} }
// A session gone from this answer cannot still be expanded, and an expanded one
// that is still here asks again -- its subagents may have changed since the
// last
// fetch.
val ids = loaded.value.map { it.id }.toSet()
expandedSessions = expandedSessions intersect ids
subagentLoads = subagentLoads.filterKeys { it in ids }
expandedSessions.forEach(::loadSubagents)
loaded loaded
} catch (e: ApiException) { } catch (e: ApiException) {
LoadState.failed(e) LoadState.failed(e)
@@ -127,6 +169,17 @@ fun SessionListScreen(
deleting = session.id in deleting, deleting = session.id in deleting,
onOpen = { onOpen(session) }, onOpen = { onOpen(session) },
onLongPress = { confirmingDelete = session }, onLongPress = { confirmingDelete = session },
expanded = session.id in expandedSessions,
subagents = subagentLoads[session.id],
onToggleSubagents = {
if (session.id in expandedSessions) {
expandedSessions = expandedSessions - session.id
} else {
expandedSessions = expandedSessions + session.id
loadSubagents(session.id)
}
},
onOpenSubagent = { subagent -> onOpenSubagent(session, subagent) },
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
} }
@@ -225,7 +278,7 @@ fun SessionListScreen(
deleteSession(settings, session.id, alsoDeleteForeign) deleteSession(settings, session.id, alsoDeleteForeign)
// After it succeeded, not before: a refused delete leaves the // After it succeeded, not before: a refused delete leaves the
// session exactly as it was, and its transcript with it. // session exactly as it was, and its transcript with it.
transcriptCache.session(session.id).purge() transcriptCache.session(TranscriptAddress(session.id)).purge()
} }
// Only this row, and only what changed. Refetching the list instead // Only this row, and only what changed. Refetching the list instead
// put every other session back through loading and handed the // put every other session back through loading and handed the
@@ -276,6 +329,12 @@ private fun SessionCard(
deleting: Boolean, deleting: Boolean,
onOpen: () -> Unit, onOpen: () -> Unit,
onLongPress: () -> Unit, onLongPress: () -> Unit,
/** Whether the expander below is open. Collapsed by default; see [SessionListScreen]. */
expanded: Boolean,
/** What the expander's own fetch answered, or null before it has been asked. */
subagents: LoadState<List<SubagentSummary>>?,
onToggleSubagents: () -> Unit,
onOpenSubagent: (SubagentSummary) -> Unit,
) { ) {
BusyItem(label = if (deleting) "deleting" else null) { BusyItem(label = if (deleting) "deleting" else null) {
Card( Card(
@@ -332,10 +391,104 @@ private fun SessionCard(
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
) )
} }
// Nothing at all for a card with no subagents: a disabled expander here would be
// noise on every ordinary session's card. Its own row at the bottom rather than
// beside the title or the machine line, so opening it never displaces text that was
// already on screen -- see UI_RULES on a control not displacing the text beside it.
if (session.subagents > 0) {
Spacer(Modifier.height(8.dp))
// The platform's minimum touch height, not the chevron's own ten or so dp:
// at the chevron's height a tap meant for it landed on the first subcard
// beneath and opened a subagent instead.
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier.fillMaxWidth()
.heightIn(min = 48.dp)
.clickable(enabled = !deleting, onClick = onToggleSubagents)
.semantics {
contentDescription =
if (expanded) "Collapse subagents" else "Expand subagents"
},
) {
Chevron(if (expanded) Pointing.Up else Pointing.Down)
}
if (expanded) {
Spacer(Modifier.height(4.dp))
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
when (subagents) {
null,
is LoadState.Loading ->
CircularProgressIndicator(
modifier = Modifier.width(20.dp).height(20.dp),
strokeWidth = 2.dp,
)
is LoadState.Error ->
// Said here rather than left silent: a fetch that failed and an
// expander that simply found nothing must not look the same --
// see UI_RULES on designing the unknown state first.
Text(
subagents.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
is LoadState.Loaded ->
subagents.value.forEach { subagent ->
SubagentCard(
subagent,
onClick = { onOpenSubagent(subagent) },
)
} }
} }
} }
} }
}
}
}
}
}
/**
* One subagent, indented inside its session's card -- the way dev-updater draws a project's
* components (`ComponentCard`, `UpdaterScreen.kt`): an outlined card, not the session card's own
* filled one, so the nesting reads as one step rather than as another session.
*/
@Composable
private fun SubagentCard(subagent: SubagentSummary, onClick: () -> Unit) {
OutlinedCard(Modifier.fillMaxWidth().clickable(onClick = onClick)) {
Column(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
Text(subagent.title, style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(2.dp))
Row(modifier = Modifier.fillMaxWidth()) {
Text(
subagentStatusLabel(subagent.status),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Text(
relativeTime(subagent.lastActivity),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
/**
* The subcard's word for a subagent's status -- see SUBAGENTS.md's "Wire shape". Its own function
* rather than a branch inside [StatusText], because a subagent's three states are not that
* composable's five: "exited" reads as "finished" here, since its process was always its parent's
* and never something of its own to have merely stopped.
*/
private fun subagentStatusLabel(status: String) =
when (status) {
"running" -> "running"
"exited" -> "finished"
else -> "unknown"
}
@Composable @Composable
fun StatusText(status: String) { fun StatusText(status: String) {
@@ -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
@@ -216,16 +220,33 @@ fun SessionScreen(
share: ShareRequest? = null, share: ShareRequest? = null,
/** Said once [share] has been attached here, so it is not attached again. */ /** Said once [share] has been attached here, so it is not attached again. */
onShareTaken: () -> Unit = {}, onShareTaken: () -> Unit = {},
/**
* Draws this screen read-only, on a subagent's own transcript instead of the session's.
*
* A subagent has no process and no controls of its own -- see SUBAGENTS.md's "Phone" -- so
* every gate below keyed on this switches off the composer, the files button, the settings cog,
* the usage bar and notifications, while everything that draws a transcript (paging, cache,
* selection, images, the status row, stream reconnects) is reused unchanged, pointed at
* [address] instead of the session's own.
*/
subagent: SubagentSummary? = null,
) { ) {
DebugStats.count("session screen recomposed") DebugStats.count("session screen recomposed")
val isSubagent = subagent != null
val address = TranscriptAddress(summary.id, subagent?.id)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val topEdgeHeld = remember { TopEdgeHold() } val topEdgeHeld = remember { TopEdgeHold() }
var items by remember { mutableStateOf(listOf<TranscriptItem>()) } var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
var status by remember { mutableStateOf(summary.status) } var status by remember { mutableStateOf(subagent?.status ?: summary.status) }
// Seeded from the row this screen was opened from, so a conversation already under way says how // Seeded from the row this screen was opened from, so a conversation already under way says how
// much it is holding before any turn happens here. Null is "nobody has measured it", which is a // much it is holding before any turn happens here. Null is "nobody has measured it", which is a
// different answer from an empty context and is drawn differently. // different answer from an empty context and is drawn differently.
var contextTokens by remember(summary.id) { mutableStateOf(summary.contextTokens) } //
// A subagent has no context measurement of its own, so it always starts unmeasured rather than
// borrowing the parent session's figure -- see UI_RULES on not showing an inferred value as one
// that was measured.
var contextTokens by
remember(address) { mutableStateOf(if (isSubagent) null else summary.contextTokens) }
// When the current compaction started. The moment comes off the `compacting` status event // When the current compaction started. The moment comes off the `compacting` status event
// itself -- the server timestamps every transcript line -- rather than off this device noticing // itself -- the server timestamps every transcript line -- rather than off this device noticing
// one, which is what makes it survive leaving the session and reopening it. // one, which is what makes it survive leaving the session and reopening it.
@@ -241,7 +262,13 @@ fun SessionScreen(
val context = LocalContext.current val context = LocalContext.current
// Seeded from what was left in the box last time and written back on every keystroke, so // Seeded from what was left in the box last time and written back on every keystroke, so
// leaving the screen does not throw away a half-typed message. See `Drafts.kt`. // leaving the screen does not throw away a half-typed message. See `Drafts.kt`.
var input by remember(summary.id) { mutableStateOf(atEnd(loadDraft(context, summary.id))) } //
// A subagent has no box to type into, so it never touches a draft at all -- not this session's,
// which is what reading one keyed only by `summary.id` would do here.
var input by
remember(summary.id) {
mutableStateOf(if (isSubagent) atEnd("") else atEnd(loadDraft(context, summary.id)))
}
// A model the reader has chosen and not yet confirmed. See [ModelSwitchWarning]: switching // A model the reader has chosen and not yet confirmed. See [ModelSwitchWarning]: switching
// makes the session re-read the whole conversation. // makes the session re-read the whole conversation.
var pendingModel by remember { mutableStateOf<String?>(null) } var pendingModel by remember { mutableStateOf<String?>(null) }
@@ -294,27 +321,26 @@ fun SessionScreen(
// Reload throws away what it was reading from. // Reload throws away what it was reading from.
val cache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) } val cache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
val source = val source =
remember(summary.id, epoch) { remember(address, epoch) { TranscriptSource(settings, address, cache.session(address)) }
TranscriptSource(settings, summary.id, cache.session(summary.id))
}
// Whether the cached tail has been shown to still be the server's own line. Nothing is resumed // Whether the cached tail has been shown to still be the server's own line. Nothing is resumed
// from a cached cursor until it has, and a probe that could not be made leaves this false for // from a cached cursor until it has, and a probe that could not be made leaves this false for
// the stream loop to try again. // the stream loop to try again.
var probePassed by remember(summary.id, epoch) { mutableStateOf(false) } var probePassed by remember(address, epoch) { mutableStateOf(false) }
// Whether the opening effect is still settling that question. It draws the cached rows and // Whether the opening effect is still settling that question. It draws the cached rows and
// lifts [ready] before the answer arrives, which is the point of the cache -- so the stream // lifts [ready] before the answer arrives, which is the point of the cache -- so the stream
// below waits for this rather than for `ready`, or it asks the same question twice. // below waits for this rather than for `ready`, or it asks the same question twice.
var probing by remember(summary.id, epoch) { mutableStateOf(true) } var probing by remember(address, epoch) { mutableStateOf(true) }
// The oldest sequence number loaded, and whether there is more behind it. Paging backwards is // The oldest sequence number loaded, and whether there is more behind it. Paging backwards is
// what keeps opening a long session cheap. // what keeps opening a long session cheap.
var oldestSeq by remember { mutableLongStateOf(0L) } var oldestSeq by remember { mutableLongStateOf(0L) }
// Where this session was last being read, from this device's own store. Read once, because the // Where this transcript was last being read, from this device's own store, keyed by the address
// answer stops being interesting the moment the list is on screen. // rather than the session id so a subagent's saved position cannot collide with its session's.
val savedAnchor = remember(summary.id, epoch) { loadScrollAnchor(context, summary.id) } // Read once, because the answer stops being interesting the moment the list is on screen.
val savedAnchor = remember(address, epoch) { loadScrollAnchor(context, address.cachePath) }
// Whether the saved position is still being put back. Nothing is drawn while it is: opening at // Whether the saved position is still being put back. Nothing is drawn while it is: opening at
// the newest end and then travelling to the anchor is exactly the journey a reader must never // the newest end and then travelling to the anchor is exactly the journey a reader must never
// see. // see.
var restoring by remember(summary.id, epoch) { mutableStateOf(savedAnchor != null) } var restoring by remember(address, epoch) { mutableStateOf(savedAnchor != null) }
// Messages the server has taken and the session has not read yet, by the id that will resolve // Messages the server has taken and the session has not read yet, by the id that will resolve
// them. From the event stream rather than from what this screen sent, so they survive leaving // them. From the event stream rather than from what this screen sent, so they survive leaving
// the session -- and a message sent from another device is drawn waiting on this one too. // the session -- and a message sent from another device is drawn waiting on this one too.
@@ -327,11 +353,22 @@ fun SessionScreen(
var loadingHistory by remember { mutableStateOf(false) } var loadingHistory by remember { mutableStateOf(false) }
var ready by remember { mutableStateOf(false) } var ready by remember { mutableStateOf(false) }
// Replies parsed ahead of the rows that draw them; see [ParsedReplies]. // Replies parsed ahead of the rows that draw them; see [ParsedReplies].
val replies = remember(summary.id) { ParsedReplies() } val replies = remember(address) { ParsedReplies() }
// Keyed like everything else describing one session's transcript. `rememberLazyListState` saves // Keyed like everything else describing one transcript. `rememberLazyListState` saves through
// through `rememberSaveable`, and this screen restores by its own anchor instead -- two // `rememberSaveable`, and this screen restores by its own anchor instead -- two restores would
// restores would fight over the first frame. // fight over the first frame.
val listState = remember(summary.id) { 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.
@@ -637,7 +674,7 @@ fun SessionScreen(
// ended and carries live events only. The window comes from this phone's own copy when there is // ended and carries live events only. The window comes from this phone's own copy when there is
// one, and then costs a single request to check that the server's transcript is still the one // one, and then costs a single request to check that the server's transcript is still the one
// it came from. See TRANSCRIPT_CACHE.md. // it came from. See TRANSCRIPT_CACHE.md.
LaunchedEffect(summary.id, epoch) { LaunchedEffect(address, epoch) {
/** /**
* One opening window onto the screen, whichever side it came from. * One opening window onto the screen, whichever side it came from.
* *
@@ -667,11 +704,16 @@ fun SessionScreen(
// A replay is as old as the last visit; the row this screen was opened from was // A replay is as old as the last visit; the row this screen was opened from was
// fetched moments ago. So the transcript comes from the cache and everything that // fetched moments ago. So the transcript comes from the cache and everything that
// is not the transcript comes from the summary -- otherwise a session that finished // is not the transcript comes from the summary -- otherwise a session that finished
// an hour ago opens saying "working" until the stream connects. // an hour ago opens saying "working" until the stream connects. A subagent's status
status = summary.status // comes from its own summary, never the parent session's: they are two different
// things running or not, and the parent's model and permission mode do not apply to
// it at all.
status = subagent?.status ?: summary.status
if (!isSubagent) {
model = summary.model model = summary.model
permissionMode = summary.permissionMode ?: "auto" permissionMode = summary.permissionMode ?: "auto"
if (summary.status != "compacting") compactingSince = null }
if (status != "compacting") compactingSince = null
// Nothing to put back, so these rows are the screen and the probe can return under // Nothing to put back, so these rows are the screen and the probe can return under
// them. A restore still has history to fetch and is gated below. // them. A restore still has history to fetch and is gated below.
if (savedAnchor == null) ready = true if (savedAnchor == null) ready = true
@@ -798,7 +840,7 @@ fun SessionScreen(
// at the top on their return. Switching apps is a choice somebody made, not a fault to report. // at the top on their return. Switching apps is a choice somebody made, not a fault to report.
// Stopping the stream deliberately makes the drop a close rather than an error, and resuming // Stopping the stream deliberately makes the drop a close rather than an error, and resuming
// reconnects from the same cursor. // reconnects from the same cursor.
LaunchedEffect(summary.id, ready, epoch, lifecycleOwner) { LaunchedEffect(address, ready, epoch, lifecycleOwner) {
if (!ready) return@LaunchedEffect if (!ready) return@LaunchedEffect
// The opening effect draws cached rows and lifts `ready` *before* it has checked that the // The opening effect draws cached rows and lifts `ready` *before* it has checked that the
// cursor under them is still the server's, so `ready` is no longer the whole gate. Without // cursor under them is still the server's, so `ready` is no longer the whole gate. Without
@@ -868,10 +910,14 @@ fun SessionScreen(
// The screen going away entirely, which the lifecycle scope above does not cover: a composable // The screen going away entirely, which the lifecycle scope above does not cover: a composable
// can leave the composition while the activity stays started. Keyed on the epoch as well, so // can leave the composition while the activity stays started. Keyed on the epoch as well, so
// Reload's replacement source is the one a later disposal closes. // Reload's replacement source is the one a later disposal closes.
DisposableEffect(summary.id, epoch) { onDispose { source.close() } } DisposableEffect(address, epoch) { onDispose { source.close() } }
// Nothing gets announced about the session somebody is reading; see NotificationService. // Nothing gets announced about the session somebody is reading; see NotificationService.
// RESUMED rather than STARTED because "looking at it" means the foreground. // RESUMED rather than STARTED because "looking at it" means the foreground.
//
// Not for a subagent: it has no notifications of its own, and it is not the session this would
// otherwise mark as being read.
if (!isSubagent) {
LaunchedEffect(summary.id, lifecycleOwner) { LaunchedEffect(summary.id, lifecycleOwner) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
NotificationService.showing(context, summary.id) NotificationService.showing(context, summary.id)
@@ -882,6 +928,7 @@ fun SessionScreen(
} }
} }
} }
}
// Back at the newest end, so the backlog [apply] held can land. Everything at once rather than // Back at the newest end, so the backlog [apply] held can land. Everything at once rather than
// paced out: they are at the bottom, which is the one place the list is allowed to follow new // paced out: they are at the bottom, which is the one place the list is allowed to follow new
@@ -924,7 +971,7 @@ fun SessionScreen(
val (index, offset, awayFromNewest) = settled val (index, offset, awayFromNewest) = settled
saveScrollAnchor( saveScrollAnchor(
context, context,
summary.id, address.cachePath,
// Nothing to restore at the newest end, which is where a session with no anchor // Nothing to restore at the newest end, which is where a session with no anchor
// opens anyway. One *before* the index, because item zero is the "below" slot. // opens anyway. One *before* the index, because item zero is the "below" slot.
if (!awayFromNewest) null if (!awayFromNewest) null
@@ -947,7 +994,7 @@ fun SessionScreen(
// //
// There is no correction beside this one. Following the newest message is not an effect: the // There is no correction beside this one. Following the newest message is not an effect: the
// list is reversed, so an arriving message extends the end the viewport is pinned to. // list is reversed, so an arriving message extends the end the viewport is pinned to.
val unitSizes = remember(summary.id) { HashMap<Any, Int>() } val unitSizes = remember(address) { HashMap<Any, Int>() }
LaunchedEffect(listState, moreHistory) { LaunchedEffect(listState, moreHistory) {
snapshotFlow { listState.layoutInfo } snapshotFlow { listState.layoutInfo }
.collect { info -> .collect { info ->
@@ -983,6 +1030,8 @@ fun SessionScreen(
} }
} }
// Only for the model picker, which a subagent does not have.
if (!isSubagent) {
LaunchedEffect(summary.setupName, summary.provider) { LaunchedEffect(summary.setupName, summary.provider) {
offeredModels = offeredModels =
try { try {
@@ -995,10 +1044,12 @@ fun SessionScreen(
.orEmpty() .orEmpty()
} }
} catch (_: Exception) { } catch (_: Exception) {
// Not worth reporting: the picker simply has nothing to offer, which is visible. // Not worth reporting: the picker simply has nothing to offer, which is
// visible.
emptyList() emptyList()
} }
} }
}
/** /**
* Asks the server to take back a message the session has not read yet. * Asks the server to take back a message the session has not read yet.
@@ -1148,8 +1199,9 @@ fun SessionScreen(
} }
// One poll for the machines' limits, read by everything on this screen that reports them. // One poll for the machines' limits, read by everything on this screen that reports them.
val usageFeed = rememberUsageFeed(settings) // Nothing meters a subagent -- it has no account of its own -- so it never starts this poll.
val usage = usageFeed.forSetup(summary.setup) val usageFeed = if (isSubagent) null else rememberUsageFeed(settings)
val usage = usageFeed?.forSession(summary) ?: SessionUsage.NotMetered
RecordFrames() RecordFrames()
var usageOpen by remember { mutableStateOf(false) } var usageOpen by remember { mutableStateOf(false) }
var settingsOpen by remember { mutableStateOf(false) } var settingsOpen by remember { mutableStateOf(false) }
@@ -1219,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
@@ -1233,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)
} }
} }
@@ -1256,20 +1321,35 @@ fun SessionScreen(
// A ring's worth, which is what the arrow already keeps on its other three sides. // A ring's worth, which is what the arrow already keeps on its other three sides.
Spacer(Modifier.width(GLYPH_BUTTON_MARGIN)) Spacer(Modifier.width(GLYPH_BUTTON_MARGIN))
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
// A subagent's own title, with the session's beneath it in a smaller style --
// the header says whose conversation this is as well as what it is. Otherwise
// just the session's title, as before.
if (subagent != null) {
Text(subagent.title, style = MaterialTheme.typography.titleMedium)
Text(
title,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
Text(title, style = MaterialTheme.typography.titleMedium) Text(title, style = MaterialTheme.typography.titleMedium)
// Machine first, then what runs on it -- the same order and the same wording // Machine first, then what runs on it -- the same order and the same
// everywhere this pair appears, so it reads as one fact rather than two // wording everywhere this pair appears, so it reads as one fact rather than
// sentences with different grammar. // two sentences with different grammar.
// //
// No model. The picker in the footer already shows what this session is set to, // No model. The picker in the footer already shows what this session is set
// and showing it twice means two things to keep in step -- they disagreed for a // to, and showing it twice means two things to keep in step -- they
// moment on every model change. // disagreed for a moment on every model change.
Text( Text(
"${summary.setupName} · ${summary.provider}", "${summary.setupName} · ${summary.provider}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
}
// None of this is a subagent's: it has no files of its own to browse, no settings,
// and nothing meters it -- see SUBAGENTS.md's "Phone".
//
// Beside the provider it reports on, which is the line directly to its left. Its // Beside the provider it reports on, which is the line directly to its left. Its
// real home is this provider's settings, which do not exist yet. A session on a // real home is this provider's settings, which do not exist yet. A session on a
// provider with no such service gets an honest "unavailable" rather than a hidden // provider with no such service gets an honest "unavailable" rather than a hidden
@@ -1284,6 +1364,7 @@ fun SessionScreen(
// Usage, files, settings -- widest scope first, narrowing to the right, so the cog // Usage, files, settings -- widest scope first, narrowing to the right, so the cog
// stays at the end where every other screen keeps it. Asked for in this order by // stays at the end where every other screen keeps it. Asked for in this order by
// Iris on 2026-09-03. // Iris on 2026-09-03.
if (!isSubagent) {
Row { Row {
GlyphButton( GlyphButton(
USAGE_GLYPH, USAGE_GLYPH,
@@ -1301,25 +1382,29 @@ fun SessionScreen(
FilesTarget( FilesTarget(
setup = summary.setup, setup = summary.setup,
setupName = summary.setupName, setupName = summary.setupName,
// Where this session works, and the machine's own home when it // Where this session works, and the machine's own home when
// was never given a directory -- resolved there rather than // it was never given a directory -- resolved there rather
// guessed at here, since this app does not know that home. // than guessed at here, since this app does not know that
// home.
start = summary.cwd?.takeIf { it.isNotBlank() } ?: "~", start = summary.cwd?.takeIf { it.isNotBlank() } ?: "~",
) )
) )
}, },
) )
// What it opens is about this session, so it sits at the end of the session's // What it opens is about this session, so it sits at the end of the
// own row. A cog and not a word because there will be more, and a bar of words // session's own row. A cog and not a word because there will be more, and a
// has nowhere to put it. // bar of words has nowhere to put it.
GlyphButton(SETTINGS_GLYPH, "Session settings", { settingsOpen = true }) GlyphButton(SETTINGS_GLYPH, "Session settings", { settingsOpen = true })
} }
} }
}
// Under the header, above everything the session itself says: it is a fact about the // Under the header, above everything the session itself says: it is a fact about the
// machine rather than a turn in the conversation, and it is the number that decides // machine rather than a turn in the conversation, and it is the number that decides
// whether to keep going. // whether to keep going. Nothing meters a subagent.
if (!isSubagent) {
SessionUsageBar(usage) SessionUsageBar(usage)
}
(streamError ?: actionError)?.let { message -> (streamError ?: actionError)?.let { message ->
Text( Text(
@@ -1565,6 +1650,7 @@ fun SessionScreen(
is TranscriptItem.ClearedNote -> ClearedRow() is TranscriptItem.ClearedNote -> ClearedRow()
is TranscriptItem.CompactedNote -> is TranscriptItem.CompactedNote ->
CompactedRow(item) CompactedRow(item)
is TranscriptItem.LimitNote -> LimitRow(item)
// Never reached: a peer message is flattened into // Never reached: a peer message is flattened into
// its own units. Here because a `when` over the // its own units. Here because a `when` over the
// item kinds has to stay exhaustive. // item kinds has to stay exhaustive.
@@ -1663,24 +1749,34 @@ fun SessionScreen(
) )
} }
// Kept for a subagent -- see SUBAGENTS.md's "Phone" -- with the wording that turns
// "exited" into "finished" for one, since it has no process to leave running or stop.
SessionStatusRow( SessionStatusRow(
status = status, status = status,
compactingFor = compactingFor, compactingFor = compactingFor,
contextTokens = contextTokens, contextTokens = contextTokens,
subagent = isSubagent,
) )
// Between the transcript and the box: above what is being typed, so the list does not // Everything from here down is the composer: a subagent cannot be messaged, so none of
// cover the thing the command is about, and below everything that explains it. // it applies -- see SUBAGENTS.md's "Phone".
if (!isSubagent) {
// Between the transcript and the box: above what is being typed, so the list does
// not cover the thing the command is about, and below everything that explains it.
CommandSuggestions( CommandSuggestions(
// Nothing to suggest about a suggestion that was just taken. `/compact` is a whole // Nothing to suggest about a suggestion that was just taken. `/compact` is a
// command *and* a prefix of itself, so picking it left the list standing there with // whole command *and* a prefix of itself, so picking it left the list standing
// the one row already chosen. Held by what was picked rather than by a flag, so // there with the one row already chosen. Held by what was picked rather than by
// typing anything else brings the list back without a second thing to reset. // a flag, so typing anything else brings the list back without a second thing
commands = if (input.text == picked) emptyList() else suggestedCommands(input.text), // to
// reset.
commands =
if (input.text == picked) emptyList() else suggestedCommands(input.text),
onPick = { command -> onPick = { command ->
// At the end of what was inserted, which is where the reader carries on typing: // At the end of what was inserted, which is where the reader carries on
// a command with an argument is put in the box half-written, and a cursor left // typing: a command with an argument is put in the box half-written, and a
// at the front makes the next keystroke the first character of "/rename". // cursor left at the front makes the next keystroke the first character of
// "/rename".
input = atEnd(command.typed()) input = atEnd(command.typed())
picked = command.typed() picked = command.typed()
}, },
@@ -1689,11 +1785,14 @@ fun SessionScreen(
// Always enabled -- a send while the session is running becomes a steering message // Always enabled -- a send while the session is running becomes a steering message
// injected at the next tool boundary, which is the point of the whole app. // injected at the next tool boundary, which is the point of the whole app.
// //
// The field gets a row of its own, above the buttons: sharing one put the full width // The field gets a row of its own, above the buttons: sharing one put the full
// behind three controls, so the thing being typed into was the narrowest on the row. // width
// behind three controls, so the thing being typed into was the narrowest on the
// row.
Column(Modifier.fillMaxWidth().padding(8.dp)) { Column(Modifier.fillMaxWidth().padding(8.dp)) {
// Directly above the box they will be sent from, so what is attached is visible // Directly above the box they will be sent from, so what is attached is visible
// rather than counted: the "+2" on the button below said how many and never which. // rather than counted: the "+2" on the button below said how many and never
// which.
PendingAttachments( PendingAttachments(
settings = settings, settings = settings,
sessionId = summary.id, sessionId = summary.id,
@@ -1706,9 +1805,12 @@ 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
// No longer "(+image)": the images are on screen above this, and a placeholder // (`composerFocus`)
// saying so said it in words beside the thing itself. // so it types through the real composer rather than a stand-in.
modifier = Modifier.fillMaxWidth().focusRequester(composerFocus),
// No longer "(+image)": the images are on screen above this, and a
// placeholder saying so said it in words beside the thing itself.
placeholder = { Text("Message") }, placeholder = { Text("Message") },
maxLines = 4, maxLines = 4,
) )
@@ -1716,17 +1818,19 @@ fun SessionScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
// Photo or file, asked here rather than by two buttons: the row is full, and // Photo or file, asked here rather than by two buttons: the row is full,
// and
// attaching is one action whichever picker answers it. // attaching is one action whichever picker answers it.
var attaching by remember { mutableStateOf(false) } var attaching by remember { mutableStateOf(false) }
Box { Box {
// Just "+". The count it used to carry was standing in for showing them. // Just "+". The count it used to carry was standing in for showing
// them.
BubbleButton(onClick = { attaching = true }) { Text("+") } BubbleButton(onClick = { attaching = true }) { Text("+") }
DropdownMenu( DropdownMenu(
expanded = attaching, expanded = attaching,
onDismissRequest = { attaching = false }, onDismissRequest = { attaching = false },
// See PickerButton: without this the menu opens a status bar's height // See PickerButton: without this the menu opens a status bar's
// away from the button in an edge-to-edge activity. // height away from the button in an edge-to-edge activity.
properties = PopupProperties(clippingEnabled = false), properties = PopupProperties(clippingEnabled = false),
shape = BubbleMenuShape, shape = BubbleMenuShape,
) { ) {
@@ -1750,11 +1854,11 @@ fun SessionScreen(
) )
} }
} }
// The settings share what is left after the actions have taken what they need. // The settings share what is left after the actions have taken what they
// A Row hands out intrinsic widths in order and clips whatever runs past the // need. A Row hands out intrinsic widths in order and clips whatever runs
// edge, so with these laid out first the arrival of Stop pushed Send off the // past the edge, so with these laid out first the arrival of Stop pushed
// screen entirely -- the app's central control, gone at the moment it is most // Send off the screen entirely -- the app's central control, gone at the
// in use. // moment it is most in use.
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -1762,16 +1866,18 @@ fun SessionScreen(
if (offeredModels.isNotEmpty()) { if (offeredModels.isNotEmpty()) {
PickerButton( PickerButton(
current = modelLabel(model), current = modelLabel(model),
// What the machine offers, plus the state a session is in when it // What the machine offers, plus the state a session is in when
// has chosen none of them. The button has always been able to say // it has chosen none of them. The button has always been able
// "default"; until this the list could not, so leaving it was a // to
// one-way trip. // say "default"; until this the list could not, so leaving it
// was a one-way trip.
options = listOf(DEFAULT_MODEL) + offeredModels, options = listOf(DEFAULT_MODEL) + offeredModels,
// Not set here. The button follows what the session reports it is // Not set here. The button follows what the session reports it
// set to, which arrives a moment later and is sometimes a different // is set to, which arrives a moment later and is sometimes a
// answer -- a name the CLI resolved, or no change at all on a // different answer -- a name the CLI resolved, or no change at
// provider whose model is fixed. Asked about first, unless there is // all on a provider whose model is fixed. Asked about first,
// nothing to lose by it -- see [ModelSwitchWarning]. // unless there is nothing to lose by it -- see
// [ModelSwitchWarning].
onPick = { chosen -> onPick = { chosen ->
if ( if (
modelLabel(chosen) == modelLabel(model) || modelLabel(chosen) == modelLabel(model) ||
@@ -1792,14 +1898,16 @@ fun SessionScreen(
}, },
) )
} }
// The same filled shape as the button beside it, not an outlined one: these are // The same filled shape as the button beside it, not an outlined one: these
// two things you can do about the session, and weighting one as secondary said // are two things you can do about the session, and weighting one as
// they were a primary action and its qualifier. What separates them is the // secondary said they were a primary action and its qualifier. What
// colour and the mark, which is what they mean. // separates them is the colour and the mark, which is what they mean.
// //
// Always here, rather than arriving with the turn as it used to. A control that // Always here, rather than arriving with the turn as it used to. A control
// comes and goes makes its own presence the signal, and a button always in the // that comes and goes makes its own presence the signal, and a button
// same place also cannot push Send off the end of the row by turning up. // always
// in the same place also cannot push Send off the end of the row by turning
// up.
val process = val process =
when { when {
running -> ProcessAction.Pause running -> ProcessAction.Pause
@@ -1819,18 +1927,21 @@ fun SessionScreen(
Glyph( Glyph(
process.glyph, process.glyph,
colour = LocalContentColor.current, colour = LocalContentColor.current,
modifier = Modifier.semantics { contentDescription = process.label }, modifier =
Modifier.semantics { contentDescription = process.label },
) )
} }
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
// The paper plane, with a clock on it while a turn is in flight: sending then // The paper plane, with a clock on it while a turn is in flight: sending
// queues the message for the next tool boundary rather than starting a turn of // then queues the message for the next tool boundary rather than starting a
// its own, and the two have to be told apart at a glance. The label says the // turn of its own, and the two have to be told apart at a glance. The label
// same thing to a screen reader. // says the same thing to a screen reader.
// //
// Disabled while there is nothing to send, rather than pressable and silent: // Disabled while there is nothing to send, rather than pressable and
// `send` has always returned early on an empty composer, so the button promised // silent:
// something it would not do. Disabled and not hidden, for the reason above. // `send` has always returned early on an empty composer, so the button
// promised something it would not do. Disabled and not hidden, for the
// reason above.
Button( Button(
onClick = { send() }, onClick = { send() },
enabled = input.text.isNotBlank() || pendingAttachments.isNotEmpty(), enabled = input.text.isNotBlank() || pendingAttachments.isNotEmpty(),
@@ -1847,12 +1958,13 @@ fun SessionScreen(
} }
} }
} }
}
// Beside the other two dialogs, and outside the list for the same reason as them: what is open // Beside the other two dialogs, and outside the list for the same reason as them: what is open
// is the screen's business rather than any row's. See [SessionImageViewer]. // is the screen's business rather than any row's. See [SessionImageViewer].
fullImage?.let { ref -> SessionImageViewer(settings, summary.id, ref) { fullImage = null } } fullImage?.let { ref -> SessionImageViewer(settings, summary.id, ref) { fullImage = null } }
if (usageOpen) { if (usageOpen) {
UsageDialog(feed = usageFeed, onDismiss = { usageOpen = false }) usageFeed?.let { UsageDialog(feed = it, onDismiss = { usageOpen = false }) }
} }
if (settingsOpen) { if (settingsOpen) {
// Measured when the dialog opens rather than kept up to date: what the reader is being told // Measured when the dialog opens rather than kept up to date: what the reader is being told
@@ -1866,6 +1978,8 @@ fun SessionScreen(
settings = settings, settings = settings,
sessionId = summary.id, sessionId = summary.id,
title = title, title = title,
effort = summary.effort.takeIf { summary.takesEffort },
takesEffort = summary.takesEffort,
cachedBytes = cachedBytes, cachedBytes = cachedBytes,
// The purge finishes before the epoch moves, because the relaunched opening effect // The purge finishes before the epoch moves, because the relaunched opening effect
// reads the same directory and would otherwise draw what is about to be deleted. The // reads the same directory and would otherwise draw what is about to be deleted. The
@@ -2143,6 +2257,13 @@ private fun SessionStatusRow(
/** Context the session is holding, or null where nothing has measured it. */ /** Context the session is holding, or null where nothing has measured it. */
contextTokens: Long?, contextTokens: Long?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
/**
* Whether this row is for a subagent rather than a session, which changes only one word:
* "exited" reads as "finished" there too, the same as the subagent list's own card -- a
* subagent's process was always its parent's, so "exited" would read as a fault rather than the
* ordinary way one of these ends.
*/
subagent: Boolean = false,
) { ) {
DebugStats.count("status row recomposed") DebugStats.count("status row recomposed")
Row( Row(
@@ -2199,7 +2320,7 @@ private fun SessionStatusRow(
Text( Text(
when (status) { when (status) {
"idle" -> "idle" "idle" -> "idle"
"exited" -> "exited" "exited" -> if (subagent) "finished" else "exited"
"awaitingInput" -> "your turn" "awaitingInput" -> "your turn"
"unknown" -> "can't tell" "unknown" -> "can't tell"
else -> status else -> status
@@ -2270,7 +2391,7 @@ private const val ONE_TAP_MS = 250L
* session is set to without spending a second line on saying it. * session is set to without spending a second line on saying it.
*/ */
@Composable @Composable
private fun PickerButton(current: String, options: List<String>, onPick: (String) -> Unit) { fun PickerButton(current: String, options: List<String>, onPick: (String) -> Unit) {
var open by remember { mutableStateOf(false) } var open by remember { mutableStateOf(false) }
// When an outside touch last closed the menu. // When an outside touch last closed the menu.
// //
@@ -6,8 +6,10 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -26,6 +28,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -54,6 +60,16 @@ fun SessionSettingsDialog(
*/ */
title: String, title: String,
onRenamed: (String) -> Unit, onRenamed: (String) -> Unit,
/**
* How hard the model thinks, as the session reports it, or null for the CLI's own default.
*
* Taken from the row this dialog was opened over rather than fetched, because unlike the
* notification switch there is nothing else that changes it: the level is this app's to set and
* the server does not resolve it into something else.
*/
effort: String?,
/** Whether a level does anything here; the row is left out entirely where it does not. */
takesEffort: Boolean,
/** /**
* What this phone is holding of the conversation, or null while that is being measured -- see * What this phone is holding of the conversation, or null while that is being measured -- see
* the Reload row below, which is what would discard it. * the Reload row below, which is what would discard it.
@@ -76,6 +92,8 @@ fun SessionSettingsDialog(
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var name by remember(sessionId) { mutableStateOf(title) } var name by remember(sessionId) { mutableStateOf(title) }
var level by remember(sessionId) { mutableStateOf(effort) }
var effortError by remember { mutableStateOf<String?>(null) }
var saving by remember { mutableStateOf(false) } var saving by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
// Null until the server has been asked. The row this dialog was opened over is a snapshot of // Null until the server has been asked. The row this dialog was opened over is a snapshot of
@@ -84,6 +102,16 @@ fun SessionSettingsDialog(
// and a spinner sits beside it, which is what not knowing looks like. // and a spinner sits beside it, which is what not knowing looks like.
var notify by remember(sessionId) { mutableStateOf<Boolean?>(null) } var notify by remember(sessionId) { mutableStateOf<Boolean?>(null) }
var notifyError by remember { mutableStateOf<String?>(null) } var notifyError by remember { mutableStateOf<String?>(null) }
// The same three-state shape the notification switch has, for the same reason: until the
// server has answered, the switch is disabled rather than showing a position nothing confirmed.
var autoResume by remember(sessionId) { mutableStateOf<Boolean?>(null) }
var resumeMessage by remember(sessionId) { mutableStateOf(DEFAULT_RESUME_MESSAGE) }
// When the server next intends to ask whether the limit has lifted, or null when nothing is
// waiting. Read once with everything else: it moves on the server's schedule, not this
// screen's, and a figure that redrew itself here would be this app re-measuring what it was
// told.
var resumeAt by remember(sessionId) { mutableStateOf<Double?>(null) }
var resumeError by remember { mutableStateOf<String?>(null) }
// Where the session works. Null until the server has been asked, for the same reason the switch // Where the session works. Null until the server has been asked, for the same reason the switch
// above is. An empty answer is a session that was never given a directory, which is not the // above is. An empty answer is a session that was never given a directory, which is not the
// same as one whose directory is unknown -- the field is only enabled once one of those is // same as one whose directory is unknown -- the field is only enabled once one of those is
@@ -97,6 +125,9 @@ fun SessionSettingsDialog(
try { try {
val fresh = withContext(Dispatchers.IO) { fetchSession(settings, sessionId) } val fresh = withContext(Dispatchers.IO) { fetchSession(settings, sessionId) }
notify = fresh.notify notify = fresh.notify
autoResume = fresh.autoResume
resumeMessage = fresh.autoResumeMessage
resumeAt = fresh.resumeAt
cwd = fresh.cwd.orEmpty() cwd = fresh.cwd.orEmpty()
typedCwd = fresh.cwd.orEmpty() typedCwd = fresh.cwd.orEmpty()
} catch (e: ApiException) { } catch (e: ApiException) {
@@ -104,6 +135,8 @@ fun SessionSettingsDialog(
// instead of offering a position nothing confirmed. // instead of offering a position nothing confirmed.
notifyError = e.message notifyError = e.message
notify = null notify = null
resumeError = e.message
autoResume = null
} }
} }
@@ -132,6 +165,26 @@ fun SessionSettingsDialog(
} }
} }
/**
* Chooses a thinking level, which ends the process the old level was launched with.
*
* Put back if the request is refused, for the reason the notification switch below gives: a
* control that stays where it was put after a refusal is stating something untrue.
*/
fun setEffort(chosen: String?) {
val was = level
level = chosen
effortError = null
scope.launch {
try {
withContext(Dispatchers.IO) { setSessionEffort(settings, sessionId, chosen) }
} catch (e: ApiException) {
level = was
effortError = e.message
}
}
}
// Moved optimistically so the switch answers the finger that moved it, and put back if the // Moved optimistically so the switch answers the finger that moved it, and put back if the
// request is refused -- a switch that waits for a round trip reads as broken on a slow tunnel, // request is refused -- a switch that waits for a round trip reads as broken on a slow tunnel,
// and one that stays moved after a refusal lies. // and one that stays moved after a refusal lies.
@@ -149,6 +202,39 @@ fun SessionSettingsDialog(
} }
} }
/**
* Turns auto-resume on or off, or changes what it would say.
*
* One request for both, because the server takes one: switching it on and typing the message
* are two halves of the same decision, and sending them separately would leave a moment where
* the session is armed with the old words.
*
* Put back if refused, like the notification switch. Turning it off also clears what was
* scheduled -- said here rather than only on the server, or the row would go on naming a time
* that no longer exists.
*/
fun setAutoResume(on: Boolean, message: String) {
val wasOn = autoResume
val wasMessage = resumeMessage
val wasAt = resumeAt
autoResume = on
resumeMessage = message
if (!on) resumeAt = null
resumeError = null
scope.launch {
try {
withContext(Dispatchers.IO) {
setSessionAutoResume(settings, sessionId, on, message)
}
} catch (e: ApiException) {
autoResume = wasOn
resumeMessage = wasMessage
resumeAt = wasAt
resumeError = e.message
}
}
}
// Nothing to do when the name has not changed, so the button says so rather than sending a // Nothing to do when the name has not changed, so the button says so rather than sending a
// request whose success would look exactly like the failure of having typed nothing. // request whose success would look exactly like the failure of having typed nothing.
val changed = name.trim().isNotEmpty() && name.trim() != title val changed = name.trim().isNotEmpty() && name.trim() != title
@@ -175,7 +261,10 @@ fun SessionSettingsDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Session settings") }, title = { Text("Session settings") },
text = { text = {
Column { // Scrollable, because this dialog grew past a screenful: a Material dialog constrains
// its own height and clips what does not fit, so the last control on the list is one
// large system font away from being unreachable with nothing on screen to say so.
Column(Modifier.verticalScroll(rememberScrollState())) {
OutlinedTextField( OutlinedTextField(
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },
@@ -219,6 +308,70 @@ fun SessionSettingsDialog(
) )
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text("Resume after a usage limit", modifier = Modifier.weight(1f))
if (autoResume == null && resumeError == null) {
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp,
)
Spacer(Modifier.width(8.dp))
}
Switch(
checked = autoResume == true,
onCheckedChange = { setAutoResume(it, resumeMessage) },
enabled = autoResume != null,
)
}
// Disabled rather than hidden while the switch is off: a field that comes and goes
// makes its own presence the signal, and a visible one teaches what the switch will
// do. Committed on the keyboard's Done rather than on every keystroke, so typing a
// sentence is one request instead of one per letter.
OutlinedTextField(
value = resumeMessage,
onValueChange = { resumeMessage = it },
label = { Text("Message to send") },
// What an empty field means, in the field: the server's own word rather than a
// session poked with nothing to read.
placeholder = { Text(DEFAULT_RESUME_MESSAGE) },
singleLine = true,
enabled = autoResume == true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(onDone = { setAutoResume(true, resumeMessage) }),
)
// What it does and what it costs, in the order it happens. The last sentence is the
// one that matters: the time below is when the server will *ask*, not a promise
// about when the session speaks.
Text(
"When this session stops because the account is out of quota, the server " +
"checks the limit and sends this message once it has lifted. It checks " +
"again if the limit is still on.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Only where something is actually waiting. Absent is not a state worth a row: a
// session that has not hit a limit has nothing scheduled, which the reader can see
// from the switch.
resumeAt?.let { at ->
Text(
"Waiting now -- next check ${formatCheckTime(at)}.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
resumeError?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(Modifier.height(8.dp))
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -264,6 +417,44 @@ fun SessionSettingsDialog(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
} }
// Left out rather than disabled, the one place this dialog does that: a disabled
// control teaches what the thing can do, and a llama session cannot do this at all
// -- the row would be teaching something false about it.
if (takesEffort) {
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text("Thinking", modifier = Modifier.weight(1f))
PickerButton(
current = level ?: DEFAULT_EFFORT,
// The level the CLI picks for itself is in the list as well as in the
// button, so leaving a level is not a one-way trip -- the same
// correction the model picker carries.
options = listOf(DEFAULT_EFFORT) + EFFORT_LEVELS,
onPick = { chosen ->
setEffort(chosen.takeIf { it != DEFAULT_EFFORT })
},
)
}
// What it costs, said where it is about to be pressed, like Move above: the
// CLI reads the level when it launches and has no control request for
// changing one.
Text(
"Changing this stops the session's process. It starts again with the " +
"next message, or with Start.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
effortError?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
}
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -351,3 +542,21 @@ fun SessionSettingsDialog(
dismissButton = { TextButton(onClick = onDismiss) { Text("Close") } }, dismissButton = { TextButton(onClick = onDismiss) { Text("Close") } },
) )
} }
/**
* When the server will next look, as a local time.
*
* A time rather than a countdown, for the reason the transcript's own limit row gives: this screen
* reads the figure once, and a span drawn from a value nothing refreshes goes stale while somebody
* is looking at it.
*/
private fun formatCheckTime(epochSeconds: Double): String =
try {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
.withZone(ZoneId.systemDefault())
.format(Instant.ofEpochSecond(epochSeconds.toLong()))
} catch (_: Exception) {
// A time that cannot be read is not a time to show: the sentence above still says a check
// is coming, which is the part the reader can act on.
"soon"
}
@@ -70,12 +70,21 @@ class UsageFeed(
/** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */ /** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */
val refresh: () -> Unit, val refresh: () -> Unit,
) { ) {
/** What [setup]'s own limits came back as. See [usageFor] for why the states are these. */ /**
fun forSetup(setup: String): SessionUsage = * What meters [session], and what that meter came back as. See [usageFor] for the states.
when (val state = snapshots) { *
* A session rather than a machine, because a machine is not what is metered: one machine runs
* the Claude CLI and an echo session side by side, and only the first of them spends anything.
*/
fun forSession(session: SessionSummary): SessionUsage {
// Settled without asking anybody: a session nothing meters has nothing to check, and
// "checking" is what the fetch's own states would say about it for as long as one is out.
val provider = session.usageProvider ?: return SessionUsage.NotMetered
return when (val state = snapshots) {
is LoadState.Loading -> SessionUsage.Waiting is LoadState.Loading -> SessionUsage.Waiting
is LoadState.Error -> SessionUsage.Unavailable(state.message) is LoadState.Error -> SessionUsage.Unavailable(state.message)
is LoadState.Loaded -> usageFor(state.value, setup) is LoadState.Loaded -> usageFor(state.value, session.setup, provider)
}
} }
} }
@@ -158,9 +167,15 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
} }
} }
// Nothing at all for a machine that meters nothing: a row saying "unknown" there would report a // Nothing at all for a session that meters nothing: a row saying "unknown" there would report
// problem about a setup somebody chose, on every screen, forever. // a problem about a setup somebody chose, on every screen, forever.
if (usage is SessionUsage.NotMetered) { //
// And nothing while the first fetch is out, which is a different silence. A request in flight
// is not a state to report -- and the session that meters nothing is exactly the one this
// cannot yet tell apart, so "5-hour usage: checking" appeared under an echo session for half a
// second and was then taken away. A row that has to be withdrawn is worse than one that
// arrives late.
if (usage is SessionUsage.NotMetered || usage is SessionUsage.Waiting) {
return return
} }
@@ -171,9 +186,10 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
// Words, not a colour and not an empty bar: every one of these is a different kind of // Words, not a colour and not an empty bar: every one of these is a different kind of
// answer from "this much is used", and only words carry a difference in kind. // answer from "this much is used", and only words carry a difference in kind.
when (val state = usage) { when (val state = usage) {
SessionUsage.NotMetered -> Unit // Both handled above, before the row exists at all.
SessionUsage.NotMetered,
SessionUsage.Waiting -> Unit
is SessionUsage.Unavailable -> UsageNote("5-hour usage unknown -- ${state.why}") is SessionUsage.Unavailable -> UsageNote("5-hour usage unknown -- ${state.why}")
SessionUsage.Waiting -> UsageNote("5-hour usage: checking")
is SessionUsage.Known -> { is SessionUsage.Known -> {
val window = state.windows.firstOrNull { it.kind == "session" } val window = state.windows.firstOrNull { it.kind == "session" }
if (window == null) { if (window == null) {
@@ -234,16 +250,22 @@ private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
} }
/** /**
* One machine's snapshot, out of every machine's. * One meter's snapshot, out of every machine's: [setup]'s row for [provider].
*
* Both halves are needed to pick it. A machine can hold more than one meter -- the Claude CLI's
* account and, while a test has one set, an echo session's invented one -- and a snapshot is one
* service on one machine.
* *
* Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it. * Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it.
* None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the * None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the
* machine having no quota rather than the question going unanswered. * machine having no quota rather than the question going unanswered.
*/ */
fun usageFor(snapshots: List<UsageSnapshot>, setup: String): SessionUsage { fun usageFor(snapshots: List<UsageSnapshot>, setup: String, provider: String): SessionUsage {
// No snapshot at all means the backend never asked, which it only does for a machine with // No snapshot at all means the backend never asked, which it only does where there is nothing
// nothing metered on it. That is a different answer from having asked and failed. // to ask about. That is a different answer from having asked and failed.
val mine = snapshots.firstOrNull { it.setup == setup } ?: return SessionUsage.NotMetered val mine =
snapshots.firstOrNull { it.setup == setup && it.provider == provider }
?: return SessionUsage.NotMetered
if (mine.state != "ok") { if (mine.state != "ok") {
return SessionUsage.Unavailable(mine.detail ?: mine.state) return SessionUsage.Unavailable(mine.detail ?: mine.state)
} }
@@ -236,6 +236,7 @@ private fun AddSetupDialog(
var address by remember { mutableStateOf("") } var address by remember { mutableStateOf("") }
var identity by remember { mutableStateOf("") } var identity by remember { mutableStateOf("") }
var attachmentsDir by remember { mutableStateOf("") } var attachmentsDir by remember { mutableStateOf("") }
var modelsDir by remember { mutableStateOf("") }
var tested by remember { mutableStateOf<String?>(null) } var tested by remember { mutableStateOf<String?>(null) }
var testing by remember { mutableStateOf(false) } var testing by remember { mutableStateOf(false) }
@@ -250,6 +251,7 @@ private fun AddSetupDialog(
port = typedPort, port = typedPort,
identityFile = identity.trim().ifEmpty { null }, identityFile = identity.trim().ifEmpty { null },
attachmentsDir = attachmentsDir.trim().ifEmpty { null }, attachmentsDir = attachmentsDir.trim().ifEmpty { null },
modelsDir = modelsDir.trim().ifEmpty { null },
) )
} }
@@ -293,6 +295,14 @@ private fun AddSetupDialog(
label = { Text("Folder for attached files (optional)") }, label = { Text("Folder for attached files (optional)") },
singleLine = true, singleLine = true,
) )
// Where that machine's GGUFs are, for a llama.cpp session on it. Blank means
// the same place this backend keeps its own downloads, read on that machine.
OutlinedTextField(
value = modelsDir,
onValueChange = { modelsDir = it },
label = { Text("Folder for models (optional)") },
singleLine = true,
)
tested?.let { tested?.let {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text(it, style = MaterialTheme.typography.bodySmall) Text(it, style = MaterialTheme.typography.bodySmall)
@@ -61,18 +61,31 @@ fun SpawnScreen(
// "auto" rather than "manual": on a phone every ask is a round trip to a question card, and // "auto" rather than "manual": on a phone every ask is a round trip to a question card, and
// answering "allow Bash?" dozens of times per task is what this app exists to avoid. // answering "allow Bash?" dozens of times per task is what this app exists to avoid.
var permissionMode by remember { mutableStateOf("auto") } var permissionMode by remember { mutableStateOf("auto") }
// Null until the server has been asked, and null again if it answers "no level chosen" -- the
// two are told apart by [defaultsAsked], because a picker that shows a level before the answer
// arrives is one you can spawn at without having chosen it.
var effort by remember { mutableStateOf<String?>(null) }
var defaultsAsked by remember { mutableStateOf(false) }
var busy by remember { mutableStateOf(false) } var busy by remember { mutableStateOf(false) }
// Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-in // Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-in
// form worth keeping, and that one leaves nothing to fill in. // form worth keeping, and that one leaves nothing to fill in.
var spawnError by remember { mutableStateOf<String?>(null) } var spawnError by remember { mutableStateOf<String?>(null) }
// Downloaded models, for a llama provider to choose between. Kept separate from the setups: a // The models on the *chosen machine*, for a llama provider to choose between. Kept separate
// Claude session needs none, so failing to list them must not stop the screen rendering. // from the setups: a Claude session needs none, so failing to list them must not stop the
// screen rendering. Refetched when the machine changes, because a model is a file on one
// machine -- see [fetchSetupModels].
var models by remember { mutableStateOf<List<LocalModel>>(emptyList()) } var models by remember { mutableStateOf<List<LocalModel>>(emptyList()) }
var modelKey by remember { mutableStateOf<String?>(null) } var modelKey by remember { mutableStateOf<String?>(null) }
var contextSize by remember { mutableStateOf("") } var contextSize by remember { mutableStateOf("") }
var temperature by remember { mutableStateOf("") } var temperature by remember { mutableStateOf("") }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// Separate from the setups fetch below and deliberately not fatal: failing to learn the
// default must leave a screen you can still spawn from, so the picker stays on "default"
// and says so rather than the whole form refusing to draw.
runCatching { withContext(Dispatchers.IO) { fetchDefaultEffort(settings) } }
.onSuccess { effort = it }
defaultsAsked = true
options = options =
try { try {
val fetched = withContext(Dispatchers.IO) { fetchSetups(settings) } val fetched = withContext(Dispatchers.IO) { fetchSetups(settings) }
@@ -83,9 +96,6 @@ fun SpawnScreen(
} catch (e: ApiException) { } catch (e: ApiException) {
LoadState.failed(e) LoadState.failed(e)
} }
models =
runCatching { withContext(Dispatchers.IO) { fetchModels(settings).local } }
.getOrDefault(emptyList())
} }
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) { Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
@@ -115,6 +125,17 @@ fun SpawnScreen(
is LoadState.Loaded -> state.value is LoadState.Loaded -> state.value
} }
val setup = setups.firstOrNull { it.name == setupName } val setup = setups.firstOrNull { it.name == setupName }
// Whichever machine is chosen now, asked again when that changes. The old machine's list
// is dropped first rather than left on screen: a file name from another machine looks
// exactly like one from this one.
LaunchedEffect(setup?.id) {
models = emptyList()
modelKey = null
val id = setup?.id ?: return@LaunchedEffect
models =
runCatching { withContext(Dispatchers.IO) { fetchSetupModels(settings, id) } }
.getOrDefault(emptyList())
}
val current = setup?.providers?.firstOrNull { it.name == providerName } val current = setup?.providers?.firstOrNull { it.name == providerName }
// Only the Claude CLI has models, a working directory and permission modes; keying the // Only the Claude CLI has models, a working directory and permission modes; keying the
// extra fields on the kind rather than the provider name keeps a second Claude provider // extra fields on the kind rather than the provider name keeps a second Claude provider
@@ -175,12 +196,13 @@ fun SpawnScreen(
) )
if (isLlama) { if (isLlama) {
// A llama session names one of the models this backend has downloaded, so the choice is // A llama session names one of the models on the machine it will run on, so the
// that list rather than free text -- a name that is not on disk is a session that // choice is that list rather than free text -- a name that is not on that machine's
// cannot start. // disk is a session that cannot start.
if (models.isEmpty()) { if (models.isEmpty()) {
Text( Text(
"No models downloaded yet. Get one from the Models screen first.", "No models on ${setup?.name ?: "this machine"}. The Models screen downloads " +
"to the backend; another machine needs the file put there itself.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -251,6 +273,19 @@ fun SpawnScreen(
selected = permissionMode, selected = permissionMode,
onSelect = { permissionMode = it }, onSelect = { permissionMode = it },
) )
Spacer(Modifier.height(16.dp))
// Says what it does to *later* spawns as well, because it does: the level chosen here
// is stored as the default, which is the whole way that default is set. A picker that
// quietly changed a global would be the same control with the fact left out.
ChipGroup(
label = "Thinking (kept as the default for new sessions)",
options = listOf(DEFAULT_EFFORT) + EFFORT_LEVELS,
// The CLI's own default is a level in the list, so this cannot be a one-way trip.
// Disabled-looking until the server has answered, for the reason above.
selected = if (defaultsAsked) effort ?: DEFAULT_EFFORT else null,
onSelect = { chosen -> effort = chosen.takeIf { it != DEFAULT_EFFORT } },
)
} }
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
@@ -268,6 +303,13 @@ fun SpawnScreen(
try { try {
val spawned = val spawned =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
// Stored before the spawn and not after it: choosing a level is
// an intent about new sessions in general, so a spawn that then
// fails must not also lose the choice. Non-fatal for the same
// reason the fetch above is -- the session is what was asked for.
if (isClaude) {
runCatching { setDefaultEffort(settings, effort) }
}
spawnSession( spawnSession(
settings, settings,
// The id, not the label: labels are editable and the server // The id, not the label: labels are editable and the server
@@ -280,6 +322,7 @@ fun SpawnScreen(
if (isLlama) modelKey else model.trim().takeIf { isClaude }, if (isLlama) modelKey else model.trim().takeIf { isClaude },
cwd = cwd.trim().takeIf { isClaude }, cwd = cwd.trim().takeIf { isClaude },
permissionMode = permissionMode.takeIf { isClaude }, permissionMode = permissionMode.takeIf { isClaude },
effort = effort.takeIf { isClaude },
// Sent only when set, so blank means "whatever llama.cpp does // Sent only when set, so blank means "whatever llama.cpp does
// by default" rather than a zero. // by default" rather than a zero.
params = params =
@@ -0,0 +1,27 @@
package com.example.aiapp
/**
* Where one transcript lives: a session's own, or one of its subagents'.
*
* The single mechanism [fetchTranscript], [EventStream], [TranscriptSource] and
* [TranscriptCache.session] all take, rather than each growing its own branch between a session and
* a subagent -- see SUBAGENTS.md's "Phone" and "Wire shape". A caller that has only a session id
* builds one with the one-argument constructor; a subagent's screen supplies both ids.
*/
data class TranscriptAddress(val sessionId: String, val subagentId: String? = null) {
/** The URL segment naming this transcript, before `/transcript` or `/events`. */
val urlPath: String
get() =
if (subagentId == null) "sessions/$sessionId"
else "sessions/$sessionId/subagents/$subagentId"
/**
* Where this transcript's cache lives on the phone, relative to the cache root.
*
* A subagent's nests under its session's directory rather than sitting beside it, so deleting a
* session's cache directory takes its subagents' with it -- the same one-way door the server's
* own storage describes.
*/
val cachePath: String
get() = if (subagentId == null) sessionId else "$sessionId/subagents/$subagentId"
}
@@ -35,8 +35,15 @@ class TranscriptCache(
private val root: File, private val root: File,
private val warn: (String) -> Unit = { Log.w("ai-app", it) }, private val warn: (String) -> Unit = { Log.w("ai-app", it) },
) { ) {
/** The cache for one session, whether or not anything has been stored for it yet. */ /**
fun session(id: String): SessionCache = SessionCache(File(root, id), warn) * The cache for one transcript, whether or not anything has been stored for it yet.
*
* A subagent's [TranscriptAddress.cachePath] nests it under its session's directory, so
* deleting the session (below) takes its subagents' caches with it -- there is no separate
* purge for one.
*/
fun session(address: TranscriptAddress): SessionCache =
SessionCache(File(root, address.cachePath), warn)
/** /**
* Deletes every session directory not in [ids], called after a successful list fetch. The path * Deletes every session directory not in [ids], called after a successful list fetch. The path
@@ -175,6 +175,18 @@ sealed class TranscriptItem {
val preTokens: Long?, val preTokens: Long?,
val postTokens: Long?, val postTokens: Long?,
) : TranscriptItem() ) : TranscriptItem()
/**
* The account ran out of quota, so the turn stopped here.
*
* A divider rather than an error: nothing failed, and what a reader scrolling back needs from
* it is the same thing a clear or a compaction gives them -- why the conversation stops at this
* line.
*
* [resetsAt] is epoch seconds and null where the session was told nothing, which is a state the
* row has words for rather than a time it invents.
*/
data class LimitNote(override val seq: Long, val resetsAt: Double?) : TranscriptItem()
} }
/** /**
@@ -458,6 +470,7 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
} else { } else {
items + TranscriptItem.ImageItem(entry.seq, event.ref) items + TranscriptItem.ImageItem(entry.seq, event.ref)
} }
is SessionEvent.LimitReached -> items + TranscriptItem.LimitNote(entry.seq, event.resetsAt)
is SessionEvent.Cleared -> items + TranscriptItem.ClearedNote(entry.seq) is SessionEvent.Cleared -> items + TranscriptItem.ClearedNote(entry.seq)
is SessionEvent.Compacted -> is SessionEvent.Compacted ->
items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens) items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens)
@@ -18,7 +18,7 @@ import java.util.concurrent.atomic.AtomicReference
*/ */
class TranscriptSource( class TranscriptSource(
private val settings: ServerSettings, private val settings: ServerSettings,
private val sessionId: String, private val address: TranscriptAddress,
val cache: SessionCache, val cache: SessionCache,
) { ) {
private val stream = AtomicReference<EventStream?>(null) private val stream = AtomicReference<EventStream?>(null)
@@ -65,7 +65,7 @@ class TranscriptSource(
val tail = cache.tail() ?: return false val tail = cache.tail() ?: return false
// `before = seq + 1` is the newest event with seq <= the cursor, which is the event *at* // `before = seq + 1` is the newest event with seq <= the cursor, which is the event *at*
// the cursor when the server still has one there. // the cursor when the server still has one there.
val answer = fetchTranscript(settings, sessionId, before = tail.seq + 1, limit = 1) val answer = fetchTranscript(settings, address, before = tail.seq + 1, limit = 1)
val matches = val matches =
answer.size == 1 && answer.size == 1 &&
try { try {
@@ -83,7 +83,7 @@ class TranscriptSource(
*/ */
suspend fun fetchOpening(): List<SeqEvent> { suspend fun fetchOpening(): List<SeqEvent> {
DebugStats.count("transcript page from server") DebugStats.count("transcript page from server")
val page = fetchTranscript(settings, sessionId, limit = OPENING_WINDOW) val page = fetchTranscript(settings, address, limit = OPENING_WINDOW)
page.forEach { (line, entry) -> cache.append(line, entry.seq) } page.forEach { (line, entry) -> cache.append(line, entry.seq) }
cache.flush() cache.flush()
return page.map { it.second } return page.map { it.second }
@@ -108,7 +108,7 @@ class TranscriptSource(
val page = val page =
fetchTranscript( fetchTranscript(
settings, settings,
sessionId, address,
before = before, before = before,
limit = limit, limit = limit,
coalesce = coalesce, coalesce = coalesce,
@@ -131,7 +131,7 @@ class TranscriptSource(
* well lose. * well lose.
*/ */
fun follow(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) { fun follow(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) {
val opened = EventStream(settings, sessionId) val opened = EventStream(settings, address)
stream.getAndSet(opened)?.close() stream.getAndSet(opened)?.close()
try { try {
opened.run(after, onOpen, onReset) { raw, entry -> opened.run(after, onOpen, onReset) { raw, entry ->
@@ -0,0 +1,32 @@
package com.example.aiapp
import java.time.ZoneId
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* What the transcript says where a session ran out of quota.
*
* The pair worth a test is the one that reads the same when it goes wrong: a reset time that
* arrived and one that never did. The second must not turn into a plausible-looking time, because a
* reader has no way of telling an invented one from a reported one.
*/
class LimitRowTest {
private val utc = ZoneId.of("UTC")
@Test
fun `a reported reset time is shown as a time`() {
// 2026-09-05T12:00:00Z. Asserted as a prefix and the clock reading rather than as the
// whole string: the platform's own short-time format is what this asks for, and it
// differs by JDK and locale down to which space character separates the meridiem.
val summary = limitSummary(1_788_609_600.0, utc)
assertTrue(summary.startsWith("Usage limit reached • resets "), summary)
assertTrue(summary.contains("12:00"), summary)
}
@Test
fun `a limit with no reset time says only what is known`() {
assertEquals("Usage limit reached", limitSummary(null, utc))
}
}
@@ -23,7 +23,7 @@ class TranscriptCacheTest {
private fun cache() = TranscriptCache(File(temp, "v1/host_8443")) { said += it } private fun cache() = TranscriptCache(File(temp, "v1/host_8443")) { said += it }
private fun session(id: String = "s") = cache().session(id) private fun session(id: String = "s") = cache().session(TranscriptAddress(id))
private fun line(seq: Long, type: String = "toolStart") = private fun line(seq: Long, type: String = "toolStart") =
"""{"seq":$seq,"ts":1.5,"type":"$type","id":"x"}""" """{"seq":$seq,"ts":1.5,"type":"$type","id":"x"}"""
+16
View File
@@ -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,
+39 -14
View File
@@ -60,6 +60,25 @@ marked **DEFERRED** are ones the agent chose not to decide alone.
flagged here because it is the first half of something Iris explicitly flagged here because it is the first half of something Iris explicitly
asked to see before P1. asked to see before P1.
- **P0's iris half is also built and smoke-tested on the emulator,
2026-09-05.** A new `bench` Cargo feature on `iris-android-app`, on top
of `transcript-screen`: the same checked-in fixture (`include_str!`, no
asset pipeline needed), the same 24-swipe scroll loop animated through
`List::scroll` and the same 400-event/20s streaming phase through
`fold_event`, "Run benchmark"/"Copy report" as named accessible
controls, and the same three added report fields (process CPU time,
peak RSS, battery current) via direct JNI calls
(`bench_jni.rs::PlatformHandle`) since `android_view` has no
`BatteryManager`/`ClipboardManager` wrapper of its own. One small public
API addition to get there: `AndroidAppState::platform_ready` (`IRIS.md`),
a default-no-op lifecycle hook handing an implementor a `JavaVM` +
`GlobalRef` it can call Java through from any thread. Packaged with a
new `release` build type on `iris-android-app`'s own Gradle project
(there was previously only `debug`), signed with the same key
`app/build-apk.sh` generates. Smoke run and the full report are in
RUST.md's P0 box; not attempted this pass: the real on-phone runs and
Iris's pass/fail call, which is the actual gate.
- **The intermittent touch-scroll dropout is root-caused and fixed: a - **The intermittent touch-scroll dropout is root-caused and fixed: a
missed `ACTION_DOWN` hit-test, not the previously-suspected coalesced missed `ACTION_DOWN` hit-test, not the previously-suspected coalesced
first `ACTION_MOVE`.** Diagnosed by temporary logcat tracing of every first `ACTION_MOVE`.** Diagnosed by temporary logcat tracing of every
@@ -245,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.
+266
View File
@@ -8,6 +8,114 @@ 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::anchor_position_display`, `FrameReport::mark_phase`/`phase_stats`/`late_at_hz` (RUST.md's "Benchmark v2")
`List` gained `anchor_position_display(&self) -> String`, reporting the
anchor's own row index and pixel offset (`idx=N/off=Mpx`, or
`idx=more-before`/`idx=more-after`/`idx=none`) -- what a scripted
benchmark reads to report fling travel. Note the anchor does not
necessarily change *slot* over a long scroll (this widget's own documented
design: the anchor is a stable identity, not re-derived from what's on
screen each frame), so this is not the same measurement as a Compose
`LazyListState.firstVisibleItemIndex`, which does track the true topmost
visible row -- the `off` half is what actually reflects how far a fling
travelled.
`iris_core::render::frame_report::FrameReport` gained three methods for
per-phase benchmark reporting: `mark_phase(name)` records a named phase
boundary at the current frame/instant; `phase_stats(now, refresh_hz)`
returns one `PhaseStats` (frames, wall duration, late count/percent,
p50/p90/p99, worst) per marked phase, sliced from the existing ring by a
new parallel `index_ring`; `late_at_hz(refresh_hz)` gives the whole run's
late count/percent judged against an arbitrary refresh rate rather than
the fixed 60Hz `JANK_THRESHOLD` every existing caller still uses (a
separate method, not a parameter on `report()`, so nothing else changes
behaviour). `RING_CAPACITY` grew 4096->16384 to hold a full multi-phase
run without evicting earlier phases' samples.
## 2026-09-06: `List::fling`, `VelocityTracker`, `FlingCalculator` (IRIS_TODO.md's "swiping has no momentum")
`iris::widget::List` gained a real fling: `fling(velocity_px_per_s)` starts
one (cancelled by the next touch-down via `cancel_fling`, or automatically
once it settles or reaches loaded content's start/end), `is_scrolling()`
reports whether one is running, and `tick_fling(now: Instant) -> bool`
advances it and returns whether it is still going -- a caller that owns a
`RequestRedraw` handle can hand it to the list once via the new
`set_redraw_handle`, after which `List` re-arms its own next frame while
flinging with no further polling needed; a caller driving a scripted
benchmark instead calls `tick_fling` itself in a loop, same as it already
drives `scroll`.
The physics is `iris::sense::FlingCalculator` + `VelocityTracker`
(`sense.rs`, beside `DragArbiter`): a port of AOSP `SplineOverScroller`'s
deceleration curve (the same one Compose's own `ScrollableDefaults.
flingBehavior()` uses), cited at the definition, so a fling here travels
the same distance a Compose `LazyColumn` would for the same initial
velocity. `VelocityTracker` estimates that velocity from the drag's last
~100ms of samples rather than one frame's last delta. Unit-tested:
velocity from known samples, fling distance/duration against the closed-
form spline result (within 1%), cancel-on-touch, and the start/end clamp
(a fling stops rather than scrolling into content that was never loaded).
Before: a touch-drag panned exactly as far as the finger moved and stopped
dead on release. After: releasing mid-drag continues scrolling and
decelerates, matching the muscle memory every other Android scroll view
already trained. `transcript_ui::selection::Selection::drag` wires this in
-- a release only flings if the gesture had committed to panning
(`DragArbiter::is_panning`, new), never a selection or an undecided tap.
## 2026-09-06: `UiRenderNode::new` returns `Result`, not `Self` (RUST.md's P0 box, phone-crash fix)
`iris_core::UiRenderNode::new(device, queue, config)` now returns
`Result<Self, String>` instead of `Self`. Why: it used to let a bind-group-
layout validation failure reach wgpu's default error handler, which panics
with no way for a caller to intervene -- exactly what aborted the P0 bench
APK on Iris's phone with the crash report truncated to "wgpu error:
Validation Error" and nothing else recoverable. It now runs its creation
calls inside wgpu error scopes and returns the full error text (wgpu's own
"Caused by" chain) as `Err` instead.
Both callers changed to match: `android::render::AndroidRenderer::new`
itself now returns `Result<Self, String>` too, building a fuller report
(adapter identity, the limits/downlevel flags a layout validates against,
then wgpu's text) on failure -- its caller,
`android::view::IrisViewPeer::surface_changed`, logs that report as one
logcat line and shows it on screen (a new `IrisView.showRendererError`,
called via an ordinary JNI method call rather than a new `native fn`)
instead of letting the process abort. `default::render::UiRenderer::new`
(the winit/desktop backend) still panics on failure -- there is no
on-screen fallback there -- but the panic message is now the same full
text rather than whatever wgpu's own handler would have printed.
No change for an app that never constructs a `UiRenderNode` directly (every
current one goes through `AndroidRenderer`/`UiRenderer`), but anyone who
does needs an `?`/`.expect()`/`match` at the call site now. Full audit and
the named hypothesis for what actually failed on the phone are in
RUST.md's P0 box, "iris bench crash on the phone, 2026-09-06."
## 2026-09-05: `AndroidAppState::platform_ready` (RUST.md's P0 box, iris half)
Added a second, optional lifecycle method to `iris::android::AndroidAppState`
(`iris/src/android/view.rs`), called once from `new_peer` right after `new`:
```rust
fn platform_ready(&mut self, rsc: &mut AndroidRsc<Self>, vm: JavaVM, view: GlobalRef) {}
```
Default does nothing, so every existing implementor (`Client`,
`TranscriptClient`) is unaffected. It exists for a caller that needs to call
into Java itself beyond what a `RequestRedraw` handle already covers --
P0's bench build (`iris-android-app`'s new `bench` feature,
`bench_client.rs`/`bench_jni.rs`) uses it to hold a `JavaVM` + `GlobalRef`
to the view so its "Copy report" control and once-a-second battery sampler
can call `BatteryManager`/`ClipboardManager` through the view's own
`Context`, from a background tokio task as well as the UI thread. `new`
itself was not extended with these two parameters: most implementors need
nothing here, and `new`'s job is building the widget tree, not holding a
platform handle. `vm`/`view` are independent handles from the ones
`new_peer` keeps for its own `RequestRedraw` (a fresh `get_java_vm`/
`new_global_ref` each), so storing them has no effect on that mechanism.
## 2026-09-05 (later still): `iris_core::device_limits()`, and iris no longer requests compute-shader limits ## 2026-09-05 (later still): `iris_core::device_limits()`, and iris no longer requests compute-shader limits
New public function, `iris_core::device_limits() -> wgpu::Limits`. Why: New public function, `iris_core::device_limits() -> wgpu::Limits`. Why:
@@ -414,3 +522,161 @@ 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
~101130ms/~76103ms across three runs (this fix) — see RUST.md's P0 box
for the full numbers and the comparison's caveats (different AVD
instances, not a controlled A/B on identical hardware state).
## 2026-09-06: bundled fonts, `content_scale`, `AndroidAppState::on_insets_changed`
From RUST.md's P0 box, working Iris's first real-phone report (font/scale/
inset bugs the emulator never showed).
- **`TextData` now bundles Noto Sans + Noto Sans Mono** (regular/bold/
italic/bold-italic static faces, OFL) and registers them ahead of the
platform's own fonts in the `SansSerif`/`Monospace` generic-family
lists, rather than relying on the platform's font enumeration alone.
`TextData::font_diagnostics() -> FontDiagnostics` reports what was found
and what each style axis resolved to — logged once at startup and shown
on a screen's Diagnostics page if it has one. Adds ~3.6 MB uncompressed
to any binary linking `iris-core`; `build-apk.sh`'s own output says the
delivered (compressed) number.
- **`UiRenderNode::new`/`resize` now take the window size explicitly**
(`window_size: impl Into<Vec2>`) instead of deriving it from the
surface's physical `SurfaceConfiguration`. Existing callers pass a
*logical* size (physical ÷ density/scale-factor) now; this is what makes
a `font_size: 16.0` 16 dp instead of 16 raw device pixels on a
high-density phone. Before this, `scale_factor` did not exist anywhere
in the crate, on either platform.
- **`AndroidUiState::content_scale: f32`** (`DisplayMetrics.density`, read
once in `new_peer`) and the desktop equivalent (`window.scale_factor()`)
now divide every physical-pixel number before it reaches layout or
touch handling — see `content_scale`'s own field doc for the full list
of what depends on it.
- **New: `AndroidAppState::on_insets_changed(&mut self, rsc, LogicalInsets)`**,
a default-no-op hook called from `render()` exactly when
`AndroidUiState::insets()` changes. Nothing previously consumed
`insets().top` at all; a screen with chrome under the status bar
implements this to pad it, in the same logical units `content_scale`
converts everything else to.
- **New: `iris_core::WgpuErrorLog`**, installed via `Device::
on_uncaptured_error` on the Android device (wgpu's default handler is an
unconditional panic outside `UiRenderNode::new`'s own error scopes).
Explicit `Arc`-backed value passed to the callback and kept on
`AndroidRenderer`, not a global — a caller wanting one on desktop builds
its own the same way.
## 2026-09-06: `Len::dp`, physical pixels throughout, the keyboard glyph wipe
Iris's phone report on build a9232ac (screenshots): text now the right
size but blurry; the keyboard still wipes every glyph; the header buttons
have nothing behind them. All three are fixed; this entry is the public
API side. docs/LAYOUT.md has the layout-side writeup, docs/RUST.md's P0
box has the full investigation and the phone verification still to do.
- **The keyboard wipe was `surface_changed` rebuilding the whole renderer
on every resize**, including an IME-driven one — a fresh, empty glyph
atlas while the CPU-side glyph cache kept UV coordinates from the old
one. `surface_changed` now calls `AndroidRenderer::resize` (reconfigures
the surface and window uniform only) when a renderer is already live,
and only builds a new one when there genuinely isn't one yet.
- **`Len` has a third field, `dp`** (Android's dp / CSS's reference pixel,
1/160in), beside the existing `abs` (now explicitly *physical* pixels)
and `rel`/`rest`. `len_fns::dp`/`Len::dp` construct one, used exactly
like `abs`/`rel`/`rest` — `dp(16)` instead of a bare `16` wherever a
size should look the same physical size on any density. This is the
unit IRIS_TODO.md's "density-independent length unit" item asked for;
it replaces the previous stopgap (the whole rendered scene divided by
`content_scale` then implicitly stretched back up), which is also what
made text blurry — a glyph rasterised at the small, pre-stretch size and
then upscaled onto the real framebuffer.
- **`UiRenderState`/`Painter` gained `density()`/`set_density()`** (physical
pixels per dp). Every place a length resolves (`Len::apply_rest`,
`Size::to_uivec2`) now takes it; `Span::gap` and `Padding`'s four sides
moved from a bare `f32` to `Len` so they take `dp(...)` too. A bare
number anywhere is unaffected — still `abs`, physical pixels.
- **Text is rasterised at physical resolution now.** `TextBuffer::shape`
takes `density` and multiplies `font_size`/`line_height` (and any span
override) by it before handing them to parley, so the atlas holds a
bitmap at the size it is actually shown at rather than a low-resolution
one stretched afterward.
- **Everything at the Android boundary is physical pixels now** — window
size, touch coordinates, insets (`LogicalInsets` renamed
`WindowInsets`). The previous "logical" division by `content_scale` is
gone; `content_scale` now feeds `set_density` instead.
- Not yet verified on Iris's actual phone (this pass had no device) —
built and checked on this checkout's emulator only. RUST.md's P0 box
says what she should check for: crisp text at two densities, the
keyboard no longer wiping, and the header's background.
## 2026-09-06: composing text, focus-on-tap, and atlas invalidation on a new renderer
Three small but public API changes, from the same phone-report pass as the
entry above (RUST.md's P0 box has the full account, including a real bug
still not root-caused).
- **`FocusHost` gained `is_focused(&self, id) -> bool`** (both platform
impls). `attr.rs`'s `Selector`/`Selectable` used to grant focus (and so
request the IME) on the very first frame of *any* press, before it was
known whether the gesture was a tap or a drag — a swipe over a text
field wrongly summoned the keyboard. They now wait for a completed tap
(press and release with no frame crossing `sense::DRAG_SLOP`) unless the
field is already focused, in which case dragging inside it to select
text is unchanged. `TextEdit` gained one new `pub(crate)` field
(`press_origin`) to track this; no public surface change there.
- **`android::ime`'s `InputConnection` now calls `InputMethodManager::
updateSelection` after every edit** (`IrisViewPeer::update_ime_selection`,
called from `after_input`). Gboard was holding keystrokes back because
nothing ever told it where the app's own selection/composing region had
moved to — this is what android-view's own demo does in its `render()`
and this bridge never did.
- **`GlyphAtlas::clear()` and `Textures::reset()`** (`iris_core`). Called
together, once, from `android::view`'s `surface_changed` exactly when a
*genuinely new* `AndroidRenderer` is built (backgrounding and returning,
not a keyboard-triggered resize, which already reuses the renderer) —
both CPU-side caches otherwise kept pointing at the old, now-destroyed
device's textures, which is why text used to vanish again after leaving
and returning to the app.
+128
View File
@@ -105,6 +105,84 @@ order and what "done" looks like. Tick and date them in place.
were exactly the same root cause measured two different ways. Frame 2 were exactly the same root cause measured two different ways. Frame 2
now reports 0 (see the numbers above); not a separate fix. now reports 0 (see the numbers above); not a separate fix.
- [ ] **A read-only text display has no widget of its own — P0's bench
report area is a `TextEdit` standing in for one (2026-09-05).** The only
way to get selectable text on screen today is `.editable(...)` plus
`.attr::<Selectable>(())` (`Selectable` is only implemented for
`TextEdit`, `iris/src/attr.rs`), which also makes the field focusable —
tapping the bench report opens the soft keyboard over text nothing lets
you type into. Harmless for a bench-only debug screen (not fixed this
pass), but a real "selectable, not editable" text primitive would
remove the keyboard side effect and is worth having before another
screen wants the same thing (P1's own transcript rows already read
their content from a `TextEdit` for the same reason).
## From the phone, 2026-09-06
Found on Iris's own phone while working RUST.md's P0 box's phone-report
follow-ups. Recorded here rather than fixed in that pass, so a follow-up
agent takes them without colliding with that pass's `bench_client.rs`/
`android/view.rs`/`android/sense.rs` changes.
- [x] **Swiping has no momentum, fixed 2026-09-06.** `List::fling`/
`VelocityTracker`/`FlingCalculator` (`iris/src/widget/list.rs`,
`iris/src/sense.rs`) -- IRIS.md's 2026-09-06 entry has the full account.
Wired through `Selection::drag`'s release path, cancelled by the next
touch-down, clamped at the loaded content's start/end. Verified by unit
test (fling distance against the closed-form spline result, cancel-on-
touch, the clamp), not yet by an on-device or emulator feel-check --
that is still open.
- [x] **Scrolling down sometimes jitters the text, fixed 2026-09-06.**
Root-caused by reading `DragArbiter::update`'s `Undecided`-to-`Panning`
transition rather than by an on-device trace (no emulator was used this
pass): it was the first named suspect, not the second. `self.last` stays
at the press origin for every `Undecided` frame (nothing pans while the
gesture might still be a selection), so the frame that finally crosses
`DRAG_SLOP` returned `Pan(dy)` with `dy` measured from `press_start` --
the *whole* pre-threshold drag, applied to the list in one step, however
many frames it had taken to get there. Fixed by applying only the
excess past `DRAG_SLOP` on that one frame (`dy - DRAG_SLOP.copysign
(dy)`), the same "consume the slop, don't replay it" rule Android's own
touch handling follows. New regression test,
`crossing_the_slop_by_a_little_pans_by_a_little` (`iris/src/sense.rs`).
**Not yet done**: an emulator trace of the real per-frame offset
confirming this was the whole story on real touch input rather than
only the arbiter's own unit tests -- worth a follow-up pass before
calling it fully closed.
- [x] **Composing text held back until a space, caret not moving, fixed
2026-09-06.** `InputMethodManager.updateSelection` was never called --
see IRIS.md's 2026-09-06 entry and RUST.md's P0 box, item 1, for the
full account and the emulator evidence.
- [x] **Swipe over the composer summons the keyboard, fixed 2026-09-06.**
`Selector`/`Selectable` now wait for a completed tap -- see IRIS.md's
2026-09-06 entry and RUST.md's P0 box, item 5. Verified via `dumpsys
input_method`'s `mInputShown` on the emulator, not yet on the phone.
- [x] **Text disappears again after leaving and returning to the app,
fixed 2026-09-06.** `GlyphAtlas::clear`/`Textures::reset` on a
genuinely new renderer -- see IRIS.md's 2026-09-06 entry and RUST.md's
P0 box, item 4. Verified on the emulator (home, reopen, screenshot);
not yet on the phone.
- [ ] **Composed/typed text never becomes visible at all -- found
2026-09-06, not fixed.** The composer bar stays empty even once the
buffer genuinely holds the typed text (confirmed indirectly: Gboard's
own suggestion strip reacts correctly to each keystroke). A new unit
test proves the widget tree's own layout math resolves the field's
region correctly across a keyboard resize, so the bug is downstream of
that -- most likely `UiRenderState::redraw`'s single-widget redraw path,
or specific to this emulator's forced `force-gles` backend (untested on
Vulkan or the real phone). RUST.md's P0 box, item 2, has the full
writeup, what was ruled out, and where to look next. **Also unverified
because of this**: item 3's composer rebuild (one `Stack`-based widget,
a capped/scrollable height, bottom padding tied to the IME/nav-bar
inset) -- structurally in place and unit-tested, but its own visual
correctness cannot be screenshotted until text actually renders.
- [ ] **The composer has no touch-drag scroll for overflowing text.** The
2026-09-06 rebuild caps the field at ~6 lines and wraps it in
`.scrollable()` for a wheel/trackpad scroll, but a real finger drag over
text that has overflowed the cap does not scroll it -- `Scroll`'s touch
handling is a follow-up, the same shape `List`'s own touch-drag pan
needed before I3/I5.
## 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
@@ -373,3 +451,53 @@ 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.
## From the phone, bench v2 (2026-09-06): streaming re-lays out the whole message
- [ ] **Streaming a delta into a long message costs a full text layout of
that message.** Iris's phone report (`docs/bench/iris-phone-v2-2026-09-06.md`):
the stream phase is the one place iris is behind Compose (p50 18.2 ms vs
13.4 ms; p99 level at ~43 ms). `TranscriptScreen::apply` replaces only
the last row, but that row is the growing message, and replacing it
re-renders its markdown and re-shapes the entire paragraph run through
parley on every event. Compose pays a reparse (8.6 ms mean) for the
same event. What "done" looks like: a streamed delta re-lays out only
the block it lands in (the last paragraph or code block), with earlier
blocks' layouts kept -- which needs a row to be a column of per-block
`Text`s rather than one `TextEdit` for the whole message, or parley's
layout to be split at block boundaries; measured by the stream phase's
p50 dropping below Compose's on the phone. Do this after the four bench
v2 defects (stale primitives, finger fling, decay curve, IME show) are
closed, since they are what make the run unrepresentative today.
+52
View File
@@ -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):
+147 -10
View File
@@ -147,12 +147,37 @@ turn.
Spawn: `claude -p --verbose --input-format stream-json --output-format Spawn: `claude -p --verbose --input-format stream-json --output-format
stream-json --permission-mode <mode>` in the chosen working directory, plus stream-json --permission-mode <mode>` in the chosen working directory, plus
`--model`. Wire-format notes are pinned against CLI 2.1.237 in `--model` and, where one has been chosen, `--effort`. Wire-format notes are
`session/claude.rs`'s module doc: permissions need the hidden pinned against CLI 2.1.237 in `session/claude.rs`'s module doc: permissions
`--permission-prompt-tool stdio` flag, AskUserQuestion answers ride need the hidden `--permission-prompt-tool stdio` flag, AskUserQuestion answers
`updatedInput.answers` keyed by question text, and `set_model`/`interrupt` ride `updatedInput.answers` keyed by question text, and `set_model`/`interrupt`
are control requests. are control requests.
**The thinking level is settled at launch** (added 2026-09-04, because it is
the largest saving available on a long session: output is about an eighth of
what a session costs and thinking is the bulk of output, against the ~1.5% that
is prose). The CLI's only two setting control requests are `set_model` and
`set_permission_mode` -- checked against the 2.1.258 binary -- so there is no
way to ask a running process to think differently. `set_session_effort` is
therefore shaped like `set_session_cwd` rather than like `set_session_model`:
it records the level and **stops the process**, and the next message or Start
launches one that has it. It lives in the session settings dialog beside the
working directory for that reason, not on the session bar beside the model and
the mode, which do take effect mid-turn. `None` is a level in its own right --
the CLI's own default -- so the picker can return to it; a level this app named
as the default instead would be this app choosing one.
**What a new session starts at is `Config::default_effort`**, applied in
`spawn_session` rather than filled in by the spawn screen, so it holds for an
import and a bare API call as well. It is set by the spawn screen's own
picker, whose label says so: one control, where new sessions are made, rather
than a settings page for a single value. It is not on a provider, because
providers are discovered and the next rediscovery would erase it, and not on
the phone, because a second device would then spawn at a level nobody there
chose. `GET`/`POST /defaults` carry it, as a struct rather than a bare value
so the permission mode -- still hardcoded to `auto` on the spawn screen -- can
move there without a second route.
**`--resume` only ever runs when nothing else has that session open.** That **`--resume` only ever runs when nothing else has that session open.** That
is the rule behind the import refusal, the single `ClaudeDriver::launch` is the rule behind the import refusal, the single `ClaudeDriver::launch`
entry point, and the `Exited` correction below; two CLIs on one session file entry point, and the `Exited` correction below; two CLIs on one session file
@@ -169,10 +194,33 @@ deliberate and easy to undo by accident:
when the process restarts. That leaves the Claude driver as the odd one when the process restarts. That leaves the Claude driver as the odd one
out rather than this one — the CLI's memory is a cache in front of the same out rather than this one — the CLI's memory is a cache in front of the same
transcript. Resolve any inconsistency in this direction. transcript. Resolve any inconsistency in this direction.
- **A llama session on an ssh host is refused.** The model is reached over - **A llama session runs on whatever machine its setup names** (2026-09-04,
HTTP and forwarding that port is not built, so refusing beats silently the last of phase 5). A transport is "run this" plus "reach this port", and
talking to the wrong machine. A transport is "run this" plus "reach this the second half is `Transport::reserve_port` — the port the server binds
port", and only the first half exists. *there* and the port that reaches it *here*, the same number locally —
carried by `Launch::reaching` onto the connection that already runs the
command. `llama-server` binds loopback on the far machine, so nothing is
served to its network. The far port is a guess from a range below the
ephemeral one, because no portable way to ask a machine for a free port
avoids racing the bind anyway; a collision is not silent, since the server
fails to bind and the readiness poll reports what its log said.
- **The model file lives on the machine that serves it** (2026-09-04). Each
setup names its own models directory (`SshConfig::models_dir`, default
`~/.local/share/ai-app/models` expanded *there*), and a spawn resolves the
key on that machine — one round trip answering "at /abs/path" or "missing",
so a model that is not there is refused at the spawn rather than becoming a
server that never becomes ready. The spawn screen offers
`GET /setups/{id}/models`, that machine's list, rather than `GET /models`,
which is this backend's downloads. Downloading *to* another machine is
deliberately not built: a multi-gigabyte transfer with no progress
anywhere, and the file gets there however anything else on that machine
did.
- **The readiness poll watches the process, not only the port.** A model that
will not load, a port already taken, a flag an older build does not know:
all exit within a second and none will ever answer `/health`, so waiting
out the 300s timeout turned the server's own account of the problem into
"gave up". The failure carries the tail of `llama-server.log`, which on a
remote session is the only copy anybody reading the phone can see.
### Models (2026-08-28) ### Models (2026-08-28)
@@ -203,6 +251,13 @@ deliberate and easy to undo by accident:
A driver says what to run; something above it turns that into a process. A driver says what to run; something above it turns that into a process.
Otherwise transport knowledge sits inside a translator whose job is a wire Otherwise transport knowledge sits inside a translator whose job is a wire
format, and every future driver has to remember to do the same. format, and every future driver has to remember to do the same.
- **A forwarded launch gets a pty and every other one does not** (measured
2026-09-04). Killing the ssh client ends a CLI because it closes the stdin
that CLI is reading; `llama-server` never reads its stdin, so the same kill
left it running on the far machine with the model loaded — one orphan per
stopped session. With `-tt` the far side takes SIGHUP when the connection
goes. Its log then arrives through a line discipline, which nothing parses.
`-T` stays everywhere else, where a pty would rewrite the JSONL.
- **`command -v` follows ssh's non-login PATH**, which is narrower than an - **`command -v` follows ssh's non-login PATH**, which is narrower than an
interactive shell's, so a binary somewhere unusual is invisible to interactive shell's, so a binary somewhere unusual is invisible to
discovery. Point `command` at an absolute path. discovery. Point `command` at an absolute path.
@@ -499,6 +554,25 @@ rate-limited bucket). Poll at ≥180 s, only while a Claude session exists or
the usage screen is open, and cache the last answer. It is undocumented, so the usage screen is open, and cache the last answer. It is undocumented, so
`usage.rs` treats every field as optional and degrades rather than erroring. `usage.rs` treats every field as optional and degrades rather than erroring.
**Per provider, not per machine (2026-09-04).** A machine is not what is
metered; the provider a session runs is. One machine offers echo, the Claude
CLI and a local model side by side, and only the second spends anything — so
pairing a session with a snapshot by machine alone drew the CLI's five-hour
window under every echo session on it, a quota that session cannot spend. A
session now names its meter (`usageProvider`, from
`DriverKind::usage_provider`, which `usage::providers_for` reads too, so the
two lists cannot disagree) and `GET /usage` is matched on machine *and*
provider. `None` is a session that meters nothing, and the phone draws
nothing at all for it — not a zero, and not "unknown".
`DriverKind::Echo` names a meter of its own that exists only when a test has
asked for one: `/usage` in an echo session sets an invented answer
(`usage::Fixture`), and with none set there is no snapshot and no bar. That
is what makes those screens' states reachable — a number near the top, a
window between blocks with no reset time, a machine nobody logged into, one
that could not be reached — without spending real quota to arrange them,
which is why none of them had ever been looked at.
**Per machine, not per backend (2026-08-29).** The credential store that **Per machine, not per backend (2026-08-29).** The credential store that
matters is the one on the machine the session runs on, because that is the matters is the one on the machine the session runs on, because that is the
account being billed — and in the layout this aims at, `ai-server` is on the account being billed — and in the layout this aims at, `ai-server` is on the
@@ -522,6 +596,68 @@ always running. So absent means **not running**, and only a timestamp that
arrives and cannot be parsed is unknown. `WindowEnd` in `ResetCountdown.kt` arrives and cannot be parsed is unknown. `WindowEnd` in `ResetCountdown.kt`
is the one rule both readers go through. is the one rule both readers go through.
### Auto-resume (2026-09-05)
**A session may pick itself back up when the account's usage limit lifts.**
Off unless somebody switched that session to it, because it spends quota the
moment quota exists and does so with nobody looking — that is not a thing a
default may decide. It sends one message, `continue` unless another was
typed, and then it is done; there is no retry loop around the conversation
itself.
**Running out of quota is a state, not an error.** `Event::LimitReached`
carries the dialect's reset time where it gave one, and recognising it
belongs to the driver — the Claude CLI ends the turn with `is_error` and
`Claude AI usage limit reached|1788546972`, and nothing above the driver
matches on a string. The transcript draws it as a divider, like a clear or a
compaction: what a reader scrolling back wants from it is why the
conversation stops at that line.
**The schedule is a plan to ask, never a plan to send.** Every reset time
available here is untrustworthy in the direction that matters: the dialect's
is written when the turn fails, and the endpoint's moves when the window
does. So the wait ends in a question to `usage.rs`, and only `ok` with no
window at 100% sends anything. A window still spent reschedules to *its own*
reset time — which is what makes a limit that lifts later than promised wait
longer, and one that lifts sooner resume sooner. A meter that cannot be
asked at all is a longer wait too, never a send: "we could not find out"
must not be able to produce the same action as "there is room".
Bounded, because something has to be: a day after the limit was hit the wait
stops and says so in the session's own transcript. A machine that can never
be asked would otherwise be retried for ever with nothing on screen saying
so.
The schedule is persisted on the session (`resume: Some(ScheduledResume)`),
not held in memory: a five-hour window routinely outlasts a backend restart,
and a wait forgotten across one is a session that silently never comes back.
`resume.rs` is the top layer — it holds the manager and the monitor and
neither holds it — which is what lets the decision be a pure function of a
snapshot and a clock. The pump reports limits downward on a broadcast, for
the reason `Shared` exists: the pump runs underneath the manager.
**Exercised with echo, never with a real account.** `/limit [minutes]` in an
echo session reports the same event a real driver does, and `/usage` sets
what the meter answers — deliberately two commands, because the two
disagreeing is the state the whole design is about. The loop was driven end
to end that way on 2026-09-05: the wait moved from the dialect's two minutes
to the meter's seven when the meter changed its mind, and the message went
out on the first check after the meter came back under the limit.
### Subagents (2026-09-05)
**A subagent is a second transcript owned by a session, in the same event
model, with no process and no controls of its own.** Full design and wire
shape in `SUBAGENTS.md`, kept separate because the app half is being built
against it in parallel and it is the shared contract between the two. The
one-paragraph reason: a session's Task-tool helpers already speak the common
event model on the parent's own stdout (each line carrying
`parent_tool_use_id`), so giving each one its own small transcript — same
file format, same paging routes, same SSE stream, reused by addressing rather
than by copying — costs a routing step in the translator and a registry
(`session/subagent.rs`) rather than a second session type with a driver, a
process and a config entry it does not need.
### HTTP surface ### HTTP surface
**`routes.rs`'s module doc comment is the table.** REST for actions, one SSE **`routes.rs`'s module doc comment is the table.** REST for actions, one SSE
@@ -800,8 +936,9 @@ Noticed and deliberately not fixed, so they are not re-found from scratch.
Phases 13 (the skeleton pipe, the full Claude driver, the usage screen) done Phases 13 (the skeleton pipe, the full Claude driver, the usage screen) done
2026-08-24. Phase 4 (llama.cpp: model browsing, downloads, and `llama-server` 2026-08-24. Phase 4 (llama.cpp: model browsing, downloads, and `llama-server`
through its OpenAI-compatible endpoint) and phase 5 (ssh) done 2026-08-28. through its OpenAI-compatible endpoint) and phase 5 (ssh) done 2026-08-28,
The file explorer and the transcript cache followed in September. What is except for the remote `llama-server` and its port forward, which landed
2026-09-04. The file explorer and the transcript cache followed in September. What is
left is real-phone/WireGuard bring-up, which is operational rather than code. left is real-phone/WireGuard bring-up, which is operational rather than code.
Each phase ended runnable and verified against the real thing. The backend Each phase ended runnable and verified against the real thing. The backend
+1403 -32
View File
File diff suppressed because it is too large. Load diff
+73
View File
@@ -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)
```
+93
View File
@@ -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)
```
+58
View File
@@ -0,0 +1,58 @@
# iris bench v2 report from Iris's phone, 2026-09-06
Build 2e3f4ad (bench v2, fling physics, keyboard-wipe fix, dp unit), run
by Iris on her Pixel 9 Pro XL, verbatim. The display was at **120 Hz**
(8.3 ms budget) where `compose-phone-v2-2026-09-06.md` ran at 60 Hz, so
compare the millisecond percentiles, not `late`.
Side by side (Compose 60 Hz / iris 120 Hz, p50 / p90 / p99 ms): fling
5.5/8.7/11.6 vs 3.8/6.9/12.6; stream 13.4/31.7/42.5 vs 18.2/35.8/43.1;
type 7.3/13.2/16.5 vs 7.2/9.2/11.2; keyboard: iris could not show the IME
(phase invalid). Process CPU 69.6 s over 125 s vs 40.6 s over 150 s; peak
RSS 577 MB vs 379 MB; battery current mean 571 mA vs 452 mA.
Iris's observations on the same run: "the scrolling is not similar at
all. It does not fling for me yet [with a finger], and the test also seems
to give it a constant velocity and abruptly stop it at some point. Also
unsure what's going on in that image with the compaction" -- her
screenshot shows the `Compacted: 180000 -> 20000 tokens.` row drawn twice
overlapping, and once more below the composer bar: primitives of a
replaced/removed row surviving in the GPU buffers, the same shape as the
header drawn twice after a keyboard resize.
```
iris bench report
per phase:
fling: 1783 frames over 53.2s
late: 104 (5.8%)
total p50 3.8ms p90 6.9ms p99 12.6ms
worst 29.1ms
stream: 401 frames over 21.3s
late: 306 (76.3%)
total p50 18.2ms p90 35.8ms p99 43.1ms
worst 43.8ms
type: 1202 frames over 65.7s
late: 309 (25.7%)
total p50 7.2ms p90 9.2ms p99 11.2ms
worst 15.3ms
keyboard: 9 frames over 9.7s
late: 9 (100.0%)
total p50 12.0ms p90 12.9ms p99 12.9ms
worst 12.9ms
frames:
3395 frames over 149.9s at 120Hz (8.3ms budget)
late: 728 (21.4%)
total p50 5.0ms p90 10.9ms p99 36.6ms
worst 43.8ms
cpu_p50 2.0ms gpu_wait_p50 2.6ms
bench:
fling: 8 flings out + 8 back at 12000px/s, travel start=idx=651/off=1217px outward=idx=651/off=101536px end=idx=651/off=1022px
scroll: 6 cycles (24 swipes, legacy tween), streamed 400/400 fixture events
type: 600 characters inserted then deleted, one per 50ms
keyboard: could not be shown (5 attempts, 0 confirmed visible)
process CPU time over this run: 40603ms
peak RSS: 379156kB
battery current: mean -452353µA over 149 samples (min -1753125, max -204687)
```
+19
View File
@@ -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,
}, },
+1
View File
@@ -1757,6 +1757,7 @@ dependencies = [
"fxhash", "fxhash",
"image", "image",
"parley", "parley",
"pollster",
"swash", "swash",
"wgpu", "wgpu",
] ]
+3
View File
@@ -1768,9 +1768,11 @@ dependencies = [
"client-core", "client-core",
"event-model", "event-model",
"iris", "iris",
"libc",
"log", "log",
"serde_json", "serde_json",
"tabs-ui", "tabs-ui",
"tokio",
"transcript-ui", "transcript-ui",
] ]
@@ -1783,6 +1785,7 @@ dependencies = [
"fxhash", "fxhash",
"image", "image",
"parley", "parley",
"pollster",
"swash", "swash",
"wgpu", "wgpu",
] ]
+25
View File
@@ -32,6 +32,23 @@ transcript-ui = { path = "../transcript-ui", optional = true }
client-core = { path = "../../client-core", optional = true } client-core = { path = "../../client-core", optional = true }
event-model = { path = "../../event-model", optional = true } event-model = { path = "../../event-model", optional = true }
serde_json = { version = "1", features = ["float_roundtrip"], optional = true } serde_json = { version = "1", features = ["float_roundtrip"], optional = true }
# P0's bench build only (docs/RUST.md): `getrusage(RUSAGE_SELF)` for
# process CPU time, matching `libc::getrusage`'s mention in that box over
# parsing `/proc/self/stat` by hand and assuming `USER_HZ`. Already in the
# workspace's own dependency tree transitively (`iris/Cargo.lock`, pinned
# at 0.2.179) -- this makes it a direct dependency at the same version
# rather than a second, possibly-drifting resolution.
libc = { version = "0.2.179", optional = true }
# P0's bench build only: the scroll animation and the streaming phase are
# both a sequence of `sleep`s inside the async task `rsc.spawn_task` already
# runs on iris's own tokio runtime (`iris/src/task.rs`'s `Tasks::init`), and
# the battery sampler is a second, concurrent task on that same runtime
# (`tokio::spawn`) -- so this crate needs `tokio` directly rather than only
# through `iris`. `rt`+`time` only: no I/O, no macros, nothing this crate
# doesn't call. Version matches the one `iris`'s own dependency tree already
# resolves to (`iris/Cargo.lock`), so there is one copy of the runtime, not
# two.
tokio = { version = "1.53.1", features = ["rt", "time"], optional = true }
[features] [features]
default = ["tabs-screen"] default = ["tabs-screen"]
@@ -41,6 +58,14 @@ transcript-screen = ["dep:transcript-ui", "dep:client-core", "dep:event-model",
# instead of SwiftShader's software Vulkan. See `iris/Cargo.toml`'s own doc # instead of SwiftShader's software Vulkan. See `iris/Cargo.toml`'s own doc
# on the feature this forwards to. # on the feature this forwards to.
force-gles = ["iris/force-gles"] force-gles = ["iris/force-gles"]
# P0's iris half (docs/RUST.md, docs/AGENTS.md's "The rigs"): the same
# checked-in fixture, scroll loop and streaming phase the Compose `bench`
# build type drives, run here against `transcript-ui`'s real screen with no
# server. Depends on `transcript-screen` for `transcript-ui`/`client-core`/
# `event-model` -- `lib.rs`'s `ActiveClient` selection gives this feature
# priority over `transcript-screen`'s own `TranscriptClient` when both are
# listed, which is how this crate's build command names both explicitly.
bench = ["transcript-screen", "dep:libc", "dep:tokio"]
[profile.release] [profile.release]
panic = "abort" panic = "abort"
+30
View File
@@ -19,9 +19,39 @@ android {
versionName = "1.0" versionName = "1.0"
} }
// A release build must be signed, and the key is per machine rather than per repo -- same
// reasoning and the same key as `app/build-apk.sh` (the Compose app): it is what a phone
// recognises the app by, and a secret never lives in a checkout (the mount is shared with an
// untrusted VM). `build-apk.sh` generates this key once and points at it through the
// environment; without it a release build here is unsigned, which is fine for everything
// except installing.
def keystore = System.getenv("AI_APP_KEYSTORE")
signingConfigs {
if (keystore != null) {
release {
storeFile = file(keystore)
storePassword = System.getenv("AI_APP_KEYSTORE_PASSWORD")
keyAlias = "ai-app"
keyPassword = storePassword
}
}
}
buildTypes { buildTypes {
debug { debug {
} }
// P0's iris half (docs/RUST.md's P0 box): the build a phone actually runs. The `.so`
// itself is built separately with `cargo ndk --release --features "transcript-screen
// force-gles bench"` straight into src/main/jniLibs/ (this crate's own Cargo.toml) --
// Gradle here only packages and signs whatever is already there, the same division as the
// debug/tabs-screen build this project started with. `applicationIdSuffix` keeps it
// installable beside a debug build of the tabs demo rather than replacing it.
release {
applicationIdSuffix ".bench"
if (keystore != null) {
signingConfig = signingConfigs.release
}
}
} }
compileOptions { compileOptions {
@@ -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));
});
}
} }
@@ -36,9 +36,28 @@ public final class MainActivity extends Activity {
int top = insets.getSystemWindowInsetTop(); int top = insets.getSystemWindowInsetTop();
int right = insets.getSystemWindowInsetRight(); int right = insets.getSystemWindowInsetRight();
int bottom = insets.getSystemWindowInsetBottom(); int bottom = insets.getSystemWindowInsetBottom();
// The manifest declares adjustResize (AGENTS.md: without it the
// keyboard pans the whole window instead of resizing it), and
// under adjustResize the window itself shrinks to make room for
// the keyboard -- which is exactly the condition under which
// WindowInsets.Type.ime()'s own *inset amount* reports zero: it
// measures how much of the window the keyboard overlaps, and
// resize already made that overlap zero by construction. That
// numeric inset is not a usable "is the keyboard open" signal
// here (found while root-causing why bench_client.rs's keyboard
// phase and auto-diagnostics never fired on the emulator despite
// the keyboard visibly opening -- RUST.md's P0 box). What does
// survive adjustResize is the boolean isVisible() answer, set
// from the platform's own start/end of the transition over a
// different path than the inset amount -- the same fact
// AGENTS.md's "Things that have bitten" already names for the
// Compose side's identical trap. Passed through as a 0/1 stand-
// in for the ime_bottom pixel amount, since nothing on the Rust
// side reads it as a real pixel value -- only `> 0.0`.
int imeBottom = 0; int imeBottom = 0;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom; && insets.isVisible(WindowInsets.Type.ime())) {
imeBottom = 1;
} }
((IrisView) v).applyWindowInsets(left, top, right, bottom, imeBottom); ((IrisView) v).applyWindowInsets(left, top, right, bottom, imeBottom);
return insets; return insets;
+90
View File
@@ -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"
+8
View File
@@ -22,6 +22,14 @@ fn main() {
if std::env::var_os("CARGO_FEATURE_TRANSCRIPT_SCREEN").is_none() { if std::env::var_os("CARGO_FEATURE_TRANSCRIPT_SCREEN").is_none() {
return; return;
} }
// P0's bench build (docs/RUST.md) opens the checked-in fixture with no
// server at all -- `bench_client.rs` never references the `pinned`
// module this generates, so requiring a live server's host/port/token/
// CA to build it (as plain `transcript-screen` does, below) would be a
// pointless requirement for a build that talks to nothing.
if std::env::var_os("CARGO_FEATURE_BENCH").is_some() {
return;
}
println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_HOST"); println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_HOST");
println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_PORT"); println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_PORT");
println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_TOKEN"); println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_TOKEN");
+69
View File
@@ -0,0 +1,69 @@
#!/bin/sh
# Installs and runs the iris `bench` build on this checkout's own emulator
# (per this-machine-android's per-checkout-AVD rule; `emu serial` picks it)
# and prints the report -- the iris half of `app/transcript-bench.sh`'s
# job. No coordinates: the button is found by its accessibility label
# through `ui-trace`, per AGENTS.md's "Driving the UI".
#
# Usage: ./run-bench.sh [--apk PATH]
# Defaults to this checkout's own release APK
# (app/build/outputs/apk/release/app-release.apk) if it exists, else the
# debug one -- build one first with ./build-apk.sh.
set -eu
cd "$(dirname "$0")"
APK=""
while [ $# -gt 0 ]; do
case "$1" in
--apk) APK="$2"; shift 2 ;;
*) echo "run-bench.sh: unknown argument: $1" >&2; exit 1 ;;
esac
done
if [ -z "$APK" ]; then
if [ -f app/build/outputs/apk/release/app-release.apk ]; then
APK=app/build/outputs/apk/release/app-release.apk
else
APK=app/build/outputs/apk/debug/app-debug.apk
fi
fi
if [ ! -f "$APK" ]; then
echo "run-bench.sh: no APK at $APK -- run ./build-apk.sh first" >&2
exit 1
fi
SERIAL=$(emu serial)
PKG=$(aapt2 dump badging "$APK" 2>/dev/null | sed -n "s/^package: name='\\([^']*\\)'.*/\\1/p")
if [ -z "$PKG" ]; then
BUILD_TOOLS=$(ls -d "$HOME"/Android/Sdk/build-tools/*/ | sort -V | tail -1)
PKG=$("${BUILD_TOOLS}aapt2" dump badging "$APK" | sed -n "s/^package: name='\\([^']*\\)'.*/\\1/p")
fi
echo "run-bench.sh: installing $APK ($PKG) on $SERIAL"
adb -s "$SERIAL" install -r "$APK" >/dev/null
adb -s "$SERIAL" shell am force-stop "$PKG"
adb -s "$SERIAL" logcat -c
adb -s "$SERIAL" shell am start -n "$PKG/dev.iris.android.demo.MainActivity" >/dev/null
ui-trace record -s "$SERIAL" -d 3000 --do "tap 'Run benchmark'" -o /tmp/run-bench-tap.txt >/dev/null
# Poll for the report line rather than a fixed sleep -- the run itself is
# a fixed script (RUST.md's "Benchmark v2": 16 flings, a 20s streaming
# phase, ~61s of typing, 10s of keyboard toggles, roughly 2.5 minutes end
# to end) but device speed varies. 260s cap rather than v1's 90s -- v2 is
# a longer script than v1's swipe-loop-only run.
i=0
while [ "$i" -lt 260 ]; 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 260s -- check logcat by hand" >&2
exit 1
fi
# -A 60 rather than v1's -A 6 -- v2's report has a per-phase block (four
# phases, four lines each) on top of the frames/bench sections v1 had.
adb -s "$SERIAL" logcat -d -s iris-android-app:I | grep -A 60 "iris bench report:"
+1000
View File
@@ -0,0 +1,1000 @@
//! P0's iris half (docs/RUST.md's P0 box, docs/AGENTS.md's "The rigs"):
//! the same fixture, scroll loop and streaming phase the Compose `bench`
//! build type's `BenchRun.kt`/`BenchFixture.kt` drive, run here against
//! `transcript-ui`'s real screen with no server -- a frame-time comparison
//! that measures the renderer rather than the data or the network.
//!
//! **Reuses `transcript_client.rs`'s shape** (folded items, the same
//! `TranscriptScreen::apply` incremental update on every event) with the
//! network half replaced by the checked-in fixture, embedded with
//! `include_str!` -- `app/bench-fixture/assets/transcript.jsonl`,
//! 1,915,760 bytes, generated by `app/bench-fixture/generate.py` and never
//! a real transcript (that file's own README). The first 3,200 lines are
//! the opening backlog, folded once through
//! `client_core::transcript_fold::fold_page` exactly as a real
//! `/transcript` page would be (then a full `transcript_ui::build_tree`,
//! same as any first load); the remaining ~400 are the streaming tail,
//! replayed one at a time through `fold_event` -- the same fold path a
//! live SSE reply arrives on -- by the "Run benchmark" control below.
//! Streaming through `apply` rather than a full rebuild per event is what
//! this file exists to measure -- see docs/RUST.md's P0 box for the
//! before/after report.
use crate::bench_jni::PlatformHandle;
use android_view::jni::{JavaVM, objects::GlobalRef};
use client_core::transcript_fold::{TranscriptItem, fold_event, fold_page, group_tool_runs};
use event_model::SeqEvent;
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
use iris::prelude::*;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
/// bench-fixture/README.md: the first `BACKLOG_COUNT` non-blank lines are
/// the opening window; the rest are the streaming tail. Kept in sync with
/// `BenchFixture.kt`'s identical constant by hand -- both read the same
/// checked-in file, so a mismatch would only mean the two apps' bench
/// builds open a different split of it, not a wrong-vs-right answer.
const BACKLOG_COUNT: usize = 3200;
/// RUST.md's "Benchmark v2" spec, written once so both apps' bench clients
/// implement the identical four phases -- see that box before changing any
/// constant here, since a mismatch would make the two reports stop
/// measuring the same thing while still looking like they do.
const STREAM_EVENTS_PER_SEC: u64 = 20;
const STREAM_SECONDS: u64 = 20;
/// Kept only so this phase's own label text still reads "scroll: 6 cycles
/// (24 swipes, legacy tween)" the way `BenchRun.kt`'s v2 report does --
/// `docs/bench/compose-phone-v2-2026-09-06.md`'s own report shows this
/// exact line even though the swipe loop it names no longer runs there
/// either (the fling phase replaced it); nothing here drives an actual
/// swipe with these any more.
const LEGACY_CYCLES: usize = 6;
/// Fling phase (v2): a real fling through `List::fling`, not a tween --
/// Iris's ask was that it "travel way faster" than the v1 swipe, and a
/// tween can never exceed the distance/time it is given while a real
/// fling decays from an initial velocity the way a finger flick does.
/// 12,000 px/s matches `BenchRun.kt`'s own constant exactly.
const FLING_VELOCITY_PX_S: f32 = 12_000.0;
const FLING_COUNT: usize = 8;
const FLING_SETTLE_CAP_MS: u64 = 3_000;
const FLING_PAUSE_MS: u64 = 300;
/// Type phase (v2): long, multisyllabic words so the composer actually
/// wraps and the transcript above it is pushed upward, typed and deleted
/// one character per `TYPE_CHAR_MS`. Exactly `BenchRun.TYPE_TEXT` --
/// verified 600 characters by `type_text_is_exactly_600_characters` below.
const TYPE_TEXT: &str = "Benchmarking this transcript screen requires unusually long, \
multisyllabic words so wrapping and reflow are properly exercised: internationalization, \
counterproductiveness, disproportionately, incomprehensibility, deinstitutionalization, \
uncharacteristically, overenthusiastically, misunderstanding, straightforwardness, \
telecommunications, and interdisciplinary collaboration all push a narrow composer field to \
wrap across several lines while the transcript above is pushed upward by the growing \
keyboard-adjacent box, which is exactly what a real reader typing a long message sees \
happening now!!!";
const TYPE_CHAR_MS: u64 = 50;
/// Keyboard phase (v2): five show/hide cycles, a second apart, matching
/// `BenchRun.kt`'s `KEYBOARD_CYCLES`/`KEYBOARD_SHOW_WAIT_MS`/
/// `KEYBOARD_HIDE_WAIT_MS`.
const KEYBOARD_CYCLES: usize = 5;
const KEYBOARD_WAIT_MS: u64 = 1_000;
/// One animation step's target cadence -- close enough to 60Hz that a
/// fling/scroll is many small moves rather than one jump, so frames are
/// actually rendered along the way, and close enough that a `ctx.update`
/// closure's effect (only applied once the next frame callback drains the
/// task channel -- `IrisViewPeer::drain_tasks`) is visible again quickly
/// when a later step in the same phase needs to read state back.
const ANIM_STEP_MS: u64 = 16;
const FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl");
pub struct BenchClient {
ui_state: AndroidUiState,
content: WeakWidget<WidgetPtr>,
report_display: WeakWidget<TextEdit>,
/// The top button row, in a `WidgetPtr` slot rather than added
/// directly (like `content`) so `on_insets_changed` can swap in a
/// version padded for the status bar once insets are known -- RUST.md's
/// P0 box, "the status-bar inset is not applied," found the row sitting
/// directly under it because nothing here read `insets().top` at all.
top_bar: WeakWidget<WidgetPtr>,
screen: Option<transcript_ui::TranscriptScreen>,
items: Vec<TranscriptItem>,
/// The events not yet streamed -- consumed by `start_benchmark`'s own
/// clone, kept here only as the source a second run would need (the
/// button can be pressed more than once; `running` just stops overlap,
/// not repeat).
stream_tail: Vec<SeqEvent>,
platform: Option<Arc<PlatformHandle>>,
last_report: Option<String>,
running: bool,
/// The keyboard phase's own confirmation channel -- updated from
/// `on_insets_changed` (the platform's own answer for whether the IME
/// is actually visible, per `WindowInsets::ime_bottom`), read from the
/// benchmark's spawned task via the shared `Arc<Mutex<_>>` rather than
/// `ctx.update`, since neither side needs the widget tree for this.
ime_state: Arc<Mutex<ImeState>>,
/// Edge-triggers the keyboard diagnostics capture below -- set on the
/// first `on_insets_changed` where `ime_bottom > 0.0`, cleared on the
/// first where it is not, so opening the keyboard fires this once
/// rather than on every insets update while it stays open (a rotation
/// or a status-bar change with the keyboard already up would otherwise
/// re-fire it).
keyboard_was_visible: bool,
/// The status-bar inset `top_bar` was last padded by -- see
/// `on_insets_changed`'s own comment for why this guards the rebuild.
last_top_pad: f32,
}
/// See `BenchClient::ime_state`'s doc. `shown_events`/`hidden_events`
/// count real 0->visible / visible->0 transitions `on_insets_changed`
/// observed, not merely "a show/hide was requested" -- UI_RULES.md: never
/// present an inferred value as a measured one. `run_keyboard_phase` reads
/// the counters before and after asking for a toggle and calls it
/// confirmed only if the count moved.
#[derive(Default)]
struct ImeState {
visible: bool,
shown_events: u32,
hidden_events: u32,
}
impl HasAndroidUiState for BenchClient {
fn android_state(&self) -> &AndroidUiState {
&self.ui_state
}
fn android_state_mut(&mut self) -> &mut AndroidUiState {
&mut self.ui_state
}
}
/// Parses the fixture once: `serde_json::Value`s for the backlog
/// (`fold_page` takes a page of raw wire JSON, same as a real
/// `/transcript` response) and folded `SeqEvent`s for the tail (`fold_event`
/// takes one live wire event at a time, same as a real SSE frame).
fn parse_fixture() -> (Vec<serde_json::Value>, Vec<SeqEvent>) {
let lines: Vec<&str> = FIXTURE_JSONL
.lines()
.filter(|line| !line.trim().is_empty())
.collect();
let mut backlog = Vec::with_capacity(BACKLOG_COUNT.min(lines.len()));
let mut stream_tail = Vec::new();
for (i, line) in lines.iter().enumerate() {
let value: serde_json::Value =
serde_json::from_str(line).expect("bench fixture is generated JSON, always valid");
if i < BACKLOG_COUNT {
backlog.push(value);
} else {
let event: SeqEvent = serde_json::from_value(value)
.expect("bench fixture event matches event-model's SeqEvent");
stream_tail.push(event);
}
}
(backlog, stream_tail)
}
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
wtext(message.to_string())
.color(Color::WHITE)
.wrap(true)
.pad(16)
.add_strong(rsc)
.any()
}
/// `getrusage(RUSAGE_SELF)`'s user+system time, in ms -- `None` only if
/// the syscall itself fails, which UI_RULES.md's "never present an
/// inferred value as a measured one" says to keep apart from a real (and
/// here, impossible) zero.
fn process_cpu_ms() -> Option<u64> {
// SAFETY: `rusage` is a plain-old-data struct `getrusage` fully
// initialises on success; on failure it is never read.
unsafe {
let mut usage: libc::rusage = std::mem::zeroed();
if libc::getrusage(libc::RUSAGE_SELF, &mut usage) != 0 {
return None;
}
let user_ms = usage.ru_utime.tv_sec as u64 * 1000 + usage.ru_utime.tv_usec as u64 / 1000;
let sys_ms = usage.ru_stime.tv_sec as u64 * 1000 + usage.ru_stime.tv_usec as u64 / 1000;
Some(user_ms + sys_ms)
}
}
/// `VmHWM` from `/proc/self/status` -- the process's peak RSS since it
/// started, in kB. Same source `BenchRun.kt`'s `peakRssLine` reads, so the
/// two reports' numbers mean the same thing.
fn peak_rss_kb() -> Option<u64> {
std::fs::read_to_string("/proc/self/status")
.ok()?
.lines()
.find_map(|line| line.strip_prefix("VmHWM:"))
.and_then(|rest| rest.trim().strip_suffix("kB"))
.and_then(|n| n.trim().parse().ok())
}
fn battery_line(samples: &[i32]) -> String {
if samples.is_empty() {
return " battery current: unavailable on this device".to_string();
}
let mean = samples.iter().map(|&v| v as i64).sum::<i64>() / samples.len() as i64;
let min = samples.iter().min().unwrap();
let max = samples.iter().max().unwrap();
format!(
" battery current: mean {mean}\u{b5}A over {} samples (min {min}, max {max})",
samples.len()
)
}
impl AndroidAppState for BenchClient {
fn new(mut ui_state: AndroidUiState, rsc: &mut AndroidRsc<Self>) -> Self {
let content = WidgetPtr::new().add(rsc);
let loading = placeholder(rsc, "Loading fixture...");
content(rsc).set(loading);
let report_display = wtext("")
.editable(EditMode::MultiLine)
.text_align(Align::LEFT)
.wrap(true)
.size(14)
.color(Color::WHITE)
.attr::<Selectable>(())
.label("Benchmark report")
.add(rsc);
let top_bar = WidgetPtr::new().add(rsc);
let controls = bench_controls(rsc, 0.0);
top_bar(rsc).set(controls);
let tree = (
top_bar,
content.height(rest(2)),
report_display.height(rest(1)).pad(dp(8)),
)
.span(Dir::DOWN)
.add_strong(rsc)
.any();
ui_state.set_root(tree);
// Startup log line (RUST.md's P0 box, "log once at startup ... the
// number of font families found, the default family resolved"):
// what font discovery actually found on this device, before
// anything is drawn.
let font = rsc.ui.text.font_diagnostics();
log::info!(
"iris fonts: {} families found, default={:?} mono={:?}, resolved regular={:?} \
bold={:?} italic={:?} mono={:?}",
font.families_found,
font.default_family,
font.default_mono_family,
font.regular_resolved,
font.bold_resolved,
font.italic_resolved,
font.mono_resolved,
);
let mut client = Self {
ui_state,
content,
report_display,
top_bar,
screen: None,
items: Vec::new(),
stream_tail: Vec::new(),
platform: None,
last_report: None,
running: false,
ime_state: Arc::new(Mutex::new(ImeState::default())),
keyboard_was_visible: false,
last_top_pad: 0.0,
};
let (backlog, stream_tail) = parse_fixture();
client.stream_tail = stream_tail;
match fold_page(&backlog) {
Ok(items) => {
client.items = items;
client.rebuild_transcript(rsc);
}
Err(message) => {
client.show_message(rsc, &format!("Couldn't fold the bench fixture: {message}"))
}
}
client
}
fn platform_ready(&mut self, _rsc: &mut AndroidRsc<Self>, vm: JavaVM, view: GlobalRef) {
self.platform = Some(Arc::new(PlatformHandle::new(vm, view)));
}
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>, _render: &mut UiRenderState) -> bool {
false
}
/// Pads the top button row by the status-bar inset -- see `top_bar`'s
/// field comment. Rebuilds the row rather than mutating a stored
/// `Padding` in place, since nothing here holds a handle to one --
/// but **only when `insets.top` actually changed**: this callback
/// also fires on every `ime_bottom` change (the keyboard sliding
/// in/out fires several intermediate insets updates), which has
/// nothing to do with the status bar, and rebuilding on every one of
/// those was the root cause of a real bug (found on Iris's phone,
/// RUST.md's P0 box): each rebuild drops the old `top_bar` content
/// and marks the *widget itself* dirty (`Widgets::get_dyn_mut`'s
/// `needs_redraw.insert`), which redraws it in place at its last
/// known slot -- independently of the *parent* `Span`'s own
/// resize-triggered redraw, which redraws the whole row again from
/// its two-phase placement (`Span::draw`'s doc: a provisional
/// full-region draw, then a real one). A `.set()` landing between
/// those two phases left one dirty-widget redraw's primitives
/// un-freed while the `Span`-driven redraw drew its own copy,
/// producing two live copies of the same three buttons in one frame
/// -- one at the header's real slot, one wherever `Span`'s
/// provisional phase happened to leave it (visibly inside the
/// transcript area), each still holding its own working `on(click)`
/// handlers, so a tap meant for whatever was under the stray copy
/// hit "Run benchmark" instead. Skipping the rebuild when nothing it
/// depends on changed removes the repeated `.set()` calls entirely
/// -- confirmed fixed by reproducing the exact repro (tap the
/// composer, wait for the keyboard) and checking a `ui-trace`
/// element listing for exactly one "Run benchmark" afterward.
///
/// Also two things downstream of the same `ime_bottom` transition:
/// **the keyboard phase's own confirmation signal** (`ime_state`'s
/// doc -- the platform's own answer for whether the IME actually
/// opened or closed, rather than assumed from having called
/// `show_ime`/`hide_ime`), and **the trigger for the keyboard
/// diagnostics capture** (RUST.md's P0 box): the IME resizing the
/// surface is exactly the case a previous commit found wiped text,
/// and Iris needs a way to get a report off the phone even if that
/// (or some other keyboard-triggered regression) is still happening
/// on the build she is holding -- `capture_keyboard_diagnostics`
/// below fires ~500ms after the keyboard becomes visible, once per
/// keyboard opening, and shows its report in a plain overlay view
/// that draws independently of whatever iris itself is doing.
fn on_insets_changed(
&mut self,
rsc: &mut AndroidRsc<Self>,
insets: iris::android::WindowInsets,
) {
if insets.top != self.last_top_pad {
self.last_top_pad = insets.top;
let controls = bench_controls(rsc, insets.top);
(self.top_bar)(rsc).set(controls);
}
// The composer bar sits directly on whichever of the IME or the
// navigation bar is currently the bottom of usable space -- see
// `transcript_ui::composer::Composer::set_bottom_inset`'s doc.
// `ime_bottom` already exceeds the plain nav-bar inset whenever the
// keyboard covers it, so the larger of the two is always the right
// answer without needing to know which is currently showing.
if let Some(screen) = &self.screen {
screen
.composer
.set_bottom_inset(rsc, insets.bottom.max(insets.ime_bottom));
}
let ime_visible = insets.ime_bottom > 0.0;
let mut ime = self.ime_state.lock().unwrap();
if ime_visible && !ime.visible {
ime.shown_events += 1;
}
if !ime_visible && ime.visible {
ime.hidden_events += 1;
}
ime.visible = ime_visible;
drop(ime);
if ime_visible && !self.keyboard_was_visible {
self.keyboard_was_visible = true;
let redraw = rsc.tasks.redraw_handle();
rsc.spawn_task(async move |mut ctx| {
tokio::time::sleep(Duration::from_millis(KEYBOARD_DIAGNOSTICS_DELAY_MS)).await;
ctx.update(|state: &mut BenchClient, rsc| {
state.capture_keyboard_diagnostics(rsc);
});
redraw.request_redraw();
});
} else if !ime_visible {
self.keyboard_was_visible = false;
}
}
}
/// How long to wait after the keyboard becomes visible before capturing
/// diagnostics -- long enough that the resize, the reported wipe (if it is
/// still happening) and a couple of frames have all had time to land, per
/// AGENTS.md's "so that operations that finish in milliseconds have states
/// on the way that nothing can observe" reasoning applied the other way:
/// this wants to observe the state *after* the transition settles, not
/// mid-flight.
const KEYBOARD_DIAGNOSTICS_DELAY_MS: u64 = 500;
type Rsc = AndroidRsc<BenchClient>;
/// The header row's own backdrop -- see `bench_controls`'s doc comment on
/// why it needs one at all. A dark neutral rather than pure black
/// (`android::render::CLEAR_COLOR`) so the row reads as a distinct panel
/// instead of a hole in the background the buttons happen to float in.
const HEADER_SURFACE: UiColor = UiColor::new(28, 28, 34, 255);
/// `top_pad` is the status-bar inset in physical pixels (0.0 until
/// `on_insets_changed` has run once) -- folded in here, rather than
/// exposing the unadded builder for a caller to `.pad()` itself, because
/// naming that builder's type at each call site is more machinery than a
/// top-of-screen padding number is worth.
///
/// **Backed by an opaque rect the full size of the row, not just the three
/// buttons.** Iris's phone report (docs/RUST.md's P0 box, screenshots on
/// build a9232ac): "the header buttons have nothing behind them and
/// overlap the transcript text" -- before this, only each button's own
/// `rect(...)` painted anything, so the gaps between and around them (and
/// the status-bar strip above them) showed whatever was one layer back
/// (`CLEAR_COLOR`, black), and the row's true height was three
/// physical-pixel-sized (`abs`, not `dp`) button boxes rather than the
/// density-correct size the transcript below was already using post-P0 --
/// exactly what reads as "overlap" once the two disagree. Fixed two ways
/// together: a `HEADER_SURFACE` rect stacked behind the whole row (this
/// function), and every size below moved from a bare number (physical
/// pixels) to `dp(...)` (IRIS_TODO.md's density-independent length unit),
/// so the row's reserved height in the outer `Span::DOWN`
/// (`AndroidAppState::new`) matches what is actually painted.
fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
let run_rect = rect(Color::rgb(40, 70, 40))
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
ctx.state.start_benchmark(rsc);
},
)
.label("Run benchmark");
let run = (
run_rect,
wtext("Run benchmark").size(18).text_align(Align::CENTER),
)
.stack()
.pad(dp(8))
.add(rsc);
let copy_rect = rect(Color::rgb(50, 50, 60))
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| {
ctx.state.copy_report();
},
)
.label("Copy report");
let copy = (
copy_rect,
wtext("Copy report").size(18).text_align(Align::CENTER),
)
.stack()
.pad(dp(8))
.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 {
fn show_message(&mut self, rsc: &mut Rsc, message: &str) {
let widget = placeholder(rsc, message);
(self.content)(rsc).set(widget);
self.screen = None;
}
fn rebuild_transcript(&mut self, rsc: &mut Rsc) {
let rows = group_tool_runs(&self.items);
let (screen, tree) = transcript_ui::build_tree(rsc, rows);
(self.content)(rsc).set(tree);
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) {
let Some(report) = &self.last_report else {
log::info!("iris bench report: nothing to copy -- run the benchmark first");
return;
};
let Some(platform) = &self.platform else {
log::info!("iris bench report: no platform handle, can't reach the clipboard");
return;
};
if platform.copy_to_clipboard("iris bench report", report) {
log::info!("iris bench report: copied to clipboard");
} else {
log::info!("iris bench report: clipboard copy failed");
}
}
/// RUST.md's "Benchmark v2": fling, then stream (unchanged from v1),
/// then type, then keyboard, then the report -- run in-process for the
/// same reason `BenchRun.kt`'s own doc gives (no usable system tracing
/// on a real phone, no agent that can drive one).
fn start_benchmark(&mut self, rsc: &mut Rsc) {
if self.running {
log::info!("iris bench report: already running");
return;
}
self.running = true;
self.android_state_mut().frame_report.reset();
self.report_display.edit(rsc).set("Running benchmark...");
let redraw = rsc.tasks.redraw_handle();
let platform = self.platform.clone();
let stream_tail = self.stream_tail.clone();
let ime_state = self.ime_state.clone();
let refresh_hz = platform
.as_ref()
.and_then(|p| p.refresh_rate_hz())
.unwrap_or(60.0);
let cpu_start = process_cpu_ms();
let run_started_at = Instant::now();
rsc.spawn_task(async move |mut ctx| {
// The battery sampler runs for the whole run, once a second,
// the same cadence `BatterySampler` uses on the Compose side
// -- via its own JNI-attached thread, not `ctx.update`, since
// a sample needs no widget-tree access.
let sampler_done = Arc::new(AtomicBool::new(false));
let samples = Arc::new(Mutex::new(Vec::<i32>::new()));
let sampler = platform.clone().map(|platform| {
let done = sampler_done.clone();
let samples = samples.clone();
tokio::spawn(async move {
while !done.load(Ordering::Relaxed) {
if let Some(value) = platform.battery_current_ua() {
samples.lock().unwrap().push(value);
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
})
});
let travel = run_fling_phase(&mut ctx, &redraw).await;
let (sent, total) = run_stream_phase(&mut ctx, &redraw, stream_tail).await;
run_type_phase(&mut ctx, &redraw, &platform).await;
let keyboard = run_keyboard_phase(&mut ctx, &platform, &ime_state).await;
sampler_done.store(true, Ordering::Relaxed);
if let Some(sampler) = sampler {
let _ = sampler.await;
}
let battery = battery_line(&samples.lock().unwrap());
let cpu_line = match (cpu_start, process_cpu_ms()) {
(Some(start), Some(end)) => {
format!(
" process CPU time over this run: {}ms",
end.saturating_sub(start)
)
}
_ => " process CPU time over this run: unavailable".to_string(),
};
let rss_line = match peak_rss_kb() {
Some(kb) => format!(" peak RSS: {kb}kB"),
None => " peak RSS: unavailable (/proc/self/status unreadable)".to_string(),
};
let total_seconds = run_started_at.elapsed().as_secs_f64();
ctx.update(move |state: &mut BenchClient, rsc| {
state.running = false;
let now = Instant::now();
let phase_lines: String = state
.android_state()
.frame_report
.phase_stats(now, refresh_hz)
.iter()
.map(|p| format!("{p}\n"))
.collect();
let per_phase = if phase_lines.is_empty() {
String::new()
} else {
format!("per phase:\n{phase_lines}\n")
};
let frames_block = match state.android_state().frame_report.report() {
Some(stats) => {
let (late, late_pct) =
state.android_state().frame_report.late_at_hz(refresh_hz);
format!(
"frames:\n {} frames over {:.1}s at {:.0}Hz ({:.1}ms budget)\n \
late: {late} ({late_pct:.1}%)\n total p50 {:.1}ms p90 {:.1}ms \
p99 {:.1}ms\n worst {:.1}ms\n cpu_p50 {:.1}ms gpu_wait_p50 {:.1}ms",
stats.total_frames,
total_seconds,
refresh_hz,
1000.0 / refresh_hz as f64,
stats.p50.as_secs_f64() * 1000.0,
stats.p90.as_secs_f64() * 1000.0,
stats.p99.as_secs_f64() * 1000.0,
stats.worst.as_secs_f64() * 1000.0,
stats.cpu_p50.as_secs_f64() * 1000.0,
stats.gpu_wait_p50.as_secs_f64() * 1000.0,
)
}
None => "frames:\n no frames recorded".to_string(),
};
let scroll_line = format!(
" scroll: {LEGACY_CYCLES} cycles ({} swipes, legacy tween), streamed \
{sent}/{total} fixture events",
LEGACY_CYCLES * 4
);
let fling_line = format!(
" fling: {FLING_COUNT} flings out + {FLING_COUNT} back at \
{FLING_VELOCITY_PX_S}px/s, travel {travel}"
);
let type_line = format!(
" type: {} characters inserted then deleted, one per {TYPE_CHAR_MS}ms",
TYPE_TEXT.chars().count()
);
let report = format!(
"iris bench report\n{per_phase}{frames_block}\n\nbench:\n{fling_line}\n\
{scroll_line}\n{type_line}\n{keyboard}\n{cpu_line}\n{rss_line}\n{battery}"
);
log::info!("iris bench report: {report}");
state.report_display.edit(rsc).set(&report);
state.last_report = Some(report);
});
redraw.request_redraw();
});
}
}
/// Runs `f` against the real `BenchClient`/`Rsc` on the main thread (the
/// same `ctx.update` every other mutation here goes through) and returns
/// its result to the caller's async task -- `ctx.update` alone has no way
/// to hand a value back, since the closure only actually runs once the
/// next frame callback drains `IrisViewPeer`'s task channel
/// (`drain_tasks`). **Must call `redraw.request_redraw()` itself, right
/// after enqueueing** -- `ctx.update` only ever pushes onto a channel;
/// nothing drains it until something schedules the frame callback that
/// calls `drain_tasks`, and a caller relying on some *earlier*,
/// already-in-flight `request_redraw()` to cover a *later* `ctx.update`
/// deadlocks the moment that earlier callback has already fired and
/// drained everything queued before this call existed. Cost a real hang
/// in this file's first version of the fling phase: every loop iteration
/// after the first sat forever with nothing scheduled to drain it.
/// Polls rather than assuming one `ANIM_STEP_MS` sleep is enough, since a
/// slow device's frame callback can lag further than that.
async fn read_from_state<T, F>(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>,
f: F,
) -> T
where
T: Send + 'static,
F: FnOnce(&mut BenchClient, &mut Rsc) -> T + Send + 'static,
{
let (tx, rx) = std::sync::mpsc::channel();
ctx.update(move |state: &mut BenchClient, rsc| {
let _ = tx.send(f(state, rsc));
});
redraw.request_redraw();
loop {
if let Ok(value) = rx.try_recv() {
return value;
}
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS)).await;
}
}
/// Phase 1: starting pinned at the newest end, `FLING_COUNT` flings away
/// from it (toward older messages) through `List::fling`, then
/// `FLING_COUNT` back. Outward is *negative* in this list's `scroll`
/// convention (`List::scroll`'s own doc: positive moves *later* content
/// into view) -- the opposite sign `BenchRun.kt`'s `runFlingPhase` uses,
/// since `TranscriptList`'s `LazyColumn` and this list define "positive"
/// the other way around; the two apps' *travel* is still directly
/// comparable because both report it as a row index + pixel offset, not a
/// signed distance.
async fn run_fling_phase(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>,
) -> String {
ctx.update(|state: &mut BenchClient, _rsc| {
state.android_state_mut().frame_report.mark_phase("fling");
});
ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).jump_to_end();
}
});
redraw.request_redraw();
// Lets the next frame's `repair_anchor` resolve `jump_to_end`'s
// `anchor = None` into a real slot before `start` is read.
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS * 2)).await;
let start = read_anchor_position(ctx, redraw).await;
for _ in 0..FLING_COUNT {
ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).fling(-FLING_VELOCITY_PX_S);
}
});
redraw.request_redraw();
wait_for_fling_settle(ctx, redraw).await;
tokio::time::sleep(Duration::from_millis(FLING_PAUSE_MS)).await;
}
let outward = read_anchor_position(ctx, redraw).await;
for _ in 0..FLING_COUNT {
ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).fling(FLING_VELOCITY_PX_S);
}
});
redraw.request_redraw();
wait_for_fling_settle(ctx, redraw).await;
tokio::time::sleep(Duration::from_millis(FLING_PAUSE_MS)).await;
}
let end = read_anchor_position(ctx, redraw).await;
format!("start={start} outward={outward} end={end}")
}
async fn read_anchor_position(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>,
) -> String {
read_from_state(ctx, redraw, |state, rsc| match &state.screen {
Some(screen) => (screen.list)(rsc).anchor_position_display(),
None => "idx=none".to_string(),
})
.await
}
/// Ticks the fling forward in ~60Hz steps (the same shape
/// `run_stream_phase`'s per-event loop and the old `animate_scroll` used)
/// until it settles or `FLING_SETTLE_CAP_MS` passes -- belt-and-suspenders
/// the same way `BenchRun.kt`'s own `waitForSettle` is, since a fling's
/// own spline-decided `duration()` already caps how long it can run.
async fn wait_for_fling_settle(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>,
) {
let cap = Duration::from_millis(FLING_SETTLE_CAP_MS);
let started = Instant::now();
while started.elapsed() < cap {
let still_scrolling = read_from_state(ctx, redraw, |state, rsc| match &state.screen {
Some(screen) => (screen.list)(rsc).tick_fling(Instant::now()),
None => false,
})
.await;
if !still_scrolling {
return;
}
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS)).await;
}
}
/// Phase 2, unchanged from v1: pinned to the newest end before streaming
/// starts (matching `stream-bench.sh`'s "Jump to latest" tap), then
/// `STREAM_EVENTS_PER_SEC * STREAM_SECONDS` fixture events replayed
/// through the real `fold_event`/`TranscriptScreen::apply` path. Returns
/// `(sent, total)`.
async fn run_stream_phase(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>,
stream_tail: Vec<SeqEvent>,
) -> (usize, usize) {
ctx.update(|state: &mut BenchClient, _rsc| {
state.android_state_mut().frame_report.mark_phase("stream");
});
ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).jump_to_end();
}
});
redraw.request_redraw();
let total = (STREAM_EVENTS_PER_SEC * STREAM_SECONDS) as usize;
let mut sent = 0usize;
for event in stream_tail.into_iter().take(total) {
ctx.update(move |state: &mut BenchClient, rsc| {
let old_items = state.items.clone();
state.items = fold_event(&state.items, &event);
match &state.screen {
Some(screen) => screen.apply(rsc, &old_items, &state.items),
None => state.rebuild_transcript(rsc),
}
});
redraw.request_redraw();
sent += 1;
tokio::time::sleep(Duration::from_millis(1000 / STREAM_EVENTS_PER_SEC)).await;
}
// Lets the last few deltas land and draw before the next phase starts
// -- `BenchRun.kt`'s own closing delay.
tokio::time::sleep(Duration::from_millis(300)).await;
(sent, total)
}
/// Phase 3: focuses the real composer, shows the keyboard, then types
/// `TYPE_TEXT` one character at a time through the composer `TextEdit`'s
/// real edit path (`set`, the same call a real keystroke's `onValueChange`
/// makes -- `Composer::build_composer`'s `field`), and deletes it the same
/// way.
async fn run_type_phase(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>,
platform: &Option<Arc<PlatformHandle>>,
) {
ctx.update(|state: &mut BenchClient, _rsc| {
state.android_state_mut().frame_report.mark_phase("type");
});
ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).jump_to_end();
state.set_focus(Some(screen.composer.field));
}
});
redraw.request_redraw();
if let Some(p) = platform {
p.show_ime();
}
// Lets focus and the keyboard's opening animation land before typing
// starts, so the frames this phase records are the wrap/reflow it is
// measuring, not the keyboard opening -- `BenchRun.kt`'s own delay.
tokio::time::sleep(Duration::from_millis(300)).await;
let mut typed = String::new();
for ch in TYPE_TEXT.chars() {
typed.push(ch);
let text = typed.clone();
ctx.update(move |state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
screen.composer.field.edit(rsc).set(&text);
}
});
redraw.request_redraw();
tokio::time::sleep(Duration::from_millis(TYPE_CHAR_MS)).await;
}
tokio::time::sleep(Duration::from_millis(200)).await;
while !typed.is_empty() {
typed.pop();
let text = typed.clone();
ctx.update(move |state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
screen.composer.field.edit(rsc).set(&text);
}
});
redraw.request_redraw();
tokio::time::sleep(Duration::from_millis(TYPE_CHAR_MS)).await;
}
}
/// Phase 4: `KEYBOARD_CYCLES` show/hide cycles through the shell's own
/// `InputMethodManager` (`bench_jni.rs`'s `show_ime`/`hide_ime`), each
/// confirmed by `on_insets_changed`'s real `ime_bottom` transition rather
/// than assumed from the JNI call having returned -- `ImeState`'s doc.
/// "keyboard: could not be shown" if the platform never confirms it even
/// once, per UI_RULES.md ("design the unknown/failed state before the
/// answer's").
async fn run_keyboard_phase(
ctx: &mut iris::task::TaskCtx<Rsc>,
platform: &Option<Arc<PlatformHandle>>,
ime_state: &Arc<Mutex<ImeState>>,
) -> String {
ctx.update(|state: &mut BenchClient, _rsc| {
state
.android_state_mut()
.frame_report
.mark_phase("keyboard");
});
let mut shown = 0;
let mut hidden = 0;
for _ in 0..KEYBOARD_CYCLES {
let before_shown = ime_state.lock().unwrap().shown_events;
if let Some(p) = platform {
p.show_ime();
}
tokio::time::sleep(Duration::from_millis(KEYBOARD_WAIT_MS)).await;
if ime_state.lock().unwrap().shown_events > before_shown {
shown += 1;
}
let before_hidden = ime_state.lock().unwrap().hidden_events;
if let Some(p) = platform {
p.hide_ime();
}
tokio::time::sleep(Duration::from_millis(KEYBOARD_WAIT_MS)).await;
if ime_state.lock().unwrap().hidden_events > before_hidden {
hidden += 1;
}
}
if shown == 0 {
format!(" keyboard: could not be shown ({KEYBOARD_CYCLES} attempts, 0 confirmed visible)")
} else {
format!(
" keyboard: shown {shown}/{KEYBOARD_CYCLES}, hidden {hidden}/{KEYBOARD_CYCLES} \
(confirmed via on_insets_changed)"
)
}
}
#[cfg(test)]
mod tests {
use super::TYPE_TEXT;
/// `BenchRun.kt`'s own `TYPE_TEXT` is verified `.length == 600`; this
/// is the same string, so it has to match exactly or the two apps'
/// type phases stop typing the same content -- RUST.md's "Benchmark
/// v2" spec is one shared string for both.
#[test]
fn type_text_is_exactly_600_characters() {
assert_eq!(TYPE_TEXT.chars().count(), 600);
}
}
+252
View File
@@ -0,0 +1,252 @@
//! JNI calls the `bench` feature needs that go through the shell's own
//! Java side rather than anything `iris`/`android-view` already wraps:
//! `BatteryManager.getIntProperty(BATTERY_PROPERTY_CURRENT_NOW)` for the
//! per-second battery sample, `ClipboardManager.setPrimaryClip` for the
//! "Copy report" control (P0's iris half, docs/RUST.md), and -- added for
//! RUST.md's "Benchmark v2" -- `Display.getRefreshRate()` for the phase
//! report's real late-frame budget and `InputMethodManager.
//! showSoftInput`/`hideSoftInputFromWindow` for the keyboard phase. None
//! of these are part of `android_view::context`'s own `Context`/
//! `Resources` wrappers (that file's own `// TODO: more methods?`), so
//! this calls them directly rather than growing that crate's wrapper for
//! calls this crate alone needs.
//!
//! Holds its own `JavaVM` + `GlobalRef` to the view (handed in through
//! [`iris::android::AndroidAppState::platform_ready`]) so it can attach
//! whichever thread calls it -- the battery sampler runs on a background
//! tokio task, not the UI thread the rest of `IrisViewPeer`'s JNI calls
//! run on. `JavaVM::attach_current_thread` is safe to call from a thread
//! already attached (the `jni` crate detects it and does not double
//! attach), so no caller here needs to know or care which thread it is.
use android_view::jni::{
JNIEnv, JavaVM,
objects::{GlobalRef, JObject, JValue},
};
/// `android.os.BatteryManager.BATTERY_PROPERTY_CURRENT_NOW` -- not exposed
/// as a constant anywhere reachable without the Android SDK jar, so named
/// here with its source rather than left as a bare `2`.
const BATTERY_PROPERTY_CURRENT_NOW: i32 = 2;
pub struct PlatformHandle {
vm: JavaVM,
view: GlobalRef,
}
impl PlatformHandle {
pub fn new(vm: JavaVM, view: GlobalRef) -> Self {
Self { vm, view }
}
fn context<'e>(&self, env: &mut JNIEnv<'e>) -> Option<JObject<'e>> {
env.call_method(
self.view.as_obj(),
"getContext",
"()Landroid/content/Context;",
&[],
)
.ok()?
.l()
.ok()
}
fn system_service<'e>(
&self,
env: &mut JNIEnv<'e>,
context: &JObject<'e>,
name: &str,
) -> Option<JObject<'e>> {
let jname = env.new_string(name).ok()?;
env.call_method(
context,
"getSystemService",
"(Ljava/lang/String;)Ljava/lang/Object;",
&[JValue::Object(jname.as_ref())],
)
.ok()?
.l()
.ok()
}
/// One sample of `BATTERY_PROPERTY_CURRENT_NOW`, in microamps. `None`
/// on any JNI failure, on a device with no `BatteryManager` service,
/// or when the platform itself answers "not supported" -- `0` or
/// `Integer.MIN_VALUE` are both documented SDK answers for that, and
/// both would read as a real (and wrong) measurement if folded into an
/// average rather than named apart. UI_RULES.md: never present an
/// inferred value as a measured one.
pub fn battery_current_ua(&self) -> Option<i32> {
let mut guard = self.vm.attach_current_thread().ok()?;
let env: &mut JNIEnv = &mut guard;
let context = self.context(env)?;
let battery_manager = self.system_service(env, &context, "batterymanager")?;
let value = env
.call_method(
&battery_manager,
"getIntProperty",
"(I)I",
&[JValue::Int(BATTERY_PROPERTY_CURRENT_NOW)],
)
.ok()?
.i()
.ok()?;
if value == 0 || value == i32::MIN {
None
} else {
Some(value)
}
}
/// Puts `text` on the system clipboard through `ClipboardManager` --
/// `true` only if the whole JNI chain (service lookup, `ClipData`,
/// `setPrimaryClip`) succeeded.
pub fn copy_to_clipboard(&self, label: &str, text: &str) -> bool {
self.try_copy_to_clipboard(label, text).is_some()
}
fn try_copy_to_clipboard(&self, label: &str, text: &str) -> Option<()> {
let mut guard = self.vm.attach_current_thread().ok()?;
let env: &mut JNIEnv = &mut guard;
let context = self.context(env)?;
let clipboard = self.system_service(env, &context, "clipboard")?;
let jlabel = env.new_string(label).ok()?;
let jtext = env.new_string(text).ok()?;
let clip = env
.call_static_method(
"android/content/ClipData",
"newPlainText",
"(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Landroid/content/ClipData;",
&[
JValue::Object(jlabel.as_ref()),
JValue::Object(jtext.as_ref()),
],
)
.ok()?
.l()
.ok()?;
env.call_method(
&clipboard,
"setPrimaryClip",
"(Landroid/content/ClipData;)V",
&[JValue::Object(&clip)],
)
.ok()?;
Some(())
}
/// The display's own refresh rate in Hz (`View::getDisplay()` ->
/// `Display::getRefreshRate()`), for RUST.md's "Benchmark v2": late
/// frames are judged against *this* device's real budget, not an
/// assumed 60Hz -- a 90Hz or 120Hz phone would otherwise call frames
/// "late" that met their own faster deadline. `None` if the view is
/// not yet attached to a window (`getDisplay` returns `null`) or the
/// platform reports a non-positive rate, which is not a real answer
/// either.
pub fn refresh_rate_hz(&self) -> Option<f32> {
let mut guard = self.vm.attach_current_thread().ok()?;
let env: &mut JNIEnv = &mut guard;
let display = env
.call_method(
self.view.as_obj(),
"getDisplay",
"()Landroid/view/Display;",
&[],
)
.ok()?
.l()
.ok()?;
if display.is_null() {
return None;
}
let rate = env
.call_method(&display, "getRefreshRate", "()F", &[])
.ok()?
.f()
.ok()?;
if rate > 0.0 { Some(rate) } else { None }
}
/// `InputMethodManager.showSoftInput(view, 0)` -- the keyboard phase's
/// own show, called directly rather than through the focus-driven
/// `pending_show_keyboard` path `android/view.rs` uses for a real tap,
/// since RUST.md's "Benchmark v2" spec asks for this "through the
/// shell's InputMethodManager" independent of focus state. `true` only
/// if the platform itself reports the request succeeded -- whether the
/// IME actually became visible is confirmed separately, from
/// `on_insets_changed`, per UI_RULES.md ("never present an inferred
/// value as a measured one").
pub fn show_ime(&self) -> bool {
self.try_toggle_ime(true).unwrap_or(false)
}
/// `InputMethodManager.hideSoftInputFromWindow(windowToken, 0)`.
pub fn hide_ime(&self) -> bool {
self.try_toggle_ime(false).unwrap_or(false)
}
fn try_toggle_ime(&self, show: bool) -> Option<bool> {
let mut guard = self.vm.attach_current_thread().ok()?;
let env: &mut JNIEnv = &mut guard;
let context = self.context(env)?;
let imm = self.system_service(env, &context, "input_method")?;
if show {
env.call_method(
&imm,
"showSoftInput",
"(Landroid/view/View;I)Z",
&[JValue::Object(self.view.as_obj()), JValue::Int(0)],
)
.ok()?
.z()
.ok()
} else {
let token = env
.call_method(
self.view.as_obj(),
"getWindowToken",
"()Landroid/os/IBinder;",
&[],
)
.ok()?
.l()
.ok()?;
env.call_method(
&imm,
"hideSoftInputFromWindow",
"(Landroid/os/IBinder;I)Z",
&[JValue::Object(&token), JValue::Int(0)],
)
.ok()?
.z()
.ok()
}
}
/// Shows `report` in the shell's plain-view diagnostics overlay
/// (`IrisView.showDiagnosticsOverlay`) -- a real `TextView` plus Copy
/// and Close controls, added over whatever iris itself is drawing
/// rather than replacing it (unlike `android::view::show_renderer_error`,
/// which exists for the case the renderer can never recover from and
/// intentionally never returns). Called from a background task after
/// the keyboard-open delay (`bench_client.rs`'s `on_insets_changed`),
/// so the Java side hops onto the UI thread itself before touching the
/// view tree -- see that method's own comment.
pub fn show_diagnostics_overlay(&self, report: &str) -> bool {
self.try_show_diagnostics_overlay(report).is_some()
}
fn try_show_diagnostics_overlay(&self, report: &str) -> Option<()> {
let mut guard = self.vm.attach_current_thread().ok()?;
let env: &mut JNIEnv = &mut guard;
let jreport = env.new_string(report).ok()?;
env.call_method(
self.view.as_obj(),
"showDiagnosticsOverlay",
"(Ljava/lang/String;)V",
&[JValue::Object(jreport.as_ref())],
)
.ok()?;
Some(())
}
}
+20 -2
View File
@@ -23,6 +23,18 @@
//! A build picks one screen or the other, never both, so `Client` and //! A build picks one screen or the other, never both, so `Client` and
//! `TranscriptClient` are cfg-gated apart rather than switched at runtime -- //! `TranscriptClient` are cfg-gated apart rather than switched at runtime --
//! there is no in-app navigation to switch *to* on either side yet. //! there is no in-app navigation to switch *to* on either side yet.
//!
//! **`bench` feature (P0's iris half, docs/RUST.md):** a third
//! `AndroidAppState`, `bench_client::BenchClient`, on the same axis --
//! `transcript_ui::build_tree` again, this time against the checked-in
//! fixture (`app/bench-fixture/assets/transcript.jsonl`) instead of a real
//! server, with a "Run benchmark" control that drives the same scroll loop
//! and streaming phase the Compose `bench` build type's `BenchRun.kt`
//! does. `bench` depends on `transcript-screen` (Cargo.toml) for
//! `transcript-ui`/`client-core`/`event-model`, so both features end up
//! enabled together -- `ActiveClient` below gives `bench` priority in that
//! case, the same way `transcript-screen` already takes priority over the
//! default `tabs-screen`.
use android_view::{ use android_view::{
Context, View, Context, View,
@@ -39,7 +51,11 @@ use iris::prelude::*;
use log::LevelFilter; use log::LevelFilter;
use std::ffi::c_void; use std::ffi::c_void;
#[cfg(feature = "transcript-screen")] #[cfg(feature = "bench")]
mod bench_client;
#[cfg(feature = "bench")]
mod bench_jni;
#[cfg(all(feature = "transcript-screen", not(feature = "bench")))]
mod transcript_client; mod transcript_client;
/// The app's `View` subclass, matching the Java side's package -- /// The app's `View` subclass, matching the Java side's package --
@@ -85,8 +101,10 @@ impl AndroidAppState for Client {
#[cfg(not(feature = "transcript-screen"))] #[cfg(not(feature = "transcript-screen"))]
type ActiveClient = Client; type ActiveClient = Client;
#[cfg(feature = "transcript-screen")] #[cfg(all(feature = "transcript-screen", not(feature = "bench")))]
type ActiveClient = transcript_client::TranscriptClient; type ActiveClient = transcript_client::TranscriptClient;
#[cfg(feature = "bench")]
type ActiveClient = bench_client::BenchClient;
extern "system" fn new_view_peer<'local>( extern "system" fn new_view_peer<'local>(
env: JNIEnv<'local>, env: JNIEnv<'local>,
+26 -9
View File
@@ -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) {
+6
View File
@@ -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.
+201
View File
@@ -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.
+64 -7
View File
@@ -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
View File
@@ -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),
+21
View File
@@ -141,6 +141,27 @@ impl Textures {
self.updates.push(Update::Patch(handle.slot, rect)); self.updates.push(Update::Patch(handle.slot, rect));
} }
/// Forget every image, page and pending update -- what a genuinely new
/// GPU device needs alongside [`crate::render::atlas::GlyphAtlas::
/// clear`], which this module's own doc references: every slot number
/// and every queued [`Update`] here describes the *old* device's
/// textures (an `Update::Push`/`Update::Patch` already drained into a
/// renderer that no longer exists is gone for good, and a fresh
/// `UiRenderNode`'s own texture manager starts with none of them
/// applied), so nothing is lost by starting this bookkeeping over too.
/// Any `TextureHandle` a caller still holds across the reset (none in
/// the transcript screen this reset is wired up for today -- confirmed
/// by grep, the only standalone (non-atlas) image anywhere in this
/// workspace is `iris/widget/image.rs`'s `Image`, used by the separate
/// `tabs-ui` example) is left pointing at a slot this instance no
/// longer recognises and needs reinserting via `add`/`add_page` again
/// -- the same pre-existing gap a renderer restart already left for
/// such a handle before this method existed, just named rather than
/// silent now.
pub fn reset(&mut self) {
*self = Self::new();
}
pub fn free(&mut self) { pub fn free(&mut self) {
for (kind, idx) in self.recv.try_iter() { for (kind, idx) in self.recv.try_iter() {
self.images[idx as usize] = None; self.images[idx as usize] = None;
+19
View File
@@ -173,6 +173,25 @@ impl GlyphAtlas {
pub fn glyph_count(&self) -> usize { pub fn glyph_count(&self) -> usize {
self.entries.len() self.entries.len()
} }
/// Forget every page and every rasterised entry -- what a genuinely new
/// GPU device needs (`android::view::IrisViewPeer::surface_changed`'s
/// "not already live" branch, e.g. after backgrounding): the pages this
/// atlas remembers are `TextureHandle`s into the *old* device's
/// textures, which no longer exist, and every `GlyphEntry`'s `uv_min`/
/// `uv_max`/`layer` point into them. Without this, a glyph already
/// cached here is treated as "already placed" and never re-inserted
/// into the fresh (empty) atlas the new renderer actually has --
/// exactly the "rectangles stay, glyphs disappear" bug the resize path
/// (`AndroidRenderer::resize`) was built to avoid for the reuse case;
/// this is its counterpart for the case where the renderer really is
/// new. Dropping `pages` also drops its `TextureHandle`s, which send a
/// free message back through their `Textures`; see `Textures::reset`'s
/// doc for why that is harmless here.
pub fn clear(&mut self) {
self.pages.clear();
self.entries.clear();
}
} }
fn fits(page: &Page, need_w: u32, need_h: u32) -> bool { fn fits(page: &Page, need_w: u32, need_h: u32) -> bool {
+253 -4
View File
@@ -1,15 +1,87 @@
use std::time::Duration; use std::time::{Duration, Instant};
/// The frame budget `dumpsys gfxinfo` also uses to call a frame "janky": the /// The frame budget `dumpsys gfxinfo` also uses to call a frame "janky": the
/// 60Hz vsync period. Kept as the same threshold so a percentage from this /// 60Hz vsync period. Kept as the same threshold so a percentage from this
/// report and a percentage from `gfxinfo` mean the same thing. /// report and a percentage from `gfxinfo` mean the same thing. Only a
/// fallback now that a caller can read the display's real refresh rate
/// (`report_at_hz`/`mark_phase`'s callers) -- most devices are 60Hz, but a
/// 90Hz or 120Hz phone judged against this constant would call every frame
/// "late" that merely met its own, faster budget.
pub const JANK_THRESHOLD: Duration = Duration::from_nanos(16_666_667); pub const JANK_THRESHOLD: Duration = Duration::from_nanos(16_666_667);
/// Enough frames for several minutes of scrolling before the oldest ones /// Enough frames for several minutes of scrolling before the oldest ones
/// start being overwritten -- the same "diagnostic, not a log" sizing /// start being overwritten -- the same "diagnostic, not a log" sizing
/// `FrameStats.kt`'s `CAP` uses on the Compose side, chosen independently /// `FrameStats.kt`'s `CAP` uses on the Compose side, chosen independently
/// here since a `Duration` is smaller than the six `Long` arrays it keeps. /// here since a `Duration` is smaller than the six `Long` arrays it keeps.
const RING_CAPACITY: usize = 4096; /// Bumped from 4096 for RUST.md's "Benchmark v2": a fling+stream+type+
/// keyboard run is ~6,500+ frames on the Compose side, comfortably under
/// this so `phase_stats` never has to report a phase as partially evicted.
const RING_CAPACITY: usize = 16384;
/// One `mark_phase` call: the wall-clock instant and the (0-based,
/// never-reset-by-`reset`-except-at-`reset`-time) absolute frame index at
/// which a phase began -- `phase_stats` slices `index_ring` against this to
/// find which recorded samples belong to which phase, since the ring
/// itself only keeps the most recent `RING_CAPACITY` samples' *values*,
/// not which phase they were in.
struct PhaseMark {
name: String,
start_index: u64,
start_at: Instant,
}
/// One phase's own slice of a report -- RUST.md's "Benchmark v2" spec's
/// "per-phase blocks in `FrameReport`... frames, late count/percent...
/// p50/p90/p99, worst, duration". `Display` matches the shape
/// `docs/bench/compose-phone-v2-2026-09-06.md`'s report already uses, so
/// the two apps' reports read the same way side by side.
pub struct PhaseStats {
pub name: String,
/// How many frames were recorded during this phase in total -- may
/// exceed `late + (samples counted)` if some of this phase's frames
/// have since been evicted from the ring by a very long run; that
/// case is named in the `Display` rather than silently under-counted.
pub frames: u64,
pub duration: Duration,
pub late: u64,
pub late_percent: f64,
pub p50: Duration,
pub p90: Duration,
pub p99: Duration,
pub worst: Duration,
/// `false` if this phase's frame count exceeds how many samples of it
/// are still in the ring -- the percentiles above are then computed
/// over whatever survived, not the whole phase. UI_RULES.md: this is
/// the "we don't fully know" state, named rather than folded silently
/// into a number that looks exact.
pub complete: bool,
}
impl std::fmt::Display for PhaseStats {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(
f,
" {}: {} frames over {:.1}s{}",
self.name,
self.frames,
self.duration.as_secs_f64(),
if self.complete {
""
} else {
" (ring evicted some of this phase)"
},
)?;
writeln!(f, " late: {} ({:.1}%)", self.late, self.late_percent)?;
writeln!(
f,
" total p50 {:.1}ms p90 {:.1}ms p99 {:.1}ms",
self.p50.as_secs_f64() * 1000.0,
self.p90.as_secs_f64() * 1000.0,
self.p99.as_secs_f64() * 1000.0,
)?;
write!(f, " worst {:.1}ms", self.worst.as_secs_f64() * 1000.0)
}
}
/// A per-frame wall-time report iris keeps of itself, because `dumpsys /// A per-frame wall-time report iris keeps of itself, because `dumpsys
/// gfxinfo` cannot see a `SurfaceView`'s own GPU-drawn frames at all /// gfxinfo` cannot see a `SurfaceView`'s own GPU-drawn frames at all
@@ -41,6 +113,11 @@ pub struct FrameReport {
/// "Where iris's frame time goes" CPU/GPU split, added 2026-09-05). /// "Where iris's frame time goes" CPU/GPU split, added 2026-09-05).
/// `ring[i] - submit_ring[i]` is that frame's `redraw_to_submit` half. /// `ring[i] - submit_ring[i]` is that frame's `redraw_to_submit` half.
submit_ring: Box<[Duration; RING_CAPACITY]>, submit_ring: Box<[Duration; RING_CAPACITY]>,
/// The absolute (0-based, since the last `reset`) frame index each
/// `ring`/`submit_ring` slot's sample belongs to -- what `phase_stats`
/// slices against `PhaseMark::start_index` to tell which recorded
/// frames fall in which phase.
index_ring: Box<[u64; RING_CAPACITY]>,
/// How many of `ring`'s slots hold a real sample -- saturates at /// How many of `ring`'s slots hold a real sample -- saturates at
/// `RING_CAPACITY`, unlike `total_frames` below which keeps counting. /// `RING_CAPACITY`, unlike `total_frames` below which keeps counting.
len: usize, len: usize,
@@ -50,6 +127,12 @@ pub struct FrameReport {
/// correct even once the ring itself only holds the most recent frames. /// correct even once the ring itself only holds the most recent frames.
total_frames: u64, total_frames: u64,
janky_frames: u64, janky_frames: u64,
/// `mark_phase` calls since the last `reset`, oldest first -- see
/// `phase_stats`. Empty on an ordinary run that never calls
/// `mark_phase`, so `phase_stats` returns an empty `Vec` and a caller
/// prints no "per phase:" section at all, matching RUST.md's "empty/
/// absent on an ordinary 'Copy' press, which never marks a phase."
phases: Vec<PhaseMark>,
} }
/// One resolved reading. `Display` is the log line both the "Frame report" /// One resolved reading. `Display` is the log line both the "Frame report"
@@ -107,10 +190,12 @@ impl FrameReport {
Self { Self {
ring: Box::new([Duration::ZERO; RING_CAPACITY]), ring: Box::new([Duration::ZERO; RING_CAPACITY]),
submit_ring: Box::new([Duration::ZERO; RING_CAPACITY]), submit_ring: Box::new([Duration::ZERO; RING_CAPACITY]),
index_ring: Box::new([0; RING_CAPACITY]),
len: 0, len: 0,
pos: 0, pos: 0,
total_frames: 0, total_frames: 0,
janky_frames: 0, janky_frames: 0,
phases: Vec::new(),
} }
} }
@@ -131,6 +216,7 @@ impl FrameReport {
pub fn record_split(&mut self, total: Duration, submit_to_present: Duration) { pub fn record_split(&mut self, total: Duration, submit_to_present: Duration) {
self.ring[self.pos] = total; self.ring[self.pos] = total;
self.submit_ring[self.pos] = submit_to_present; self.submit_ring[self.pos] = submit_to_present;
self.index_ring[self.pos] = self.total_frames;
self.pos = (self.pos + 1) % RING_CAPACITY; self.pos = (self.pos + 1) % RING_CAPACITY;
self.len = (self.len + 1).min(RING_CAPACITY); self.len = (self.len + 1).min(RING_CAPACITY);
self.total_frames += 1; self.total_frames += 1;
@@ -142,12 +228,89 @@ impl FrameReport {
/// Clears every counter and every sample -- what the "Reset frame /// Clears every counter and every sample -- what the "Reset frame
/// report" control calls, so a report covers only what was scrolled /// report" control calls, so a report covers only what was scrolled
/// after the button was pressed (the same reason `FrameStats.kt`'s /// after the button was pressed (the same reason `FrameStats.kt`'s
/// `reset()` exists on the Compose side). /// `reset()` exists on the Compose side). Also clears every phase
/// mark, so a fresh run starts with no "per phase:" section until it
/// marks one of its own.
pub fn reset(&mut self) { pub fn reset(&mut self) {
self.len = 0; self.len = 0;
self.pos = 0; self.pos = 0;
self.total_frames = 0; self.total_frames = 0;
self.janky_frames = 0; self.janky_frames = 0;
self.phases.clear();
}
/// Marks the start of a named phase at the current moment -- every
/// frame recorded from here until the next `mark_phase` (or `reset`)
/// belongs to it. RUST.md's "Benchmark v2": a scripted bench run calls
/// this once per phase (fling/stream/type/keyboard) so `phase_stats`
/// can slice one whole run's frames by what was happening during each.
pub fn mark_phase(&mut self, name: &str) {
self.phases.push(PhaseMark {
name: name.to_string(),
start_index: self.total_frames,
start_at: Instant::now(),
});
}
/// One [`PhaseStats`] per `mark_phase` call since the last `reset`,
/// oldest first. `now` closes the last phase's wall-clock span (there
/// is no "next phase" instant to use for it); `refresh_hz` is what
/// each phase's own `late`/`late_percent` is judged against, read from
/// the display rather than assumed -- RUST.md's "Benchmark v2": "late
/// count/% against the display's refresh rate."
pub fn phase_stats(&self, now: Instant, refresh_hz: f32) -> Vec<PhaseStats> {
if self.phases.is_empty() || refresh_hz <= 0.0 {
return Vec::new();
}
let budget = Duration::from_secs_f64(1.0 / refresh_hz as f64);
self.phases
.iter()
.enumerate()
.map(|(i, phase)| {
let (end_index, end_at) = match self.phases.get(i + 1) {
Some(next) => (next.start_index, next.start_at),
None => (self.total_frames, now),
};
let frames = end_index.saturating_sub(phase.start_index);
let mut samples: Vec<Duration> = (0..self.len)
.filter(|&j| {
let idx = self.index_ring[j];
idx >= phase.start_index && idx < end_index
})
.map(|j| self.ring[j])
.collect();
let complete = samples.len() as u64 >= frames;
if samples.is_empty() {
return PhaseStats {
name: phase.name.clone(),
frames,
duration: end_at.saturating_duration_since(phase.start_at),
late: 0,
late_percent: 0.0,
p50: Duration::ZERO,
p90: Duration::ZERO,
p99: Duration::ZERO,
worst: Duration::ZERO,
complete,
};
}
samples.sort_unstable();
let pct = |p: usize| samples[(samples.len() * p / 100).min(samples.len() - 1)];
let late = samples.iter().filter(|&&d| d > budget).count() as u64;
PhaseStats {
name: phase.name.clone(),
frames,
duration: end_at.saturating_duration_since(phase.start_at),
late,
late_percent: 100.0 * late as f64 / samples.len() as f64,
p50: pct(50),
p90: pct(90),
p99: pct(99),
worst: *samples.last().expect("checked not empty above"),
complete,
}
})
.collect()
} }
/// `None` if nothing has been recorded since the last reset -- the /// `None` if nothing has been recorded since the last reset -- the
@@ -186,6 +349,28 @@ impl FrameReport {
gpu_wait_p50: median(submit_samples), gpu_wait_p50: median(submit_samples),
}) })
} }
/// `(late count, late percent)` over every sample still in the ring,
/// judged against `refresh_hz`'s own frame budget rather than the
/// fixed 60Hz `JANK_THRESHOLD` -- RUST.md's "Benchmark v2": "late
/// count/% against the display's refresh rate... print 'at N Hz (X ms
/// budget)' like Compose does." A separate method from `report()`
/// rather than a parameter on it, so `report()`'s own `janky_percent`
/// (and the exact-boundary test pinned to `JANK_THRESHOLD`) is
/// unaffected for every existing caller that never measured a real
/// refresh rate. `(0, 0.0)` with nothing recorded or a non-positive
/// `refresh_hz`.
pub fn late_at_hz(&self, refresh_hz: f32) -> (u64, f64) {
if self.len == 0 || refresh_hz <= 0.0 {
return (0, 0.0);
}
let budget = Duration::from_secs_f64(1.0 / refresh_hz as f64);
let late = self.ring[..self.len]
.iter()
.filter(|&&d| d > budget)
.count() as u64;
(late, 100.0 * late as f64 / self.len as f64)
}
} }
impl Default for FrameReport { impl Default for FrameReport {
@@ -296,4 +481,68 @@ mod tests {
// same pattern here. // same pattern here.
assert!(stats.worst <= Duration::from_millis(5)); assert!(stats.worst <= Duration::from_millis(5));
} }
#[test]
fn no_marks_means_no_phases() {
let mut r = FrameReport::new();
r.record(Duration::from_millis(5));
assert!(r.phase_stats(Instant::now(), 60.0).is_empty());
}
#[test]
fn phases_slice_frames_by_when_they_were_marked() {
let mut r = FrameReport::new();
r.mark_phase("a");
for _ in 0..5 {
r.record(Duration::from_millis(10)); // 10ms: late at 60Hz (16.7ms budget)... no, 10<16.7, not late
}
r.mark_phase("b");
for _ in 0..3 {
r.record(Duration::from_millis(20)); // 20ms: late at 60Hz
}
let now = Instant::now();
let phases = r.phase_stats(now, 60.0);
assert_eq!(phases.len(), 2);
assert_eq!(phases[0].name, "a");
assert_eq!(phases[0].frames, 5);
assert_eq!(phases[0].late, 0);
assert_eq!(phases[0].worst, Duration::from_millis(10));
assert_eq!(phases[1].name, "b");
assert_eq!(phases[1].frames, 3);
assert_eq!(phases[1].late, 3);
assert_eq!(phases[1].late_percent, 100.0);
assert_eq!(phases[1].worst, Duration::from_millis(20));
assert!(phases[0].complete);
assert!(phases[1].complete);
}
#[test]
fn the_last_phase_runs_until_now() {
let mut r = FrameReport::new();
r.mark_phase("only");
r.record(Duration::from_millis(1));
std::thread::sleep(Duration::from_millis(20));
let now = Instant::now();
let phases = r.phase_stats(now, 60.0);
assert_eq!(phases.len(), 1);
assert!(phases[0].duration >= Duration::from_millis(20));
}
#[test]
fn reset_clears_phase_marks() {
let mut r = FrameReport::new();
r.mark_phase("a");
r.record(Duration::from_millis(1));
r.reset();
assert!(r.phase_stats(Instant::now(), 60.0).is_empty());
}
#[test]
fn late_at_hz_uses_the_given_refresh_rate_not_the_fixed_60hz_constant() {
let mut r = FrameReport::new();
// 10ms is under 60Hz's 16.7ms budget but over 120Hz's 8.3ms one.
r.record(Duration::from_millis(10));
assert_eq!(r.late_at_hz(60.0), (0, 0.0));
assert_eq!(r.late_at_hz(120.0), (1, 100.0));
}
} }
+150 -18
View File
@@ -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,
} }
+14
View File
@@ -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
} }
+9 -1
View File
@@ -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)
} }
+22 -1
View File
@@ -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
View File
@@ -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 {
+5 -2
View File
@@ -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()
+4
View File
@@ -12,6 +12,10 @@ impl<T: HasAndroidUiState> FocusHost for T {
self.android_state_mut().focus = id; self.android_state_mut().focus = id;
} }
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
self.android_state().focus == Some(id)
}
fn focus_gained(&mut self, region: Option<PixelRegion>) { fn focus_gained(&mut self, region: Option<PixelRegion>) {
// Showing the keyboard is a JNI call (`InputMethodManager.showSoftInput`), // Showing the keyboard is a JNI call (`InputMethodManager.showSoftInput`),
// and this runs deep inside the platform-agnostic sensor dispatch // and this runs deep inside the platform-agnostic sensor dispatch
+46
View File
@@ -49,6 +49,52 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
fn focus(&self) -> Option<WeakWidget<TextEdit>> { fn focus(&self) -> Option<WeakWidget<TextEdit>> {
self.state.android_state().focus self.state.android_state().focus
} }
/// Tell Gboard where the caret/selection and the composing region
/// actually are, via `InputMethodManager.updateSelection` -- every one
/// of android-view's own demo's `set_composing_text_internal`/`render`
/// calls this, and this bridge never did, which is what left Gboard's
/// own model of the field diverging from `TextEdit`'s real one after
/// the very first edit (RUST.md's P0 box, "doesn't enter it until I
/// hit space, and also doesn't move cursor forward" -- Gboard holds
/// its composing keystrokes back until it believes the app has caught
/// up, and without this call it never does). Called from
/// [`IrisViewPeer::after_input`], the one tail every touch/key/IME
/// callback already runs through, rather than duplicated at each of
/// this file's mutating methods.
///
/// `candidates_start`/`candidates_end` report the composing region;
/// `-1, -1` when nothing is composing, matching `EditorInfo`'s own
/// convention. `compose_len` is tracked in `char`s (this module's doc
/// comment), so this reports it as that many UTF-16 units back from the
/// caret -- exact for the common BMP case, the same approximation
/// `set_composing_text` already makes.
pub(super) fn update_ime_selection(&mut self, ctx: &mut CallbackCtx) {
let Some(focus) = self.focus() else { return };
let text = &self.rsc[focus];
let Some(sel) = text.selection_range() else {
return;
};
let content = text.text();
let sel_start = byte_to_utf16(content, sel.start) as i32;
let sel_end = byte_to_utf16(content, sel.end) as i32;
let compose_len = self.state.android_state().compose_len;
let (comp_start, comp_end) = if compose_len > 0 {
let caret = byte_to_utf16(content, text.caret().unwrap_or(sel.end)) as i32;
(caret - compose_len as i32, caret)
} else {
(-1, -1)
};
let imm = ctx.view.input_method_manager(&mut ctx.env);
imm.update_selection(
&mut ctx.env,
&ctx.view,
sel_start,
sel_end,
comp_start,
comp_end,
);
}
} }
impl<State: AndroidAppState> InputConnection for IrisViewPeer<State> { impl<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
+2 -1
View File
@@ -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
View File
@@ -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);
} }
} }
+301 -16
View File
@@ -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, 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(),
} }
} }
@@ -107,6 +148,61 @@ pub trait AndroidAppState: HasAndroidUiState {
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
} }
/// Called once, right after `new`, with a fresh `JavaVM` handle and a
/// global reference to this app's own `View` -- for a caller that
/// needs to call into Java itself beyond what a [`RequestRedraw`]
/// handle already covers (P0's bench build calling
/// `BatteryManager`/`ClipboardManager` through the view's `Context`,
/// docs/RUST.md). Not folded into `new` itself: most implementors need
/// nothing here, and `new`'s job is building the widget tree, not
/// holding a platform handle -- the default does nothing. `vm`/`view`
/// are independent handles from the ones `new_peer` keeps for its own
/// `RequestRedraw` (a fresh `get_java_vm`/`new_global_ref` each), so
/// storing them has no effect on that mechanism.
#[allow(unused_variables)]
fn platform_ready(&mut self, rsc: &mut AndroidRsc<Self>, vm: JavaVM, view: GlobalRef) {}
/// Called from `render()` whenever `AndroidUiState::insets()` differs
/// from what it was last frame -- once at startup for the status bar
/// (RUST.md's P0 box: "the status-bar inset is not applied" reported
/// the two top buttons sitting under it, because nothing read `.top`
/// at all), and again on a rotation or the keyboard opening/closing.
/// `insets` is in the same physical-pixel units everything else in the
/// tree now uses (`AndroidUiState::content_scale`'s field comment), so
/// a widget can add it to a layout size directly -- `dp(...) +
/// abs(insets.top)` if the widget wants a density-independent size
/// plus the system bar's own (already-physical) height. The default
/// does nothing -- most screens have no chrome that sits under a
/// system bar.
#[allow(unused_variables)]
fn on_insets_changed(&mut self, rsc: &mut AndroidRsc<Self>, insets: WindowInsets) {}
}
/// `insets::Insets` as `f32`, for the widget-facing callback above -- a
/// distinct type from `insets::Insets` so a caller of `on_insets_changed`
/// is not coupled to that module's own (`i32`, JNI-shaped) representation.
/// Both are physical pixels; this used to divide by `content_scale` into a
/// separate *logical* unit (hence the old name, `LogicalInsets`), back when
/// the rest of layout was logical too -- see `AndroidUiState::content_scale`'s
/// field comment for why that stopgap is gone.
#[derive(Clone, Copy, Default, Debug, PartialEq)]
pub struct WindowInsets {
pub left: f32,
pub top: f32,
pub right: f32,
pub bottom: f32,
pub ime_bottom: f32,
}
impl WindowInsets {
fn from_physical(insets: Insets) -> Self {
Self {
left: insets.left as f32,
top: insets.top as f32,
right: insets.right as f32,
bottom: insets.bottom as f32,
ime_bottom: insets.ime_bottom as f32,
}
}
} }
/// The android-view analogue of `default::DefaultRsc` -- identical in /// The android-view analogue of `default::DefaultRsc` -- identical in
@@ -229,6 +325,13 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
show_soft_input(&mut ctx.env, &ctx.view); show_soft_input(&mut ctx.env, &ctx.view);
} }
// RUST.md's P0 box, "doesn't enter it until I hit space, and also
// doesn't move cursor forward": Gboard needs `updateSelection`
// after every edit to keep its own model of the field in sync, or
// it holds keystrokes back rather than trusting a screen it
// believes is stale. See `update_ime_selection`'s own doc.
self.update_ime_selection(ctx);
let ui_state = self.state.android_state_mut(); let ui_state = self.state.android_state_mut();
ui_state.cursor.end_frame(); ui_state.cursor.end_frame();
if self.render.needs_redraw(&ui_state.root, self.rsc.widgets()) { if self.render.needs_redraw(&ui_state.root, self.rsc.widgets()) {
@@ -253,10 +356,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(),
@@ -281,7 +397,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()
@@ -324,6 +460,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,
@@ -366,6 +527,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();
@@ -418,7 +581,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
@@ -427,13 +589,117 @@ 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) => {
// A genuinely new renderer means a genuinely new GPU device
// and a fresh, empty glyph atlas -- the CPU-side glyph
// cache (`TextData::atlas`) and the texture bookkeeping it
// is built on (`UiData::textures`) both outlive `renderer`
// itself (they live on `self.rsc`, not on `AndroidRenderer`),
// so without this they would keep pointing at the *old*
// device's now-gone textures -- the app-switch counterpart
// to the keyboard-resize glyph wipe this same function's
// `already_live` branch above already fixed by reusing the
// renderer instead of rebuilding it. One mechanism either
// way: this call only runs on the branch that actually
// builds a new renderer, exactly where invalidation is
// needed, never on the reuse branch, where it would throw
// away perfectly valid GPU state for nothing.
self.rsc.ui.text.atlas.clear();
self.rsc.ui.textures.reset();
self.state.android_state_mut().renderer = Some(renderer);
self.render(ctx);
}
Err(report) => {
// One line for logcat (UI_RULES.md: "the full text for
// whoever can read the log" lives here), the multi-line
// original on screen -- `show_renderer_error` below.
log::error!("iris renderer init failed: {}", report.replace('\n', " | "));
// Deferred, not called directly: `Activity::setContentView`
// tears the old view hierarchy down synchronously, which
// fires `IrisView`'s own `onFocusChanged` before
// `setContentView` returns -- straight back into this same
// `IrisViewPeer` through `on_focus_changed` while
// `with_peer` (android-view's dispatch, `view.rs` upstream)
// still holds this peer's `RefCell` borrow for the
// `surface_changed` call in progress. Found by inducing a
// validation error and hitting `RefCell already borrowed`
// at exactly that reentrant call (RUST.md's P0 box).
// `push_dynamic_deferred_callback` runs after `with_peer`
// drops the borrow, which is what every other callback in
// this file that reaches into Java already relies on
// (`raise_if_enabled`, above).
ctx.push_dynamic_deferred_callback(move |env, view| {
show_renderer_error(env, view, &report);
});
}
}
} }
fn surface_destroyed<'local>( fn surface_destroyed<'local>(
@@ -544,10 +810,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));
@@ -559,12 +834,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 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_view = env.new_global_ref(&view.0).unwrap();
state.platform_ready(&mut rsc, platform_vm, platform_view);
let mut render = UiRenderState::new();
// Every `Len::dp` in the tree resolves against this from now on -- see
// `UiRenderState::density`'s field doc and `Len::dp`'s.
render.set_density(content_scale);
let peer = IrisViewPeer { let peer = IrisViewPeer {
rsc, rsc,
render: UiRenderState::new(), render,
state, state,
task_recv, task_recv,
}; };
+67 -9
View File
@@ -22,6 +22,13 @@ pub trait FocusHost {
/// it was hit in (`None` when the widget could not be located, which /// it was hit in (`None` when the widget could not be located, which
/// happens for one it was just deselected from). /// happens for one it was just deselected from).
fn focus_gained(&mut self, region: Option<PixelRegion>); fn focus_gained(&mut self, region: Option<PixelRegion>);
/// Whether `id` is the current focus target -- what [`select`] uses to
/// tell a fresh press (which must wait to see whether it becomes a tap
/// or a drag before focusing/showing the IME, Iris 2026-09-06: "if I
/// swipe over the input bar it brings up the keyboard") from a drag
/// continuing inside a field that was already focused (an ordinary
/// drag-to-select, unaffected).
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool;
} }
/// Helper shared by every `FocusHost` impl, so the double-click window is /// Helper shared by every `FocusHost` impl, so the double-click window is
@@ -33,6 +40,17 @@ pub fn recent_click(last_click: &mut Instant) -> bool {
recent recent
} }
/// `PressStart`/`Pressing`/`PressEnd`, all for the left button -- what
/// [`Selector`]/[`Selectable`] register instead of [`CursorSense::
/// click_or_drag`], so their shared handler (`on_press`, below) sees every
/// frame of a gesture and can tell a completed tap from a drag itself,
/// rather than reacting to `PressStart` alone the way `click_or_drag`'s
/// consumer used to (Iris, 2026-09-06: "if I swipe over the input bar it
/// brings up the keyboard").
fn press_track() -> CursorSenses {
CursorSense::click() | CursorSense::Pressing(CursorButton::Left) | CursorSense::unclick()
}
pub struct Selector; pub struct Selector;
impl<Rsc: HasEvents, W: Widget + 'static> WidgetAttr<Rsc, W> for Selector impl<Rsc: HasEvents, W: Widget + 'static> WidgetAttr<Rsc, W> for Selector
@@ -42,7 +60,7 @@ where
type Input = WeakWidget<TextEdit>; type Input = WeakWidget<TextEdit>;
fn run(rsc: &mut Rsc, container: WeakWidget<W>, id: Self::Input) { fn run(rsc: &mut Rsc, container: WeakWidget<W>, id: Self::Input) {
rsc.register_event(container, CursorSense::click_or_drag(), move |ctx, rsc| { rsc.register_event(container, press_track(), move |ctx, rsc| {
let region = ctx.data.render.window_region(&id, &*rsc).unwrap(); let region = ctx.data.render.window_region(&id, &*rsc).unwrap();
let id_pos = region.top_left; let id_pos = region.top_left;
let container_pos = ctx let container_pos = ctx
@@ -53,14 +71,14 @@ where
.top_left; .top_left;
let pos = ctx.data.pos + container_pos - id_pos; let pos = ctx.data.pos + container_pos - id_pos;
let size = region.size(); let size = region.size();
select( on_press(
rsc, rsc,
ctx.data.render, ctx.data.render,
ctx.state, ctx.state,
id, id,
pos, pos,
size, size,
ctx.data.sense.is_dragging(), ctx.data.sense,
); );
}); });
} }
@@ -75,31 +93,71 @@ where
type Input = (); type Input = ();
fn run(rsc: &mut Rsc, id: WeakWidget<TextEdit>, _: Self::Input) { fn run(rsc: &mut Rsc, id: WeakWidget<TextEdit>, _: Self::Input) {
rsc.register_event(id, CursorSense::click_or_drag(), move |ctx, rsc| { rsc.register_event(id, press_track(), move |ctx, rsc| {
select( on_press(
rsc, rsc,
ctx.data.render, ctx.data.render,
ctx.state, ctx.state,
id, id,
ctx.data.pos, ctx.data.pos,
ctx.data.size, ctx.data.size,
ctx.data.sense.is_dragging(), ctx.data.sense,
); );
}); });
} }
} }
fn select( /// One press-track frame (`PressStart`, `Pressing` or `PressEnd`) over a
/// selectable field. A field that is *already* focused behaves exactly as
/// `click_or_drag` always did -- every frame updates the selection, which
/// is what lets a finger already inside a focused field drag out a
/// selection. A field that is **not** focused withholds `select`'s
/// focus-granting side effects (and so the platform-specific `focus_gained`
/// that shows the keyboard) until the press resolves as a tap: `PressEnd`
/// with no frame in between having moved past [`DRAG_SLOP`] from where the
/// press began. A drag recognised before release simply cancels the
/// pending tap and does nothing further here -- it is not consumed, so
/// whatever is behind the field (a list to pan) still sees every frame of
/// it, the same as a drag that never touched a selectable field at all.
fn on_press(
rsc: &mut impl UiRsc, rsc: &mut impl UiRsc,
render: &UiRenderState, render: &UiRenderState,
state: &mut impl FocusHost, state: &mut impl FocusHost,
id: WeakWidget<TextEdit>, id: WeakWidget<TextEdit>,
pos: Vec2, pos: Vec2,
size: Vec2, size: Vec2,
dragging: bool, sense: CursorSense,
) { ) {
if state.is_focused(id) {
let recent = matches!(sense, CursorSense::PressStart(_)) && state.recent_click();
id.edit(rsc).select(pos, size, sense.is_dragging(), recent);
return;
}
match sense {
CursorSense::PressStart(_) => {
id.edit(rsc).text.press_origin = Some(pos);
}
CursorSense::Pressing(_) => {
let ctx = id.edit(rsc);
if let Some(origin) = ctx.text.press_origin
&& ((pos.x - origin.x).abs() > DRAG_SLOP || (pos.y - origin.y).abs() > DRAG_SLOP)
{
// Past the slop before release: this is a drag, not a tap
// -- give up the pending focus rather than granting it once
// the finger lifts wherever it happens to be by then.
ctx.text.press_origin = None;
}
}
CursorSense::PressEnd(_) => {
let was_tap = id.edit(rsc).text.press_origin.take().is_some();
if was_tap {
let recent = state.recent_click(); let recent = state.recent_click();
id.edit(rsc).select(pos, size, dragging, recent); id.edit(rsc).select(pos, size, false, recent);
state.set_focus(Some(id)); state.set_focus(Some(id));
state.focus_gained(render.window_region(&id, &*rsc)); state.focus_gained(render.window_region(&id, &*rsc));
} }
}
_ => {}
}
}
+4
View File
@@ -10,6 +10,10 @@ impl<T: HasDefaultUiState> FocusHost for T {
self.default_state_mut().focus = id; self.default_state_mut().focus = id;
} }
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
self.default_state().focus == Some(id)
}
fn focus_gained(&mut self, region: Option<PixelRegion>) { fn focus_gained(&mut self, region: Option<PixelRegion>) {
let state = self.default_state_mut(); let state = self.default_state_mut();
let Some(region) = region else { return }; let Some(region) = region else { return };
+17 -5
View File
@@ -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 {
+2 -1
View File
@@ -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() {
+30 -3
View File
@@ -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,
+77
View File
@@ -183,3 +183,80 @@ fn a_mask_stays_put_while_its_scrolled_content_moves() {
assert_eq!(mask_delta_before, [0.0, 0.0]); assert_eq!(mask_delta_before, [0.0, 0.0]);
assert_eq!(mask_delta_after, [0.0, 0.0]); assert_eq!(mask_delta_after, [0.0, 0.0]);
} }
/// Reproduces `transcript_ui::composer::build_composer`'s exact tree shape
/// (a `Rect` background stacked behind a `Span::RIGHT`-wrapped, padded,
/// `rest`-width `TextEdit`, itself the second child of an outer
/// `Span::DOWN` beside a `rest(1)`-height sibling) without the event/
/// resource plumbing `composer.rs`'s builders need, to isolate whether the
/// bug Iris reported on 2026-09-06 ("text seems to not appear in box")
/// is this crate's layout engine or something specific to the real
/// composer/screen. `TextEditable::edit` only needs `UiRsc`, so a plain
/// insert exercises the exact redraw path a keystroke does.
fn composer_like_tree(rsc: &mut TestRsc) -> (WeakWidget<TextEdit>, StrongWidget) {
let field = wtext("")
.editable(EditMode::MultiLine)
.text_align(Align::LEFT)
.wrap(true)
.size(18)
.color(UiColor::WHITE)
.add(rsc);
let bar = (field.pad(dp(12)).width(rest(1)),)
.span(Dir::RIGHT)
.background(rect(UiColor::new(40, 40, 46, 255)))
.add(rsc);
let list_stand_in = rect(UiColor::BLACK).height(rest(1)).add(rsc);
let tree = (list_stand_in, bar).span(Dir::DOWN).add_strong(rsc).any();
(field, tree)
}
/// The reproduction itself. A window this tall stands in for the keyboard
/// closed; the second, shorter `resize` stands in for `adjustResize`
/// shrinking the surface when the IME opens -- exactly the sequence
/// `IrisViewPeer::surface_changed` drives on a real keyboard open. Typing
/// happens both before and after, since Iris's report was specifically
/// that text typed *after* the keyboard was already up did not appear.
#[test]
fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (field, root) = composer_like_tree(&mut rsc);
let mut render = UiRenderState::new();
render.resize((1080.0, 2298.0));
render.update(&root, &mut rsc);
field.edit(&mut rsc).insert("a");
render.update(&root, &mut rsc);
let before_px = render.window_region(&field, &rsc).unwrap();
// The field is one line plus 12dp of padding on a 2298-tall window --
// nowhere near the whole window's height, and anchored at the bottom.
assert!(
before_px.bot_right.y - before_px.top_left.y < 200.0,
"before a resize: {before_px:?}"
);
assert!(
before_px.top_left.y > 1800.0,
"expected the bar near the bottom before a resize: {before_px:?}"
);
// The keyboard opens: a real `surface_changed`/`resize` to a shorter
// window, then a further keystroke -- the redraw that must land in the
// bar's new (also short) region, not whatever region a provisional
// measurement pass used along the way.
render.resize((1080.0, 1478.0));
render.update(&root, &mut rsc);
field.edit(&mut rsc).insert("b");
render.update(&root, &mut rsc);
let after_px = render.window_region(&field, &rsc).unwrap();
assert!(
after_px.bot_right.y - after_px.top_left.y < 200.0,
"after a resize + keystroke: {after_px:?}"
);
assert!(
after_px.top_left.y > 1200.0,
"expected the bar near the bottom of the shorter window: {after_px:?}"
);
}
+427 -4
View File
@@ -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());
} }
+468 -8
View File
@@ -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,115 @@ 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
}
/// The anchor's own row index and pixel offset, formatted the same
/// shape Compose's `firstVisibleItemIndex`/`firstVisibleItemScrollOffset`
/// report (`idx=N/off=Mpx`) -- what RUST.md's "Benchmark v2" fling
/// phase reads before/after/between its fling runs so the two apps'
/// travel can be compared directly. `more_before`/`more_after`
/// sentinels print as `idx=more-before`/`idx=more-after` rather than
/// leaking their internal `isize` representation; `idx=none` if the
/// list has never drawn (no anchor yet -- e.g. right after
/// `jump_to_end` and before the next frame runs `repair_anchor`).
pub fn anchor_position_display(&self) -> String {
match self.anchor {
None => "idx=none".to_string(),
Some(a) if a.slot == BEFORE_SLOT => "idx=more-before".to_string(),
Some(a) if a.slot == AFTER_SLOT => "idx=more-after".to_string(),
Some(a) => format!("idx={}/off={}px", a.slot, a.offset.round() as i64),
}
}
/// Snap to the newest content (last item, or the `more_after` /// 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 +752,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 +871,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 +1220,276 @@ 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
);
}
#[test]
fn anchor_position_display_before_any_draw_is_none() {
let list = List::new(Axis::Y);
assert_eq!(list.anchor_position_display(), "idx=none");
}
#[test]
fn anchor_position_display_reports_slot_and_offset() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
let _ = (&root, &mut render);
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
assert!(list_ref.anchor_position_display().starts_with("idx="));
assert!(!list_ref.anchor_position_display().contains("none"));
}
} }
+4 -3
View File
@@ -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,
+10 -9
View File
@@ -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),
} }
} }
} }
+57 -44
View File
@@ -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())
} }
} }
+1 -1
View File
@@ -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);
+3 -2
View File
@@ -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 {
+16 -10
View File
@@ -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
} }
} }
+87 -1
View File
@@ -32,6 +32,15 @@ pub struct TextEdit {
#[cfg_attr(target_os = "android", allow(dead_code))] #[cfg_attr(target_os = "android", allow(dead_code))]
history: Vec<(String, Option<Selection>)>, history: Vec<(String, Option<Selection>)>,
double_hit: Option<usize>, double_hit: Option<usize>,
/// Where an in-flight press over this field began, while it is still
/// undecided whether the gesture is a tap (focus/show the IME) or a
/// drag (attr.rs's `Selector`/`Selectable`, Iris 2026-09-06: a swipe
/// over the composer must not summon the keyboard). `None` both before
/// any press and once the gesture has been decided either way --
/// `attr.rs` is the only reader/writer, kept `pub(crate)` rather than
/// behind an accessor since it is pure bookkeeping with no invariant
/// beyond "some press is undecided," same shape as `double_hit` above.
pub(crate) press_origin: Option<Vec2>,
pub mode: EditMode, pub mode: EditMode,
} }
@@ -48,6 +57,7 @@ impl TextEdit {
selection: None, selection: None,
history: Default::default(), history: Default::default(),
double_hit: None, double_hit: None,
press_origin: None,
mode, mode,
} }
} }
@@ -141,7 +151,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 +178,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;
@@ -683,6 +708,67 @@ mod tests {
assert_eq!(content(&t), ""); assert_eq!(content(&t), "");
} }
/// `android/ime.rs`'s `set_composing_text` calls `replace` and expects
/// the caret to land right after the inserted text, growing with it on
/// every re-send -- the buffer-level half of RUST.md's P0 box ("doesn't
/// enter it until I hit space, and also doesn't move cursor forward").
#[test]
fn composing_advances_the_caret_with_the_growing_text() {
let (mut t, mut d) = edit("", EditMode::SingleLine);
ctx(&mut t, &mut d).set_caret(0);
ctx(&mut t, &mut d).replace(0, "h");
assert_eq!(t.caret(), Some(1));
ctx(&mut t, &mut d).replace(1, "hi");
assert_eq!(content(&t), "hi");
assert_eq!(t.caret(), Some(2));
ctx(&mut t, &mut d).replace(2, "hit");
assert_eq!(content(&t), "hit");
assert_eq!(t.caret(), Some(3));
}
/// The IME's `commitText` (`android_view::InputConnection::commit_text`'s
/// default body): finish a composition in place, same as a real word
/// boundary (a space) landing after Gboard's composing span.
#[test]
fn committing_composed_text_leaves_it_in_place_with_the_caret_after_it() {
let (mut t, mut d) = edit("say ", EditMode::SingleLine);
ctx(&mut t, &mut d).set_caret(4);
ctx(&mut t, &mut d).replace(0, "hi");
assert_eq!(content(&t), "say hi");
// `finish_composing_text`/`commit_text` do not themselves touch the
// buffer -- only the IME's own `compose_len` bookkeeping resets, in
// `android/ime.rs`. Confirms the buffer already holds committed
// text as plain, uncomposed content: a further `replace(0, " ")`
// (the space that ends the word) appends rather than overwriting.
ctx(&mut t, &mut d).replace(0, " ");
assert_eq!(content(&t), "say hi ");
assert_eq!(t.caret(), Some(7));
}
/// `TextEditCtx::delete_byte_range` is `deleteSurroundingText`'s entry
/// point once `android/ime.rs` has converted UTF-16 code units to
/// bytes -- exercised directly here in bytes, since the UTF-16 math
/// itself is `android/ime.rs`'s own `byte_to_utf16`/`utf16_to_byte`,
/// outside this widget-only test module.
#[test]
fn delete_byte_range_removes_exactly_that_range() {
let (mut t, mut d) = edit("hello world", EditMode::SingleLine);
ctx(&mut t, &mut d).delete_byte_range(5, 11);
assert_eq!(content(&t), "hello");
assert_eq!(t.caret(), Some(5));
}
/// `set_cursor_byte` is `setSelection`'s entry point -- collapses to a
/// caret at the given byte offset regardless of any span that was there.
#[test]
fn set_cursor_byte_collapses_to_a_caret_there() {
let (mut t, mut d) = edit("hello world", EditMode::SingleLine);
ctx(&mut t, &mut d).select_all();
ctx(&mut t, &mut d).set_cursor_byte(5);
assert_eq!(t.selected_text(), None);
assert_eq!(t.caret(), Some(5));
}
#[test] #[test]
fn motion_moves_the_caret_and_shift_extends_a_span() { fn motion_moves_the_caret_and_shift_extends_a_span() {
let (mut t, mut d) = edit("abc", EditMode::SingleLine); let (mut t, mut d) = edit("abc", EditMode::SingleLine);
+57 -3
View File
@@ -8,14 +8,58 @@
//! measures whatever vertical space is left each frame -- nothing here //! measures whatever vertical space is left each frame -- nothing here
//! computes a height by hand, and growing this field is exactly the //! computes a height by hand, and growing this field is exactly the
//! O(1)-move-chain case LAYOUT.md and I3's benchmark already measured. //! O(1)-move-chain case LAYOUT.md and I3's benchmark already measured.
//!
//! **Rebuilt 2026-09-06** (Iris's phone report on the dc01f88 build: the
//! grey bar drawn as a short, fixed strip with the typed text ~150px below
//! it on black, and empty black between the bar and the keyboard). One
//! widget now, top to bottom: an opaque background sized to its content
//! (`.background`, the same `Stack` idiom the header row's `HEADER_SURFACE`
//! already uses), the field inside `dp` padding and capped at
//! [`MAX_LINES`] before it scrolls instead of growing forever, and an
//! outer [`Pad`] whose `bottom` [`TranscriptScreen::set_bottom_inset`]
//! rewrites in place whenever the keyboard opens/closes -- never rebuilt,
//! since `field` is strongly owned inside this tree and this crate's
//! widgets cannot be re-parented once added (this module's own comment
//! below on why `build_composer` hands back a **weak** id).
use iris::prelude::*; use iris::prelude::*;
/// Caps the field's growth at roughly six lines of its own 18px text
/// before it scrolls instead of consuming the whole screen -- an
/// approximation (line-height and padding folded into one round `dp`
/// number) rather than a value derived from the font's real metrics,
/// which nothing in this crate exposes to a caller today.
const MAX_LINES: f32 = 6.0;
const APPROX_LINE_HEIGHT_DP: f32 = 24.0;
const FIELD_PAD_DP: f32 = 12.0;
/// `field` is exposed so the caller can read its content on submit /// `field` is exposed so the caller can read its content on submit
/// (`field.edit(rsc).text()`) and clear it afterward /// (`field.edit(rsc).text()`) and clear it afterward
/// (`field.edit(rsc).set("")`). /// (`field.edit(rsc).set("")`).
pub struct Composer { pub struct Composer {
pub field: WeakWidget<TextEdit>, pub field: WeakWidget<TextEdit>,
/// The bar's own outer padding -- only `bottom` is ever changed, by
/// [`Self::set_bottom_inset`]. A `Pad` around the whole bar rather than
/// a rebuilt tree, because `field` lives inside it and cannot be
/// re-added to a new wrapper once it is strongly owned here.
outer_pad: WeakWidget<Pad>,
}
impl Composer {
/// Called by the platform shell (Android's `on_insets_changed`, e.g.)
/// whenever the space below the bar changes: the IME's own inset while
/// it is open, the navigation-bar inset otherwise. Takes a plain
/// `f32` in the caller's own physical-pixel units rather than an
/// Android-specific insets type, so this crate stays usable from the
/// winit backend too, which has no navigation bar to report.
/// Rewrites the existing `Pad` in place (marking it dirty through the
/// ordinary `Widgets::get_mut` path) instead of swapping in a new one,
/// so the field's focus, selection and in-progress text are untouched.
pub fn set_bottom_inset(&self, rsc: &mut impl UiRsc, inset: f32) {
if let Some(pad) = rsc.ui_mut().widgets.get_mut(&self.outer_pad) {
pad.padding.bottom = Len::abs(inset);
}
}
} }
/// Returns the composer plus its own bar as a **weak** id -- the caller /// Returns the composer plus its own bar as a **weak** id -- the caller
@@ -39,10 +83,20 @@ where
.label("Message") .label("Message")
.add(rsc); .add(rsc);
let bar: WeakWidget = (field.pad(12).width(rest(1)),) // One widget: an opaque bar sized to its own content (`.background`'s
.span(Dir::RIGHT) // `Stack{child: 1}`, the header row's own idiom) wrapping the padded,
// height-capped field -- not a background rect and a field drawn as
// two independent siblings, which is what let the two disagree on
// where the bar actually was.
let content = field
.pad(dp(FIELD_PAD_DP))
.max_height(dp(APPROX_LINE_HEIGHT_DP * MAX_LINES + FIELD_PAD_DP * 2.0))
.scrollable()
.width(rest(1))
.background(rect(UiColor::new(40, 40, 46, 255))) .background(rect(UiColor::new(40, 40, 46, 255)))
.add(rsc); .add(rsc);
(Composer { field }, bar) let outer_pad: WeakWidget<Pad> = content.pad(Padding::ZERO).add(rsc);
(Composer { field, outer_pad }, outer_pad)
} }
+234
View File
@@ -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);
}
}
+11 -2
View File
@@ -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()
} }
+26 -1
View File
@@ -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
+1
View File
@@ -36,6 +36,7 @@ dependencies = [
"sha2", "sha2",
"tempfile", "tempfile",
"thiserror", "thiserror",
"time",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tower", "tower",
+6
View File
@@ -57,6 +57,12 @@ ureq = { version = "3", features = ["json"] }
# both in the graph rustls refuses to auto-select one. # both in the graph rustls refuses to auto-select one.
rustls = "0.23" rustls = "0.23"
libc = "0.2.189" libc = "0.2.189"
# One ISO-8601 timestamp: the reset time on the invented rate-limit window
# an echo session's `/usage` puts up. Already in the tree behind the
# certificate machinery, so this is a direct name for what is compiled
# anyway rather than a new crate -- and the alternative was hand-rolling a
# civil-from-days conversion to print one line.
time = { version = "0.3", features = ["formatting"] }
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
+14
View File
@@ -207,6 +207,20 @@ mod tests {
/// like any other. /// like any other.
#[tokio::test] #[tokio::test]
async fn a_spooled_enrollment_is_adopted_on_first_use() { async fn a_spooled_enrollment_is_adopted_on_first_use() {
// Under a subscriber, like every other exercise of this middleware.
// `tracing` caches a callsite's interest process-wide the first time it
// is reached, so the refusal at the end of this test -- reached with no
// subscriber on this thread -- could cache the rejection warning as
// never-enabled and make the tripwire above see an empty log. That
// failed about one full-suite run in ten, in the test that exists to
// notice a credential leak, which is the worst place for a flake.
let _guard = tracing::subscriber::set_default(
tracing_subscriber::fmt()
.with_max_level(tracing::Level::TRACE)
.with_writer(std::io::sink)
.finish(),
);
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let manager = manager_with_token(dir.path(), "first"); let manager = manager_with_token(dir.path(), "first");
let spooled = generate_token(); let spooled = generate_token();
+155
View File
@@ -30,6 +30,18 @@ pub struct Config {
pub tokens: Vec<TokenEntry>, pub tokens: Vec<TokenEntry>,
pub setups: Vec<SetupConfig>, pub setups: Vec<SetupConfig>,
pub sessions: Vec<SessionConfig>, pub sessions: Vec<SessionConfig>,
/// What a new session's thinking level is when nothing chose one.
///
/// Here rather than on a provider because providers are *discovered*: a
/// default written onto one would be erased by the next rediscovery, which
/// is the kind of setting that looks like it stuck until the day it did
/// not. Here rather than on the phone because a second device would then
/// spawn sessions the first one's owner did not expect.
///
/// `None` is the CLI's own default, and stays reachable: this is a level
/// somebody chose, not a level this app picked for them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_effort: Option<String>,
} }
/// A machine, and the things it can run. /// A machine, and the things it can run.
@@ -78,6 +90,16 @@ pub struct ProviderConfig {
pub models: Vec<String>, pub models: Vec<String>,
} }
impl ProviderConfig {
/// The executable to run for this provider: its override, or its kind's
/// default.
pub fn program(&self) -> &str {
self.command
.as_deref()
.unwrap_or(self.kind.default_program())
}
}
/// How to reach a setup that isn't this machine, with the system `ssh` client /// How to reach a setup that isn't this machine, with the system `ssh` client
/// -- so `~/.ssh/config`, agents and jump hosts all keep working, and there is /// -- so `~/.ssh/config`, agents and jump hosts all keep working, and there is
/// one place to configure connections. A remote session is the identical /// one place to configure connections. A remote session is the identical
@@ -94,6 +116,19 @@ pub struct SshConfig {
/// Extra `-o` settings, each written as `Key=value`. /// Extra `-o` settings, each written as `Key=value`.
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub options: Vec<String>, pub options: Vec<String>,
/// Where this machine keeps the GGUF models it can serve, absent for
/// the same default this backend uses (`~/.local/share/ai-app/models`
/// -- `$XDG_DATA_HOME` is not read on the far side, since it is this
/// machine's environment that would answer). A `~` prefix is the
/// remote home.
///
/// Here rather than on the provider because it is a fact about the
/// machine, and because a machine reached over ssh is where the model
/// has to be: a llama.cpp session serves the file from the machine
/// that runs `llama-server`, and this backend's own downloads are on
/// whichever machine that is only when they are the same one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub models_dir: Option<PathBuf>,
/// Where a file attached from the phone is put on this machine so the /// Where a file attached from the phone is put on this machine so the
/// session can read it. Absent means the session's own working directory, /// session can read it. Absent means the session's own working directory,
/// or the login home for a session that has none. A `~` prefix is the /// or the login home for a session that has none. A `~` prefix is the
@@ -145,6 +180,45 @@ impl DriverKind {
} }
} }
/// Which paid service meters a session of this kind, and `None` for one
/// that costs nothing.
///
/// What decides which account -- if any -- a rate-limit bar is about is the
/// provider a session runs, not the machine it runs on: an echo session on
/// a machine that also has the Claude CLI was drawn with that CLI's
/// five-hour window, a quota it cannot spend.
///
/// Echo names a meter of its own that exists only when a test has asked for
/// one (`/usage` in `session::echo`), which is how the bar's states are
/// reached without an account. With none set there is no snapshot, and the
/// phone draws nothing.
///
/// The string is a [`crate::usage::UsageProvider::name`], and it is what
/// pairs a session with one of `GET /usage`'s snapshots -- so
/// `usage::providers_for` reads this rather than matching on kinds again.
pub fn usage_provider(self) -> Option<&'static str> {
match self {
Self::ClaudeCli => Some(crate::usage::CLAUDE),
Self::Echo => Some(crate::usage::ECHO),
Self::LlamaCpp => None,
}
}
/// The executable a provider of this kind runs when it names none.
///
/// Here rather than at each spawn site because it is not only the spawn
/// that runs it: `usage` runs the Claude CLI too, to have it refresh its
/// own OAuth token, and a default that disagreed with the driver's would
/// ask the wrong binary on a machine with two installs.
pub fn default_program(self) -> &'static str {
match self {
Self::ClaudeCli => "claude",
Self::LlamaCpp => "llama-server",
// Echo is translated in-process; nothing is spawned for it.
Self::Echo => "echo",
}
}
/// Whether the conversation exists outside this app, so that deleting the /// Whether the conversation exists outside this app, so that deleting the
/// session here does not end it. /// session here does not end it.
/// ///
@@ -162,6 +236,24 @@ impl DriverKind {
Self::Echo | Self::LlamaCpp => false, Self::Echo | Self::LlamaCpp => false,
} }
} }
/// Whether a thinking level means anything to this kind, so the phone can
/// offer the control only where it does something.
///
/// Reported from here rather than decided on the phone, and asked of the
/// *kind* rather than branched on: the alternative is the session-type
/// `if` this app does not have anywhere else. `--effort` is the Claude
/// CLI's; a llama session's sampling is `params`, and echo does not think.
///
/// It matters more than a control that would simply do nothing, because
/// choosing a level stops the process -- so on a session that cannot use
/// one it is a button whose only effect is the cost.
pub fn takes_effort(self) -> bool {
match self {
Self::ClaudeCli => true,
Self::Echo | Self::LlamaCpp => false,
}
}
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -196,6 +288,17 @@ pub struct SessionConfig {
/// the CLI stays the one authority on which modes exist. /// the CLI stays the one authority on which modes exist.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub permission_mode: Option<String>, pub permission_mode: Option<String>,
/// How hard the model thinks, passed straight to `--effort`. A string for
/// the same reason `permission_mode` is: the CLI owns which levels exist.
///
/// Unlike the model and the mode, there is no control request that changes
/// one -- checked against 2.1.258, whose only two are `set_model` and
/// `set_permission_mode` -- so this is settled at launch and `None` means
/// whatever the CLI's own default is. That is a state the phone has to be
/// able to *choose*, not just start in, which is why it is an option
/// rather than a level with a default written here.
#[serde(skip_serializing_if = "Option::is_none")]
pub effort: Option<String>,
/// Settings the driver interprets, chosen at spawn. /// Settings the driver interprets, chosen at spawn.
/// ///
/// Deliberately untyped: what a temperature or a context size means is the /// Deliberately untyped: what a temperature or a context size means is the
@@ -215,6 +318,30 @@ pub struct SessionConfig {
/// turned off in one tap where one that never arrived is not diagnosable. /// turned off in one tap where one that never arrived is not diagnosable.
#[serde(default = "notify_default")] #[serde(default = "notify_default")]
pub notify: bool, pub notify: bool,
/// Whether a session stopped by the account's usage limit sends itself a
/// message once the limit lifts, instead of waiting for a person.
///
/// Off unless somebody asked for it. It spends quota the moment it becomes
/// available and it does so while nobody is looking, which is exactly the
/// kind of thing that must not happen because a default said so.
#[serde(default, skip_serializing_if = "not_set")]
pub auto_resume: bool,
/// What that message says. `None` is [`DEFAULT_RESUME_MESSAGE`], and stays
/// reachable: it is this app's word, not one somebody chose, so clearing
/// the field goes back to it rather than sending an empty message.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_resume_message: Option<String>,
/// The message this session owes itself once the limit lifts, and when to
/// try. Written when a limit is hit, moved when the wait turns out to be
/// wrong, and cleared when the message goes out or auto-resume is turned
/// off -- see [`ScheduledResume`].
///
/// Persisted rather than held in memory because the wait outlives the
/// process doing it: a five-hour window and a weekly one both routinely
/// outlast a backend restart, and a resume forgotten across one is a
/// session that silently never comes back.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resume: Option<ScheduledResume>,
/// Whether this session's process is stopped when the server exits, instead /// Whether this session's process is stopped when the server exits, instead
/// of being left running for the next start to adopt. /// of being left running for the next start to adopt.
/// ///
@@ -231,6 +358,28 @@ pub struct SessionConfig {
pub created: f64, pub created: f64,
} }
/// A message owed to a session whose account ran out, and when to try sending
/// it.
///
/// `since` is the whole reason this is a struct: the wait is rescheduled every
/// time the meter is asked and still says no, so `at` alone cannot say how long
/// this has been going on -- and something has to, or a machine that can never
/// be asked is retried until somebody notices. See `crate::resume`.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScheduledResume {
/// Epoch seconds: when the limit is next worth checking. Never a promise
/// that the message goes out then -- the meter is asked first.
pub at: f64,
/// Epoch seconds the limit was hit.
pub since: f64,
}
/// What an auto-resume says when nothing else was chosen. One word, because
/// the session already knows what it was doing and this is only the nudge that
/// lets it carry on.
pub const DEFAULT_RESUME_MESSAGE: &str = "continue";
fn notify_default() -> bool { fn notify_default() -> bool {
true true
} }
@@ -385,6 +534,7 @@ mod tests {
port: Some(2222), port: Some(2222),
identity_file: None, identity_file: None,
options: Vec::new(), options: Vec::new(),
models_dir: None,
attachments_dir: None, attachments_dir: None,
}), }),
providers: vec![ProviderConfig { providers: vec![ProviderConfig {
@@ -395,6 +545,7 @@ mod tests {
}], }],
}, },
], ],
default_effort: Some("low".to_string()),
sessions: vec![SessionConfig { sessions: vec![SessionConfig {
id: "abc123".to_string(), id: "abc123".to_string(),
setup: "vm".to_string(), setup: "vm".to_string(),
@@ -403,8 +554,12 @@ mod tests {
model: None, model: None,
cwd: None, cwd: None,
permission_mode: None, permission_mode: None,
effort: None,
params: BTreeMap::new(), params: BTreeMap::new(),
notify: true, notify: true,
auto_resume: false,
auto_resume_message: None,
resume: None,
throwaway: false, throwaway: false,
created: 1234.5, created: 1234.5,
}], }],
+11 -1
View File
@@ -16,6 +16,7 @@ mod config;
mod files; mod files;
mod media; mod media;
mod models; mod models;
mod resume;
mod routes; mod routes;
mod session; mod session;
mod setups; mod setups;
@@ -264,7 +265,16 @@ async fn main() -> Result<()> {
// No providers listed here any more: which machines can be asked, and about // No providers listed here any more: which machines can be asked, and about
// what, comes from the setups at the moment the screen is opened -- so a // what, comes from the setups at the moment the screen is opened -- so a
// machine added from the phone reports its limits without a restart. // machine added from the phone reports its limits without a restart.
let monitor = Arc::new(usage::UsageMonitor::new()); // The fixture is the manager's, because that is where the `/usage` command
// that sets it is typed; the monitor is what serves it.
let monitor = Arc::new(usage::UsageMonitor::new(manager.usage_fixture()));
// The one thing in here that acts without a request behind it: a session
// switched to auto-resume waits out its account's usage limit and picks
// itself back up. Started whether or not any session has it on, because
// the setting is per session and changes from the phone -- see
// `resume::run`.
tokio::spawn(resume::run(Arc::clone(&manager), Arc::clone(&monitor)));
// The bearer-token middleware wraps the entire router -- routes and fallback // The bearer-token middleware wraps the entire router -- routes and fallback
// alike -- here and only here, so a new route can't forget auth. // alike -- here and only here, so a new route can't forget auth.
+79
View File
@@ -29,6 +29,8 @@ use serde::Serialize;
use wg_app_link::private; use wg_app_link::private;
use crate::session::transport::{Launch, Transport};
/// Identifies this client to HuggingFace. They ask for one, and a request /// Identifies this client to HuggingFace. They ask for one, and a request
/// without it is more likely to be rate-limited. /// without it is more likely to be rate-limited.
const USER_AGENT: &str = concat!("ai-server/", env!("CARGO_PKG_VERSION")); const USER_AGENT: &str = concat!("ai-server/", env!("CARGO_PKG_VERSION"));
@@ -521,6 +523,83 @@ fn collect(root: &Path, dir: &Path, found: &mut Vec<LocalModel>) {
} }
} }
/// Where a machine reached over ssh keeps its models, when its setup does
/// not say.
///
/// The same place this backend puts its own downloads, written out rather
/// than derived: `$XDG_DATA_HOME` here describes *this* machine's
/// environment, and the far machine's is the far machine's business. A
/// setup whose models are elsewhere says so (`SshConfig::models_dir`).
const FAR_MODELS_DIR: &str = "~/.local/share/ai-app/models";
/// Which directory holds the models on the machine `transport` reaches.
///
/// One answer, because two things ask: the list a spawn screen offers,
/// and the path a session hands `llama-server`. A machine that listed one
/// directory and served from another would offer models that then failed
/// to load, which reads as the model being broken.
pub fn dir_on(transport: &Transport, local: &Path) -> String {
match transport {
Transport::Here => local.to_string_lossy().into_owned(),
Transport::Ssh { ssh, .. } => ssh
.models_dir
.as_ref()
.map_or(FAR_MODELS_DIR.to_string(), |dir| {
dir.to_string_lossy().into_owned()
}),
}
}
/// Every GGUF on the machine a setup names, which is the machine that
/// would have to serve it.
///
/// The local half of this is [`ModelStore::list`], reading the same shape
/// off this machine's disk; a caller picks by transport, since a setup
/// with no ssh *is* this machine and asking a shell about it would be a
/// slower way to the same answer. What must not happen is offering this
/// backend's downloads for a session on another machine: the file has to
/// be where `llama-server` runs, and a list that says otherwise is a
/// claim about the wrong filesystem.
///
/// `dir` is that machine's models directory, `~` included -- expanded on
/// the far side, which is the only place that knows what it is. A
/// directory that is not there is an empty list rather than a failure: a
/// machine that has never had a model put on it is an ordinary state, and
/// the same one as a machine whose directory exists and is empty.
pub async fn on_machine(transport: &Transport, dir: &str) -> Result<Vec<LocalModel>> {
let script = "p=$1; case $p in \"~\") p=$HOME;; \"~/\"*) p=$HOME/${p#\"~/\"};; esac; \
[ -d \"$p\" ] || exit 0; \
find \"$p\" -type f -name '*.gguf' -printf '%s\\t%P\\0'";
let launch = Launch::new(
"sh",
vec![
"-c".to_string(),
script.to_string(),
"sh".to_string(),
dir.to_string(),
],
None,
);
let out = transport.capture(&launch).await?;
let mut found: Vec<LocalModel> = out
.split('\0')
.filter(|record| !record.is_empty())
// Two fields, and the name last, so a `\t` in a filename survives.
.filter_map(|record| record.split_once('\t'))
.filter_map(|(bytes, key)| {
let (repo, file) = key.rsplit_once('/')?;
Some(LocalModel {
key: key.to_string(),
repo: repo.to_string(),
file: file.to_string(),
bytes: bytes.trim().parse().unwrap_or(0),
})
})
.collect();
found.sort_by(|a, b| a.key.cmp(&b.key));
Ok(found)
}
/// A model repository on HuggingFace, as the browse screen shows it. /// A model repository on HuggingFace, as the browse screen shows it.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff. Show more