Author SHA1 Message Date
iris bf3479f5c4 client-core: an unasked page is not an empty one, and two guarded invariants
Review of 73251d6's port of TranscriptSource/joinPages.

`TranscriptSource::page` answered `before == 0` with an empty `Vec`, which
is the same value it answers "this conversation has no more history" with.
That is the state the Kotlin keeps apart: `loadOlderPage` returns false at
`oldestSeq == 0` *without* touching `moreHistory`, and returns false on an
empty page *by latching it*. Collapsing the two moved AGENTS.md's paging
bug one layer down rather than fixing it. `page` returns `OlderPage` now --
`Events(vec![])` is the start of the conversation, `NothingLoaded` is not
an answer about the conversation at all.

`join_pages`' `debug_assert!` on seq ordering across the boundary is not a
true invariant: a peer note carries the seq its turn began at, which can be
older than the page it arrived in, so an ordinary transcript would have
panicked a debug build there. Replaced with the one the function exists to
enforce -- no tool id surviving in both halves.

`fetch_transcript_lines` stores `RawValue`'s exact server bytes, so the
"neither source can produce a newline" comment in `SessionCache::append`
now rests on the server's serializer staying compact rather than on a
local normalization. Checked with a `debug_assert!` in `append` and
`store_page` rather than trusted.

Tests for the failure half, which the port had none of: a 500 mid-page, a
cached line this build cannot read, and the `after` bound in the case that
actually carries one (the existing test asserted only the case with no
bound). `cargo fmt`, `cargo clippy --all-targets`, `cargo test` (112) clean
in client-core; `cargo check -p desktop-app` clean.
2026-09-06 13:00:37 -04:00
iris 312455956d Merge remote-tracking branch 'origin/rustify' into worktree-agent-a6e37a2335f436d08 2026-09-06 12:39:22 -04:00
irisandClaude Fable 5.1 73251d6b8b client-core: port TranscriptSource and joinPages page-boundary healing
Closes docs/RUST.md's "client-core prerequisites for P1" box: the
cache-vs-server stitching TranscriptSource.kt does, and the
joinPages/healSplitMessage/adoptRun page-boundary healing
TranscriptItems.kt does, both ported into client-core with no UI
framework dependency.

Neither Kotlin file had a JVM unit test of its own, so the port used the
Kotlin source and AGENTS.md's "things that have bitten" paging incidents
as the spec instead of a test-for-test transcription. Both regressions
get a dedicated test: TranscriptSource::page refuses before == 0 before
touching the cache or the network (loadOlderPage's incident), and
adopt_run now runs on every page join rather than only the one where a
split call was found (the "one run drawn as two" incident).

fetch_transcript_lines (api.rs, additive) pairs each transcript line with
the exact server bytes via serde_json::value::RawValue rather than
re-serializing a parsed Value, so a cached line and a live SSE frame for
the same event agree byte-for-byte -- the fetch_transcript_page other
callers under iris/ depend on is untouched.

client-core: 85 -> 109 tests. cargo test/clippy --all-targets/fmt clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 12:38:59 -04:00
iris 2e00e71552 docs: Iris's 11:39 phone report on the 02:07 build, four open items 2026-09-06 11:42:21 -04:00
irisandClaude Fable 5.1 f802de94b5 Merge worktree-agent-a754368325fa06839 into rustify: DragGesture, pointer capture, edge-to-edge insets
Generalizes drag arbitration into a default-input DragGesture with
pointer capture and CursorSense::Drop, and opts MainActivity into
edge-to-edge so IME insets are redelivered. See e12c708.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 11:38:24 -04:00
iris 9717d1c4b0 docs/RUST.md: 2026-09-06 orchestrator plan for the P0 defects and the P1 prerequisites 2026-09-06 11:37:32 -04:00
iris 9458f443ad Merge remote-tracking branch 'origin/rustify' into worktree-agent-a754368325fa06839 2026-09-06 02:10:55 -04:00
irisandClaude Fable 5.1 e12c708246 iris: generalize drag arbitration into a default-input DragGesture, with pointer capture and Drop
Iris asked (2026-09-06) that dragging be part of iris's default input
system rather than duplicated per app: "anything that provides good
performance and can be generalized well is part of iris rather than the
app." DragArbiter and VelocityTracker (both already in iris::sense) are
now bundled into a new DragGesture, which also takes exclusive pointer
capture (UiRenderState::capture_pointer/release_pointer/captured_pointer)
the moment a gesture commits to panning or selecting, and delivers a new
CursorSense::Drop -- not PressEnd -- to the captured widget when the
button lifts, wherever on screen that happens to be.

This directly targets the phone bench's "finger flings do nothing":
per-widget hit testing silently drops a gesture the instant the pointer
moves off every registered region, which a fast pan/fling does routinely
(crossing several virtualised rows, or ending off the loaded content
entirely) -- so PressEnd, and the velocity/fling-start decision hanging
off it, was frequently never delivered at all. Capture targets List's own
stable id (List::key_at resolves the row-under-pointer from its
extents), not a row's, since List retires rows mid-drag as content
scrolls.

transcript-ui::Selection::drag now only decides pan-vs-select from
DragGesture's outcome; row.rs's per-row registration is only ever a
gesture's first frame, with lib.rs registering the List-level
continuation once. New tests: sense_tests.rs's two pointer-capture
regressions, list.rs's replacing_the_last_row_many_times_does_not_leak_primitives
(a P0 stale-primitives diagnostic -- passes, pinning the widget-arena
layer as not the leak). MainActivity.java opts into edge-to-edge
(Window::setDecorFitsSystemWindows(false), API 30+, no new dependency)
so window insets are redelivered on every change including a pure IME
toggle -- the named-but-untried fix for the phone bench's "keyboard:
could not be shown" and the emulator's identical non-confirmation.

cargo fmt/clippy/test clean across the iris workspace.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 02:10:48 -04:00
iris 543f6d92f0 Merge worktree-agent-a9002910a315fe719 into rustify: composing text, tap-vs-swipe focus, composer rebuild, atlas reset 2026-09-06 02:08:12 -04:00
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
47 changed files with 4383 additions and 413 deletions

No files matched your search

+6 -1
View File
@@ -17,7 +17,12 @@ edition = "2024"
[dependencies] [dependencies]
event-model = { path = "../event-model" } event-model = { path = "../event-model" }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["float_roundtrip"] } # "raw_value" is `fetch_transcript_lines`'s reason -- it needs the exact
# bytes the server sent, not this crate's own re-serialization of a parsed
# `Value`, so a cached line and a live SSE frame for the same event agree
# byte-for-byte (see that method's doc). "float_roundtrip" is why they
# agree on a `ts` at all -- see server/Cargo.toml's identical comment.
serde_json = { version = "1", features = ["float_roundtrip", "raw_value"] }
# The blocking HTTP client for the REST calls and the long-lived SSE GETs. # The blocking HTTP client for the REST calls and the long-lived SSE GETs.
# `server/` already depends on ureq for its own outbound HTTPS (the usage # `server/` already depends on ureq for its own outbound HTTPS (the usage
# poll in usage.rs) and it is rustls-backed like the rest of this project's # poll in usage.rs) and it is rustls-backed like the rest of this project's
+67 -1
View File
@@ -10,6 +10,7 @@
use std::io::Read; use std::io::Read;
use event_model::SeqEvent;
use serde::Deserialize; use serde::Deserialize;
use serde_json::Value; use serde_json::Value;
@@ -116,6 +117,14 @@ impl<T: Transport> ApiClient<T> {
Self { transport } Self { transport }
} }
/// The transport underneath, for a caller that needs the raw SSE
/// stream (`event_stream::follow_session_events`) rather than one of
/// this client's typed REST calls -- `transcript_source::TranscriptSource`
/// is the one that does.
pub fn transport(&self) -> &T {
&self.transport
}
fn json_request<R: for<'de> Deserialize<'de>>( fn json_request<R: for<'de> Deserialize<'de>>(
&self, &self,
method: &str, method: &str,
@@ -266,6 +275,61 @@ impl<T: Transport> ApiClient<T> {
limit: u32, limit: u32,
coalesce: bool, coalesce: bool,
) -> Result<Vec<Value>, ApiError> { ) -> Result<Vec<Value>, ApiError> {
self.json_request(
"GET",
&transcript_path(session_id, before, limit, coalesce, None),
None,
)
}
/// A page of transcript history, each line handed back paired with the
/// exact text it came from, and bounded below by `after` -- the shape
/// `crate::transcript_source::TranscriptSource` needs to store what it
/// fetched in the transcript cache without a second round trip to fetch
/// the raw text separately. Ported from `Api.kt`'s `fetchTranscript`.
///
/// Uses [`serde_json::value::RawValue`] rather than re-serializing a
/// parsed [`Value`], so the stored line is the exact bytes the server
/// sent (key order and float literal included) rather than this
/// crate's own idea of how to write them back out -- the cache and a
/// live SSE frame must agree byte-for-byte on the same event, which is
/// exactly what caught the `serde_json` float-rounding bug this
/// project's `AGENTS.md` records.
pub fn fetch_transcript_lines(
&self,
session_id: &str,
before: Option<u64>,
limit: u32,
coalesce: bool,
after: Option<u64>,
) -> Result<Vec<(String, SeqEvent)>, ApiError> {
let path = transcript_path(session_id, before, limit, coalesce, after);
let raw: Vec<Box<serde_json::value::RawValue>> = self.json_request("GET", &path, None)?;
raw.into_iter()
.map(|value| {
let line = value.get().to_string();
let event: SeqEvent = serde_json::from_str(&line).map_err(|e| ApiError {
message: format!(
"the server sent a transcript line this build couldn't parse: {e}"
),
status: None,
})?;
Ok((line, event))
})
.collect()
}
}
/// The query string shared by [`ApiClient::fetch_transcript_page`] and
/// [`ApiClient::fetch_transcript_lines`], so the two agree on how each
/// parameter is written rather than keeping two copies to drift.
fn transcript_path(
session_id: &str,
before: Option<u64>,
limit: u32,
coalesce: bool,
after: Option<u64>,
) -> String {
let mut path = format!("/sessions/{session_id}/transcript?limit={limit}"); let mut path = format!("/sessions/{session_id}/transcript?limit={limit}");
if let Some(before) = before { if let Some(before) = before {
path.push_str(&format!("&before={before}")); path.push_str(&format!("&before={before}"));
@@ -273,8 +337,10 @@ impl<T: Transport> ApiClient<T> {
if coalesce { if coalesce {
path.push_str("&coalesce=true"); path.push_str("&coalesce=true");
} }
self.json_request("GET", &path, None) if let Some(after) = after {
path.push_str(&format!("&after={after}"));
} }
path
} }
/// The blocking [`Transport`] backed by `ureq`, the same crate `server/` /// The blocking [`Transport`] backed by `ureq`, the same crate `server/`
+1
View File
@@ -11,5 +11,6 @@ pub mod notifications;
pub mod sse; pub mod sse;
pub mod transcript_cache; pub mod transcript_cache;
pub mod transcript_fold; pub mod transcript_fold;
pub mod transcript_source;
pub use event_model::*; pub use event_model::*;
+14 -2
View File
@@ -361,6 +361,10 @@ impl SessionCache {
{ {
return Ok(false); return Ok(false);
} }
debug_assert!(
lines.iter().all(|l| !l.contains('\n')),
"a stored page's lines must each be one line"
);
fs::create_dir_all(&this.dir)?; fs::create_dir_all(&this.dir)?;
let kind = if rows { "rows" } else { "raw" }; let kind = if rows { "rows" } else { "raw" };
let mut content = lines.join("\n"); let mut content = lines.join("\n");
@@ -389,8 +393,16 @@ impl SessionCache {
return Ok(()); return Ok(());
}; };
// Written as it arrived. A newline inside it would split one // Written as it arrived. A newline inside it would split one
// event into two unreadable halves, but neither source can // event into two unreadable halves. No source here can produce
// produce one. // one -- an SSE `data:` field cannot hold a raw newline, and a
// fetched line is one element of a compact JSON array -- but
// that is a fact about the *server's* serializer rather than
// anything this file controls, so it is checked rather than
// trusted.
debug_assert!(
!line.contains('\n'),
"a cached transcript line must be one line: {line}"
);
use std::io::Write; use std::io::Write;
writer.write_all(line.as_bytes())?; writer.write_all(line.as_bytes())?;
writer.write_all(b"\n")?; writer.write_all(b"\n")?;
+323
View File
@@ -294,6 +294,199 @@ fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptIte
out out
} }
/// Puts a page of older items in front of the ones already loaded, healing
/// whatever the page boundary cut in two. Ported from `TranscriptItems.kt`'s
/// `joinPages`.
///
/// Two things straddle a boundary: a tool call separated from its result,
/// and a message separated from the rest of itself. Both were one thing
/// before the transcript was cut into pages.
///
/// A boundary lands wherever it lands, and roughly half the time that is
/// between a call and its result. The newer page then holds a `ToolEnd`
/// whose start it never saw, which `fold_event` draws as a row of its own
/// -- correctly, because a call that renders as nothing is indistinguishable
/// from one that never happened. When the older page arrives it brings the
/// real `ToolStart`, and concatenating the two lists left *both*: the same
/// call twice.
///
/// Merged by the call's own id rather than by position, because position is
/// exactly what a page boundary destroys. The older row wins on what a
/// start knows and the newer on what an end knows, which is the only way
/// round that loses nothing.
///
/// The third thing is the *run*, and it is the one the Kotlin original used
/// to miss (AGENTS.md's "things that have bitten"): every page ends up
/// here, but `adopt_run` must run on *every* join, not only the one where a
/// split call was found -- a boundary landing cleanly between two finished
/// calls, which is most of them, would otherwise leave the older page's
/// calls under the run name they were folded with. On screen: one run of
/// tool calls drawn as two groups, with the seam wherever the reader
/// happened to have paged.
pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<TranscriptItem> {
let (older, newer) = heal_split_message(earlier, later);
let started_earlier: std::collections::HashSet<&str> = older
.iter()
.filter_map(TranscriptItem::as_tool_run)
.collect();
// Owned rather than borrowed from `newer`: `kept` below needs to consume `newer` by
// value, and a map borrowing it would keep that alive.
let ended_later: std::collections::HashMap<String, TranscriptItem> = newer
.iter()
.filter_map(|item| item.as_tool_run().map(|id| (id.to_string(), item.clone())))
.filter(|(id, _)| started_earlier.contains(id.as_str()))
.collect();
let healed: Vec<TranscriptItem> = older
.into_iter()
.map(|row| match row {
TranscriptItem::ToolRun {
seq,
id,
run_id,
tool,
input,
asks: row_asks,
images: row_images,
..
} if ended_later.contains_key(id.as_str()) => {
let &TranscriptItem::ToolRun {
ref output,
done,
asks: ref half_asks,
images: ref half_images,
..
} = &ended_later[id.as_str()]
else {
unreachable!("filtered to ToolRun above");
};
TranscriptItem::ToolRun {
seq,
id,
run_id,
tool,
input,
output: output.clone(),
done,
// Kept from both halves: a question or an image can be
// attached to either, depending on which side of the
// boundary its event fell.
asks: row_asks.into_iter().chain(half_asks.clone()).collect(),
images: row_images.into_iter().chain(half_images.clone()).collect(),
}
}
other => other,
})
.collect();
let kept: Vec<TranscriptItem> = newer
.into_iter()
.filter(|item| match item.as_tool_run() {
Some(id) => !ended_later.contains_key(id),
None => true,
})
.collect();
let mut out = adopt_run(&healed, &kept);
out.extend(kept);
// What this function exists to prevent, checked rather than assumed: the same
// call drawn twice, once from the page that saw its start and once from the page
// that saw its end. Not a seq-ordering check -- a peer note is stamped with the
// seq its turn began at, which can be older than the page it arrived in, so the
// two pages' seqs legitimately interleave at the boundary.
debug_assert!(
{
let mut ids: Vec<&str> = out.iter().filter_map(TranscriptItem::as_tool_run).collect();
let before = ids.len();
ids.sort_unstable();
ids.dedup();
ids.len() == before
},
"join_pages left the same tool call in both halves"
);
out
}
/// Rejoins a message the page boundary cut, and hands back the two pages to
/// concatenate. Ported from `TranscriptItems.kt`'s `healSplitMessage`.
///
/// `fold_event` never leaves two assistant messages next to each other
/// inside one page, so two meeting at a join are always the two halves of
/// one reply, and leaving them apart drew a single answer as two with a
/// paragraph break through the middle of a sentence.
///
/// The newer half keeps its identity, for the reason `adopt_run`'s doc
/// gives. It grows by what the older half brings, which is safe here and
/// nowhere else -- the join is at the oldest end of what is loaded, so the
/// growth extends off the top of the screen.
fn heal_split_message(
earlier: &[TranscriptItem],
later: &[TranscriptItem],
) -> (Vec<TranscriptItem>, Vec<TranscriptItem>) {
let (
Some(TranscriptItem::AssistantMsg {
text: head_text, ..
}),
Some(TranscriptItem::AssistantMsg {
seq: tail_seq,
text: tail_text,
settled: tail_settled,
}),
) = (earlier.last(), later.first())
else {
return (earlier.to_vec(), later.to_vec());
};
let merged = TranscriptItem::AssistantMsg {
seq: *tail_seq,
text: format!("{head_text}{tail_text}"),
settled: *tail_settled,
};
let mut newer = vec![merged];
newer.extend(later[1..].iter().cloned());
(earlier[..earlier.len() - 1].to_vec(), newer)
}
/// Hands the older calls at the join the name of the run they are joining.
/// Ported from `TranscriptItems.kt`'s `adoptRun`.
///
/// The two pages were folded separately, so a run split by the boundary
/// came back as two runs with two names. Naming the joined run after the
/// *older* half would be the obvious way round and is wrong: the newer half
/// is the part already on screen, and renaming it is renaming the row the
/// reader is looking at, which is how a list loses its anchor.
fn adopt_run(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<TranscriptItem> {
let Some(TranscriptItem::ToolRun { run_id, tool, .. }) = later.first() else {
return earlier.to_vec();
};
// A question is in a run of its own on both sides of the join, the same as it would be
// had the two pages been folded as one. Without this the heal would merge a group
// straight through the row the reader was asked something on.
if tool == ASK_USER_QUESTION {
return earlier.to_vec();
}
let joining = run_id.clone();
let tail_len = earlier
.iter()
.rev()
.take_while(|item| matches!(item, TranscriptItem::ToolRun { tool, .. } if tool != ASK_USER_QUESTION))
.count();
if tail_len == 0 {
return earlier.to_vec();
}
let split = earlier.len() - tail_len;
let mut out = earlier[..split].to_vec();
out.extend(earlier[split..].iter().cloned().map(|mut item| {
// `take_while` above already restricted this slice to non-question tool calls;
// this just guards the invariant rather than trusting it silently.
debug_assert!(
matches!(&item, TranscriptItem::ToolRun { tool, .. } if tool != ASK_USER_QUESTION),
"adopt_run must never rename a question's own run"
);
if let TranscriptItem::ToolRun { run_id, .. } = &mut item {
*run_id = joining.clone();
}
item
}));
out
}
/// Folds one transcript event onto `items`, the way `foldEvent` does in /// Folds one transcript event onto `items`, the way `foldEvent` does in
/// `TranscriptItems.kt`. Every wire event has a case; see the module doc /// `TranscriptItems.kt`. Every wire event has a case; see the module doc
/// for the one difference from the Kotlin original (no `Unknown` fallback /// for the one difference from the Kotlin original (no `Unknown` fallback
@@ -955,4 +1148,134 @@ mod tests {
let err = fold_page(&values).unwrap_err(); let err = fold_page(&values).unwrap_err();
assert!(err.contains("couldn't parse")); assert!(err.contains("couldn't parse"));
} }
fn tool_start(seq: u64, id: &str, tool: &str) -> SeqEvent {
event(
seq,
Event::ToolStart {
id: id.to_string(),
tool: tool.to_string(),
input: serde_json::json!({}),
},
)
}
fn tool_end(seq: u64, id: &str, output: &str) -> SeqEvent {
event(
seq,
Event::ToolEnd {
id: id.to_string(),
output: output.to_string(),
},
)
}
/// AGENTS.md's "things that have bitten": `joinPages` used to run
/// `adoptRun` only on the path where a *split* call was found, so a
/// boundary landing cleanly between two already-finished calls -- most
/// of them -- left the older page's calls under the run name they were
/// folded with, drawing one run of tool calls as two groups. Two
/// finished, unrelated calls (no id in common) must still end up under
/// one run name after the join.
#[test]
fn a_clean_boundary_between_two_finished_runs_is_still_healed_into_one_run() {
let older = fold_all(&[tool_start(1, "a", "Bash"), tool_end(2, "a", "old output")]);
let newer = fold_all(&[tool_start(3, "b", "Bash"), tool_end(4, "b", "new output")]);
let joined = join_pages(&older, &newer);
let run_ids: Vec<_> = joined
.iter()
.map(|item| match item {
TranscriptItem::ToolRun { run_id, .. } => run_id.as_str(),
other => panic!("expected only ToolRun items, got {other:?}"),
})
.collect();
assert_eq!(
run_ids,
vec!["b", "b"],
"the older call must adopt the newer, already-on-screen run's name"
);
}
#[test]
fn a_call_split_across_the_boundary_merges_into_one_row() {
let older = fold_all(&[tool_start(1, "x", "Bash")]);
let newer = fold_all(&[tool_end(2, "x", "the result")]);
let joined = join_pages(&older, &newer);
assert_eq!(
joined,
vec![TranscriptItem::ToolRun {
seq: 1,
id: "x".to_string(),
run_id: "x".to_string(),
tool: "Bash".to_string(),
input: "{}".to_string(),
output: "the result".to_string(),
done: true,
asks: Vec::new(),
images: Vec::new(),
}],
"the older half's tool/input and the newer half's output/done must both survive"
);
}
#[test]
fn a_message_split_across_the_boundary_is_rejoined_with_the_newer_halfs_identity() {
let older = vec![TranscriptItem::AssistantMsg {
seq: 1,
text: "Hel".to_string(),
settled: false,
}];
let newer = vec![
TranscriptItem::AssistantMsg {
seq: 2,
text: "lo".to_string(),
settled: true,
},
TranscriptItem::UserMsg {
seq: 3,
text: "next".to_string(),
attachments: Vec::new(),
},
];
let joined = join_pages(&older, &newer);
assert_eq!(
joined,
vec![
TranscriptItem::AssistantMsg {
seq: 2,
text: "Hello".to_string(),
settled: true,
},
TranscriptItem::UserMsg {
seq: 3,
text: "next".to_string(),
attachments: Vec::new(),
},
]
);
}
/// A question is in a run of its own on both sides of a join -- healing
/// must never rename the run of calls the reader was asked something
/// on, the same rule `splitRun` enforces for a live turn boundary.
#[test]
fn adopt_run_never_renames_into_a_question_row() {
let older = fold_all(&[tool_start(1, "a", "Bash"), tool_end(2, "a", "done")]);
let newer = vec![TranscriptItem::ToolRun {
seq: 3,
id: "q".to_string(),
run_id: "q".to_string(),
tool: ASK_USER_QUESTION.to_string(),
input: "{}".to_string(),
output: String::new(),
done: false,
asks: Vec::new(),
images: Vec::new(),
}];
let joined = join_pages(&older, &newer);
match &joined[0] {
TranscriptItem::ToolRun { run_id, .. } => assert_eq!(run_id, "a"),
other => panic!("expected a ToolRun, got {other:?}"),
}
}
} }
+588
View File
@@ -0,0 +1,588 @@
//! Where a session screen gets a transcript from: this phone's copy first,
//! the server for the rest. Ported from `app/.../TranscriptSource.kt`; see
//! `docs/TRANSCRIPT_CACHE.md` for the design this implements and
//! `docs/CLIENT_CORE.md` for how this file corresponds to the Kotlin.
//!
//! One seam rather than a cache the screen has to remember to consult.
//! Everything fetched before is asked of this, and everything the server
//! sends is written into the cache on the way past, so a caller never
//! learns which side answered. The one rule worth keeping in mind: the
//! cache is never load-bearing. Every read here has a network path beside
//! it producing the same result.
//!
//! **Not ported**: `EventStream.kt`'s reconnect-with-backoff loop and the
//! ability to close a live stream from another thread. Both are wall-clock
//! and thread-lifetime concerns that belong to whatever runtime the caller
//! embeds this crate in (a Tokio task, an iris timer, a Kotlin coroutine
//! scope) rather than to this pure logic -- `follow` below is the same
//! decorator shape `iris/desktop-app/src/app.rs` and
//! `iris/android-app/src/transcript_client.rs` already hand-wrote around
//! `event_stream::follow_session_events`, just with the cache write built
//! in so a future caller does not have to repeat it a third time.
use event_model::SeqEvent;
use crate::api::{ApiClient, ApiError, Transport};
use crate::event_stream::{self, StreamItem};
use crate::transcript_cache::SessionCache;
/// How many events a session screen opens with, cached or fetched.
///
/// The server's own default page size, named here because the cached
/// opening has to be the same size as the fetched one -- a reader must not
/// get a shorter first screen for having been here before (`OPENING_WINDOW`
/// in the Kotlin original).
pub const OPENING_WINDOW: u32 = 80;
/// A transcript-line parse failure, told apart from [`ApiError`] so a
/// caller can tell "the server is unreachable" from "the server (or this
/// phone's own disk) sent something this build cannot read" -- the two
/// mean different things to a reader (retry, versus a build that is
/// behind).
#[derive(Debug, Clone)]
pub struct ParseError(pub String);
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for ParseError {}
/// Either half of what can go wrong asking for a page: the network, or a
/// line neither the cache's nor the server's copy of `parseSeqEvent` could
/// read.
#[derive(Debug, Clone)]
pub enum PageError {
Api(ApiError),
Parse(ParseError),
}
impl From<ApiError> for PageError {
fn from(e: ApiError) -> Self {
Self::Api(e)
}
}
impl From<ParseError> for PageError {
fn from(e: ParseError) -> Self {
Self::Parse(e)
}
}
/// What [`TranscriptSource::page`] found, kept as two states rather than
/// one possibly-empty list.
///
/// The difference is the whole of AGENTS.md's `loadOlderPage` incident: an
/// empty [`Self::Events`] means "this conversation has no more history",
/// which a caller is meant to latch, and [`Self::NothingLoaded`] means the
/// question could not be asked yet, which it must not. Collapsing the two
/// into an empty `Vec` puts the bug back, because the caller cannot tell
/// them apart -- and `unwrap_or_default()` on an `Option` would do the
/// same silently.
#[derive(Debug, Clone, PartialEq)]
pub enum OlderPage {
/// The events before the cursor, oldest first. Empty means the start
/// of the conversation has been reached.
Events(Vec<SeqEvent>),
/// Nothing is loaded, so there was no cursor to page back from
/// (`before == 0`). Not an answer about the conversation at all.
NothingLoaded,
}
fn parse_line(line: &str) -> Result<SeqEvent, ParseError> {
serde_json::from_str(line).map_err(|e| ParseError(format!("{e}")))
}
/// This phone's copy of one session's transcript, plus the server it
/// falls back to. Ported from the Kotlin `TranscriptSource` class.
pub struct TranscriptSource<T: Transport> {
api: ApiClient<T>,
session_id: String,
pub cache: SessionCache,
}
impl<T: Transport> TranscriptSource<T> {
pub fn new(api: ApiClient<T>, session_id: impl Into<String>, cache: SessionCache) -> Self {
Self {
api,
session_id: session_id.into(),
cache,
}
}
/// The cached opening window, or `None` when there is nothing usable
/// to draw.
///
/// Meant to be drawn *before* [`Self::probe`] returns, which is the
/// whole point of the feature: the rows are on screen while the check
/// that they are still the server's rows is in flight, and a failed
/// check replaces them exactly as a reset does.
pub fn cached_opening(&self, limit: usize) -> Option<Vec<SeqEvent>> {
self.cache.tail()?;
let lines = self.cache.newest(limit);
if lines.is_empty() {
return None;
}
match lines.iter().map(|l| parse_line(l)).collect() {
Ok(events) => Some(events),
// A line this build cannot read at all, which the cache's own checks cannot
// see: it reads a seq off a line, not an event. Nothing to serve, so a cold
// open.
Err(ParseError(_)) => {
self.cache.purge();
None
}
}
}
/// Whether the server's event at the cached cursor is still the cached
/// one.
///
/// A caller must not resume a live stream from a cached seq unless it
/// is the same conversation: a transcript is append-only in ordinary
/// use, but the file backing it can be replaced or truncated (a
/// sandbox re-seeded with the same ids, a backup restored, a session
/// re-imported), and the server's catch-up on such a file would hand
/// this phone a continuation of a *different* conversation, spliced
/// onto the cached one with no seam. Caught with one request of a few
/// hundred bytes.
///
/// `Ok(false)` purges the cache and means "open cold". `Err` is the
/// server not being askable, which is neither: the cached rows stay
/// on screen and the caller tries again on its own reconnect schedule.
///
/// What this cannot see is a line changed in the middle of the file
/// with the tail intact -- that is what a full reload is for.
pub fn probe(&self) -> Result<bool, ApiError> {
let Some(tail) = self.cache.tail() else {
return Ok(false);
};
// `before = seq + 1` is the newest event with seq <= the cursor, which is the
// event *at* the cursor when the server still has one there.
let page = self.api.fetch_transcript_lines(
&self.session_id,
Some(tail.seq + 1),
1,
false,
None,
)?;
let matches = page.len() == 1
&& parse_line(&tail.line)
.map(|cached| cached == page[0].1)
.unwrap_or(false);
if !matches {
self.cache.purge();
}
Ok(matches)
}
/// Today's opening fetch, kept as the start of the live run. Only
/// called when the cache has nothing to open with, or when
/// [`Self::probe`] said what it had was not the server's.
pub fn fetch_opening(&self) -> Result<Vec<SeqEvent>, ApiError> {
let page =
self.api
.fetch_transcript_lines(&self.session_id, None, OPENING_WINDOW, false, None)?;
for (line, event) in &page {
self.cache.append(line, event.seq);
}
self.cache.flush();
Ok(page.into_iter().map(|(_, event)| event).collect())
}
/// The page before `before`: from the cache when it holds it,
/// otherwise from the server bounded by what the cache already has.
///
/// The server bound (`after`) is what keeps the cache worth having. A
/// coalesced page reaches back as far as its row count takes it -- a
/// single reply is hundreds of lines -- so a page fetched after the
/// reader has been away could run straight past the cached run and
/// overlap it, and an overlapping page cannot be stored. Told where
/// this phone's copy starts, the server stops there instead.
///
/// `before == 0` answers [`OlderPage::NothingLoaded`] without asking
/// the cache or the server anything -- see AGENTS.md's "things that
/// have bitten": there is no event before the first one, so the
/// request is not a harmless no-op, and its empty answer is
/// indistinguishable from having reached the start of history.
/// Guarded here rather than left to every caller, because it is a fact
/// about the question, not about who is asking it.
pub fn page(&self, before: u64, limit: u32, coalesce: bool) -> Result<OlderPage, PageError> {
if before == 0 {
return Ok(OlderPage::NothingLoaded);
}
if let Some(lines) = self.cache.page(before, limit as usize, coalesce) {
let events: Vec<SeqEvent> = lines
.iter()
.map(|l| parse_line(l).map_err(PageError::from))
.collect::<Result<_, _>>()?;
return Ok(OlderPage::Events(events));
}
let after = self.cache.covered_up_to(before).map(|v| v - 1);
let page = self.api.fetch_transcript_lines(
&self.session_id,
Some(before),
limit,
coalesce,
after,
)?;
if let Some((_, first_event)) = page.first() {
// `before` rather than the newest line's seq: a coalesced page covers
// everything up to the cursor it was asked with, and nothing in its lines
// says so.
let lines: Vec<String> = page.iter().map(|(line, _)| line.clone()).collect();
self.cache
.store_page(&lines, first_event.seq, before, coalesce);
}
Ok(OlderPage::Events(
page.into_iter().map(|(_, event)| event).collect(),
))
}
/// [`event_stream::follow_session_events`], with every frame written to
/// the cache before `on_item` sees it.
///
/// Before, so that an event held back for a reader who is scrolled
/// away is already on disk -- what the cache holds is what the server
/// sent, not what a screen has got round to drawing. Flushed on each
/// status change, which is a turn's boundary and the granularity a
/// crash may as well lose, and once more when the stream ends.
pub fn follow(
&self,
after: u64,
mut on_item: impl FnMut(StreamItem) -> bool,
) -> Result<(), ApiError> {
let cache = &self.cache;
let result = event_stream::follow_session_events(
self.api.transport(),
&self.session_id,
after,
|item| {
if let StreamItem::Event { raw, event } = &item {
cache.append(raw, event.seq);
if matches!(event.event, event_model::Event::Status { .. }) {
cache.flush();
}
}
on_item(item)
},
);
cache.flush();
result
}
/// Leaves the cache with everything it was given -- called once a
/// caller is done with this source, mirroring the Kotlin `close`'s
/// final flush (that method's stream cancellation itself is the
/// runtime concern the module doc says is not ported here).
pub fn close(&self) {
self.cache.flush();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::{Body, RawResponse};
use std::collections::VecDeque;
use std::io::Read;
use std::sync::Mutex;
/// A transport that answers fixed bodies in call order, and records
/// every path it was asked for -- so a test can assert *how many*
/// requests a method made, which is the point for the `before == 0`
/// guard (AGENTS.md's regression: the guard must stop the request
/// before it happens, not merely tolerate the empty answer).
#[derive(Default)]
struct ScriptedTransport {
responses: Mutex<VecDeque<(u16, String)>>,
calls: Mutex<Vec<String>>,
}
impl ScriptedTransport {
fn respond(&self, status: u16, body: impl Into<String>) {
self.responses
.lock()
.unwrap()
.push_back((status, body.into()));
}
fn call_count(&self) -> usize {
self.calls.lock().unwrap().len()
}
}
impl Transport for ScriptedTransport {
fn request(
&self,
_method: &str,
path: &str,
_body: Option<Body>,
) -> Result<RawResponse, ApiError> {
self.calls.lock().unwrap().push(path.to_string());
let (status, body) = self
.responses
.lock()
.unwrap()
.pop_front()
.unwrap_or_else(|| panic!("ScriptedTransport got an unscripted request: {path}"));
Ok(RawResponse {
status,
body: body.into_bytes(),
})
}
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError> {
self.calls.lock().unwrap().push(path.to_string());
let (_, body) = self
.responses
.lock()
.unwrap()
.pop_front()
.unwrap_or_else(|| {
panic!("ScriptedTransport got an unscripted stream request: {path}")
});
Ok(Box::new(std::io::Cursor::new(body.into_bytes())))
}
}
fn source(
transport: ScriptedTransport,
cache_root: &std::path::Path,
) -> TranscriptSource<ScriptedTransport> {
let api = ApiClient::new(transport);
let cache = crate::transcript_cache::TranscriptCache::new(cache_root).session("s1");
TranscriptSource::new(api, "s1", cache)
}
fn status_line(seq: u64) -> String {
format!(r#"{{"seq":{seq},"ts":1.0,"type":"status","state":"idle"}}"#)
}
#[test]
fn a_cold_cache_has_no_opening_and_fetches_from_the_server() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{}]", status_line(1)));
let source = source(transport, dir.path());
assert_eq!(source.cached_opening(80), None);
let opening = source.fetch_opening().unwrap();
assert_eq!(opening.len(), 1);
assert_eq!(opening[0].seq, 1);
// The fetch wrote through: reopening the same cache now has something to show.
assert!(source.cache.tail().is_some());
}
#[test]
fn probe_matching_the_cached_tail_leaves_the_cache_alone() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{}]", status_line(1)));
let source = source(transport, dir.path());
source.fetch_opening().unwrap();
let transport2 = ScriptedTransport::default();
transport2.respond(200, format!("[{}]", status_line(1)));
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
assert!(source2.probe().unwrap());
assert!(source2.cache.tail().is_some());
}
#[test]
fn probe_mismatching_the_cached_tail_purges_the_cache() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{}]", status_line(1)));
let source = source(transport, dir.path());
source.fetch_opening().unwrap();
// The server now answers with a different event at the same seq -- the file
// behind this session was replaced.
let transport2 = ScriptedTransport::default();
let different = r#"{"seq":1,"ts":1.0,"type":"status","state":"running"}"#.to_string();
transport2.respond(200, format!("[{different}]"));
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
assert!(!source2.probe().unwrap());
assert!(source2.cache.tail().is_none());
}
#[test]
fn probe_finding_no_server_leaves_the_cache_untouched() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{}]", status_line(1)));
let source = source(transport, dir.path());
source.fetch_opening().unwrap();
let transport2 = ScriptedTransport::default();
transport2.respond(500, "server on fire");
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
assert!(source2.probe().is_err());
assert!(
source2.cache.tail().is_some(),
"an unreachable server must not be treated as a mismatch"
);
}
/// The regression this module exists to close: `before == 0` must
/// never reach the network or the cache, because an empty answer there
/// is indistinguishable from "there is genuinely no more history" --
/// AGENTS.md's `loadOlderPage` incident.
#[test]
fn paging_before_the_first_event_makes_no_request_at_all() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
let source = source(transport, dir.path());
assert_eq!(source.page(0, 80, true).unwrap(), OlderPage::NothingLoaded);
assert_eq!(source.api.transport().call_count(), 0);
}
#[test]
fn a_page_already_covered_by_the_cache_never_reaches_the_server() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{},{}]", status_line(1), status_line(2)));
let source = source(transport, dir.path());
source.fetch_opening().unwrap();
let calls_before = source.api.transport().call_count();
let OlderPage::Events(page) = source.page(2, 10, true).unwrap() else {
panic!("a cursor of 2 is a real question about the conversation");
};
assert_eq!(page.len(), 1);
assert_eq!(page[0].seq, 1);
assert_eq!(
source.api.transport().call_count(),
calls_before,
"a cache hit must not touch the network"
);
}
/// With nothing older cached there is no floor to give the server, so
/// the request carries no `after` at all.
#[test]
fn a_server_page_with_nothing_older_cached_carries_no_bound() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{}]", status_line(5)));
let source = source(transport, dir.path());
source.fetch_opening().unwrap();
let transport2 = ScriptedTransport::default();
transport2.respond(200, format!("[{}]", status_line(3)));
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
source2.page(5, 10, true).unwrap();
assert_eq!(
source2.api.transport().calls.lock().unwrap()[0],
"/sessions/s1/transcript?limit=10&before=5&coalesce=true"
);
}
/// The half the test above cannot show: when the cache *does* hold an
/// older run, the fetch is floored at its end, or the page would run
/// straight past it and overlap -- which `store_page` then refuses,
/// silently costing the phone the page it just paid for.
#[test]
fn a_server_page_is_floored_at_the_end_of_the_cached_run() {
let dir = tempfile::tempdir().unwrap();
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
// A stored page covering [3, 6) and two live events above it, so the run this
// phone holds is [3, 8) -- the newest chunk has to be an appended one, or the
// cache reads the directory as damaged and discards it.
let lines: Vec<String> = (3..6).map(status_line).collect();
assert!(cache.store_page(&lines, 3, 6, true));
cache.append(&status_line(6), 6);
cache.append(&status_line(7), 7);
cache.flush();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{}]", status_line(9)));
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
source.page(10, 10, true).unwrap();
assert_eq!(
source.api.transport().calls.lock().unwrap()[0],
"/sessions/s1/transcript?limit=10&before=10&coalesce=true&after=7",
"the fetch must stop one seq below where this phone's copy ends"
);
}
/// A page the server could not answer is an error, never an empty
/// page: the caller would read the second as "this conversation has no
/// more history" and stop paging for good.
#[test]
fn a_failing_server_page_is_an_error_rather_than_an_empty_one() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(500, "server on fire");
let source = source(transport, dir.path());
assert!(matches!(source.page(9, 10, true), Err(PageError::Api(_)),));
}
/// A cached line this build cannot read is told apart from the network
/// failing, for the same reason: neither is "no more history".
#[test]
fn an_unreadable_cached_page_is_a_parse_error_rather_than_an_empty_one() {
let dir = tempfile::tempdir().unwrap();
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
cache.store_page(
&[r#"{"seq":3,"but":"not an event"}"#.to_string()],
3,
4,
true,
);
cache.append(&status_line(4), 4);
cache.flush();
let transport = ScriptedTransport::default();
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
assert!(matches!(source.page(4, 10, true), Err(PageError::Parse(_)),));
assert_eq!(
source.api.transport().call_count(),
0,
"a cache hit that cannot be read must not fall through to the server unnoticed"
);
}
#[test]
fn a_bad_cached_opening_line_purges_rather_than_panicking() {
let dir = tempfile::tempdir().unwrap();
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
cache.append("not json at all", 1);
cache.flush();
let transport = ScriptedTransport::default();
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
assert_eq!(source.cached_opening(80), None);
assert!(
source.cache.tail().is_none(),
"a damaged line purges the cache"
);
}
#[test]
fn follow_writes_events_to_the_cache_before_the_caller_sees_them() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("{}\n\n", sse_frame(&status_line(1))));
let source = source(transport, dir.path());
let mut seen = Vec::new();
source
.follow(0, |item| {
if let StreamItem::Event { event, .. } = item {
seen.push(event.seq);
}
true
})
.unwrap();
assert_eq!(seen, vec![1]);
assert_eq!(source.cache.tail().unwrap().seq, 1);
}
fn sse_frame(data: &str) -> String {
format!("data:{data}")
}
}
+84 -17
View File
@@ -25,16 +25,19 @@ next (a Masonry or iris transcript screen, most likely).
| `sse.rs` | `Sse.kt` (the framing half) | Done, new tests (Kotlin had none of its own beyond integration) | | `sse.rs` | `Sse.kt` (the framing half) | Done, new tests (Kotlin had none of its own beyond integration) |
| `api.rs` | `Api.kt` | Partial -- see below | | `api.rs` | `Api.kt` | Partial -- see below |
| `event_stream.rs` | `EventStream.kt` | Done | | `event_stream.rs` | `EventStream.kt` | Done |
| `transcript_fold.rs` | `TranscriptItems.kt`, `ToolRows.kt` | Partial -- see below | | `transcript_fold.rs` | `TranscriptItems.kt`, `ToolRows.kt` | Done -- see below |
| `config.rs` | `ServerConfig.kt`'s `handleEnrollment` | New, desktop-only so far -- see below | | `config.rs` | `ServerConfig.kt`'s `handleEnrollment` | New, desktop-only so far -- see below |
| *(not started)* | `TranscriptSource.kt` | Not started | | `transcript_source.rs` | `TranscriptSource.kt` | Done -- see below |
| *(not ported, and may never be)* | `TranscriptUnits.kt` | Out of scope -- see below | | *(not ported, and may never be)* | `TranscriptUnits.kt` | Out of scope -- see below |
Every file above whose Kotlin counterpart had a JVM unit test (`AnsiTest`, Every file above whose Kotlin counterpart had a JVM unit test (`AnsiTest`,
`HighlighterTest`, `TranscriptCacheTest`) has had every one of those test `HighlighterTest`, `TranscriptCacheTest`) has had every one of those test
cases ported alongside it, plus new tests for the pieces that had none cases ported alongside it, plus new tests for the pieces that had none
(`sse.rs`, `api.rs`, `event_stream.rs`, `transcript_fold.rs`). Test count by (`sse.rs`, `api.rs`, `event_stream.rs`, `transcript_fold.rs`,
crate as of this writing: **85 in `client-core`**, 0 in `event-model` (its `transcript_source.rs` -- the Kotlin `TranscriptSource.kt`/`TranscriptItems.kt`
had no JVM unit tests of their own, so these were written fresh against the
Kotlin source and AGENTS.md's paging incidents as the spec). Test count by
crate as of this writing: **109 in `client-core`**, 0 in `event-model` (its
types carry no logic of their own to test -- `server/`'s own tests exercise types carry no logic of their own to test -- `server/`'s own tests exercise
them via `session::transcript`'s round-trip coverage). them via `session::transcript`'s round-trip coverage).
@@ -88,13 +91,27 @@ the full table to work from when one of these is next.
including tool-call/question/image attachment and peer-message placement. including tool-call/question/image attachment and peer-message placement.
`group_tool_runs` groups adjacent calls into `TranscriptRow::Tools`. `group_tool_runs` groups adjacent calls into `TranscriptRow::Tools`.
**Not ported:** `TranscriptItems.kt`'s `joinPages` (and its `join_pages` (with `heal_split_message` and `adopt_run`, both private) is
`healSplitMessage`/`adoptRun` helpers) -- the page-boundary healing that now ported too, 2026-09-06 -- the page-boundary healing that merges a tool
merges a tool call split across two fetched pages and re-merges a run a call split across two fetched pages, rejoins a message a boundary cut
boundary cut through. This matters the moment paging backward through through, and renames a run of tool calls onto whichever name is already on
history is exercised; it is deliberately left rather than rushed, since screen. Ported with AGENTS.md's "things that have bitten" incidents as the
it is exactly the kind of boundary logic this project's own "things that spec rather than a JVM test file (`TranscriptItems.kt` had none of its
have bitten" section warns reads fine and is wrong at the edges. own): `a_clean_boundary_between_two_finished_runs_is_still_healed_into_one_run`
is the regression test for the bug that shipped -- `adopt_run` must run on
*every* join, not only the one where a split call was found, or a boundary
landing cleanly between two already-finished calls (most of them) leaves
one run drawn as two. `a_call_split_across_the_boundary_merges_into_one_row`,
`a_message_split_across_the_boundary_is_rejoined_with_the_newer_halfs_identity`,
and `adopt_run_never_renames_into_a_question_row` cover the other three
edges the Kotlin doc calls out. `join_pages` ends in a `debug_assert!`
that no tool id survives in both halves -- the duplicate row it exists to
prevent, checked rather than assumed. What it deliberately does *not*
assert is seq ordering across the boundary: a peer note carries the seq
its turn began at (`place_peer_note`), which can be older than the page
it arrived in, so the two pages' seqs legitimately interleave there. An
earlier draft asserted it and would have panicked in debug builds on an
ordinary transcript.
**Known gap, and a decision for whoever closes it:** `event_model::Event` **Known gap, and a decision for whoever closes it:** `event_model::Event`
has no `Unknown`/catch-all variant, unlike `Events.kt`'s hand-kept mirror. has no `Unknown`/catch-all variant, unlike `Events.kt`'s hand-kept mirror.
@@ -119,12 +136,61 @@ caller-specific (the code rules' "ask for the least you need"). Its only
caller today is `desktop-app`; a future Android build of this crate would caller today is `desktop-app`; a future Android build of this crate would
be a second one, not a reason to move the type. be a second one, not a reason to move the type.
## What `transcript_source.rs` covers, and what it does not
`TranscriptSource<T: Transport>` is the seam a session screen asks for a
page, ported test-for-test against the Kotlin doc rather than a JVM test
file (there wasn't one): `cached_opening`, `probe`, `fetch_opening`,
`page` and `follow`, each matching its Kotlin namesake's contract --
including `probe`'s three-way outcome (matches / cache purged /
unreachable, told apart so a caller never treats "couldn't ask" as "was
wrong") and `page`'s cache-vs-server split bounded by `covered_up_to`.
Two additions beyond a literal port, both load-bearing:
- **`page(before, ..)` refuses `before == 0` before touching the cache or
the network**, answering `OlderPage::NothingLoaded`. This is AGENTS.md's
`loadOlderPage` incident (`before = 0` is "no event before the first
one," indistinguishable from "reached the start of history" if a caller
ever asks it) moved out of the Kotlin screen and into this layer, so
every future caller gets the guard rather than having to remember it.
**The return type is `OlderPage`, not a `Vec`, and that is the guard.**
The Kotlin's two falses are different answers -- `oldestSeq == 0`
returns without touching `moreHistory`, an empty page latches it false
-- so a port that answered both with an empty list would have moved the
bug rather than fixed it, one layer down and out of sight of the screen
that used to hold the check. `OlderPage::Events(vec![])` means the start
of the conversation; `OlderPage::NothingLoaded` is not an answer about
the conversation at all. Reviewed 2026-09-06.
`paging_before_the_first_event_makes_no_request_at_all` asserts zero
transport calls, not just the variant, since a request that happens to
answer empty is exactly what caused the original bug, and
`a_failing_server_page_is_an_error_rather_than_an_empty_one` plus
`an_unreadable_cached_page_is_a_parse_error_rather_than_an_empty_one`
are the same rule for the two ways a page can fail.
- **`fetch_transcript_lines`** (new in `api.rs`) hands back each line
paired with the exact server bytes it came from, via
`serde_json::value::RawValue` rather than re-serializing a parsed
`Value` -- the cache and a live SSE frame for the same event have to
agree byte-for-byte, which is exactly what the `serde_json`
float-rounding bug (AGENTS.md) was about. The existing
`fetch_transcript_page` is untouched (other callers under `iris/`
depend on its signature); the two share a `transcript_path` helper so
the query string is written in one place.
**Not ported:** `EventStream.kt`'s reconnect-with-backoff loop, and
`TranscriptSource.close`'s ability to cancel a live stream from another
thread. Both are wall-clock/thread-lifetime policy that belongs to
whichever runtime embeds this crate (iris's own timers, a Tokio task, a
Kotlin coroutine scope), not to this pure logic -- `follow` is the same
"write to the cache, then hand the frame to the caller" decorator
`iris/desktop-app/src/app.rs` and `iris/android-app/src/transcript_client.rs`
already hand-wrote around `event_stream::follow_session_events` before this
existed; the cache write moved into one shared place so a third caller
does not repeat it again by hand.
## What is not started at all ## What is not started at all
- **`TranscriptSource.kt`** -- the layer that decides whether a page comes
from the transcript cache or the server, and stitches the two. Needs
`transcript_cache.rs` and `api.rs`'s transcript-page method, both of
which exist now, so this is unblocked whenever picked up.
- **The markdown *block* model beyond syntax spans** -- `highlight/markdown.rs` - **The markdown *block* model beyond syntax spans** -- `highlight/markdown.rs`
colours a `.md` file or fence for the highlighter, but does not build the colours a `.md` file or fence for the highlighter, but does not build the
block tree (headings, lists, tables, fences as distinct nodes) that a block tree (headings, lists, tables, fences as distinct nodes) that a
@@ -142,5 +208,6 @@ be a second one, not a reason to move the type.
`./run-tests.sh` from the repo root now runs `event-model`, `client-core` `./run-tests.sh` from the repo root now runs `event-model`, `client-core`
and `server` in that order (each `cargo test`, forwarding arguments the and `server` in that order (each `cargo test`, forwarding arguments the
same way it always has). From `client-core/` directly: `cargo test`, same way it always has). From `client-core/` directly: `cargo test`
`cargo clippy --all-targets`, `cargo fmt` -- all clean as of this writing. (109 tests), `cargo clippy --all-targets`, `cargo fmt` -- all clean as of
this writing (2026-09-06).
+97
View File
@@ -8,6 +8,31 @@ 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") ## 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 `iris::widget::List` gained a real fling: `fling(velocity_px_per_s)` starts
@@ -583,3 +608,75 @@ inset bugs the emulator never showed).
Explicit `Arc`-backed value passed to the callback and kept on Explicit `Arc`-backed value passed to the callback and kept on
`AndroidRenderer`, not a global — a caller wanting one on desktop builds `AndroidRenderer`, not a global — a caller wanting one on desktop builds
its own the same way. 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.
+106 -2
View File
@@ -149,6 +149,81 @@ agent takes them without colliding with that pass's `bench_client.rs`/
confirming this was the whole story on real touch input rather than 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 only the arbiter's own unit tests -- worth a follow-up pass before
calling it fully closed. 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.
## From the phone, 2026-09-06, 11:39 (build delivered 02:07, commit 543f6d9)
Iris's report on the build with the composing-text, tap-vs-swipe and
atlas-reset fixes, with a screenshot, verbatim. Each is open until an
agent ticks it here with the evidence.
- [ ] **"The app definitely does not start with keyboard spacing
correct. This is how it looks without me doing anything initially."**
The screenshot shows the composer bar (the grey band) sitting about
two thirds of the way down a 704x1568 screen, with black below it to
the bottom, and the transcript ending at "Claude / Results" just above
it -- at launch, no keyboard. So the composer's bottom padding, which
the 2026-09-06 rebuild tied to the IME/nav-bar inset, is being fed a
large value at start on the phone. Suspects, in order: the initial
inset delivery on the phone (GrapheneOS, gesture navigation) versus
the emulator; `ime_bottom` now carrying a `1`/`0` boolean through a
field the composer may still read as pixels or dp; a stale value from
before the first `on_insets_changed`. Reproduce with the phone's
screen size and density on the emulator before guessing.
- [ ] **"Swiping still gets caught by the grey bar but keeps working
after I go past it."** A pan that starts on the composer is held by
the composer until the finger leaves its region, then the list takes
over. The tap-vs-swipe fix in `attr.rs` stops the *focus*, but the
press frames are still being handled by the field rather than passed
to the list from the first slop-crossing frame. The `DragGesture`
merge (RUST.md's plan box) should make this one mechanism: once a
gesture commits to a pan, the list captures it wherever it began.
- [ ] **"Flinging still does not work."** Expected on this build: finger
flings are dropped by per-widget hit testing, which `DragGesture`'s
pointer capture (commit `e12c708`, not yet merged at 02:07) targets.
Stays open until verified on her phone, not the emulator.
- [ ] **"Text still disappears if I leave and come back to the app."**
The `GlyphAtlas::clear`/`Textures::reset` fix was verified on the
emulator under `force-gles` only; the phone runs Vulkan. So either the
reset is not reached on the phone's path (a different surface-
lifecycle sequence -- `surface_destroyed`/`surface_created` ordering,
or the renderer not being rebuilt but its textures lost), or the CPU
glyph cache and the GPU atlas still disagree after it. Needs logging
of the renderer lifecycle on the phone build, readable from `adb
logcat` when Iris next runs it, since no emulator here has a Vulkan
adapter under host GPU.
## Build ## Build
@@ -421,8 +496,19 @@ do not duplicate it there.
## Build (asked for by Iris, 2026-09-06): a density-independent length unit ## Build (asked for by Iris, 2026-09-06): a density-independent length unit
- [ ] **A third length kind beside relative and pixels, so display scales - [x] **A third length kind beside relative and pixels, so display scales
"just work".** Iris's words: "another length type similar to absolute & "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 relative, so instead there would be relative, pixels, and another unit
like em or whatever is standard. That way different display scales like em or whatever is standard. That way different display scales
should just work." Today a length is either a fraction of the parent should just work." Today a length is either a fraction of the parent
@@ -439,3 +525,21 @@ do not duplicate it there.
and control sizes; the emulator at two densities and the phone draw the 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 same layout at the same physical size. After the bench setup is
finished, before P1 draws any new screen. 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):
+542 -7
View File
@@ -34,6 +34,87 @@ the emulator, not by Mesa" and "the present mode was not the cause" are
worth as much as the successes, because they are what stops the next worth as much as the successes, because they are what stops the next
session spending an afternoon on them again. session spending an afternoon on them again.
## Where things stand (2026-09-06, orchestrator plan)
Written by the design agent on picking the branch up after a `/clear`, so
the next session can resume from here. P0 is delivered and Iris's phone
report v2 is in (`docs/bench/iris-phone-v2-2026-09-06.md`); **P1 stays
gated on her verdict**, so this pass works the P0 defects and the pure
prerequisites in this order. Each item is ticked here by the agent that
closes it.
- [ ] **Merge the `DragGesture` work** left complete but unmerged in the
worktree branch `worktree-agent-a754368325fa06839` (commit
`e12c708`, 2026-09-06 02:10, two minutes after the last merge to
`rustify`; already contains `rustify` at `543f6d9`). It targets two
of the four bench-v2 defects: finger flings dropped by per-widget
hit testing (pointer capture + `CursorSense::Drop`), and IME insets
never redelivered (`MainActivity.java` edge-to-edge). Before
merging: build, clippy, tests; then on the emulator confirm the
three things `20b1225` (tap-vs-swipe focus) and the phone asked for
still hold together — a swipe over the composer does not summon
the keyboard, a tap does, a finger fling on the list keeps moving
after the finger lifts, and `on_insets_changed` now fires on an
IME toggle. Then remove the worktree.
- [ ] **Fix `docs/REVIEW-2026-09-06.md`** (after the merge, since the
review's finding 1 is in `selection.rs`, which the merge rewrites).
Finding 1 is a real crash — `TranscriptScreen::apply`'s `Rebuild`
arm leaves `Selection` holding `WeakWidget`s to freed rows, and the
next tap anywhere panics. Findings 25 are `debug_assert!`s on
invariants, 8 and 10 are the missing tests. Commit the review file
with the fixes.
- [ ] **Iris's 11:39 phone report on the 02:07 build** (four items,
verbatim in `IRIS_TODO.md`'s "From the phone, 2026-09-06, 11:39"):
composer floating two thirds down the screen at launch with black
below it; a swipe starting on the composer held until the finger
leaves it; no fling (expected, `DragGesture` unmerged); text still
lost on app-switch on the phone despite the emulator-verified
atlas reset. The first and last are the same class as the next
box and go to that agent; the middle two are the merge box's.
- [ ] **Stale primitives and invisible composer text** — the header drawn
twice after a keyboard resize, the `Compacted:` row drawn twice on
Iris's phone, and typed text never appearing (P0 box item 2). All
three sit on the `redraw_updates` targeted-redraw path and may be
one bug; the P0 box says the next step is instrumentation inside
`Span::draw`/`draw_inner` showing where each placement's
primitives actually land on the frame it goes wrong.
`list.rs`'s new `replacing_the_last_row_many_times_does_not_leak_
primitives` test already pins the widget arena as *not* the leak.
- [ ] **Composer touch-drag scroll** for overflowed text — now that
dragging is a default-input `DragGesture`, `Scroll` should get its
touch pan from the same mechanism `List` uses, not a copy.
- [ ] **Streaming re-layout** (IRIS_TODO.md's last section) — after the
above, since they make the stream phase unrepresentative today.
- [x] **client-core prerequisites for P1, in parallel** (pure Rust,
disjoint from `iris/`), closed 2026-09-06: `TranscriptSource`'s
cache-vs-server stitching (new `client-core/src/transcript_source.rs`)
and `joinPages`/`healSplitMessage`/`adoptRun` page-boundary healing
(new functions in `transcript_fold.rs`), per `CLIENT_CORE.md`. Ported
against the Kotlin source and AGENTS.md's paging incidents as the
spec (`TranscriptSource.kt`/`TranscriptItems.kt` had no JVM unit
tests of their own to port test-for-test). `client-core` goes from
85 to 109 tests; `cargo test`/`clippy --all-targets`/`fmt` all clean.
Both AGENTS.md regressions have a dedicated test: `loadOlderPage`'s
`before == 0` guard moved into `TranscriptSource::page` itself
(`paging_before_the_first_event_makes_no_request_at_all` asserts
zero transport calls, not just an empty result), and
`a_clean_boundary_between_two_finished_runs_is_still_healed_into_one_run`
pins `adopt_run` running on *every* join rather than only the
split-call path. One incidental fix needed to port `TranscriptSource`
faithfully: `api.rs` gained `fetch_transcript_lines` (additive, the
existing `fetch_transcript_page` untouched since `iris/` depends on
its signature), which pairs each event with the exact server bytes
it came from via `serde_json::value::RawValue` rather than
re-serializing a parsed `Value` -- needed so the cache and a live SSE
frame agree byte-for-byte, the same class of bug as the
`float_roundtrip` fix. Deliberately not ported: `EventStream.kt`'s
reconnect/backoff and cross-thread stream cancellation, which are
runtime policy for whichever framework embeds this crate, not pure
logic -- see `CLIENT_CORE.md`'s new section for the full account.
- **Then**: redeliver `~/host/bench/iris-bench-arm64.apk` for Iris with
its README saying what changed, and record any choice she should see in
`DECISIONS.md`.
## Where things stand (2026-09-05) ## Where things stand (2026-09-05)
- **Streaming no longer costs a full rebuild** (P0's box, "Streaming no - **Streaming no longer costs a full rebuild** (P0's box, "Streaming no
@@ -4438,6 +4519,129 @@ device.
apk/release/iris-bench-arm64.apk`; that repo's own README gained a apk/release/iris-bench-arm64.apk`; that repo's own README gained a
dated entry. Still not confirmed on Iris's actual phone. dated entry. Still not confirmed on Iris's actual phone.
**Redelivered again, 2026-09-06, a later pass.** Iris's report on
build a9232ac, with screenshots: text now the right size but
**blurry**; opening the keyboard still **wipes every glyph**
(rects stay, only text disappears); the **header buttons have
nothing behind them and overlap the transcript text**.
**1. The keyboard wipe.** Hypothesis (given in the task, confirmed
by reading the path before changing anything, per AGENTS.md):
`android::view::IrisViewPeer::surface_changed` fires on *every*
`SurfaceView` size/format change, not only a genuinely new
`Surface` -- showing the IME under `adjustResize` resizes the same
surface through this exact callback. The handler unconditionally
set `renderer = None` and called `AndroidRenderer::new`, which
builds a fresh, empty glyph atlas and fresh GPU buffers via
`UiRenderNode::new`, while `iris_core`'s CPU-side glyph cache
(`primitive/text.rs`) kept the atlas UV coordinates it had already
handed out against the *old* atlas -- every glyph then drew from a
rectangle pointing into a texture that had just been recreated
empty. Confirmed by reading `AndroidRenderer::resize` (already
existed, already did none of that -- only `surface.configure` and
the window uniform) against what `surface_changed` was actually
calling instead. **Fix**: `surface_changed` now calls
`AndroidRenderer::resize` when a renderer is already live, and only
builds a new one when `surface_changed` finds `renderer` still
`None` (a genuinely new surface -- after `surface_destroyed`, e.g.
backgrounding). Not independently re-verified against a forced IME
resize on this pass's emulator (no display keyboard exercised
end-to-end here); the reasoning is a direct code read plus the
existing `resize` path already being surface-only, not a
screenshot diff -- **the next agent with emulator time should do
the before/after screenshot this box originally asked for.**
**2. The blur.** Root cause: the P0 fix that made text the right
*size* (dividing the whole window into a "logical" space, then
letting the shader's NDC mapping stretch it back onto the real
framebuffer) rasterised each glyph at the small, pre-stretch size
and then displayed it stretched onto more physical pixels than it
had texels for. **Fix, and the density-independent length unit
Iris asked for the same day (IRIS_TODO.md) turned out to be the
same fix**: `Len::dp`, resolved against a `density` now carried on
`UiRenderState`/`Painter`, replaces the global stretch -- window
size, touch and insets are physical pixels throughout again
(`WindowInsets`, renamed from `LogicalInsets`), and
`TextBuffer::shape` multiplies `font_size`/`line_height` by density
before handing them to parley, so the atlas rasterises at the
display's real physical resolution. Full design in docs/LAYOUT.md's
"Density: `Len::dp`" section and the public-API summary in
docs/IRIS.md's 2026-09-06 entry.
**3. The header.** Only each button's own `rect(...)` painted
anything, so the gaps between/around them and the status-bar strip
above showed `CLEAR_COLOR` (black) one layer back, and the row's
reserved height was three `abs` (now-physical-pixel) button boxes
-- smaller than the dp-correct size the transcript below uses,
which is what read as "overlap" once the two disagreed. Fixed with
a `HEADER_SURFACE` rect stacked behind the whole row and every
header size moved onto `dp(...)`.
**4. Keyboard diagnostics, so Iris can report back even if a
keyboard-triggered regression persists.** `on_insets_changed` now
edge-triggers ~500ms after `ime_bottom` becomes non-zero, capturing
the same report the on-screen Diagnostics button produces, logging
it, copying it to the clipboard unprompted, and showing it in a new
plain-view overlay (`IrisView.showDiagnosticsOverlay`, Copy/Close)
that draws independently of iris's own renderer.
**Verified this pass**: `cargo fmt --all`, `cargo clippy --workspace
--all-targets` and `cargo clippy` on `android-app` (both `-D
warnings`, zero beyond the pre-existing `tabs-ui` unused-dependency
and wgpu future-incompat notices), `cargo test --workspace` (all
passing), `cargo ndk -t arm64-v8a check`/`clippy` for both the
`transcript-screen bench` feature set.
**Then run on this checkout's own emulator** (x86_64 debug,
`--features "transcript-screen force-gles bench"` -- this AVD has no
Vulkan adapter under a plain `-gpu host` boot, matching every prior
emulator finding in this file): `run-bench.sh` end to end, no crash,
`frames=534 janky%=79.03 ... cpu_p50=1.3ms`, 24/24 swipes, 400/400
streamed events -- unchanged in shape from prior readings, so the
diff cost nothing on the success path. **Header background**:
screenshot confirms the `HEADER_SURFACE` panel now sits behind all
three buttons (`/tmp/bench-after-run.png` this pass). **Keyboard
wipe**: forced a real `surface_changed` two ways -- `adb shell wm
size 1080x1900` (screenshot before/after, text intact) and actually
opening the soft keyboard via `settings put secure
show_ime_with_hard_keyboard 1` + tapping the message field
(ui-trace confirmed a real resize, elements moved -547px; keyboard
visible in the screenshot, text still fully rendered, not wiped).
Both are real evidence the reuse-renderer fix works, though neither
is the literal before/after diff this box originally asked for --
**still worth a deliberate side-by-side screenshot pair in a future
pass.**
**Found during this same verification, not fixed, needs a follow-up
pass**: after the keyboard-triggered resize, the top button row
appeared to render a **second time**, well below its real position,
inside the transcript's scroll area (same colours/text, unmistakably
the same three buttons) -- and a tap aimed at the composer's
"Message" field landed on "Run benchmark" instead (a second
benchmark run started, visible in logcat as two `iris bench report:`
lines from one session). Only seen after a resize with the keyboard
genuinely open; the plain `wm size` resize screenshot pair did not
show it, nor did the fresh-install screenshot before either resize.
**Not root-caused this pass** -- time ran out before isolating
whether this is the `Span::DOWN` two-phase draw (LAYOUT.md's
provisional-then-real placement) leaving a phase-1 primitive
retained somewhere it should have been moved from, something
specific to the keyboard's `on_insets_changed` rebuild racing a
redraw, or unrelated to this pass's changes entirely (not verified
against a build predating this session's commits, so do not treat
"caused by this pass" as established -- MACHINE.md's pinned rule
about not attributing without measuring applies here too). Also
noteworthy: `capture_keyboard_diagnostics` never fired in this
session (no "iris keyboard diagnostics" log line) despite the
keyboard visibly opening -- `on_insets_changed`'s `ime_bottom` may
not be populated the way expected on this emulator/API level, or
the duplicate-row state above interfered; **also needs a follow-up
pass** before relying on the auto-capture on a real phone.
**Not verified this pass**: anything on Iris's real phone, the
two-density crispness check IRIS_TODO.md's unit item asks for, and
the two open items just above.
**Fling and jitter, 2026-09-06.** The two `IRIS_TODO.md` "From the **Fling and jitter, 2026-09-06.** The two `IRIS_TODO.md` "From the
phone" items this box's own text names as follow-ups are fixed -- phone" items this box's own text names as follow-ups are fixed --
`List::fling`/`VelocityTracker`/`FlingCalculator` (IRIS.md's `List::fling`/`VelocityTracker`/`FlingCalculator` (IRIS.md's
@@ -4450,13 +4654,344 @@ device.
emulator trace** -- this pass did not open an emulator, so the emulator trace** -- this pass did not open an emulator, so the
"trace the list's offset per frame" verification this box's own "trace the list's offset per frame" verification this box's own
todo asked for is still open, as is a feel-check of the fling on todo asked for is still open, as is a feel-check of the fling on
real touch input. **Benchmark v2's four-phase spec (fling/stream/ real touch input.
type/keyboard) in `bench_client.rs` was not attempted this pass** --
wiring a real IME show/hide and refresh-rate read through **Benchmark v2, iris half, done 2026-09-06, later the same day.**
`bench_jni.rs`, and `FrameReport`'s per-phase accounting, is real `bench_client.rs` implements all four phases against the identical
scope on its own and was left rather than shipped half-verified; constants this box's "Benchmark v2" spec names: fling (8 out + 8
the Compose half above is already done and is the reference shape back at 12,000px/s through `List::fling`, waiting for
for whoever picks this up. No redelivery this pass. `!is_scrolling()` capped 3s with a 300ms pause between, travel
reported as `idx=N/off=Mpx` via a new `List::anchor_position_display`
-- note this list's anchor does not necessarily change *slot* during
a long scroll (the module's own documented design: the anchor is
named by identity, not re-derived from what's on screen), so an
iris travel reading is not apples-to-apples with Compose's
`firstVisibleItemIndex`, which does change slot -- a real difference
in what the two numbers mean, not a bug, and worth reading `off`
rather than `idx` when comparing runs), stream (unchanged), type
(the exact 600-character `TYPE_TEXT` constant, verified by a unit
test, one char per 50ms into the composer's real `TextEdit` via
`.set()` -- the same whole-string-replace shape `BenchRun.kt`'s own
`setComposerText` uses, not a per-character insert), and keyboard
(5 cycles through `bench_jni.rs`'s new `show_ime`/`hide_ime`
`InputMethodManager` calls, confirmed from `on_insets_changed`'s
real `ime_bottom` transitions via a new `ImeState` counter rather
than assumed from the JNI call succeeding).
`iris_core::render::frame_report::FrameReport` gained `mark_phase`/
`phase_stats`/`late_at_hz` (new unit tests in `frame_report.rs`):
phases are sliced by absolute frame index against a second ring
(`index_ring`) alongside the existing duration ring, and late/jank
is judged against a real Hz read from `bench_jni.rs`'s new
`refresh_rate_hz` (`View::getDisplay().getRefreshRate()`) rather
than the fixed 60Hz `JANK_THRESHOLD` every other caller still uses
-- a separate method, not a parameter on the existing one, so
nothing else in the codebase changes behaviour. `RING_CAPACITY`
4096->16384 since one full v2 run is 3,000+ frames.
**A real deadlock, found and fixed while wiring this up.** Getting
a value back out of a task spawned via `rsc.spawn_task` has no
built-in return channel (`ctx.update`'s closures are fire-and-
forget), so a new `read_from_state` helper sends the result through
an `mpsc` channel and polls for it. Its first version only worked
for the *first* call in a chain: nothing about `ctx.update` drains
itself, so unless something calls `redraw.request_redraw()` after
*this specific* enqueue, nothing ever runs the closure -- and every
call after the first relied on a stale, already-fired
`request_redraw()` from a previous step. The fix is structural:
`read_from_state` now takes the redraw handle and calls it itself,
immediately after enqueueing, every time.
**Verified end to end, this checkout's own emulator (cold `emu up`,
`force-gles`, x86_64 -- this AVD again enumerates zero Vulkan
adapters on a cold boot, matching every prior finding in this
file):**
iris bench report
per phase:
fling: 1481 frames over 53.2s
late: 158 (10.7%)
total p50 11.2ms p90 16.9ms p99 26.5ms
worst 43.3ms
stream: 401 frames over 20.8s
late: 342 (85.3%)
total p50 26.1ms p90 49.1ms p99 57.2ms
worst 61.3ms
type: 1202 frames over 63.1s
late: 89 (7.4%)
total p50 12.8ms p90 15.1ms p99 23.3ms
worst 26.7ms
keyboard: 9 frames over 9.1s
late: 2 (22.2%)
total p50 6.3ms p90 25.8ms p99 25.8ms
worst 25.8ms
frames:
3093 frames over 146.3s at 60Hz (16.7ms budget)
late: 591 (19.1%)
total p50 12.4ms p90 22.2ms p99 51.2ms
worst 61.3ms
cpu_p50 0.7ms gpu_wait_p50 11.6ms
bench:
fling: 8 flings out + 8 back at 12000px/s, travel start=idx=651/off=1336px outward=idx=651/off=101672px end=idx=651/off=1427px
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: 43303ms
peak RSS: 193152kB
battery current: mean 900000µA over 146 samples (min 900000, max 900000)
Read this the same way every prior emulator smoke run in this box
is read: software rasterisation, not a phone number, and the
battery line is the emulator's fixed mocked-charger constant again.
**Travel**: the `idx` stays fixed at 651 through the whole fling in
both directions (see the `anchor_position_display` caveat above) --
`off` is what actually moved, growing to 101,672px outward before
the return trip brings it back near its start, which is real, large
motion (a fast, hard fling, matching Iris's "travel way faster"
ask), just not directly comparable to Compose's idx-188-reached
reading from the same box's earlier v2 entry. **`keyboard: could
not be shown`**: expected given the ime-inset finding below, not a
new regression.
Redelivered: `./build-apk.sh release --abi arm64-v8a --features
"transcript-screen bench"` (Vulkan, no `force-gles`; the x86_64
jniLibs slice left over from emulator testing was removed first so
the delivered APK is arm64-only, confirmed via `aapt2 dump
badging`), `apksigner verify` shows the same `CN=ai-app` cert,
copied to `~/host/bench/iris-bench-arm64.apk` and `~/repos/
ai-app-bench/iris/build/outputs/apk/release/iris-bench-arm64.apk`;
that repo's own README gained a dated entry. `run-bench.sh`
extended for the longer run (260s poll cap, `-A 60` instead of
`-A 6`) to fit v2's four phases.
**(a) The header-duplicate bug (found by a concurrent pass on this
branch): investigated, not fixed.** Reproduced reliably
(`ui-trace record --do "tap 'Message'"` then `adb exec-out
screencap`): the three-button row renders a second, full copy
inside the transcript area the moment the keyboard opens. Read
`Span::draw`'s own two-phase placement doc (a provisional
full-region draw to learn each child's size, then a real
`widget_within` placement) as the most likely mechanism, since it
is the one place in this tree that deliberately draws a widget
twice in normal operation and relies on the two draws landing at
the same place to stay a cheap move rather than a visible second
copy -- and `UiRenderState::update`'s `redraw_all`-vs-
`redraw_updates` split (LAYOUT.md) means a `.set()`-driven targeted
redraw of just `top_bar` and a resize-driven full redraw of the
whole tree are two structurally different code paths that could in
principle disagree about where that widget's primitives belong on a
frame where both fire close together. **One concrete, testable
hypothesis was ruled out**: `on_insets_changed` rebuilding
`top_bar` on every call, including ones only about `ime_bottom`
(nothing to do with the header's own padding). Added a guard
(`last_top_pad`, skips the rebuild unless `insets.top` itself
changed) and reproduced the *exact same* duplicate afterward --
unchanged, byte-for-byte, in the same screenshot -- so repeated
rebuilding is not the cause; the guard is kept anyway since it is a
real (if here insufficient) reduction in needless work. **Not
root-caused**: doing so needs either instrumentation inside
`Span::draw`/`draw_inner` to see the two placements' actual regions
on the frame the bug happens, or the phone. Left for a follow-up
pass rather than guessed at further.
**(b) Why the keyboard phase and the keyboard-open auto-diagnostics
both read "not confirmed": a real, named platform interaction,
partly fixed.** `MainActivity.java`'s manifest declares
`windowSoftInputMode="adjustResize"` (AGENTS.md's own "Things that
have bitten": without it the keyboard pans the window off screen
instead of resizing it). Under `adjustResize`, `WindowInsets.
Type.ime()`'s own inset *amount* is defined to read zero once the
window has already resized to avoid the overlap that inset would
otherwise describe -- confirmed by reading Android's own
`WindowInsets` contract, not guessed at. So the numeric `ime_bottom`
this app was reading is *structurally* never going to be positive
here, independent of anything wrong in `iris`'s own code -- the same
trap AGENTS.md already names for the Compose side
(`WindowInsets.isImeVisible` "does not share the failure mode").
**Fixed**: `MainActivity.java`'s `OnApplyWindowInsetsListener` now
reads `insets.isVisible(WindowInsets.Type.ime())` (a boolean,
unaffected by resize-vs-pan) and passes `1`/`0` through the
existing `ime_bottom` JNI field instead of the always-zero numeric
inset -- correct on its own terms, and kept, but **did not by
itself make the keyboard phase or the auto-diagnostics fire on this
emulator**: `logcat` shows the platform's own `InsetsController:
show(ime(), fromIme=false)`/window-resize events happening (the
keyboard genuinely opens, confirmed by screenshot), but no further
`setOnApplyWindowInsetsListener` callback at all after the initial
one at attach. Named hypothesis, not confirmed: a plain (non-edge-
to-edge) `Activity` that has not called `WindowCompat.
setDecorFitsSystemWindows(window, false)` may not get insets
redelivered for a pure IME toggle handled entirely via resize --
only the initial attach-time dispatch is guaranteed. Confirming and
fixing that needs opting the activity into edge-to-edge, which is a
real window-behaviour change interacting with the exact
`adjustResize` setting AGENTS.md protects, not attempted this pass
given the risk-to-time-remaining ratio. Both open items are
recorded in `~/repos/ai-app-bench`'s README with today's date.
**Composing text, the tap-vs-swipe focus rule, and app-switch text
loss, 2026-09-06.** Iris's report on this same dc01f88 build: typing
doesn't enter text or move the caret until a space is hit; typed
text doesn't visibly appear and there is empty black space below the
composer bar; text disappears again after leaving and returning to
the app; and (a follow-up message the same day) swiping over the
composer bar wrongly summons the keyboard.
1. **The caret/composing bug's cause**: `android/ime.rs`'s
`InputConnection` never called `InputMethodManager.updateSelection`
after an edit -- confirmed by reading android-view's own demo
(`~/src/android-view/demo/src/lib.rs`'s `render()`), which calls it
every time its editor's generation changes. Without it, Gboard has
no confirmation the app is keeping up and holds keystrokes back
rather than trusting a screen it believes is stale -- exactly
"doesn't enter it until I hit space." **Fix**: `IrisViewPeer::
update_ime_selection` (new, `ime.rs`) reports the real selection
and (an approximation, `compose_len` chars back from the caret)
the composing region, called from `after_input`'s existing tail so
every touch/key/IME callback already runs it. The buffer-level
half (`replace`/`insert_str` correctly advancing the caret) was
already correct and is now covered by four new unit tests in
`iris/src/widget/text/edit.rs` (composing, `commitText`,
`deleteSurroundingText`, `setSelection`). **Verified**: on the
emulator (`force-gles`, no Vulkan adapter on this AVD), tapping a
real Gboard key now shows a real, single-character-appropriate
suggestion strip ("H | How | Hey") rather than stale state, and a
`render()` log line fires for every keystroke -- both confirm the
`InputConnection` calls are landing and are being processed, which
a hand-typed `adb shell input text` did *not* reliably exercise on
this AVD (no `render()` at all followed one such call -- most
likely a modern `input text` no longer round-trips through
`commitText` the way older docs assume; Gboard-key taps are the
real path and the one this fix was verified against).
2. **A second, deeper bug found while verifying (1), not root-caused
this pass**: composed text never becomes visible on screen at
all -- the grey composer bar stays empty, with no glyph anywhere
in the frame, confirmed on repeated Gboard-key taps and across a
keyboard-resize. **Ruled out**: the widget tree's own layout math.
A new unit test, `layout_tests::
composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region`,
builds the composer's exact tree shape (`Stack{rect, Span{Pad{
TextEdit}}}` inside an outer `Span::DOWN`) with no GPU or window,
resizes it the way a real keyboard-triggered `surface_changed`
does, edits the field both before and after, and asserts the
field's `window_region` stays a small box near the bottom of
whichever window size is current -- it passes, both before and
after this pass's composer rebuild (item 3 below), so the CPU-side
region a redraw lands at is provably correct. The bug is
therefore downstream of that -- most likely something specific to
the GPU-side redraw a content-only edit takes (`UiRenderState::
redraw`, which redraws a single dirtied widget directly at its
stored region rather than re-running its ancestors' layout) or to
this AVD's forced `force-gles` backend (the only one available
here; Iris's phone deliveries have used real Vulkan) -- neither
isolated this pass. **Not attributable to this pass's changes**:
reproduced identically before touching `composer.rs` (the very
first build tested, before the composer rebuild below, already
had it) and the render-engine files this pass did not touch
(`core/src/render/mod.rs`, `core/src/ui/render_state.rs`) are the
likely next place to look -- specifically `UiRenderState::redraw`'s
reuse of a widget's own last-drawn region versus a full tree walk.
**Needs**: either a Vulkan-capable emulator boot or the real phone
to rule `force-gles` in or out, and a GPU-side primitive dump
(the existing `frame diagnostics` log line, extended to name which
primitives a frame actually wrote) to see whether the glyph quads
are emitted at all or emitted somewhere off-screen.
3. **The composer bar rebuilt as one widget**, per this box's own
ask: `transcript_ui::composer::build_composer` (unchanged
`Stack{background, Span{Pad{TextEdit}}}` idiom, the same one the
header row's `HEADER_SURFACE` already uses) now also caps the
field at roughly six lines (`MaxSize` + `.scrollable()` for a
wheel/trackpad overflow scroll -- a real touch-drag scroll on
overflowing composer text is not wired and is a follow-up) and
wraps the whole bar in one `Pad` whose `bottom` a new
`Composer::set_bottom_inset(rsc, inset)` rewrites in place
whenever the platform's insets change, called from
`bench_client.rs`'s existing `on_insets_changed` with
`insets.bottom.max(insets.ime_bottom)` -- the IME's own inset
while it is open, the navigation bar's otherwise. Rewritten in
place rather than rebuilt through a `WidgetPtr` swap (`top_bar`'s
own pattern) because the field is strongly owned inside this tree
and cannot be re-added to a new wrapper without panicking
("was already added") -- rebuilding would also drop focus,
selection and in-progress text on every keyboard toggle.
**Verified**: `ui-trace` box readouts before/after a keyboard
open on the emulator (the field's row correctly reports a
547px move matching the real IME-triggered resize); the
known-separate "top row renders twice after a keyboard resize"
bug this box already recorded is unrelated and still open. **Not
fixed by this alone**: item 2 above -- the text still does not
render, so the "empty space at the bottom" symptom's other half
(nothing filling the space the bar itself now correctly reserves)
needs item 2's fix first before a real before/after screenshot is
worth taking.
4. **App-switch text loss, fixed and verified.** `surface_destroyed`
(backgrounding) drops the whole `AndroidRenderer` -- device,
atlas, buffers -- and a subsequent `surface_changed` with no live
renderer builds a genuinely new one (`AndroidRenderer::new`,
distinct from the keyboard-resize path this box already fixed by
*reusing* the renderer). But `iris_core::TextData::atlas` (the
CPU-side glyph cache) and `UiData::textures` (the CPU-side texture
bookkeeping the atlas is built on) live on `AndroidRsc`, which
outlives any one `AndroidRenderer` -- so both kept pointing at the
*old*, now-destroyed device's textures across the switch, the
exact "rectangles stay, glyphs disappear" shape, just triggered by
backgrounding instead of the keyboard. **Fix**: new
`GlyphAtlas::clear()` and `Textures::reset()` (`iris/core/src/
render/atlas.rs`, `iris/core/src/primitive/texture.rs`), called
together from `surface_changed`'s "genuinely new renderer" branch
only -- the same `already_live` check that already decides
reuse-vs-new, so this is one mechanism gated on the one condition
that needs it, not a second ad hoc check. **Verified on the
emulator**: backgrounded via `KEYCODE_HOME`, reopened via
`am start`, screenshotted -- every pre-existing glyph (headings,
body text, the whole diagnostics report) is intact, `frame_count`
resets to 1 confirming a genuinely new renderer was built, no
crash.
5. **Swipe-vs-tap focus, fixed and verified** (Iris's follow-up the
same day: "if I swipe over the input bar it brings up the
keyboard... scrolling should be pinned"). `attr.rs`'s `Selector`/
`Selectable` registered `CursorSense::click_or_drag()`, which
calls `select()` -- and so grants focus and requests the IME --
on the *first* frame of any press, before it is known whether the
gesture will end up a tap or a drag. Rewritten around a shared
`on_press` dispatcher over `PressStart`/`Pressing`/`PressEnd`: a
field that is **already** focused behaves exactly as before
(every frame updates the selection, so dragging inside a focused
field to select text still works); a field that is **not**
focused records where the press began (`TextEdit::press_origin`,
new field) and only grants focus on `PressEnd` if no intervening
frame crossed `sense::DRAG_SLOP` -- a drag recognised early simply
clears the pending tap and does nothing further, so it is never
consumed and whatever is behind the field still sees every frame
of it. New `FocusHost::is_focused` (both platform impls) is what
lets `on_press` tell the two cases apart. **Verified on the
emulator**: `dumpsys input_method`'s `mInputShown` reads `false`
after a `swipe` gesture starting on the composer bar (`ui-trace`
confirms the field's own box never moved, i.e. no keyboard-driven
resize happened), and reads `true` after an ordinary `tap` on the
same field. **Coordination note**: a concurrent pass is moving
drag arbitration into `sense.rs` behind a new `Drop` event: this
fix touches only `attr.rs` (new `press_track`/`on_press`) and
`iris/src/widget/text/edit.rs` (the new `press_origin` field), not
`sense.rs` itself, so it should merge cleanly, but the next agent
through here should check whether `Selector`/`Selectable`'s
`Pressing`-frame delivery still arrives the way this code assumes
once that lands.
**Checks this pass**: `cargo fmt --all` clean, `cargo clippy
--workspace --all-targets` and `cargo ndk -t x86_64 -P 26 clippy
--features "transcript-screen bench force-gles"` both zero warnings
beyond the pre-existing `tabs-ui` unused-dependency notice, `cargo
test --workspace` all passing (new tests: four in `edit.rs`, one in
`layout_tests.rs`). **Not done**: item 2's root cause; a real
before/after screenshot pair for item 3 (blocked on item 2); anything
on Vulkan or the real phone.
- [ ] **P1 — session screen parity.** History paging backward (with the - [ ] **P1 — session screen parity.** History paging backward (with the
page-boundary healing `client-core` does not have yet, below), page-boundary healing `client-core` does not have yet, below),
+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)
```
@@ -1,8 +1,15 @@
package dev.iris.android.demo; package dev.iris.android.demo;
import android.app.Activity; 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.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.ScrollView;
import android.widget.TextView; import android.widget.TextView;
@@ -68,4 +75,81 @@ public final class IrisView extends RustView {
scroll.addView(text); scroll.addView(text);
activity.setContentView(scroll); activity.setContentView(scroll);
} }
private static final String DIAGNOSTICS_OVERLAY_TAG = "iris-diagnostics-overlay";
/**
* The bench build's keyboard diagnostics capture
* (`bench_client.rs`'s `on_insets_changed` /
* `capture_keyboard_diagnostics`, via `bench_jni.rs`'s
* `PlatformHandle::show_diagnostics_overlay`): unlike
* `showRendererError` above, this adds a panel *over* this view
* (`MainActivity`'s `FrameLayout` still holds `IrisView` underneath,
* running) rather than replacing the activity's content, and gives it
* a Copy button and a Close that removes the panel -- so it draws
* (and can be read) whether or not iris itself is still putting
* anything on screen, without abandoning the session that produced
* it. Runs on the UI thread regardless of which thread calls it,
* since the call comes from a background task (a delayed capture
* after the keyboard opens), and touching the view tree off the UI
* thread is undefined.
*/
void showDiagnosticsOverlay(String report) {
Context context = getContext();
if (!(context instanceof Activity)) {
return;
}
Activity activity = (Activity) context;
activity.runOnUiThread(() -> {
ViewGroup parent = (ViewGroup) getParent();
if (parent == null) {
return;
}
View existing = parent.findViewWithTag(DIAGNOSTICS_OVERLAY_TAG);
if (existing != null) {
parent.removeView(existing);
}
float density = activity.getResources().getDisplayMetrics().density;
int pad = (int) (16 * density);
LinearLayout overlay = new LinearLayout(activity);
overlay.setTag(DIAGNOSTICS_OVERLAY_TAG);
overlay.setOrientation(LinearLayout.VERTICAL);
overlay.setBackgroundColor(0xEE000000);
overlay.setPadding(pad, pad, pad, pad);
TextView text = new TextView(activity);
text.setText(report);
text.setTextIsSelectable(true);
text.setTextColor(0xFFFFFFFF);
ScrollView scroll = new ScrollView(activity);
scroll.addView(text);
overlay.addView(scroll, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f));
LinearLayout buttonRow = new LinearLayout(activity);
buttonRow.setOrientation(LinearLayout.HORIZONTAL);
buttonRow.setPadding(0, pad, 0, 0);
Button copy = new Button(activity);
copy.setText("Copy");
copy.setOnClickListener(v -> {
ClipboardManager clipboard =
(ClipboardManager) activity.getSystemService(Context.CLIPBOARD_SERVICE);
if (clipboard != null) {
clipboard.setPrimaryClip(ClipData.newPlainText("iris diagnostics", report));
}
});
Button close = new Button(activity);
close.setText("Close");
close.setOnClickListener(v -> parent.removeView(overlay));
buttonRow.addView(copy);
buttonRow.addView(close);
overlay.addView(buttonRow);
parent.addView(overlay, new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
});
}
} }
@@ -31,14 +31,52 @@ public final class MainActivity extends Activity {
setContentView(layout); setContentView(layout);
view.requestFocus(); view.requestFocus();
// RUST.md's P0 box, defect 4 ("keyboard: could not be shown"):
// `logcat` showed the platform's own IME open/resize happening
// while `setOnApplyWindowInsetsListener` fired only once, at
// attach, and never again for a pure keyboard toggle -- a plain
// (non-edge-to-edge) window is only guaranteed that one initial
// dispatch; `adjustResize` handling the IME entirely by resizing
// the window is not itself a trigger for a fresh one. Opting into
// edge-to-edge (a platform call, API 30+, no new dependency) is
// what makes the system redeliver insets on every change,
// including the ones this activity actually cares about --
// `getSystemWindowInset*` below is unaffected by this (it has
// always reported the raw system-bar/IME overlap regardless of
// who consumes it), so the on-screen bars and the padding Rust
// already derives from those four numbers are unchanged; only the
// callback's firing became reliable.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
getWindow().setDecorFitsSystemWindows(false);
}
view.setOnApplyWindowInsetsListener((v, insets) -> { view.setOnApplyWindowInsetsListener((v, insets) -> {
int left = insets.getSystemWindowInsetLeft(); int left = insets.getSystemWindowInsetLeft();
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;
+10 -5
View File
@@ -46,10 +46,13 @@ adb -s "$SERIAL" shell am start -n "$PKG/dev.iris.android.demo.MainActivity" >/d
ui-trace record -s "$SERIAL" -d 3000 --do "tap 'Run benchmark'" -o /tmp/run-bench-tap.txt >/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 # Poll for the report line rather than a fixed sleep -- the run itself is
# fixed script (24 swipes + a 20s streaming phase) but device speed varies. # 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 i=0
while [ "$i" -lt 90 ]; do while [ "$i" -lt 260 ]; do
LINE=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null | grep "iris bench report:" || true) LINE=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null | grep "iris bench report:" || true)
if [ -n "$LINE" ]; then if [ -n "$LINE" ]; then
break break
@@ -58,7 +61,9 @@ while [ "$i" -lt 90 ]; do
sleep 1 sleep 1
done done
if [ -z "$LINE" ]; then if [ -z "$LINE" ]; then
echo "run-bench.sh: no report after 90s -- check logcat by hand" >&2 echo "run-bench.sh: no report after 260s -- check logcat by hand" >&2
exit 1 exit 1
fi fi
adb -s "$SERIAL" logcat -d -s iris-android-app:I | grep -A 6 "iris bench report:" # -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:"
+588 -97
View File
@@ -26,9 +26,9 @@ use client_core::transcript_fold::{TranscriptItem, fold_event, fold_page, group_
use event_model::SeqEvent; use event_model::SeqEvent;
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState}; use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
use iris::prelude::*; use iris::prelude::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration; use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
/// bench-fixture/README.md: the first `BACKLOG_COUNT` non-blank lines are /// 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 /// the opening window; the rest are the streaming tail. Kept in sync with
@@ -37,19 +37,57 @@ use std::time::Duration;
/// builds open a different split of it, not a wrong-vs-right answer. /// builds open a different split of it, not a wrong-vs-right answer.
const BACKLOG_COUNT: usize = 3200; const BACKLOG_COUNT: usize = 3200;
/// `BenchRun.kt`'s own constants -- kept identical so the two apps' bench /// RUST.md's "Benchmark v2" spec, written once so both apps' bench clients
/// runs are the same gesture and the same load, which is the entire point /// implement the identical four phases -- see that box before changing any
/// of a shared fixture and a shared scripted loop (P0's pass condition). /// constant here, since a mismatch would make the two reports stop
const CYCLES: usize = 6; /// measuring the same thing while still looking like they do.
const SWIPE_PX: f32 = 900.0;
const SWIPE_MS: u64 = 200;
const SWIPE_PAUSE_MS: u64 = 500;
const STREAM_EVENTS_PER_SEC: u64 = 20; const STREAM_EVENTS_PER_SEC: u64 = 20;
const STREAM_SECONDS: 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 /// One animation step's target cadence -- close enough to 60Hz that a
/// `List::scroll` swipe is many small moves rather than one jump, so /// fling/scroll is many small moves rather than one jump, so frames are
/// frames are actually rendered along the way (the point of animating it /// actually rendered along the way, and close enough that a `ctx.update`
/// at all rather than calling `scroll` once per swipe). /// 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 ANIM_STEP_MS: u64 = 16;
const FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl"); const FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl");
@@ -74,6 +112,35 @@ pub struct BenchClient {
platform: Option<Arc<PlatformHandle>>, platform: Option<Arc<PlatformHandle>>,
last_report: Option<String>, last_report: Option<String>,
running: bool, 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 { impl HasAndroidUiState for BenchClient {
@@ -184,7 +251,7 @@ impl AndroidAppState for BenchClient {
let tree = ( let tree = (
top_bar, top_bar,
content.height(rest(2)), content.height(rest(2)),
report_display.height(rest(1)).pad(8), report_display.height(rest(1)).pad(dp(8)),
) )
.span(Dir::DOWN) .span(Dir::DOWN)
.add_strong(rsc) .add_strong(rsc)
@@ -219,6 +286,9 @@ impl AndroidAppState for BenchClient {
platform: None, platform: None,
last_report: None, last_report: None,
running: false, 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(); let (backlog, stream_tail) = parse_fixture();
@@ -245,20 +315,134 @@ impl AndroidAppState for BenchClient {
/// Pads the top button row by the status-bar inset -- see `top_bar`'s /// Pads the top button row by the status-bar inset -- see `top_bar`'s
/// field comment. Rebuilds the row rather than mutating a stored /// field comment. Rebuilds the row rather than mutating a stored
/// `Padding` in place, since nothing here holds a handle to one. /// `Padding` in place, since nothing here holds a handle to one --
fn on_insets_changed(&mut self, rsc: &mut AndroidRsc<Self>, insets: iris::android::LogicalInsets) { /// 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); let controls = bench_controls(rsc, insets.top);
(self.top_bar)(rsc).set(controls); (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>; type Rsc = AndroidRsc<BenchClient>;
/// `top_pad` is the status-bar inset in logical units (0.0 until /// 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 /// `on_insets_changed` has run once) -- folded in here, rather than
/// exposing the unadded builder for a caller to `.pad()` itself, because /// 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 /// naming that builder's type at each call site is more machinery than a
/// top-of-screen padding number is worth. /// 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 { fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
let run_rect = rect(Color::rgb(40, 70, 40)) let run_rect = rect(Color::rgb(40, 70, 40))
.on( .on(
@@ -273,7 +457,7 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
wtext("Run benchmark").size(18).text_align(Align::CENTER), wtext("Run benchmark").size(18).text_align(Align::CENTER),
) )
.stack() .stack()
.pad(8) .pad(dp(8))
.add(rsc); .add(rsc);
let copy_rect = rect(Color::rgb(50, 50, 60)) let copy_rect = rect(Color::rgb(50, 50, 60))
@@ -289,7 +473,7 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
wtext("Copy report").size(18).text_align(Align::CENTER), wtext("Copy report").size(18).text_align(Align::CENTER),
) )
.stack() .stack()
.pad(8) .pad(dp(8))
.add(rsc); .add(rsc);
let diag_rect = rect(Color::rgb(60, 45, 70)) let diag_rect = rect(Color::rgb(60, 45, 70))
@@ -305,12 +489,14 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
wtext("Diagnostics").size(18).text_align(Align::CENTER), wtext("Diagnostics").size(18).text_align(Align::CENTER),
) )
.stack() .stack()
.pad(8) .pad(dp(8))
.add(rsc); .add(rsc);
(run, copy, diagnostics) let buttons = (run, copy, diagnostics).span(Dir::RIGHT).add(rsc);
.span(Dir::RIGHT)
.height(56) (rect(HEADER_SURFACE), buttons)
.stack()
.height(dp(56))
.pad(Padding::top(top_pad)) .pad(Padding::top(top_pad))
.add_strong(rsc) .add_strong(rsc)
.any() .any()
@@ -350,6 +536,36 @@ impl BenchClient {
self.last_report = Some(report); self.last_report = Some(report);
} }
/// The keyboard's own diagnostics capture -- see `on_insets_changed`'s
/// doc comment. Reuses `show_diagnostics`'s exact report (so it is the
/// same text the on-screen `Diagnostics` button produces, plus the
/// per-frame log `FrameReport` already keeps around the resize --
/// `frame_report.report()` above covers "the frames around the
/// resize" without a second accounting mechanism), then does three
/// things the button does not: logs it (so a `logcat` pull gets it
/// even if nothing on screen does), copies it to the clipboard
/// unprompted, and shows it in the shell's plain overlay view, which
/// draws independently of iris's own renderer -- the whole point,
/// since the renderer is exactly what might be in the wiped state
/// this exists to report on.
fn capture_keyboard_diagnostics(&mut self, rsc: &mut Rsc) {
self.show_diagnostics(rsc);
let Some(report) = self.last_report.clone() else {
return;
};
log::info!("iris keyboard diagnostics:\n{report}");
let Some(platform) = &self.platform else {
log::info!("iris keyboard diagnostics: no platform handle, can't reach the shell");
return;
};
if platform.copy_to_clipboard("iris keyboard diagnostics", &report) {
log::info!("iris keyboard diagnostics: copied to clipboard");
} else {
log::info!("iris keyboard diagnostics: clipboard copy failed");
}
platform.show_diagnostics_overlay(&report);
}
fn copy_report(&mut self) { fn copy_report(&mut self) {
let Some(report) = &self.last_report else { let Some(report) = &self.last_report else {
log::info!("iris bench report: nothing to copy -- run the benchmark first"); log::info!("iris bench report: nothing to copy -- run the benchmark first");
@@ -366,10 +582,10 @@ impl BenchClient {
} }
} }
/// P0's scripted run: `BenchRun.kt`'s scroll loop, then its streaming /// RUST.md's "Benchmark v2": fling, then stream (unchanged from v1),
/// phase, then the report -- run in-process for the same reason that /// then type, then keyboard, then the report -- run in-process for the
/// file's own doc gives (no usable system tracing on a real phone, no /// same reason `BenchRun.kt`'s own doc gives (no usable system tracing
/// agent that can drive one). /// on a real phone, no agent that can drive one).
fn start_benchmark(&mut self, rsc: &mut Rsc) { fn start_benchmark(&mut self, rsc: &mut Rsc) {
if self.running { if self.running {
log::info!("iris bench report: already running"); log::info!("iris bench report: already running");
@@ -382,35 +598,21 @@ impl BenchClient {
let redraw = rsc.tasks.redraw_handle(); let redraw = rsc.tasks.redraw_handle();
let platform = self.platform.clone(); let platform = self.platform.clone();
let stream_tail = self.stream_tail.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 cpu_start = process_cpu_ms();
let run_started_at = Instant::now();
rsc.spawn_task(async move |mut ctx| { rsc.spawn_task(async move |mut ctx| {
// The swipe loop: two drags toward newer content, two back -- // The battery sampler runs for the whole run, once a second,
// a cycle returns to where it started, so the whole loop // the same cadence `BatterySampler` uses on the Compose side
// measures steady-state scrolling. `BenchRun.kt`'s own // -- via its own JNI-attached thread, not `ctx.update`, since
// comment on this shape. // a sample needs no widget-tree access.
for _ in 0..CYCLES {
for delta in [SWIPE_PX, SWIPE_PX, -SWIPE_PX, -SWIPE_PX] {
animate_scroll(&mut ctx, &redraw, delta, SWIPE_MS).await;
tokio::time::sleep(Duration::from_millis(SWIPE_PAUSE_MS)).await;
}
}
// Pinned to the newest end before streaming starts, matching
// `stream-bench.sh`'s "Jump to latest" tap.
ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).jump_to_end();
}
});
redraw.request_redraw();
// The battery sampler runs concurrently with the streaming
// phase, once a second, the same cadence `BatterySampler` uses
// on the Compose side -- via its own JNI-attached thread, not
// `ctx.update`, since a sample needs no widget-tree access.
let sampler_done = Arc::new(AtomicBool::new(false)); let sampler_done = Arc::new(AtomicBool::new(false));
let samples = Arc::new(std::sync::Mutex::new(Vec::<i32>::new())); let samples = Arc::new(Mutex::new(Vec::<i32>::new()));
let sampler = platform.clone().map(|platform| { let sampler = platform.clone().map(|platform| {
let done = sampler_done.clone(); let done = sampler_done.clone();
let samples = samples.clone(); let samples = samples.clone();
@@ -424,27 +626,10 @@ impl BenchClient {
}) })
}); });
let total = (STREAM_EVENTS_PER_SEC * STREAM_SECONDS) as usize; let travel = run_fling_phase(&mut ctx, &redraw).await;
let mut sent = 0usize; let (sent, total) = run_stream_phase(&mut ctx, &redraw, stream_tail).await;
for event in stream_tail.into_iter().take(total) { run_type_phase(&mut ctx, &redraw, &platform).await;
ctx.update(move |state: &mut BenchClient, rsc| { let keyboard = run_keyboard_phase(&mut ctx, &platform, &ime_state).await;
let old_items = state.items.clone();
state.items = fold_event(&state.items, &event);
match &state.screen {
// The path P0 asked to measure: update only the
// row(s) that changed instead of rebuilding all
// ~3,200 of them per event.
Some(screen) => screen.apply(rsc, &old_items, &state.items),
None => state.rebuild_transcript(rsc),
}
});
redraw.request_redraw();
sent += 1;
tokio::time::sleep(Duration::from_millis(1000 / STREAM_EVENTS_PER_SEC)).await;
}
// Lets the last few deltas land and draw before the report is
// read -- `BenchRun.kt`'s own closing delay.
tokio::time::sleep(Duration::from_millis(300)).await;
sampler_done.store(true, Ordering::Relaxed); sampler_done.store(true, Ordering::Relaxed);
if let Some(sampler) = sampler { if let Some(sampler) = sampler {
@@ -453,7 +638,10 @@ impl BenchClient {
let battery = battery_line(&samples.lock().unwrap()); let battery = battery_line(&samples.lock().unwrap());
let cpu_line = match (cpu_start, process_cpu_ms()) { let cpu_line = match (cpu_start, process_cpu_ms()) {
(Some(start), Some(end)) => { (Some(start), Some(end)) => {
format!(" process CPU time over this run: {}ms", end.saturating_sub(start)) format!(
" process CPU time over this run: {}ms",
end.saturating_sub(start)
)
} }
_ => " process CPU time over this run: unavailable".to_string(), _ => " process CPU time over this run: unavailable".to_string(),
}; };
@@ -461,19 +649,61 @@ impl BenchClient {
Some(kb) => format!(" peak RSS: {kb}kB"), Some(kb) => format!(" peak RSS: {kb}kB"),
None => " peak RSS: unavailable (/proc/self/status unreadable)".to_string(), 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| { ctx.update(move |state: &mut BenchClient, rsc| {
state.running = false; state.running = false;
let scroll_line = format!( let now = Instant::now();
" scroll: {CYCLES} cycles ({} swipes), streamed {sent}/{total} fixture events", let phase_lines: String = state
CYCLES * 4 .android_state()
); .frame_report
let frames_line = match state.android_state().frame_report.report() { .phase_stats(now, refresh_hz)
Some(stats) => format!("{stats}"), .iter()
None => "no frames recorded".to_string(), .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!( let report = format!(
"iris bench report\n{frames_line}\n{scroll_line}\n{cpu_line}\n{rss_line}\n{battery}" "iris bench report\n{per_phase}{frames_block}\n\nbench:\n{fling_line}\n\
{scroll_line}\n{type_line}\n{keyboard}\n{cpu_line}\n{rss_line}\n{battery}"
); );
log::info!("iris bench report: {report}"); log::info!("iris bench report: {report}");
state.report_display.edit(rsc).set(&report); state.report_display.edit(rsc).set(&report);
@@ -484,26 +714,287 @@ impl BenchClient {
} }
} }
/// Moves `List::scroll` by `total_px` over `duration_ms`, in ~60Hz steps, /// Runs `f` against the real `BenchClient`/`Rsc` on the main thread (the
/// so the swipe is many rendered frames rather than one jump -- the same /// same `ctx.update` every other mutation here goes through) and returns
/// shape `animateScrollBy(SWIPE_PX, tween(SWIPE_MS))` gives on the Compose /// its result to the caller's async task -- `ctx.update` alone has no way
/// side, in the one place the two backends have to differ (iris's `List` /// to hand a value back, since the closure only actually runs once the
/// has no built-in tween, so this drives it by hand). /// next frame callback drains `IrisViewPeer`'s task channel
async fn animate_scroll( /// (`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>, ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn iris::task::RequestRedraw>, redraw: &Arc<dyn RequestRedraw>,
total_px: f32, f: F,
duration_ms: u64, ) -> T
) { where
let steps = (duration_ms / ANIM_STEP_MS).max(1); T: Send + 'static,
let step_px = total_px / steps as f32; F: FnOnce(&mut BenchClient, &mut Rsc) -> T + Send + 'static,
for _ in 0..steps { {
let (tx, rx) = std::sync::mpsc::channel();
ctx.update(move |state: &mut BenchClient, rsc| { ctx.update(move |state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen { let _ = tx.send(f(state, rsc));
(screen.list)(rsc).scroll(step_px);
}
}); });
redraw.request_redraw(); redraw.request_redraw();
loop {
if let Ok(value) = rx.try_recv() {
return value;
}
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS)).await; 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);
}
}
+123 -5
View File
@@ -1,11 +1,14 @@
//! JNI calls the `bench` feature needs that go through the shell's own //! JNI calls the `bench` feature needs that go through the shell's own
//! Java side rather than anything `iris`/`android-view` already wraps: //! Java side rather than anything `iris`/`android-view` already wraps:
//! `BatteryManager.getIntProperty(BATTERY_PROPERTY_CURRENT_NOW)` for the //! `BatteryManager.getIntProperty(BATTERY_PROPERTY_CURRENT_NOW)` for the
//! per-second battery sample, and `ClipboardManager.setPrimaryClip` for //! per-second battery sample, `ClipboardManager.setPrimaryClip` for the
//! the "Copy report" control (P0's iris half, docs/RUST.md). Neither is //! "Copy report" control (P0's iris half, docs/RUST.md), and -- added for
//! part of `android_view::context`'s own `Context`/`Resources` wrappers //! RUST.md's "Benchmark v2" -- `Display.getRefreshRate()` for the phase
//! (that file's own `// TODO: more methods?`), so this calls them //! report's real late-frame budget and `InputMethodManager.
//! directly rather than growing that crate's wrapper for two one-off //! 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. //! calls this crate alone needs.
//! //!
//! Holds its own `JavaVM` + `GlobalRef` to the view (handed in through //! Holds its own `JavaVM` + `GlobalRef` to the view (handed in through
@@ -131,4 +134,119 @@ impl PlatformHandle {
.ok()?; .ok()?;
Some(()) 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(())
}
} }
+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)?;
} }
+46 -10
View File
@@ -66,6 +66,17 @@ 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 {
@@ -75,6 +86,7 @@ impl Default for TextData {
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.register_bundled_fonts();
data data
@@ -363,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 {
@@ -419,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 {
@@ -443,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());
@@ -459,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));
} }
} }
@@ -576,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));
}
} }
+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)
} }
+66 -1
View File
@@ -9,11 +9,32 @@ 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,
draw_started: HashSet<WidgetId>, draw_started: HashSet<WidgetId>,
/// The widget currently holding exclusive pointer input, if any --
/// `iris::sense::SensorUi::run_sensors` reads and clears this every
/// call. Interior mutability (a `Mutex`, not a bare `Cell`, since a
/// `CursorData` reaching this through an async `task_on` handler needs
/// `Send`/`Sync`) because `run_sensors` takes `&self` (widgets are
/// dispatched to, not owned, at that layer) and this render state is
/// the one structure both backends (winit, android-view) already hold
/// across frames, the same way `old_root`/`resized` are -- see
/// `iris::sense`'s pointer-capture doc for why a drag needs this: once
/// a gesture has committed to panning or selecting, every later sample
/// of it must reach the same widget even if the finger has moved off
/// whatever hit region first noticed the press. Never held across an
/// await or another lock -- every access here is a single get/set.
captured: std::sync::Mutex<Option<WidgetId>>,
/// `Widget::draw` calls and `Primitives::region_mut` rewrites since the /// `Widget::draw` calls and `Primitives::region_mut` rewrites since the
/// last `take_counters`. LAYOUT.md section 8's pass conditions are /// last `take_counters`. LAYOUT.md section 8's pass conditions are
/// stated in terms of these two: an unchanged frame must cost 0 of /// stated in terms of these two: an unchanged frame must cost 0 of
@@ -35,9 +56,11 @@ 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(),
captured: Default::default(),
draw_count: 0, draw_count: 0,
region_mut_count: 0, region_mut_count: 0,
mov_count: 0, mov_count: 0,
@@ -60,6 +83,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 +348,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;
@@ -336,6 +373,13 @@ impl UiRenderState {
active.textures.clear(); active.textures.clear();
rsc.ui_mut().textures.free(); rsc.ui_mut().textures.free();
if undraw { if undraw {
// A captured widget that goes away mid-gesture (List's
// virtualisation retiring a row, a rebuild) must not leave
// the pointer permanently captured by an id nothing will
// ever draw again -- `captured`'s own path out.
if *self.captured.lock().unwrap() == Some(id) {
*self.captured.lock().unwrap() = None;
}
// Permanent removal: retire this widget's own move slot // Permanent removal: retire this widget's own move slot
// (the self-ownership ref taken when it was allocated) and // (the self-ownership ref taken when it was allocated) and
// the up-link ref it held on its parent's slot -- read from // the up-link ref it held on its parent's slot -- read from
@@ -408,6 +452,27 @@ impl UiRenderState {
self.active.len() self.active.len()
} }
/// Give `id` exclusive pointer input from the next `run_sensors` call
/// on -- see `captured`'s field doc. Overwrites any previous capture
/// (a gesture that starts a new one has already decided the old one
/// is over).
pub fn capture_pointer(&self, id: WidgetId) {
*self.captured.lock().unwrap() = Some(id);
}
/// Release exclusive pointer input, if any is held -- called once
/// `run_sensors` has delivered the terminal `Drop` to the capturing
/// widget, or by that widget itself if it decides the gesture is over
/// some other way.
pub fn release_pointer(&self) {
*self.captured.lock().unwrap() = None;
}
/// The widget currently holding exclusive pointer input, if any.
pub fn captured_pointer(&self) -> Option<WidgetId> {
*self.captured.lock().unwrap()
}
pub fn debug(&self, widgets: &Widgets, label: &str) -> impl Iterator<Item = &ActiveData> { pub fn debug(&self, widgets: &Widgets, label: &str) -> impl Iterator<Item = &ActiveData> {
self.active.iter().filter_map(move |(&id, inst)| { self.active.iter().filter_map(move |(&id, inst)| {
let l = widgets.label(id); let l = widgets.label(id);
+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> {
+1 -1
View File
@@ -23,7 +23,7 @@ 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, LogicalInsets, AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState, IrisViewPeer, WindowInsets,
new_peer, new_peer,
}; };
+21 -19
View File
@@ -208,14 +208,14 @@ impl AndroidRenderer {
surface.configure(&device, &config); surface.configure(&device, &config);
let encoder = Self::create_encoder(&device); let encoder = Self::create_encoder(&device);
// Logical size (physical / `content_scale`) -- see // Physical pixels, matching the swapchain's own `width`/`height`
// `android::view::AndroidUiState::content_scale`'s field comment // exactly -- see `android::view::AndroidUiState::content_scale`'s
// for why this crate now divides at all (RUST.md's P0 box, "text // field comment for why this is no longer divided into a separate
// is far too small"). The swapchain above stays at the real // logical space (that stopgap is what made text blurry, RUST.md's
// physical `width`/`height` for a sharp framebuffer. // P0 box). `Len::dp` folds the density in at layout time instead,
let logical_size = // so nothing here needs to know it at all.
iris_core::util::Vec2::new(width as f32 / content_scale, height as f32 / content_scale); let window_size = iris_core::util::Vec2::new(width as f32, height as f32);
let ui = match UiRenderNode::new(&device, &queue, &config, logical_size) { let ui = match UiRenderNode::new(&device, &queue, &config, window_size) {
Ok(ui) => ui, Ok(ui) => ui,
Err(wgpu_error) => return Err(Self::diagnostic(&adapter, &wgpu_error)), Err(wgpu_error) => return Err(Self::diagnostic(&adapter, &wgpu_error)),
}; };
@@ -398,25 +398,27 @@ impl AndroidRenderer {
submit_start.elapsed() submit_start.elapsed()
} }
/// Logical size (physical / `content_scale`) -- the unit layout and /// Physical pixels -- the unit layout and hit-testing use, matching
/// hit-testing use, matching the window uniform's own units. See /// the window uniform's own units. See
/// `android::view::AndroidUiState::content_scale`'s field comment. /// `android::view::AndroidUiState::content_scale`'s field comment.
pub fn size(&self) -> iris_core::util::Vec2 { pub fn size(&self) -> iris_core::util::Vec2 {
iris_core::util::Vec2::new( iris_core::util::Vec2::new(self.config.width as f32, self.config.height as f32)
self.config.width as f32 / self.content_scale,
self.config.height as f32 / self.content_scale,
)
} }
/// 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);
let logical = iris_core::util::Vec2::new( let size = iris_core::util::Vec2::new(width as f32, height as f32);
width as f32 / self.content_scale, self.ui.resize(size, &self.queue);
height as f32 / self.content_scale,
);
self.ui.resize(logical, &self.queue);
} }
} }
+138 -68
View File
@@ -70,19 +70,31 @@ 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), read once at /// `DisplayMetrics.density` (`new_peer`'s doc comment): physical pixels
/// view construction: physical pixels per dp on this device. Neither /// per dp on this device, read once at view construction and carried
/// this crate nor `default::` had ever divided by it before RUST.md's /// on `UiRenderState::density` (`render.set_density`, `new_peer`) from
/// P0 box's phone report ("text is far too small") -- `window_size` /// then on -- every `Len::dp` in the widget tree resolves against it at
/// below and `surface_changed`'s call into `UiRenderState::resize` both /// layout time (`Len::dp`'s field doc, IRIS_TODO.md's
/// report *logical* (physical / `content_scale`) dimensions now, which /// "density-independent length unit" item, 2026-09-06).
/// is what makes a `font_size: 16.0` 16 dp rather than 16 raw device ///
/// pixels on a ~3x-density phone. The actual wgpu surface/swapchain /// **Everything else in this module is physical pixels, matching the
/// stays at the real physical resolution (`AndroidRenderer`'s own /// real wgpu surface/swapchain resolution** -- window size, touch
/// `config.width/height`) for a sharp framebuffer; only the *logical* /// coordinates, insets. That is a correction from an earlier version
/// coordinate system layout, hit-testing and the window uniform agree /// of this comment, which had `window_size`/`surface_changed`'s
/// on is scaled. Touch coordinates (`on_touch_event`) are divided by /// `UiRenderState::resize` call divide by `content_scale` into a
/// this too, so they land in the same space layout is using. /// *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, pub content_scale: f32,
/// The last insets `render()` saw -- compared each frame so /// The last insets `render()` saw -- compared each frame so
/// `AndroidAppState::on_insets_changed` fires only when they actually /// `AndroidAppState::on_insets_changed` fires only when they actually
@@ -154,21 +166,26 @@ pub trait AndroidAppState: HasAndroidUiState {
/// (RUST.md's P0 box: "the status-bar inset is not applied" reported /// (RUST.md's P0 box: "the status-bar inset is not applied" reported
/// the two top buttons sitting under it, because nothing read `.top` /// the two top buttons sitting under it, because nothing read `.top`
/// at all), and again on a rotation or the keyboard opening/closing. /// at all), and again on a rotation or the keyboard opening/closing.
/// `insets` is in the same *logical* units `content_scale` converts /// `insets` is in the same physical-pixel units everything else in the
/// everything else to (physical / `content_scale`), so a widget can add /// tree now uses (`AndroidUiState::content_scale`'s field comment), so
/// it to a layout size directly. The default does nothing -- most /// a widget can add it to a layout size directly -- `dp(...) +
/// screens have no chrome that sits under a system bar. /// 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)] #[allow(unused_variables)]
fn on_insets_changed(&mut self, rsc: &mut AndroidRsc<Self>, insets: LogicalInsets) {} fn on_insets_changed(&mut self, rsc: &mut AndroidRsc<Self>, insets: WindowInsets) {}
} }
/// `insets::Insets`, converted from physical to logical units -- see /// `insets::Insets` as `f32`, for the widget-facing callback above -- a
/// `AndroidUiState::content_scale`'s field comment. A distinct type from /// distinct type from `insets::Insets` so a caller of `on_insets_changed`
/// `insets::Insets` (rather than dividing in place) so a reader at the call /// is not coupled to that module's own (`i32`, JNI-shaped) representation.
/// site can tell which unit a value is already in without checking where it /// Both are physical pixels; this used to divide by `content_scale` into a
/// came from. /// 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)] #[derive(Clone, Copy, Default, Debug, PartialEq)]
pub struct LogicalInsets { pub struct WindowInsets {
pub left: f32, pub left: f32,
pub top: f32, pub top: f32,
pub right: f32, pub right: f32,
@@ -176,14 +193,14 @@ pub struct LogicalInsets {
pub ime_bottom: f32, pub ime_bottom: f32,
} }
impl LogicalInsets { impl WindowInsets {
fn from_physical(insets: Insets, content_scale: f32) -> Self { fn from_physical(insets: Insets) -> Self {
Self { Self {
left: insets.left as f32 / content_scale, left: insets.left as f32,
top: insets.top as f32 / content_scale, top: insets.top as f32,
right: insets.right as f32 / content_scale, right: insets.right as f32,
bottom: insets.bottom as f32 / content_scale, bottom: insets.bottom as f32,
ime_bottom: insets.ime_bottom as f32 / content_scale, ime_bottom: insets.ime_bottom as f32,
} }
} }
} }
@@ -308,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()) {
@@ -343,10 +367,9 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
let ui_state = self.state.android_state(); let ui_state = self.state.android_state();
let current_insets = ui_state.insets(); let current_insets = ui_state.insets();
if current_insets != ui_state.last_insets { if current_insets != ui_state.last_insets {
let content_scale = ui_state.content_scale; let physical = WindowInsets::from_physical(current_insets);
let logical = LogicalInsets::from_physical(current_insets, content_scale);
self.state.android_state_mut().last_insets = current_insets; self.state.android_state_mut().last_insets = current_insets;
self.state.on_insets_changed(&mut self.rsc, logical); self.state.on_insets_changed(&mut self.rsc, physical);
} }
let ui_state = self.state.android_state(); let ui_state = self.state.android_state();
@@ -504,16 +527,10 @@ 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 as every other Android coordinate // Device (physical) pixels, same space layout now uses throughout
// -- divided so a touch lands in the same *logical* space layout // -- see `AndroidUiState::content_scale`'s field comment.
// now uses (`AndroidUiState::content_scale`'s field comment). let x = event.x(&mut ctx.env);
// Without this, `window_size()` reporting logical dims while touch let y = event.y(&mut ctx.env);
// stayed physical would land every tap off by exactly the density
// factor on any phone denser than 1x.
let ui_state = self.state.android_state();
let content_scale = ui_state.content_scale;
let x = event.x(&mut ctx.env) / content_scale;
let y = event.y(&mut ctx.env) / content_scale;
let ui_state = self.state.android_state_mut(); let ui_state = self.state.android_state_mut();
match action { match action {
MotionAction::Down => { MotionAction::Down => {
@@ -564,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
@@ -574,29 +590,55 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
// 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.
// //
// **Logical, not physical** -- `content_scale`'s field comment on // **Physical pixels, matching `AndroidRenderer`'s own
// `AndroidUiState`. This call sets `UiRenderState::output_size`, // `size()`/`resize()`/`new()`** -- `AndroidUiState::content_scale`'s
// which is what every widget's absolute `PixelRegion` (a fixed // field comment. This call sets `UiRenderState::output_size`, which
// `.height(56)`, in particular) is computed against; `AndroidRenderer`'s // every `rel`/`rest` length resolves against and every `abs`
// own `size()`/`resize()`/`new()` already report logical dimensions // pixel-region compares to directly; a `dp(56)` height now folds
// to the *shader*'s window uniform, so leaving this call on raw // in the density at `Len::apply_rest` time instead of this call
// physical `width`/`height` split the two into different units -- // dividing the whole window into a separate logical space, which
// layout placed a "56"-unit-tall row in an ~2219-tall physical // is what used to make every `abs`-unit size (a fixed `.height(56)`
// canvas (an absolute, correctly-56-unit box), the shader then // in particular) mean something different from a `rest`-based one.
// divided that same 56 by a ~845-unit *logical* window dimension, self.render.resize((width as f32, height as f32));
// and the row rendered far too short rather than too tall or
// right, because a fixed-size item's absolute unit value never // **Reuse the existing renderer (device, atlas, buffers, bind
// adapts to the mismatch the way a `rest(n)`-proportional one // groups) when one is already live -- only reconfigure the
// does. Found by measuring a fresh install's top button row at // surface.** `surfaceChanged` fires on *every* size or format
// ~40 physical px instead of the ~147px `56 * content_scale` // change, not only on a genuinely new `Surface`/window: showing
// predicts, immediately after the density fix below was added. // the IME under `adjustResize` resizes the same `SurfaceView` and
let content_scale = self.state.android_state().content_scale; // is reported through this exact callback. Rebuilding the whole
self.render // `AndroidRenderer` here used to mean a fresh `UiRenderNode::new`
.resize((width as f32 / content_scale, height as f32 / content_scale)); // -- a brand-new, empty glyph atlas and fresh GPU buffers -- while
// Drop the old renderer (and the surface it owns) before building // `iris_core`'s CPU-side glyph cache (`primitive/text.rs`) kept the
// one from the new window -- see `AndroidRenderer`'s doc comment. // 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
.renderer
.as_mut()
.expect("checked Some above")
.resize(width as u32, height as u32);
self.render(ctx);
return;
}
let window = holder.surface(&mut ctx.env).to_native_window(&mut ctx.env);
// `AndroidRenderer::new` used to panic here through wgpu's own // `AndroidRenderer::new` used to panic here through wgpu's own
// default uncaptured-error handler on a bind-group-layout // default uncaptured-error handler on a bind-group-layout
// validation failure -- exactly what aborted the P0 bench APK on // validation failure -- exactly what aborted the P0 bench APK on
@@ -607,9 +649,30 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
// the one place in the app that can turn it into something a // 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 // person can read, since `ctx.view`/`ctx.env` (needed to reach the
// Java side) are only in scope inside a `ViewPeer` callback. // 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; let content_scale = self.state.android_state().content_scale;
match AndroidRenderer::new(window, width as u32, height as u32, content_scale) { match AndroidRenderer::new(window, width as u32, height as u32, content_scale) {
Ok(renderer) => { 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.state.android_state_mut().renderer = Some(renderer);
self.render(ctx); self.render(ctx);
} }
@@ -771,15 +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(), content_scale); let ui_state = AndroidUiState::new(shared.clone(), content_scale);
let mut state = State::new(ui_state, &mut rsc); let mut state = State::new(ui_state, &mut rsc);
let platform_vm = env.get_java_vm().unwrap(); let platform_vm = env.get_java_vm().unwrap();
let platform_view = env.new_global_ref(&view.0).unwrap(); let platform_view = env.new_global_ref(&view.0).unwrap();
state.platform_ready(&mut rsc, platform_vm, platform_view); state.platform_ready(&mut rsc, platform_vm, platform_view);
let mut render = UiRenderState::new();
// Every `Len::dp` in the tree resolves against this from now on -- see
// `UiRenderState::density`'s field doc and `Len::dp`'s.
render.set_density(content_scale);
let peer = IrisViewPeer { let peer = IrisViewPeer {
rsc, rsc,
render: UiRenderState::new(), render,
state, state,
task_recv, task_recv,
}; };
+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 };
+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:?}"
);
}
+206
View File
@@ -22,6 +22,14 @@ pub enum CursorSense {
Hovering, Hovering,
HoverEnd, HoverEnd,
Scroll, Scroll,
/// Delivered exactly once, in place of `PressEnd`, to whichever widget
/// currently holds pointer capture (`UiRenderState::capture_pointer`)
/// when the button lifts -- see `iris::sense`'s pointer-capture doc
/// and `DragGesture`. A widget must register this explicitly (it is
/// never bundled into `click_or_drag`/`unclick`, since most widgets
/// never call `capture_pointer` and have no use for it) to receive it
/// at all; ordinary hit-tested widgets keep seeing `PressEnd`.
Drop,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -31,6 +39,21 @@ impl Event for CursorSenses {
type Data<'a> = CursorData<'a>; type Data<'a> = CursorData<'a>;
type State = SensorState; type State = SensorState;
fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option<Self::Data<'a>> { fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option<Self::Data<'a>> {
// `Drop` is never derived from raw cursor/hover state below (the
// free `should_run`'s own arm for it is only ever asked here,
// never independently true or false against the button) -- it is
// set exclusively by `run_sensors`' pointer-capture branch, which
// has already decided this exact frame is the captured widget's
// terminal event. Matching it by identity, ahead of the general
// derivation, matters because a captured widget's registration
// list very likely also carries `PressEnd` (`unclick()`, for the
// ordinary un-captured case) -- the same button-lift condition
// `PressEnd` matches on, so falling through to the loop below
// would let whichever of the two happens to be registered first
// win, silently swallowing the `Drop` a caller relied on.
if data.sense == CursorSense::Drop {
return self.contains(&CursorSense::Drop).then(|| data.clone());
}
if let Some(sense) = should_run(self, &data.cursor, data.hover) { if let Some(sense) = should_run(self, &data.cursor, data.hover) {
let mut data = data.clone(); let mut data = data.clone();
data.sense = sense; data.sense = sense;
@@ -177,6 +200,48 @@ impl SensorUi for UiRenderState {
cursor: CursorState, cursor: CursorState,
window_size: Vec2, window_size: Vec2,
) { ) {
// Exclusive pointer capture (`UiRenderState::capture_pointer`,
// `DragGesture`): once some widget has committed to a drag, every
// other widget sees nothing from this pointer at all -- no hover,
// no click, no press -- until it releases. This is what lets a
// fast pan or a selection keep going once the finger has moved
// off whatever hit region first noticed the press (including
// right off the end of the gesture, at `PressEnd`/`Cancel`): a
// per-widget hit test would otherwise silently stop delivering to
// *anyone* the moment the pointer left every registered region,
// which is exactly what used to leave a fling never started (no
// widget ever saw the release). The captured widget keeps getting
// ordinary `Pressing` frames while the button is down and gets
// exactly one `Drop` -- not `PressEnd` -- the frame it lifts,
// which also releases the capture.
if let Some(id) = self.captured_pointer() {
let Some(shape) = self.resolved_region(&id, rsc) else {
self.release_pointer();
return;
};
let region = shape.to_px(window_size);
let button_down = cursor.buttons.select(&CursorButton::Left).is_on();
let sense = if button_down {
CursorSense::Pressing(CursorButton::Left)
} else {
CursorSense::Drop
};
let data = CursorData {
pos: cursor.pos - region.top_left,
size: region.bot_right - region.top_left,
scroll_delta: cursor.scroll_delta,
hover: ActivationState::On,
cursor: cursor.clone(),
sense,
render: self,
};
rsc.run_event::<CursorSense>(id, data, state);
if !button_down {
self.release_pointer();
}
return;
}
// in order to remove this take, need to store active list in UiRenderState somehow // in order to remove this take, need to store active list in UiRenderState somehow
// this would probably be done through a generic parameter that adds yet another rsc / // this would probably be done through a generic parameter that adds yet another rsc /
// state like thing, but local to render state, and is passed to UiRsc events so you can // state like thing, but local to render state, and is passed to UiRsc events so you can
@@ -266,6 +331,15 @@ pub fn should_run(
CursorSense::Hovering => hover.is_on(), CursorSense::Hovering => hover.is_on(),
CursorSense::HoverEnd => hover.is_end(), CursorSense::HoverEnd => hover.is_end(),
CursorSense::Scroll => cursor.scroll_delta != Vec2::ZERO, CursorSense::Scroll => cursor.scroll_delta != Vec2::ZERO,
// Never derived here -- `Drop` only ever fires through
// `CursorSenses::should_run`'s own special case, ahead of this
// loop, for the one widget `run_sensors`' capture branch is
// delivering it to this frame. If this arm answered from raw
// button state instead, an ordinary hit-tested widget that
// happened to register `Drop` (with no capture involved at
// all) would see it fire on every plain button-up under the
// cursor.
CursorSense::Drop => false,
} { } {
return Some(*sense); return Some(*sense);
} }
@@ -541,6 +615,138 @@ impl DragArbiter {
} }
} }
/// What a [`DragGesture`] decided this frame -- [`DragOutcome`] plus the
/// one further state a shared gesture needs: the drag ending, with the
/// released velocity if (and only if) it had committed to panning.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum GestureOutcome {
Undecided,
/// Same units and sign as [`DragOutcome::Pan`] -- the caller's own
/// convention (`List::scroll`'s, for a transcript) to apply.
Pan(f32),
SelectStart,
SelectExtend,
/// The drag ended -- `PressEnd` or the capture's own terminal `Drop`.
/// `Some(velocity)` only if the gesture had committed to panning
/// (never a tap, a long-press selection, or one still `Undecided`);
/// same units as `Pan`, so a caller hands it to `List::fling` with
/// whatever sign flip it already applies to `Pan`.
Released(Option<f32>),
}
/// Bundles a [`DragArbiter`] and a [`VelocityTracker`] into the one thing
/// most drag-driven widgets need: arbitrate pan-vs-hold, track the pan's
/// velocity, and take pointer capture (`UiRenderState::capture_pointer`)
/// the moment the gesture commits so the rest of it -- including the
/// terminal release -- keeps reaching the same widget even after the
/// finger has moved off whatever hit region first noticed the press. Iris
/// asked for this to live here rather than in `transcript-ui::Selection`
/// (2026-09-06, recorded in `IRIS.md`): "dragging should be part of the
/// default input system ... anything that provides good performance and
/// can be generalized well is part of iris rather than the app." A caller
/// still decides what a committed pan or a completed selection *means*
/// (transcript-ui's pan-vs-select is one call site; a slider or a plain
/// scroll area is another) -- this only owns the *mechanics* every one of
/// them would otherwise duplicate.
pub struct DragGesture {
arbiter: DragArbiter,
velocity: VelocityTracker,
}
impl Default for DragGesture {
fn default() -> Self {
Self::new()
}
}
impl DragGesture {
pub fn new() -> Self {
Self {
arbiter: DragArbiter::new(),
velocity: VelocityTracker::new(),
}
}
/// Whether this gesture has no press in flight -- a thin passthrough
/// to the underlying `DragArbiter::is_idle`, for a caller (a test, a
/// diagnostic) that wants to observe the recovery behaviour `handle`'s
/// idle-recovery branch documents without reaching into a private
/// field.
pub fn is_idle(&self) -> bool {
self.arbiter.is_idle()
}
/// Feed one frame of a gesture through. `id` is the widget iris should
/// give exclusive pointer input to once this gesture commits to
/// panning or selecting -- a stable widget that outlives the gesture
/// (a `List`'s own id, not one of its virtualised rows, which can be
/// retired mid-drag as content scrolls). `render` is `CursorData`'s
/// own field, already in hand at every call site. `already_selected`
/// only matters for the first frame of a gesture (`PressStart`, or the
/// recovery branch below) -- see `DragArbiter::press_start`'s doc.
pub fn handle(
&mut self,
render: &UiRenderState,
id: WidgetId,
sense: CursorSense,
pos_window: Vec2,
now: Instant,
already_selected: bool,
) -> GestureOutcome {
match sense {
CursorSense::PressStart(_) => {
self.velocity.reset();
self.arbiter.press_start(pos_window, now, already_selected);
self.dispatch(render, id, pos_window, now)
}
CursorSense::Drop | CursorSense::PressEnd(_) => {
let released = if self.arbiter.is_panning() {
Some(self.velocity.velocity())
} else {
None
};
self.arbiter.release();
render.release_pointer();
GestureOutcome::Released(released)
}
// See `DragArbiter::update`'s own doc: a `Pressing` frame can
// arrive with no matching `PressStart` if the touch-down
// landed outside whichever hit region first noticed it.
_ if self.arbiter.is_idle() => {
self.velocity.reset();
self.arbiter.press_start(pos_window, now, already_selected);
self.dispatch(render, id, pos_window, now)
}
_ => self.dispatch(render, id, pos_window, now),
}
}
fn dispatch(
&mut self,
render: &UiRenderState,
id: WidgetId,
pos: Vec2,
now: Instant,
) -> GestureOutcome {
match self.arbiter.update(pos, now) {
DragOutcome::Undecided => GestureOutcome::Undecided,
DragOutcome::Pan(dy) => {
render.capture_pointer(id);
self.velocity.add_sample(dy, now);
GestureOutcome::Pan(dy)
}
DragOutcome::SelectStart => {
render.capture_pointer(id);
GestureOutcome::SelectStart
}
DragOutcome::SelectExtend => {
render.capture_pointer(id);
GestureOutcome::SelectExtend
}
}
}
}
/// How far back a [`VelocityTracker`] looks when estimating a fling's /// How far back a [`VelocityTracker`] looks when estimating a fling's
/// initial speed -- Android's own `VelocityTracker` defaults to a similar /// initial speed -- Android's own `VelocityTracker` defaults to a similar
/// short window so a gesture's last flick dominates over its slower start. /// short window so a gesture's last flick dominates over its slower start.
+124
View File
@@ -122,3 +122,127 @@ fn a_button_over_a_list_scrolls_the_list_and_still_clicks() {
"the button on top must still receive an actual click" "the button on top must still receive an actual click"
); );
} }
/// The bug behind "finger flings do nothing" (RUST.md's P0 phone report,
/// defect 2): a fast gesture's `PressEnd` can land at a screen position
/// nothing is registered at -- past the edge of whatever widget noticed
/// the press, in a gap, or off the loaded content entirely. Before pointer
/// capture, `run_sensors`' hit test simply delivered nothing that frame,
/// so a widget mid-drag never saw its release and never got a chance to
/// start a fling. `UiRenderState::capture_pointer`/`DragGesture` fix this
/// by giving the drag's widget every frame regardless of where the
/// pointer is, including the terminal `Drop` in place of `PressEnd`.
#[test]
fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() {
let mut rsc = SenseRsc {
ui: UiData::default(),
events: EventManager::default(),
};
// A small draggable widget in the corner -- the release below lands
// far outside it, exactly the "moved off the hit region" case.
let draggable = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any();
let draggable_weak = draggable.weak();
let dropped = Rc::new(Cell::new(false));
{
let dropped = dropped.clone();
rsc.register_event(
draggable_weak,
CursorSense::click_or_drag() | CursorSense::unclick() | CursorSense::Drop,
move |ctx, rsc| match ctx.data.sense {
CursorSense::PressStart(_) | CursorSense::Pressing(_) => {
// Any committed drag takes capture -- a real caller
// would gate this on a `DragArbiter`/`DragGesture`
// decision, but this test only needs to exercise the
// capture-and-release mechanics themselves.
ctx.data.render.capture_pointer(draggable_weak.id());
let _ = rsc;
}
CursorSense::Drop => dropped.set(true),
_ => {}
},
);
}
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&draggable, &mut rsc);
let mut state = ();
let mut press = cursor_at((5.0, 5.0).into());
press.buttons.left = ActivationState::Start;
render.run_sensors(&mut rsc, &mut state, press, (100.0, 100.0).into());
assert_eq!(
render.captured_pointer(),
Some(draggable.id()),
"the press should have taken capture"
);
// The release lands nowhere near the widget's own region -- the exact
// shape of a fast fling's `ACTION_UP`.
let mut release = cursor_at((95.0, 95.0).into());
release.buttons.left = ActivationState::End;
render.run_sensors(&mut rsc, &mut state, release, (100.0, 100.0).into());
assert!(
dropped.get(),
"a release outside every widget's hit region must still reach \
the widget holding pointer capture"
);
assert_eq!(
render.captured_pointer(),
None,
"Drop must release the capture"
);
}
/// A widget that never registers `CursorSense::Drop` at all must not be
/// affected by someone else's capture -- capture is per-gesture, not
/// global suppression of the whole input system for widgets that were
/// never party to it. (Practically this matters because a captured
/// widget's registration list still has to include `Drop` for `should_run`
/// to ever match it; this pins that half of the contract.)
#[test]
fn capturing_one_widget_starves_every_other_widget_of_events() {
let mut rsc = SenseRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let a = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let a_weak = a.weak();
let b = rsc.ui.widgets.add_strong(Rect::new(UiColor::RED));
let b_weak = b.weak();
let b_hovered = Rc::new(Cell::new(false));
{
let b_hovered = b_hovered.clone();
rsc.register_event(b_weak, CursorSense::Hovering, move |_ctx, _rsc| {
b_hovered.set(true);
});
}
let root = rsc
.ui
.widgets
.add_strong(Stack {
children: vec![a.any(), b.any()],
size: StackSize::default(),
})
.any();
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
render.capture_pointer(a_weak.id());
let mut state = ();
let cursor = cursor_at((50.0, 50.0).into());
render.run_sensors(&mut rsc, &mut state, cursor, (100.0, 100.0).into());
assert!(
!b_hovered.get(),
"while a's drag holds capture, b must see no hover at all"
);
}
+109 -1
View File
@@ -489,6 +489,24 @@ impl List {
true 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) {
@@ -534,6 +552,20 @@ impl List {
self.extents.get(&key).map(|e| (e.top, e.bottom)) self.extents.get(&key).map(|e| (e.top, e.bottom))
} }
/// The row whose on-screen box (as of the last layout) contains
/// `viewport_pos`, or `None` if it falls outside every row currently
/// drawn (a gap, a header, or off the loaded content entirely). O
/// (visible rows), same as `reanchor_at_tap`. What a caller resolves a
/// pointer-captured gesture's row-under-the-finger against once the
/// gesture is no longer being delivered through any one row's own hit
/// region -- see `iris::sense`'s pointer-capture doc.
pub fn key_at(&self, viewport_pos: f32) -> Option<RowKey> {
self.extents
.iter()
.find(|(_, ext)| viewport_pos >= ext.top && viewport_pos <= ext.bottom)
.map(|(&key, _)| key)
}
fn slot_exists(&self, slot: isize) -> bool { fn slot_exists(&self, slot: isize) -> bool {
match slot { match slot {
BEFORE_SLOT => self.more_before.is_some(), BEFORE_SLOT => self.more_before.is_some(),
@@ -734,9 +766,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)
}; };
@@ -1313,6 +1346,63 @@ mod tests {
} }
} }
/// RUST.md's P0 phone report (Iris's screenshot, 2026-09-06): a
/// replaced row's primitives drawn a second time, overlapping the
/// replacement. Reproduces the exact path `TranscriptScreen::apply`'s
/// `ReplaceLast` case drives up to 400 times during a streamed reply
/// (`bench_client.rs`'s stream phase): the last slot's widget is
/// swapped for a brand-new one, same key, and (since a fresh widget
/// has no cached height) placed via `place`'s `draw_twice` path every
/// time -- the provisional-then-real two-draw sequence LAYOUT.md
/// documents as the one place in this crate that deliberately draws a
/// widget twice. If `draw_inner`'s old-children diffing or
/// `UiRenderState::remove`'s primitive freeing ever failed to retire
/// the evicted widget (or the provisional draw's own primitives), it
/// would show up here as `active_widgets` growing without bound.
/// **Passes as written** -- this pins the widget-arena layer as
/// correct in isolation; see the P0 box for where the duplicate was
/// actually chased to instead (`Span`'s two-phase draw and the
/// `redraw_all`-vs-`redraw_updates` split, still open).
#[test]
fn replacing_the_last_row_many_times_does_not_leak_primitives() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = List::new(Axis::Y);
for key in 0..5u64 {
let (_bg_id, row) = background_styled_row(&mut rsc, 20.0);
list.push_back(ListRow::new(key, row));
}
let (list_weak, root) = add_list(&mut rsc, list);
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
let before = render.active_widgets();
for i in 0..400u32 {
// A varying height keeps every replace on the `draw_twice`
// (cache-miss) path rather than settling into the O(1)
// same-size `mov` fast path once the height happens to repeat.
let (_bg_id, new_row) = background_styled_row(&mut rsc, 20.0 + (i % 3) as f32);
rsc.ui
.widgets
.get_mut(&list_weak)
.unwrap()
.replace_back(ListRow::new(4, new_row));
render.update(&root, &mut rsc);
}
let after = render.active_widgets();
assert_eq!(
before, after,
"400 replaces of the last row must leave exactly the same \
number of active widgets as before a leaked id (and the \
primitives that live as long as its ActiveData does) would \
show up here as growth"
);
}
/// Enough rows, tall enough, that a fling toward the start has real /// Enough rows, tall enough, that a fling toward the start has real
/// room to travel before `at_start` clamps it -- shared by the fling /// room to travel before `at_start` clamps it -- shared by the fling
/// tests below. /// tests below.
@@ -1455,4 +1545,22 @@ mod tests {
first.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
} }
} }
+73 -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()
} }
@@ -697,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)
} }
+38 -1
View File
@@ -51,7 +51,7 @@ pub mod selection;
use client_core::transcript_fold::TranscriptRow as FoldedRow; use client_core::transcript_fold::TranscriptRow as FoldedRow;
use iris::prelude::*; use iris::prelude::*;
use selection::Selection; use selection::Selection;
use std::{cell::RefCell, rc::Rc}; use std::{cell::RefCell, rc::Rc, time::Instant};
pub struct TranscriptScreen { pub struct TranscriptScreen {
/// The transcript's own `List` -- exposed so a caller can read /// The transcript's own `List` -- exposed so a caller can read
@@ -220,6 +220,43 @@ where
}) })
.add(rsc); .add(rsc);
// The continuation of a row-started drag once it has committed and
// taken pointer capture on `list`'s own id (`row.rs`'s registration is
// only ever the gesture's first frame) -- registered once here, not
// once per row, since `DragGesture`'s single shared instance must see
// each frame of one gesture exactly once. `ctx.data.pos`/`size` are
// already relative to `list`'s own on-screen box (this is what it was
// registered against), which is exactly the viewport-pixel space
// `List::key_at`/`extent` work in, so the row-under-the-pointer is
// resolved from those instead of a per-row hit test.
{
let selection = selection.clone();
list.on(
CursorSense::Pressing(CursorButton::Left) | CursorSense::Drop,
move |ctx, rsc| {
let pos = ctx.data.pos;
let row = list(rsc).key_at(pos.y).and_then(|key| {
let (top, bottom) = list(rsc).extent(key)?;
Some((
key,
Vec2::new(pos.x, pos.y - top),
Vec2::new(ctx.data.size.x, bottom - top),
))
});
selection.borrow_mut().drag(
rsc,
list,
row,
ctx.data.cursor.pos,
ctx.data.sense,
Instant::now(),
ctx.data.render,
);
},
)
.add(rsc);
}
let (composer, composer_bar) = composer::build_composer(rsc); let (composer, composer_bar) = composer::build_composer(rsc);
let tree = (list.width(rest(1)).height(rest(1)), composer_bar) let tree = (list.width(rest(1)).height(rest(1)), composer_bar)
+13 -7
View File
@@ -137,20 +137,26 @@ where
field field
// `| CursorSense::unclick()` on top of the usual click-or-drag set // `| CursorSense::unclick()` on top of the usual click-or-drag set
// -- the arbiter inside `Selection::drag` needs the release too, // -- this row's own registration only ever needs to see a
// to go back to idle for the next press (`DragArbiter::release`). // gesture's *first* frame (`PressStart`, or a `Pressing` that
// missed it -- `DragGesture::handle`'s idle-recovery branch); once
// it commits, `DragGesture` takes pointer capture on `list`'s own
// id and every further frame, including the terminal `Drop`,
// reaches `lib.rs`'s list-level registration instead -- see
// `iris::sense`'s pointer-capture doc for why that has to be a
// stable id rather than this row's, which `List` can retire mid-
// drag as content scrolls.
.on( .on(
CursorSense::click_or_drag() | CursorSense::unclick(), CursorSense::click_or_drag() | CursorSense::unclick(),
move |ctx, rsc| { move |ctx, rsc| {
selection.borrow_mut().drag( selection.borrow_mut().drag(
rsc, rsc,
list, list,
key, Some((key, ctx.data.pos, ctx.data.size)),
ctx.data.pos,
ctx.data.size,
ctx.data.cursor.pos, ctx.data.cursor.pos,
ctx.data.sense, ctx.data.sense,
Instant::now(), Instant::now(),
ctx.data.render,
); );
}, },
) )
@@ -174,8 +180,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()
} }
+48 -70
View File
@@ -36,17 +36,15 @@ use std::{collections::BTreeMap, time::Instant};
pub struct Selection { pub struct Selection {
rows: BTreeMap<RowKey, WeakWidget<TextEdit>>, rows: BTreeMap<RowKey, WeakWidget<TextEdit>>,
anchor: Option<(RowKey, Vec2)>, anchor: Option<(RowKey, Vec2)>,
/// One arbiter shared by every row's drag handler -- RUST.md's I5 /// One gesture shared by every row's drag handler -- RUST.md's I5
/// gesture conflict (a row's own `click_or_drag()` and a list-level /// gesture conflict (a row's own `click_or_drag()` and a list-level
/// 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::DragGesture`'s own doc for the arbitration, velocity
arbiter: DragArbiter, /// tracking and pointer-capture mechanics this no longer owns itself
/// Tracks the last ~100ms of this gesture's pan deltas (in the same /// -- Iris's 2026-09-06 ask (`IRIS.md`) that a drag's *mechanics* live
/// signed units `list.scroll` takes), so a release that turns out to /// in iris's default input layer, with only the pan-vs-select
/// have been panning can hand `List::fling` a realistic initial /// *decision* staying here.
/// velocity instead of one frame's noisy last delta -- gesture: DragGesture,
/// IRIS_TODO.md's "swiping has no momentum."
velocity: VelocityTracker,
} }
impl Default for Selection { impl Default for Selection {
@@ -60,8 +58,7 @@ impl Selection {
Self { Self {
rows: BTreeMap::new(), rows: BTreeMap::new(),
anchor: None, anchor: None,
arbiter: DragArbiter::new(), gesture: DragGesture::new(),
velocity: VelocityTracker::new(),
} }
} }
@@ -165,85 +162,66 @@ impl Selection {
/// row, is what makes that consistent as a drag crosses row /// row, is what makes that consistent as a drag crosses row
/// boundaries). /// boundaries).
/// ///
/// `pos_row`/`size` are row-local, as `begin`/`extend` want; /// `row`, if given, is `(key, pos_row, size)` for whichever row the
/// pointer is currently over -- row-local, as `begin`/`extend` want.
/// `None` once the gesture is pointer-captured (`iris::sense`'s
/// pointer-capture doc) and the current position falls outside every
/// row `List` has loaded (a gap, or off the end of the content); a
/// `Pan` outcome never needs it, so this only actually matters mid-
/// selection, where it is rare and the frame is simply dropped.
/// `pos_window` is in window space, since a pan's delta has to stay /// `pos_window` is in window space, since a pan's delta has to stay
/// meaningful even when this frame's event landed on a different row /// meaningful even when this frame's event landed on a different row
/// than the last one. /// than the last one. `render` is `CursorData`'s own field -- what
/// `DragGesture` needs to take pointer capture.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn drag( pub fn drag(
&mut self, &mut self,
ui: &mut impl UiRsc, ui: &mut impl UiRsc,
list: WeakWidget<List>, list: WeakWidget<List>,
key: RowKey, row: Option<(RowKey, Vec2, Vec2)>,
pos_row: Vec2,
size: Vec2,
pos_window: Vec2, pos_window: Vec2,
sense: CursorSense, sense: CursorSense,
now: Instant, now: Instant,
render: &UiRenderState,
) { ) {
let outcome = match sense { if matches!(sense, CursorSense::PressStart(_)) {
CursorSense::PressStart(_) => {
let already_selected = self.has_selection(ui);
self.arbiter.press_start(pos_window, now, already_selected);
self.velocity.reset();
// A fresh touch-down cancels any fling still coasting from // A fresh touch-down cancels any fling still coasting from
// the previous gesture -- `List::fling`'s own doc, and // the previous gesture -- `List::fling`'s own doc, and
// Android's `Scroller::abortAnimation` for the same reason. // Android's `Scroller::abortAnimation` for the same reason.
list(ui).cancel_fling(); list(ui).cancel_fling();
self.arbiter.update(pos_window, now)
} }
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();
return;
}
// A `Pressing` frame with the arbiter still `Idle` means this
// gesture's `ACTION_DOWN` landed somewhere no row's sensor
// covers (a row's own padding/gap, or a header with no
// selection handler) and this row is only now getting the
// touch as it moves across it -- the touch is definitely still
// down (that's what `Pressing` means), so without this the
// arbiter would sit in `Idle` answering `Undecided` for the
// rest of the gesture (`DragArbiter::update`'s own doc).
// Recovered by starting the press here instead of where it
// was missed -- RUST.md's I5 intermittent-touch-scroll-dropout
// finding, 2026-09-05.
_ 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); let outcome =
self.velocity.reset(); self.gesture
list(ui).cancel_fling(); .handle(render, list.id(), sense, pos_window, now, already_selected);
self.arbiter.update(pos_window, now)
}
_ => self.arbiter.update(pos_window, now),
};
match outcome { match outcome {
DragOutcome::Undecided => {} GestureOutcome::Undecided => {}
DragOutcome::Pan(dy) => { GestureOutcome::Pan(dy) => list(ui).scroll(-dy),
let amt = -dy; GestureOutcome::SelectStart => {
self.velocity.add_sample(amt, now); if let Some((key, pos_row, size)) = row {
list(ui).scroll(amt); // Grep-able on "iris selection" the way the frame
} // report is on "iris frame report" -- selection has no
DragOutcome::SelectStart => { // accessibility label of its own yet, so this is the
// Grep-able on "iris selection" the way the frame report is // smallest way to confirm a real on-device long-
// on "iris frame report" -- selection has no accessibility // press-then-drag actually reached here (RUST.md's I5
// label of its own yet, so this is the smallest way to // box, "Measurements taken" (c)).
// confirm a real on-device long-press-then-drag actually
// reached here (RUST.md's I5 box, "Measurements taken" (c)).
log::info!("iris selection: begin at row {key:?}"); log::info!("iris selection: begin at row {key:?}");
self.begin(ui, key, pos_row, size); self.begin(ui, key, pos_row, size);
} }
DragOutcome::SelectExtend => { }
GestureOutcome::SelectExtend => {
if let Some((key, pos_row, size)) = row {
log::info!("iris selection: extend to row {key:?}"); log::info!("iris selection: extend to row {key:?}");
self.extend(ui, key, pos_row, size); self.extend(ui, key, pos_row, size);
} }
} }
// 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` -- exactly what
// `DragGesture`'s `Some(v)` already encodes.
GestureOutcome::Released(Some(v)) => list(ui).fling(-v),
GestureOutcome::Released(None) => {}
}
} }
/// The concatenated selected text, in row order, `None` if nothing is /// The concatenated selected text, in row order, `None` if nothing is
@@ -344,8 +322,9 @@ mod tests {
let mut sel = Selection::new(); let mut sel = Selection::new();
sel.register(1, field); sel.register(1, field);
assert!(sel.arbiter.is_idle()); assert!(sel.gesture.is_idle());
let render = UiRenderState::new();
let now = Instant::now(); let now = Instant::now();
let size = Vec2::new(100.0, 20.0); let size = Vec2::new(100.0, 20.0);
// No `PressStart` is ever sent -- only the `Pressing` frames a // No `PressStart` is ever sent -- only the `Pressing` frames a
@@ -353,15 +332,14 @@ mod tests {
sel.drag( sel.drag(
&mut rsc, &mut rsc,
list, list,
1, Some((1, Vec2::ZERO, size)),
Vec2::ZERO,
size,
Vec2::new(540.0, 700.0), Vec2::new(540.0, 700.0),
CursorSense::Pressing(CursorButton::Left), CursorSense::Pressing(CursorButton::Left),
now, now,
&render,
); );
assert!( assert!(
!sel.arbiter.is_idle(), !sel.gesture.is_idle(),
"a Pressing frame with the arbiter still Idle must recover \ "a Pressing frame with the arbiter still Idle must recover \
the press rather than leaving it stuck" the press rather than leaving it stuck"
); );