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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

cargo fmt --all across the touched files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:44:43 -04:00
irisandClaude Fable 5.1 184a6c5b33 IRIS_TODO.md: a density-independent length unit, asked for by Iris
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:43:16 -04:00
30 changed files with 2040 additions and 202 deletions

No files matched your search

+113
View File
@@ -8,6 +8,37 @@ capability that moved. Small and trivial changes do not go here.
An entry gives the date, what changed, why, and a short before/after where
it helps judge the change without the session that made it. Newest first.
## 2026-09-06: `List::fling`, `VelocityTracker`, `FlingCalculator` (IRIS_TODO.md's "swiping has no momentum")
`iris::widget::List` gained a real fling: `fling(velocity_px_per_s)` starts
one (cancelled by the next touch-down via `cancel_fling`, or automatically
once it settles or reaches loaded content's start/end), `is_scrolling()`
reports whether one is running, and `tick_fling(now: Instant) -> bool`
advances it and returns whether it is still going -- a caller that owns a
`RequestRedraw` handle can hand it to the list once via the new
`set_redraw_handle`, after which `List` re-arms its own next frame while
flinging with no further polling needed; a caller driving a scripted
benchmark instead calls `tick_fling` itself in a loop, same as it already
drives `scroll`.
The physics is `iris::sense::FlingCalculator` + `VelocityTracker`
(`sense.rs`, beside `DragArbiter`): a port of AOSP `SplineOverScroller`'s
deceleration curve (the same one Compose's own `ScrollableDefaults.
flingBehavior()` uses), cited at the definition, so a fling here travels
the same distance a Compose `LazyColumn` would for the same initial
velocity. `VelocityTracker` estimates that velocity from the drag's last
~100ms of samples rather than one frame's last delta. Unit-tested:
velocity from known samples, fling distance/duration against the closed-
form spline result (within 1%), cancel-on-touch, and the start/end clamp
(a fling stops rather than scrolling into content that was never loaded).
Before: a touch-drag panned exactly as far as the finger moved and stopped
dead on release. After: releasing mid-drag continues scrolling and
decelerates, matching the muscle memory every other Android scroll view
already trained. `transcript_ui::selection::Selection::drag` wires this in
-- a release only flings if the gesture had committed to panning
(`DragArbiter::is_panning`, new), never a selection or an undecided tap.
## 2026-09-06: `UiRenderNode::new` returns `Result`, not `Self` (RUST.md's P0 box, phone-crash fix)
`iris_core::UiRenderNode::new(device, queue, config)` now returns
@@ -513,3 +544,85 @@ phase dropped from 369.3ms/284.5ms (full rebuild per event, prior pass) to
~101130ms/~76103ms across three runs (this fix) — see RUST.md's P0 box
for the full numbers and the comparison's caveats (different AVD
instances, not a controlled A/B on identical hardware state).
## 2026-09-06: bundled fonts, `content_scale`, `AndroidAppState::on_insets_changed`
From RUST.md's P0 box, working Iris's first real-phone report (font/scale/
inset bugs the emulator never showed).
- **`TextData` now bundles Noto Sans + Noto Sans Mono** (regular/bold/
italic/bold-italic static faces, OFL) and registers them ahead of the
platform's own fonts in the `SansSerif`/`Monospace` generic-family
lists, rather than relying on the platform's font enumeration alone.
`TextData::font_diagnostics() -> FontDiagnostics` reports what was found
and what each style axis resolved to — logged once at startup and shown
on a screen's Diagnostics page if it has one. Adds ~3.6 MB uncompressed
to any binary linking `iris-core`; `build-apk.sh`'s own output says the
delivered (compressed) number.
- **`UiRenderNode::new`/`resize` now take the window size explicitly**
(`window_size: impl Into<Vec2>`) instead of deriving it from the
surface's physical `SurfaceConfiguration`. Existing callers pass a
*logical* size (physical ÷ density/scale-factor) now; this is what makes
a `font_size: 16.0` 16 dp instead of 16 raw device pixels on a
high-density phone. Before this, `scale_factor` did not exist anywhere
in the crate, on either platform.
- **`AndroidUiState::content_scale: f32`** (`DisplayMetrics.density`, read
once in `new_peer`) and the desktop equivalent (`window.scale_factor()`)
now divide every physical-pixel number before it reaches layout or
touch handling — see `content_scale`'s own field doc for the full list
of what depends on it.
- **New: `AndroidAppState::on_insets_changed(&mut self, rsc, LogicalInsets)`**,
a default-no-op hook called from `render()` exactly when
`AndroidUiState::insets()` changes. Nothing previously consumed
`insets().top` at all; a screen with chrome under the status bar
implements this to pad it, in the same logical units `content_scale`
converts everything else to.
- **New: `iris_core::WgpuErrorLog`**, installed via `Device::
on_uncaptured_error` on the Android device (wgpu's default handler is an
unconditional panic outside `UiRenderNode::new`'s own error scopes).
Explicit `Arc`-backed value passed to the callback and kept on
`AndroidRenderer`, not a global — a caller wanting one on desktop builds
its own the same way.
## 2026-09-06: `Len::dp`, physical pixels throughout, the keyboard glyph wipe
Iris's phone report on build a9232ac (screenshots): text now the right
size but blurry; the keyboard still wipes every glyph; the header buttons
have nothing behind them. All three are fixed; this entry is the public
API side. docs/LAYOUT.md has the layout-side writeup, docs/RUST.md's P0
box has the full investigation and the phone verification still to do.
- **The keyboard wipe was `surface_changed` rebuilding the whole renderer
on every resize**, including an IME-driven one — a fresh, empty glyph
atlas while the CPU-side glyph cache kept UV coordinates from the old
one. `surface_changed` now calls `AndroidRenderer::resize` (reconfigures
the surface and window uniform only) when a renderer is already live,
and only builds a new one when there genuinely isn't one yet.
- **`Len` has a third field, `dp`** (Android's dp / CSS's reference pixel,
1/160in), beside the existing `abs` (now explicitly *physical* pixels)
and `rel`/`rest`. `len_fns::dp`/`Len::dp` construct one, used exactly
like `abs`/`rel`/`rest` — `dp(16)` instead of a bare `16` wherever a
size should look the same physical size on any density. This is the
unit IRIS_TODO.md's "density-independent length unit" item asked for;
it replaces the previous stopgap (the whole rendered scene divided by
`content_scale` then implicitly stretched back up), which is also what
made text blurry — a glyph rasterised at the small, pre-stretch size and
then upscaled onto the real framebuffer.
- **`UiRenderState`/`Painter` gained `density()`/`set_density()`** (physical
pixels per dp). Every place a length resolves (`Len::apply_rest`,
`Size::to_uivec2`) now takes it; `Span::gap` and `Padding`'s four sides
moved from a bare `f32` to `Len` so they take `dp(...)` too. A bare
number anywhere is unaffected — still `abs`, physical pixels.
- **Text is rasterised at physical resolution now.** `TextBuffer::shape`
takes `density` and multiplies `font_size`/`line_height` (and any span
override) by it before handing them to parley, so the atlas holds a
bitmap at the size it is actually shown at rather than a low-resolution
one stretched afterward.
- **Everything at the Android boundary is physical pixels now** — window
size, touch coordinates, insets (`LogicalInsets` renamed
`WindowInsets`). The previous "logical" division by `content_scale` is
gone; `content_scale` now feeds `set_density` instead.
- Not yet verified on Iris's actual phone (this pass had no device) —
built and checked on this checkout's emulator only. RUST.md's P0 box
says what she should check for: crisp text at two densities, the
keyboard no longer wiping, and the header's background.
+65
View File
@@ -117,6 +117,39 @@ order and what "done" looks like. Tick and date them in place.
screen wants the same thing (P1's own transcript rows already read
their content from a `TextEdit` for the same reason).
## From the phone, 2026-09-06
Found on Iris's own phone while working RUST.md's P0 box's phone-report
follow-ups. Recorded here rather than fixed in that pass, so a follow-up
agent takes them without colliding with that pass's `bench_client.rs`/
`android/view.rs`/`android/sense.rs` changes.
- [x] **Swiping has no momentum, fixed 2026-09-06.** `List::fling`/
`VelocityTracker`/`FlingCalculator` (`iris/src/widget/list.rs`,
`iris/src/sense.rs`) -- IRIS.md's 2026-09-06 entry has the full account.
Wired through `Selection::drag`'s release path, cancelled by the next
touch-down, clamped at the loaded content's start/end. Verified by unit
test (fling distance against the closed-form spline result, cancel-on-
touch, the clamp), not yet by an on-device or emulator feel-check --
that is still open.
- [x] **Scrolling down sometimes jitters the text, fixed 2026-09-06.**
Root-caused by reading `DragArbiter::update`'s `Undecided`-to-`Panning`
transition rather than by an on-device trace (no emulator was used this
pass): it was the first named suspect, not the second. `self.last` stays
at the press origin for every `Undecided` frame (nothing pans while the
gesture might still be a selection), so the frame that finally crosses
`DRAG_SLOP` returned `Pan(dy)` with `dy` measured from `press_start` --
the *whole* pre-threshold drag, applied to the list in one step, however
many frames it had taken to get there. Fixed by applying only the
excess past `DRAG_SLOP` on that one frame (`dy - DRAG_SLOP.copysign
(dy)`), the same "consume the slop, don't replay it" rule Android's own
touch handling follows. New regression test,
`crossing_the_slop_by_a_little_pans_by_a_little` (`iris/src/sense.rs`).
**Not yet done**: an emulator trace of the real per-frame offset
confirming this was the whole story on real touch input rather than
only the arbiter's own unit tests -- worth a follow-up pass before
calling it fully closed.
## Build
- [x] **Benchmarks**, not unit tests, run on demand (2026-09-05; a
@@ -385,3 +418,35 @@ do not duplicate it there.
redundant. Decide after the layout change lands, by writing a button
both ways and keeping the one that is shorter to explain; delete the
other rather than keeping two ways.
## Build (asked for by Iris, 2026-09-06): a density-independent length unit
- [x] **A third length kind beside relative and pixels, so display scales
"just work".** Done 2026-09-06 — `Len::dp`/`len_fns::dp`, resolved
against `UiRenderState`/`Painter::density()` at `apply_rest` time; text
additionally rasterises at the resolved (physical) size instead of
scaling a low-resolution bitmap afterward, which was making text blurry.
`Span::gap`/`Padding` moved from `f32` to `Len` so they take `dp(...)`
too; transcript-ui's row/composer padding and one example migrated.
`em` was not added — nothing in this pass needed a text-relative unit,
and `dp`'s own doc says why it and physical pixels are kept as separate
fields rather than one the caller pre-multiplies. Not yet verified on
Iris's own phone at two densities (this pass had no device) — see
docs/RUST.md's P0 box and docs/IRIS.md's 2026-09-06 entry for what to
check. Iris's words: "another length type similar to absolute &
relative, so instead there would be relative, pixels, and another unit
like em or whatever is standard. That way different display scales
should just work." Today a length is either a fraction of the parent
(`rest`/relative) or physical pixels, and the phone drew 16 px text at
roughly a third of its intended size until the P0 fixes applied the
display's scale factor globally. That global scale is a stopgap for the
benchmark; the real shape is a unit resolved against the display's
density at layout time — Android's `dp` / CSS's reference pixel is the
standard (1 unit = 1/160 in), with `em` as the text-relative option —
so a widget author writes `16.dp()` once and never sees the scale.
Done when: `Length` (or whatever the enum is called) has the third
variant; every place that resolves a length takes the density; the
examples and `transcript-ui` use the new unit for text sizes, padding
and control sizes; the emulator at two densities and the phone draw the
same layout at the same physical size. After the bench setup is
finished, before P1 draws any new screen.
+52
View File
@@ -861,6 +861,58 @@ unspecified rather than getting them wrong:
conditions, so the remaining slack was accepted rather than chased
further.
## Density: `Len::dp`, resolved at `apply_rest` time (2026-09-06)
Iris asked for a third length kind beside `abs` (physical pixels) and
`rel`/`rest` (a fraction of the parent) — IRIS_TODO.md's "density-
independent length unit" — after the P0 phone pass found 16px text
drawing at roughly a third size on a real phone. The fix that shipped
first (RUST.md's P0 box) was a global stopgap: divide the whole window
into a "logical" coordinate space (physical ÷ `content_scale`) and let
the shader's NDC mapping stretch it back up onto the real framebuffer.
That fixed the *size* but not the *sharpness* — a glyph rasterised at the
small, pre-stretch size and then stretched onto more physical pixels than
it has texels for is blurry, which is exactly what Iris's next report
said.
**The fix**: `Len` gained a `dp` field, resolved against a `density: f32`
(physical pixels per dp) at the one place a `Len` becomes a `UiScalar`
(`Len::apply_rest`) — `abs + dp * density`. `density` lives on
`UiRenderState` (`set_density`/`density()`) and `Painter` (`density()`),
set once from `DisplayMetrics.density` in `android::view::new_peer`; the
desktop backend has no per-monitor density wired up yet and stays at
`1.0`. Every layout call site that used to call `.apply_rest()`/
`.to_uivec2()` now passes `painter.density()` (nine call sites — `Span`,
`Sized`, `MaxSize`, `Aligned`, `Scroll`, `List::place`, and
`UiRenderState::reposition` itself). This also meant the Android
boundary's global logical-space stopgap could come out entirely: window
size, touch coordinates and insets are physical pixels again, matching
`AndroidRenderer`'s own swapchain resolution, with `dp` doing the
per-length work the global divide used to do for everything at once.
**Text is the case that needed more than the `Len` plumbing.** A widget's
`font_size`/`line_height` are plain `f32`, not routed through `Len` at
all (there is no sensible `rel`/`rest` for a font size). `TextBuffer::
shape` now takes `density` directly and multiplies `font_size`/
`line_height` (and any span override) by it before handing them to
parley — so the size that reaches both the line-breaker and the
rasteriser (`TextData::place`, which reads back whatever `shape` set) is
the display's *physical* size, and the glyph atlas holds a bitmap at the
resolution it is actually shown at. `GlyphKey.size` already keys on the
resolved size, so a cache entry is naturally per-physical-size with no
further change. The one caller with no `Painter` to read density from
(`TextEditCtx::layout`, cursor movement and hit-testing) reads a second
copy kept directly on `TextData` (`TextData::density`) instead — an
accepted duplication rather than threading a `Painter` into every input
handler for one field, the same tradeoff `AndroidRenderer::content_scale`
already makes for the Diagnostics page.
**What did not change**: `rel`/`rest` are unaffected (already
resolution-independent, a fraction of the parent). `Span::gap` and
`Padding`'s four sides moved from bare `f32` to `Len` so `dp(...)` works
on them the same as any other size; a bare number is still `abs`,
physical pixels, unchanged.
## For IRIS.md
When this lands, copy this entry into `IRIS.md` (newest first):
+266
View File
@@ -4315,6 +4315,272 @@ device.
confirming the phase marks partition the whole run rather than
overlapping or dropping frames between them.
**Iris's first real phone report, 2026-09-06** (the redelivered,
no-`force-gles` APK above): no crash. Two screenshots, before any
touch: headings/links/code/table all render correctly. Four defects
found and worked this pass:
1. **Every glyph disappears on the first tap or scroll; rectangles
stay drawn** (the keyboard case is the same thing -- a tap on the
composer). **Not root-caused this pass.** Audited `GpuTextures`'
atlas-grow/patch path, `ArrBuf`'s resize-on-length-change
contract, and the masks/move_offsets/rsc bind-group rebuild logic
in `core/src/render/mod.rs` against wgpu's queue-ordering
contract -- everything read as spec-correct (a `queue.write_texture`/
`write_buffer` issued before a later `queue.submit` is guaranteed
visible to it on the same queue, and a dropped `Buffer`/`Texture`/
`BindGroup` still in flight is kept alive by wgpu's own tracker).
No violation found by static reading; reproducing needs either
the phone or a Mali driver trace, neither available this pass.
Instrumented for the next report instead: `Device::
on_uncaptured_error` is now installed on the Android device
(`WgpuErrorLog`, `android::render::AndroidRenderer`), and
`IrisViewPeer::render` logs masks/moves-resized, atlas
pages-grown and image bind-group creates for the first 10 frames
after every `surface_changed` -- exactly the window this bug
lands in. The bench screen's new "Diagnostics" button (below)
surfaces the error log and adapter identity on demand.
2. **Bold words render as blank gaps of the correct advance width**
(regular, links, inline code render fine). Fixed by bundling Noto
Sans/Noto Sans Mono (regular/bold/italic/bold-italic, static
cuts, OFL) into `iris-core` and registering them ahead of the
platform's own fonts -- `core/src/primitive/text.rs`'s
`TextData::register_bundled_fonts`. Named hypothesis, not
confirmed on the phone: the system "Roboto" on a modern Android
device is the variable "Roboto Flex," and this crate's glyph
path (`TextData::place`) does not apply `Synthesis`/variable-axis
correction at all -- a bundled *static* per-style face sidesteps
the question rather than answering it. `TextData::font_diagnostics`
reports what got resolved; logged once at startup and shown on
the Diagnostics page.
3. **Text far too small** -- iris had no device-pixel-ratio handling
on *either* platform before this pass (grepped for `scale_factor`
across the whole crate: zero hits). `DisplayMetrics.density`
(Android) / `Window::scale_factor()` (desktop) now divides every
physical-pixel number (window size, touch coordinates, the
shader's window uniform) down to logical units before it reaches
layout, so a `font_size: 16.0` is 16 dp rather than 16 raw device
pixels on a ~3x-density phone. Cost a second, real bug found only
by measuring on this checkout's emulator after the first fix
landed: `android::view::IrisViewPeer::surface_changed`'s call
into `UiRenderState::resize` (the layout engine's own notion of
the canvas, which every widget's absolute `PixelRegion` is
computed against) was still being handed raw physical
`width`/`height`, while `AndroidRenderer`'s side of the same
resize had already switched to logical -- splitting layout and
the shader into two different units. A fixed-size widget (the
bench screen's `.height(56)` button row) exposed it at ~40
physical px against the ~147px `56 * content_scale` predicts;
a proportional (`rest(n)`) size hid it by adapting to whichever
total it was given. Both are logical now. **Not fully verified**:
a fresh-install emulator screenshot after both fixes shows
visibly larger, readable text (`docs/bench/` has neither
screenshot committed -- see AGENTS.md on transcripts/screenshots
not going in this repo -- but the before/after is described in
the commit), and the button row's own height still isn't
obviously matching `56 * content_scale` on this run -- worth a
second look with `ui-trace show --field box` once there's time,
but not a blocker for the magnitude of the original bug (3x too
small).
4. **Status-bar inset not applied** -- confirmed nothing in this
app ever read `insets().top` at all (`android/insets.rs` has
carried `Insets.top` since it was written; nothing consumed it).
Fixed with a new, generic hook: `AndroidAppState::
on_insets_changed(rsc, LogicalInsets)`, called from `render()`
exactly when `AndroidUiState::insets()` changes, in logical units
matching everything else `content_scale` now divides.
`BenchClient::on_insets_changed` rebuilds the root tree with
`Padding::top(insets.top)` on the button row -- rebuilding the
whole tree rather than one `WidgetPtr` slot's content, because
the first attempt (a `Pad` dropped into an unrelated `WidgetPtr`
slot with no height override of its own) did not propagate the
wrapped span's fixed height correctly, which is what surfaced
finding 3's `UiRenderState::resize` bug in the first place.
Verified via `ui-trace show --field box`: the button row's top
(150 physical px) sits 8px below `statusBarBackground`'s bottom
edge (142px) on this checkout's emulator.
**A named `Diagnostics` control now exists** (RUST.md's own earlier
ask): a third button on the bench screen's top row, filling the
existing benchmark-report `TextEdit` with adapter identity/backend/
driver, font resolution, the atlas's live view count, every
uncaptured wgpu error since surface creation, and the frame report
-- `android::render::AndroidRenderer::diagnostics_report`. Uses the
existing "Copy report" button/clipboard path rather than a second
one.
**Verified this pass, this checkout's emulator** (`EMU_GPU` default,
`--features force-gles` -- this cold `emu up` again enumerated zero
Vulkan adapters, the same pre-existing flakiness earlier boxes
documented, not something this pass's diff caused): `cargo fmt --all
-- --check`, `cargo clippy --workspace --all-targets` (zero warnings
beyond the pre-existing wgpu future-incompat notice), `cargo test
--workspace` (all passing, unchanged pure-logic counts), `cargo ndk
-t x86_64 -P 26 check` clean, `./run-bench.sh` end to end
(`frames=691`, 24/24 swipes, 400/400 streamed events, no crash),
fresh-install screenshots and `ui-trace` box readouts for the four
items above. **Not verified this pass**: the actual phone (no
access), and item 1's root cause (needs either the phone's next
Diagnostics-page report or a Mali trace).
**Recorded but not fixed this pass** (a follow-up agent takes these,
to avoid colliding with this pass's `bench_client.rs`/`view.rs`
changes) -- see `IRIS_TODO.md`'s "From the phone, 2026-09-06":
swiping has no momentum (stops exactly where the finger releases,
unlike Compose's fling), and scrolling down sometimes jitters the
text.
**Redelivered, 2026-09-06, later the same day.** New arm64 APK
(Vulkan, no `force-gles`, bundled fonts, content-scale fix,
Diagnostics control), same `dev.iris.android.demo.bench` id, same
`CN=ai-app` signing cert, copied to `~/host/bench/
iris-bench-arm64.apk` and `~/repos/ai-app-bench/iris/build/outputs/
apk/release/iris-bench-arm64.apk`; that repo's own README gained a
dated entry. Still not confirmed on Iris's actual phone.
**Redelivered again, 2026-09-06, a later pass.** Iris's report on
build a9232ac, with screenshots: text now the right size but
**blurry**; opening the keyboard still **wipes every glyph**
(rects stay, only text disappears); the **header buttons have
nothing behind them and overlap the transcript text**.
**1. The keyboard wipe.** Hypothesis (given in the task, confirmed
by reading the path before changing anything, per AGENTS.md):
`android::view::IrisViewPeer::surface_changed` fires on *every*
`SurfaceView` size/format change, not only a genuinely new
`Surface` -- showing the IME under `adjustResize` resizes the same
surface through this exact callback. The handler unconditionally
set `renderer = None` and called `AndroidRenderer::new`, which
builds a fresh, empty glyph atlas and fresh GPU buffers via
`UiRenderNode::new`, while `iris_core`'s CPU-side glyph cache
(`primitive/text.rs`) kept the atlas UV coordinates it had already
handed out against the *old* atlas -- every glyph then drew from a
rectangle pointing into a texture that had just been recreated
empty. Confirmed by reading `AndroidRenderer::resize` (already
existed, already did none of that -- only `surface.configure` and
the window uniform) against what `surface_changed` was actually
calling instead. **Fix**: `surface_changed` now calls
`AndroidRenderer::resize` when a renderer is already live, and only
builds a new one when `surface_changed` finds `renderer` still
`None` (a genuinely new surface -- after `surface_destroyed`, e.g.
backgrounding). Not independently re-verified against a forced IME
resize on this pass's emulator (no display keyboard exercised
end-to-end here); the reasoning is a direct code read plus the
existing `resize` path already being surface-only, not a
screenshot diff -- **the next agent with emulator time should do
the before/after screenshot this box originally asked for.**
**2. The blur.** Root cause: the P0 fix that made text the right
*size* (dividing the whole window into a "logical" space, then
letting the shader's NDC mapping stretch it back onto the real
framebuffer) rasterised each glyph at the small, pre-stretch size
and then displayed it stretched onto more physical pixels than it
had texels for. **Fix, and the density-independent length unit
Iris asked for the same day (IRIS_TODO.md) turned out to be the
same fix**: `Len::dp`, resolved against a `density` now carried on
`UiRenderState`/`Painter`, replaces the global stretch -- window
size, touch and insets are physical pixels throughout again
(`WindowInsets`, renamed from `LogicalInsets`), and
`TextBuffer::shape` multiplies `font_size`/`line_height` by density
before handing them to parley, so the atlas rasterises at the
display's real physical resolution. Full design in docs/LAYOUT.md's
"Density: `Len::dp`" section and the public-API summary in
docs/IRIS.md's 2026-09-06 entry.
**3. The header.** Only each button's own `rect(...)` painted
anything, so the gaps between/around them and the status-bar strip
above showed `CLEAR_COLOR` (black) one layer back, and the row's
reserved height was three `abs` (now-physical-pixel) button boxes
-- smaller than the dp-correct size the transcript below uses,
which is what read as "overlap" once the two disagreed. Fixed with
a `HEADER_SURFACE` rect stacked behind the whole row and every
header size moved onto `dp(...)`.
**4. Keyboard diagnostics, so Iris can report back even if a
keyboard-triggered regression persists.** `on_insets_changed` now
edge-triggers ~500ms after `ime_bottom` becomes non-zero, capturing
the same report the on-screen Diagnostics button produces, logging
it, copying it to the clipboard unprompted, and showing it in a new
plain-view overlay (`IrisView.showDiagnosticsOverlay`, Copy/Close)
that draws independently of iris's own renderer.
**Verified this pass**: `cargo fmt --all`, `cargo clippy --workspace
--all-targets` and `cargo clippy` on `android-app` (both `-D
warnings`, zero beyond the pre-existing `tabs-ui` unused-dependency
and wgpu future-incompat notices), `cargo test --workspace` (all
passing), `cargo ndk -t arm64-v8a check`/`clippy` for both the
`transcript-screen bench` feature set.
**Then run on this checkout's own emulator** (x86_64 debug,
`--features "transcript-screen force-gles bench"` -- this AVD has no
Vulkan adapter under a plain `-gpu host` boot, matching every prior
emulator finding in this file): `run-bench.sh` end to end, no crash,
`frames=534 janky%=79.03 ... cpu_p50=1.3ms`, 24/24 swipes, 400/400
streamed events -- unchanged in shape from prior readings, so the
diff cost nothing on the success path. **Header background**:
screenshot confirms the `HEADER_SURFACE` panel now sits behind all
three buttons (`/tmp/bench-after-run.png` this pass). **Keyboard
wipe**: forced a real `surface_changed` two ways -- `adb shell wm
size 1080x1900` (screenshot before/after, text intact) and actually
opening the soft keyboard via `settings put secure
show_ime_with_hard_keyboard 1` + tapping the message field
(ui-trace confirmed a real resize, elements moved -547px; keyboard
visible in the screenshot, text still fully rendered, not wiped).
Both are real evidence the reuse-renderer fix works, though neither
is the literal before/after diff this box originally asked for --
**still worth a deliberate side-by-side screenshot pair in a future
pass.**
**Found during this same verification, not fixed, needs a follow-up
pass**: after the keyboard-triggered resize, the top button row
appeared to render a **second time**, well below its real position,
inside the transcript's scroll area (same colours/text, unmistakably
the same three buttons) -- and a tap aimed at the composer's
"Message" field landed on "Run benchmark" instead (a second
benchmark run started, visible in logcat as two `iris bench report:`
lines from one session). Only seen after a resize with the keyboard
genuinely open; the plain `wm size` resize screenshot pair did not
show it, nor did the fresh-install screenshot before either resize.
**Not root-caused this pass** -- time ran out before isolating
whether this is the `Span::DOWN` two-phase draw (LAYOUT.md's
provisional-then-real placement) leaving a phase-1 primitive
retained somewhere it should have been moved from, something
specific to the keyboard's `on_insets_changed` rebuild racing a
redraw, or unrelated to this pass's changes entirely (not verified
against a build predating this session's commits, so do not treat
"caused by this pass" as established -- MACHINE.md's pinned rule
about not attributing without measuring applies here too). Also
noteworthy: `capture_keyboard_diagnostics` never fired in this
session (no "iris keyboard diagnostics" log line) despite the
keyboard visibly opening -- `on_insets_changed`'s `ime_bottom` may
not be populated the way expected on this emulator/API level, or
the duplicate-row state above interfered; **also needs a follow-up
pass** before relying on the auto-capture on a real phone.
**Not verified this pass**: anything on Iris's real phone, the
two-density crispness check IRIS_TODO.md's unit item asks for, and
the two open items just above.
**Fling and jitter, 2026-09-06.** The two `IRIS_TODO.md` "From the
phone" items this box's own text names as follow-ups are fixed --
`List::fling`/`VelocityTracker`/`FlingCalculator` (IRIS.md's
2026-09-06 entry) and the `DragArbiter` slop-release jump (fixed by
applying only the excess past `DRAG_SLOP` on the crossing frame,
not the whole pre-threshold drag) -- both wired through
`Selection::drag`'s release path, both covered by new unit tests in
`iris/src/sense.rs` and `iris/src/widget/list.rs`. **Root-caused by
reading `DragArbiter::update` and testing it directly, not by an
emulator trace** -- this pass did not open an emulator, so the
"trace the list's offset per frame" verification this box's own
todo asked for is still open, as is a feel-check of the fling on
real touch input. **Benchmark v2's four-phase spec (fling/stream/
type/keyboard) in `bench_client.rs` was not attempted this pass** --
wiring a real IME show/hide and refresh-rate read through
`bench_jni.rs`, and `FrameReport`'s per-phase accounting, is real
scope on its own and was left rather than shipped half-verified;
the Compose half above is already done and is the reference shape
for whoever picks this up. No redelivery this pass.
- [ ] **P1 — session screen parity.** History paging backward (with the
page-boundary healing `client-core` does not have yet, below),
`TranscriptSource`-backed cache/server stitching, jump-to-latest,
+93
View File
@@ -0,0 +1,93 @@
# Compose bench v2 report from Iris's phone, 2026-09-06
Bench v2 (fling / stream / type / keyboard, RUST.md's P0 box) on the
Compose `bench` build, run by Iris on her Pixel 9 Pro XL, verbatim. Note
the display was at **60 Hz** for this run (16.7 ms budget) where the v1
run was at 120 Hz -- the phone's adaptive refresh rate decides, and
`late` is judged against whichever it was, so compare a run with a run at
the same rate. The iris v2 report goes beside this when it exists.
What it says: fling, type and keyboard are all essentially clean on
Compose (0.1%, 0.9% and 0% late; fling p50 5.5 ms, p99 11.6 ms). The
whole tail is the streaming phase again -- 41.9% late, p99 42.5 ms,
driven by `markdown reparsed while streaming` (8.6 ms mean, 30.3 ms
worst) and `record: one block` (6.3 ms mean, 25.5 ms worst). Process CPU
69.6 s over the 125.5 s run; peak RSS 577 MB; battery current mean
571 mA over 126 samples.
```
ai-app render report
device: Pixel 9 Pro XL (Google), Android 17
build: release
transcript:
108 events, 26 rows, 58 units loaded
viewport 1531px, 2 units visible
on screen: the list's own 0px, AssistantMsg 24520px
0 tool calls and 0 groups open
per phase:
fling: 3278 frames over 32.7s
late: 4 (0.1%)
total p50 5.5ms p90 8.7ms p99 11.6ms
worst 49.0ms
stream: 1041 frames over 21.3s
late: 436 (41.9%)
total p50 13.4ms p90 31.7ms p99 42.5ms
worst 52.5ms
type: 2446 frames over 61.5s
late: 23 (0.9%)
total p50 7.3ms p90 13.2ms p99 16.5ms
worst 38.9ms
keyboard: 358 frames over 10.0s
late: 0 (0.0%)
total p50 6.3ms p90 8.6ms p99 11.1ms
worst 12.0ms
frames:
7122 frames over 125.5s at 60Hz (16.7ms budget)
late: 463 (6.5%)
total p50 6.0ms p90 13.8ms p99 34.0ms
waited p50 0.5ms p90 1.1ms p99 19.9ms
input p50 0.0ms p90 0.0ms p99 0.0ms
anim p50 0.7ms p90 4.5ms p99 7.6ms
layout p50 0.1ms p90 0.1ms p99 0.2ms
draw p50 0.7ms p90 2.9ms p99 21.9ms
sync p50 0.1ms p90 0.2ms p99 0.6ms
issue p50 1.4ms p90 2.4ms p99 3.2ms
swap p50 0.4ms p90 0.8ms p99 1.2ms
gpu p50 1.5ms p90 2.1ms p99 6.6ms
where the draw phase went:
draw phase 1.74ms per frame, of which:
the transcript: 0.24ms (measure 0.10, place 0.14, record 0.00)
everything else: 1.51ms (86%)
work since this was last copied:
draw: the whole transcript: 280, 1.9ms total, 0.0ms mean, 0.0ms worst
grouped tool runs: 407, 18.6ms total, 0.0ms mean, 0.2ms worst
markdown cut into pieces: 40, 0.4ms total, 0.0ms mean, 0.0ms worst
markdown parsed while composing: 2, 1.6ms total, 0.8ms mean, 1.1ms worst
markdown ready: 323
markdown reparsed while streaming: 395, 3406.9ms total, 8.6ms mean, 30.3ms worst
markdown warmed: 40, 38.2ms total, 1.0ms mean, 4.3ms worst
measure: the whole transcript: 1978, 683.9ms total, 0.3ms mean, 15.9ms worst
message composed: 397
message cut into parts: 40, 2.9ms total, 0.1ms mean, 0.2ms worst
place: the whole transcript: 4308, 996.0ms total, 0.2ms mean, 2.4ms worst
record: one block: 394, 2472.7ms total, 6.3ms mean, 25.5ms worst
session screen recomposed: 1630
status row recomposed: 1
transcript page from server: 10
unit composed: 927
units flattened: 408, 85.5ms total, 0.2ms mean, 1.8ms worst
bench:
fling: 8 flings out + 8 back at 12000px/s, travel start=idx=0/off=0px outward=idx=188/off=182px end=idx=0/off=0px
scroll: 6 cycles (24 swipes, legacy tween), streamed 400/400 fixture events
type: 600 characters inserted then deleted, one per 50ms
keyboard: shown 5/5, hidden 5/5 (confirmed via isImeVisible)
process CPU time over this run: 69564ms
peak RSS: 577452kB
battery current: mean -571483µA over 126 samples (min -2361718, max -99218)
```
@@ -1,8 +1,15 @@
package dev.iris.android.demo;
import android.app.Activity;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TextView;
@@ -68,4 +75,81 @@ public final class IrisView extends RustView {
scroll.addView(text);
activity.setContentView(scroll);
}
private static final String DIAGNOSTICS_OVERLAY_TAG = "iris-diagnostics-overlay";
/**
* The bench build's keyboard diagnostics capture
* (`bench_client.rs`'s `on_insets_changed` /
* `capture_keyboard_diagnostics`, via `bench_jni.rs`'s
* `PlatformHandle::show_diagnostics_overlay`): unlike
* `showRendererError` above, this adds a panel *over* this view
* (`MainActivity`'s `FrameLayout` still holds `IrisView` underneath,
* running) rather than replacing the activity's content, and gives it
* a Copy button and a Close that removes the panel -- so it draws
* (and can be read) whether or not iris itself is still putting
* anything on screen, without abandoning the session that produced
* it. Runs on the UI thread regardless of which thread calls it,
* since the call comes from a background task (a delayed capture
* after the keyboard opens), and touching the view tree off the UI
* thread is undefined.
*/
void showDiagnosticsOverlay(String report) {
Context context = getContext();
if (!(context instanceof Activity)) {
return;
}
Activity activity = (Activity) context;
activity.runOnUiThread(() -> {
ViewGroup parent = (ViewGroup) getParent();
if (parent == null) {
return;
}
View existing = parent.findViewWithTag(DIAGNOSTICS_OVERLAY_TAG);
if (existing != null) {
parent.removeView(existing);
}
float density = activity.getResources().getDisplayMetrics().density;
int pad = (int) (16 * density);
LinearLayout overlay = new LinearLayout(activity);
overlay.setTag(DIAGNOSTICS_OVERLAY_TAG);
overlay.setOrientation(LinearLayout.VERTICAL);
overlay.setBackgroundColor(0xEE000000);
overlay.setPadding(pad, pad, pad, pad);
TextView text = new TextView(activity);
text.setText(report);
text.setTextIsSelectable(true);
text.setTextColor(0xFFFFFFFF);
ScrollView scroll = new ScrollView(activity);
scroll.addView(text);
overlay.addView(scroll, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f));
LinearLayout buttonRow = new LinearLayout(activity);
buttonRow.setOrientation(LinearLayout.HORIZONTAL);
buttonRow.setPadding(0, pad, 0, 0);
Button copy = new Button(activity);
copy.setText("Copy");
copy.setOnClickListener(v -> {
ClipboardManager clipboard =
(ClipboardManager) activity.getSystemService(Context.CLIPBOARD_SERVICE);
if (clipboard != null) {
clipboard.setPrimaryClip(ClipData.newPlainText("iris diagnostics", report));
}
});
Button close = new Button(activity);
close.setText("Close");
close.setOnClickListener(v -> parent.removeView(overlay));
buttonRow.addView(copy);
buttonRow.addView(close);
overlay.addView(buttonRow);
parent.addView(overlay, new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
});
}
}
+187 -7
View File
@@ -58,6 +58,12 @@ pub struct BenchClient {
ui_state: AndroidUiState,
content: WeakWidget<WidgetPtr>,
report_display: WeakWidget<TextEdit>,
/// The top button row, in a `WidgetPtr` slot rather than added
/// directly (like `content`) so `on_insets_changed` can swap in a
/// version padded for the status bar once insets are known -- RUST.md's
/// P0 box, "the status-bar inset is not applied," found the row sitting
/// directly under it because nothing here read `insets().top` at all.
top_bar: WeakWidget<WidgetPtr>,
screen: Option<transcript_ui::TranscriptScreen>,
items: Vec<TranscriptItem>,
/// The events not yet streamed -- consumed by `start_benchmark`'s own
@@ -68,6 +74,13 @@ pub struct BenchClient {
platform: Option<Arc<PlatformHandle>>,
last_report: Option<String>,
running: bool,
/// Edge-triggers the keyboard diagnostics capture below -- set on the
/// first `on_insets_changed` where `ime_bottom > 0.0`, cleared on the
/// first where it is not, so opening the keyboard fires this once
/// rather than on every insets update while it stays open (a rotation
/// or a status-bar change with the keyboard already up would otherwise
/// re-fire it).
keyboard_was_visible: bool,
}
impl HasAndroidUiState for BenchClient {
@@ -172,27 +185,48 @@ impl AndroidAppState for BenchClient {
.label("Benchmark report")
.add(rsc);
let controls = bench_controls(rsc);
let top_bar = WidgetPtr::new().add(rsc);
let controls = bench_controls(rsc, 0.0);
top_bar(rsc).set(controls);
let tree = (
controls,
top_bar,
content.height(rest(2)),
report_display.height(rest(1)).pad(8),
report_display.height(rest(1)).pad(dp(8)),
)
.span(Dir::DOWN)
.add_strong(rsc)
.any();
ui_state.set_root(tree);
// Startup log line (RUST.md's P0 box, "log once at startup ... the
// number of font families found, the default family resolved"):
// what font discovery actually found on this device, before
// anything is drawn.
let font = rsc.ui.text.font_diagnostics();
log::info!(
"iris fonts: {} families found, default={:?} mono={:?}, resolved regular={:?} \
bold={:?} italic={:?} mono={:?}",
font.families_found,
font.default_family,
font.default_mono_family,
font.regular_resolved,
font.bold_resolved,
font.italic_resolved,
font.mono_resolved,
);
let mut client = Self {
ui_state,
content,
report_display,
top_bar,
screen: None,
items: Vec::new(),
stream_tail: Vec::new(),
platform: None,
last_report: None,
running: false,
keyboard_was_visible: false,
};
let (backlog, stream_tail) = parse_fixture();
@@ -216,11 +250,84 @@ impl AndroidAppState for BenchClient {
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>, _render: &mut UiRenderState) -> bool {
false
}
/// Pads the top button row by the status-bar inset -- see `top_bar`'s
/// field comment. Rebuilds the row rather than mutating a stored
/// `Padding` in place, since nothing here holds a handle to one.
///
/// **Also the trigger for the keyboard diagnostics capture** (RUST.md's
/// P0 box): the IME resizing the surface is exactly the case the
/// previous commit found wiped text, and Iris needs a way to get a
/// report off the phone even if that (or some other keyboard-triggered
/// regression) is still happening on the build she is holding --
/// `capture_keyboard_diagnostics` below fires ~500ms after the
/// keyboard becomes visible, once per keyboard opening, and shows its
/// report in a plain overlay view that draws independently of
/// whatever iris itself is doing.
fn on_insets_changed(
&mut self,
rsc: &mut AndroidRsc<Self>,
insets: iris::android::WindowInsets,
) {
let controls = bench_controls(rsc, insets.top);
(self.top_bar)(rsc).set(controls);
let ime_visible = insets.ime_bottom > 0.0;
if ime_visible && !self.keyboard_was_visible {
self.keyboard_was_visible = true;
let redraw = rsc.tasks.redraw_handle();
rsc.spawn_task(async move |mut ctx| {
tokio::time::sleep(Duration::from_millis(KEYBOARD_DIAGNOSTICS_DELAY_MS)).await;
ctx.update(|state: &mut BenchClient, rsc| {
state.capture_keyboard_diagnostics(rsc);
});
redraw.request_redraw();
});
} else if !ime_visible {
self.keyboard_was_visible = false;
}
}
}
/// How long to wait after the keyboard becomes visible before capturing
/// diagnostics -- long enough that the resize, the reported wipe (if it is
/// still happening) and a couple of frames have all had time to land, per
/// AGENTS.md's "so that operations that finish in milliseconds have states
/// on the way that nothing can observe" reasoning applied the other way:
/// this wants to observe the state *after* the transition settles, not
/// mid-flight.
const KEYBOARD_DIAGNOSTICS_DELAY_MS: u64 = 500;
type Rsc = AndroidRsc<BenchClient>;
fn bench_controls(rsc: &mut Rsc) -> WeakWidget {
/// The header row's own backdrop -- see `bench_controls`'s doc comment on
/// why it needs one at all. A dark neutral rather than pure black
/// (`android::render::CLEAR_COLOR`) so the row reads as a distinct panel
/// instead of a hole in the background the buttons happen to float in.
const HEADER_SURFACE: UiColor = UiColor::new(28, 28, 34, 255);
/// `top_pad` is the status-bar inset in physical pixels (0.0 until
/// `on_insets_changed` has run once) -- folded in here, rather than
/// exposing the unadded builder for a caller to `.pad()` itself, because
/// naming that builder's type at each call site is more machinery than a
/// top-of-screen padding number is worth.
///
/// **Backed by an opaque rect the full size of the row, not just the three
/// buttons.** Iris's phone report (docs/RUST.md's P0 box, screenshots on
/// build a9232ac): "the header buttons have nothing behind them and
/// overlap the transcript text" -- before this, only each button's own
/// `rect(...)` painted anything, so the gaps between and around them (and
/// the status-bar strip above them) showed whatever was one layer back
/// (`CLEAR_COLOR`, black), and the row's true height was three
/// physical-pixel-sized (`abs`, not `dp`) button boxes rather than the
/// density-correct size the transcript below was already using post-P0 --
/// exactly what reads as "overlap" once the two disagree. Fixed two ways
/// together: a `HEADER_SURFACE` rect stacked behind the whole row (this
/// function), and every size below moved from a bare number (physical
/// pixels) to `dp(...)` (IRIS_TODO.md's density-independent length unit),
/// so the row's reserved height in the outer `Span::DOWN`
/// (`AndroidAppState::new`) matches what is actually painted.
fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
let run_rect = rect(Color::rgb(40, 70, 40))
.on(
CursorSense::click(),
@@ -234,7 +341,7 @@ fn bench_controls(rsc: &mut Rsc) -> WeakWidget {
wtext("Run benchmark").size(18).text_align(Align::CENTER),
)
.stack()
.pad(8)
.pad(dp(8))
.add(rsc);
let copy_rect = rect(Color::rgb(50, 50, 60))
@@ -250,10 +357,33 @@ fn bench_controls(rsc: &mut Rsc) -> WeakWidget {
wtext("Copy report").size(18).text_align(Align::CENTER),
)
.stack()
.pad(8)
.pad(dp(8))
.add(rsc);
(run, copy).span(Dir::RIGHT).height(56).add(rsc)
let diag_rect = rect(Color::rgb(60, 45, 70))
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
ctx.state.show_diagnostics(rsc);
},
)
.label("Diagnostics");
let diagnostics = (
diag_rect,
wtext("Diagnostics").size(18).text_align(Align::CENTER),
)
.stack()
.pad(dp(8))
.add(rsc);
let buttons = (run, copy, diagnostics).span(Dir::RIGHT).add(rsc);
(rect(HEADER_SURFACE), buttons)
.stack()
.height(dp(56))
.pad(Padding::top(top_pad))
.add_strong(rsc)
.any()
}
impl BenchClient {
@@ -270,6 +400,56 @@ impl BenchClient {
self.screen = Some(screen);
}
/// RUST.md's P0 box: "a named `Diagnostics` control ... with 'copy this
/// and send it to Iris'." Fills `report_display` (the same TextEdit the
/// benchmark report uses) rather than a separate widget, so the
/// existing "Copy report" button and clipboard path work on whichever
/// text is currently shown -- `last_report` is what `copy_report` reads,
/// so it's set here too rather than adding a second copy path.
fn show_diagnostics(&mut self, rsc: &mut Rsc) {
let font = rsc.ui.text.font_diagnostics();
let frame_report = match self.android_state().frame_report.report() {
Some(stats) => format!("{stats}"),
None => "no frames recorded yet".to_string(),
};
let report = match &self.android_state().renderer {
Some(renderer) => renderer.diagnostics_report(&font, &frame_report),
None => "iris diagnostics: no renderer yet (no surface)".to_string(),
};
self.report_display.edit(rsc).set(&report);
self.last_report = Some(report);
}
/// The keyboard's own diagnostics capture -- see `on_insets_changed`'s
/// doc comment. Reuses `show_diagnostics`'s exact report (so it is the
/// same text the on-screen `Diagnostics` button produces, plus the
/// per-frame log `FrameReport` already keeps around the resize --
/// `frame_report.report()` above covers "the frames around the
/// resize" without a second accounting mechanism), then does three
/// things the button does not: logs it (so a `logcat` pull gets it
/// even if nothing on screen does), copies it to the clipboard
/// unprompted, and shows it in the shell's plain overlay view, which
/// draws independently of iris's own renderer -- the whole point,
/// since the renderer is exactly what might be in the wiped state
/// this exists to report on.
fn capture_keyboard_diagnostics(&mut self, rsc: &mut Rsc) {
self.show_diagnostics(rsc);
let Some(report) = self.last_report.clone() else {
return;
};
log::info!("iris keyboard diagnostics:\n{report}");
let Some(platform) = &self.platform else {
log::info!("iris keyboard diagnostics: no platform handle, can't reach the shell");
return;
};
if platform.copy_to_clipboard("iris keyboard diagnostics", &report) {
log::info!("iris keyboard diagnostics: copied to clipboard");
} else {
log::info!("iris keyboard diagnostics: clipboard copy failed");
}
platform.show_diagnostics_overlay(&report);
}
fn copy_report(&mut self) {
let Some(report) = &self.last_report else {
log::info!("iris bench report: nothing to copy -- run the benchmark first");
+27
View File
@@ -131,4 +131,31 @@ impl PlatformHandle {
.ok()?;
Some(())
}
/// Shows `report` in the shell's plain-view diagnostics overlay
/// (`IrisView.showDiagnosticsOverlay`) -- a real `TextView` plus Copy
/// and Close controls, added over whatever iris itself is drawing
/// rather than replacing it (unlike `android::view::show_renderer_error`,
/// which exists for the case the renderer can never recover from and
/// intentionally never returns). Called from a background task after
/// the keyboard-open delay (`bench_client.rs`'s `on_insets_changed`),
/// so the Java side hops onto the UI thread itself before touching the
/// view tree -- see that method's own comment.
pub fn show_diagnostics_overlay(&self, report: &str) -> bool {
self.try_show_diagnostics_overlay(report).is_some()
}
fn try_show_diagnostics_overlay(&self, report: &str) -> Option<()> {
let mut guard = self.vm.attach_current_thread().ok()?;
let env: &mut JNIEnv = &mut guard;
let jreport = env.new_string(report).ok()?;
env.call_method(
self.view.as_obj(),
"showDiagnosticsOverlay",
"(Ljava/lang/String;)V",
&[JValue::Object(jreport.as_ref())],
)
.ok()?;
Some(())
}
}
+64 -7
View File
@@ -9,7 +9,31 @@ pub struct Size {
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Len {
/// Physical pixels -- a raw device pixel, unaffected by the display's
/// density. Rare to want directly (a hairline border is the usual
/// case); most sizes should be `dp` instead. See `dp`'s own doc for why
/// the two are kept separate rather than one field a caller has to
/// remember to pre-multiply.
pub abs: f32,
/// Density-independent pixels -- Android's `dp` / CSS's reference pixel
/// (1 unit = 1/160in), resolved against the display's density at
/// layout time (`apply_rest`'s `density` parameter) rather than at the
/// point a widget is built, since density is a property of the device
/// this ends up running on, not of the widget tree. This is the unit
/// IRIS_TODO.md's "a density-independent length unit" item asked for,
/// 2026-09-06: before it existed, every size in the tree was `abs`
/// (physical pixels), and the only way to make a 16px design draw at
/// the right *size* on a denser display was a single global multiply
/// applied to the whole rendered scene after layout -- which is also
/// what made text blurry (RUST.md's P0 box, "blurry ... glyphs drawn
/// at logical size and stretched by the scale"): a glyph rasterised at
/// 16 physical px and then stretched 3x by that global multiply is a
/// 48px area sampled from a 16px bitmap. Resolving `dp` per-length at
/// layout time instead means the font size handed to the text shaper
/// is already the physical size (`16.0.dp() * 3.0`), so the glyph
/// atlas rasterises at the display's real resolution and nothing
/// downstream needs to stretch anything.
pub dp: f32,
pub rel: f32,
pub rest: f32,
}
@@ -67,10 +91,10 @@ impl Size {
}
}
pub fn to_uivec2(self) -> UiVec2 {
pub fn to_uivec2(self, density: f32) -> UiVec2 {
UiVec2 {
x: self.x.apply_rest(),
y: self.y.apply_rest(),
x: self.x.apply_rest(density),
y: self.y.apply_rest(density),
}
}
@@ -98,26 +122,43 @@ impl Size {
impl Len {
pub const ZERO: Self = Self {
abs: 0.0,
dp: 0.0,
rel: 0.0,
rest: 0.0,
};
pub const REST: Self = Self {
abs: 0.0,
dp: 0.0,
rel: 0.0,
rest: 1.0,
};
pub fn apply_rest(&self) -> UiScalar {
/// Resolves to a `UiScalar`, folding `dp` into `abs` pixels against
/// `density` (physical pixels per dp -- 1.0 on a desktop or an
/// unscaled display, `content_scale` on Android; see `dp`'s field
/// doc). Every other component of `Len` is already resolution-
/// independent (`rel` is a fraction of the parent; `rest` becomes a
/// fraction too, below), so `density` only ever touches this one term.
pub fn apply_rest(&self, density: f32) -> UiScalar {
UiScalar {
rel: self.rel + if self.rest > 0.0 { 1.0 } else { 0.0 },
abs: self.abs,
abs: self.abs + self.dp * density,
}
}
pub fn abs(abs: impl UiNum) -> Self {
Self {
abs: abs.to_f32(),
dp: 0.0,
rel: 0.0,
rest: 0.0,
}
}
pub fn dp(dp: impl UiNum) -> Self {
Self {
abs: 0.0,
dp: dp.to_f32(),
rel: 0.0,
rest: 0.0,
}
@@ -125,6 +166,7 @@ impl Len {
pub fn rel(rel: impl UiNum) -> Self {
Self {
abs: 0.0,
dp: 0.0,
rel: rel.to_f32(),
rest: 0.0,
}
@@ -132,6 +174,7 @@ impl Len {
pub fn rest(ratio: impl UiNum) -> Self {
Self {
abs: 0.0,
dp: 0.0,
rel: 0.0,
rest: ratio.to_f32(),
}
@@ -144,6 +187,15 @@ pub mod len_fns {
pub fn abs(abs: impl UiNum) -> Len {
Len {
abs: abs.to_f32(),
dp: 0.0,
rel: 0.0,
rest: 0.0,
}
}
pub fn dp(dp: impl UiNum) -> Len {
Len {
abs: 0.0,
dp: dp.to_f32(),
rel: 0.0,
rest: 0.0,
}
@@ -151,6 +203,7 @@ pub mod len_fns {
pub fn rel(rel: impl UiNum) -> Len {
Len {
abs: 0.0,
dp: 0.0,
rel: rel.to_f32(),
rest: 0.0,
}
@@ -158,14 +211,15 @@ pub mod len_fns {
pub fn rest(ratio: impl UiNum) -> Len {
Len {
abs: 0.0,
dp: 0.0,
rel: 0.0,
rest: ratio.to_f32(),
}
}
}
impl_op!(Len Add add; abs rel rest);
impl_op!(Len Sub sub; abs rel rest);
impl_op!(Len Add add; abs dp rel rest);
impl_op!(Len Sub sub; abs dp rel rest);
impl_op!(Size Add add; x y);
impl_op!(Size Sub sub; x y);
@@ -187,6 +241,9 @@ impl std::fmt::Display for Len {
if self.abs != 0.0 {
write!(f, "{} abs;", self.abs)?;
}
if self.dp != 0.0 {
write!(f, "{} dp;", self.dp)?;
}
if self.rel != 0.0 {
write!(f, "{} rel;", self.rel)?;
}
+68 -22
View File
@@ -26,10 +26,8 @@ use swash::{
const NOTO_SANS_REGULAR: &[u8] = include_bytes!("../../assets/fonts/NotoSans-Regular.ttf");
const NOTO_SANS_BOLD: &[u8] = include_bytes!("../../assets/fonts/NotoSans-Bold.ttf");
const NOTO_SANS_ITALIC: &[u8] = include_bytes!("../../assets/fonts/NotoSans-Italic.ttf");
const NOTO_SANS_BOLD_ITALIC: &[u8] =
include_bytes!("../../assets/fonts/NotoSans-BoldItalic.ttf");
const NOTO_SANS_MONO_REGULAR: &[u8] =
include_bytes!("../../assets/fonts/NotoSansMono-Regular.ttf");
const NOTO_SANS_BOLD_ITALIC: &[u8] = include_bytes!("../../assets/fonts/NotoSans-BoldItalic.ttf");
const NOTO_SANS_MONO_REGULAR: &[u8] = include_bytes!("../../assets/fonts/NotoSansMono-Regular.ttf");
const NOTO_SANS_MONO_BOLD: &[u8] = include_bytes!("../../assets/fonts/NotoSansMono-Bold.ttf");
/// What starting up found about text rendering, for the on-screen
@@ -68,6 +66,17 @@ pub struct TextData {
pub layout_cx: LayoutContext<UiColor>,
scale_cx: ScaleContext,
pub atlas: GlyphAtlas,
/// Physical pixels per dp -- a second copy of
/// `UiRenderState::density`, kept here too because `TextEditCtx::layout`
/// (cursor movement and hit-testing, `widget/text/edit.rs`) shapes text
/// from an event callback that has a `TextData` but no `Painter`, so it
/// has nowhere else to read the display's density from. Both copies are
/// set together, from the one place either backend learns the real
/// value (`android::view::new_peer`); this is the same accepted
/// duplication as `AndroidRenderer::content_scale`; a single source of
/// truth would mean carrying a `Painter` (or output size) into every
/// input handler for the sake of one field.
pub density: f32,
}
impl Default for TextData {
@@ -77,6 +86,7 @@ impl Default for TextData {
layout_cx: LayoutContext::new(),
scale_cx: ScaleContext::new(),
atlas: GlyphAtlas::default(),
density: 1.0,
};
data.register_bundled_fonts();
data
@@ -203,14 +213,26 @@ impl TextData {
family_id.and_then(|id| self.font_cx.collection.family_name(id).map(str::to_string))
};
let regular_resolved =
resolve_family(GenericFamily::SansSerif, FontWeight::NORMAL, FontStyle::Normal);
let bold_resolved =
resolve_family(GenericFamily::SansSerif, FontWeight::BOLD, FontStyle::Normal);
let italic_resolved =
resolve_family(GenericFamily::SansSerif, FontWeight::NORMAL, FontStyle::Italic);
let mono_resolved =
resolve_family(GenericFamily::Monospace, FontWeight::NORMAL, FontStyle::Normal);
let regular_resolved = resolve_family(
GenericFamily::SansSerif,
FontWeight::NORMAL,
FontStyle::Normal,
);
let bold_resolved = resolve_family(
GenericFamily::SansSerif,
FontWeight::BOLD,
FontStyle::Normal,
);
let italic_resolved = resolve_family(
GenericFamily::SansSerif,
FontWeight::NORMAL,
FontStyle::Italic,
);
let mono_resolved = resolve_family(
GenericFamily::Monospace,
FontWeight::NORMAL,
FontStyle::Normal,
);
FontDiagnostics {
families_found,
@@ -353,7 +375,7 @@ pub struct TextBuffer {
/// `set_spans` forces `shaped` to `None` directly, the same way `edit`
/// does, since spans change far less often than a naive equality check
/// on the whole `Vec` would cost to compute every frame.
shaped: Option<(TextAttrs, Option<f32>)>,
shaped: Option<(TextAttrs, Option<f32>, f32)>,
}
impl TextBuffer {
@@ -409,19 +431,42 @@ impl TextBuffer {
Vec2::new(self.layout.width(), self.layout.height())
}
/// Lay the text out, unless it is already laid out for these attributes and
/// this width.
pub fn shape(&mut self, data: &mut TextData, attrs: &TextAttrs, width: Option<f32>) {
if self.shaped.as_ref() == Some(&(attrs.clone(), width)) {
/// Lay the text out, unless it is already laid out for these
/// attributes, this width and this density.
///
/// **`attrs.font_size`/`line_height` and every span's own `font_size`
/// are density-independent (dp) units, multiplied by `density` here --
/// the one place text crosses from the widget tree's dp sizes into the
/// physical pixels the shaper and rasteriser (`TextData::place`) both
/// then work in.** This is what makes glyphs sharp on a dense display:
/// before this existed, `font_size` was already a physical-pixel value
/// (RUST.md's P0 box's global-scale stopgap resolved density by
/// stretching the whole rendered frame afterward instead), so a glyph
/// was rasterised small and then upscaled by whatever the display's
/// scale factor was -- exactly the blur Iris's report described.
/// Multiplying here instead means the font size hitting `ScaleContext`
/// in `place` below is already the display's real physical size, so
/// the atlas holds a bitmap at the resolution it is actually shown at.
/// `GlyphKey.size` already keys on that resolved `font_size`
/// (`(font_size * 16.0).round()`), so a cache entry is naturally per
/// physical size with no change needed there.
pub fn shape(
&mut self,
data: &mut TextData,
attrs: &TextAttrs,
width: Option<f32>,
density: f32,
) {
if self.shaped.as_ref() == Some(&(attrs.clone(), width, density)) {
return;
}
let mut builder = data
.layout_cx
.ranged_builder(&mut data.font_cx, &self.text, 1.0, true);
builder.push_default(StyleProperty::FontFamily(attrs.family.family()));
builder.push_default(StyleProperty::FontSize(attrs.font_size));
builder.push_default(StyleProperty::FontSize(attrs.font_size * density));
builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute(
attrs.line_height,
attrs.line_height * density,
)));
builder.push_default(StyleProperty::Brush(attrs.color));
for span in &self.spans {
@@ -433,7 +478,7 @@ impl TextBuffer {
builder.push(StyleProperty::FontFamily(family.family()), range.clone());
}
if let Some(size) = span.font_size {
builder.push(StyleProperty::FontSize(size), range.clone());
builder.push(StyleProperty::FontSize(size * density), range.clone());
}
if span.bold {
builder.push(StyleProperty::FontWeight(FontWeight::BOLD), range.clone());
@@ -449,7 +494,7 @@ impl TextBuffer {
self.layout.break_all_lines(width);
self.layout
.align(Alignment::Start, AlignmentOptions::default());
self.shaped = Some((attrs.clone(), width));
self.shaped = Some((attrs.clone(), width, density));
}
}
@@ -566,8 +611,9 @@ impl TextData {
attrs: &TextAttrs,
width: Option<f32>,
textures: &mut Textures,
density: f32,
) -> RenderedText {
buffer.shape(self, attrs, width);
buffer.shape(self, attrs, width, density);
let glyphs = self.place(buffer, textures);
RenderedText {
glyphs: std::sync::Arc::new(glyphs),
+9 -1
View File
@@ -165,8 +165,10 @@ impl<'a> Painter<'a> {
attrs: &TextAttrs,
width: Option<f32>,
) -> RenderedText {
let density = self.state.density;
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.
@@ -210,6 +212,12 @@ impl<'a> Painter<'a> {
self.state.output_size
}
/// Physical pixels per `dp` -- see `UiRenderState::density`'s field
/// doc. What `Len::dp`'s `apply_rest` call resolves against.
pub fn density(&self) -> f32 {
self.state.density
}
pub fn px_size(&mut self) -> Vec2 {
self.region.size().to_abs(self.state.output_size)
}
+22 -1
View File
@@ -9,6 +9,12 @@ pub struct UiRenderState {
pub active: HashMap<WidgetId, ActiveData>,
pub layers: PrimitiveLayers,
pub(super) output_size: Vec2,
/// Physical pixels per `dp` -- see `Len::dp`'s field doc. `1.0` (an
/// unscaled display) until a backend that knows its own density calls
/// `set_density` (Android's `content_scale`, read at `surface_changed`
/// time); the winit backend has no analogous per-monitor value wired up
/// yet and stays at the default.
pub(super) density: f32,
old_root: Option<WidgetId>,
resized: bool,
@@ -35,6 +41,7 @@ impl UiRenderState {
active: Default::default(),
layers: Default::default(),
output_size: Vec2::ZERO,
density: 1.0,
old_root: None,
resized: false,
draw_started: Default::default(),
@@ -60,6 +67,20 @@ impl UiRenderState {
self.resized = true;
}
/// Sets the physical-pixels-per-dp ratio every `Len::dp` in the tree
/// resolves against from the next layout pass on -- see `density`'s
/// field doc. Not folded into `resize` because the two change on
/// different triggers (a surface resize on every rotation or keyboard
/// open; a density change only if the app follows the display to a
/// different screen, which Android surfaces separately).
pub fn set_density(&mut self, density: f32) {
self.density = density;
}
pub fn density(&self) -> f32 {
self.density
}
pub fn update<'a>(&mut self, root: impl Into<Option<&'a StrongWidget>>, rsc: &mut dyn UiRsc) {
// safety mechanism for memory leaks; might wanna return a result instead so user can
// decide whether to panic or not
@@ -311,7 +332,7 @@ impl UiRenderState {
};
let from = active
.size
.to_uivec2()
.to_uivec2(self.density)
.align(RegionAlign::TOP_LEFT)
.within(&active.region);
let slot = active.move_slot;
+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);
span.push(text);
span.push(img);
span.pad(8.0).background(rect(tint)).add_strong(rsc).any()
span.pad(dp(8.0))
.background(rect(tint))
.add_strong(rsc)
.any()
} else {
wtext(row_text(i))
.wrap(true)
.color(text_color)
.pad(8.0)
.pad(dp(8.0))
.background(rect(tint))
.add_strong(rsc)
.any()
+2 -1
View File
@@ -23,7 +23,8 @@ mod view;
pub use insets::Insets;
pub use render::AndroidRenderer;
pub use view::{
AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState, IrisViewPeer, new_peer,
AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState, IrisViewPeer, WindowInsets,
new_peer,
};
/// Registers the extra native methods this backend needs beyond what
+21 -21
View File
@@ -208,16 +208,14 @@ impl AndroidRenderer {
surface.configure(&device, &config);
let encoder = Self::create_encoder(&device);
// Logical size (physical / `content_scale`) -- see
// `android::view::AndroidUiState::content_scale`'s field comment
// for why this crate now divides at all (RUST.md's P0 box, "text
// is far too small"). The swapchain above stays at the real
// physical `width`/`height` for a sharp framebuffer.
let logical_size = iris_core::util::Vec2::new(
width as f32 / content_scale,
height as f32 / content_scale,
);
let ui = match UiRenderNode::new(&device, &queue, &config, logical_size) {
// Physical pixels, matching the swapchain's own `width`/`height`
// exactly -- see `android::view::AndroidUiState::content_scale`'s
// field comment for why this is no longer divided into a separate
// logical space (that stopgap is what made text blurry, RUST.md's
// P0 box). `Len::dp` folds the density in at layout time instead,
// so nothing here needs to know it at all.
let window_size = iris_core::util::Vec2::new(width as f32, height as f32);
let ui = match UiRenderNode::new(&device, &queue, &config, window_size) {
Ok(ui) => ui,
Err(wgpu_error) => return Err(Self::diagnostic(&adapter, &wgpu_error)),
};
@@ -400,25 +398,27 @@ impl AndroidRenderer {
submit_start.elapsed()
}
/// Logical size (physical / `content_scale`) -- the unit layout and
/// hit-testing use, matching the window uniform's own units. See
/// Physical pixels -- the unit layout and hit-testing use, matching
/// the window uniform's own units. See
/// `android::view::AndroidUiState::content_scale`'s field comment.
pub fn size(&self) -> iris_core::util::Vec2 {
iris_core::util::Vec2::new(
self.config.width as f32 / self.content_scale,
self.config.height as f32 / self.content_scale,
)
iris_core::util::Vec2::new(self.config.width as f32, self.config.height as f32)
}
/// Reconfigures the surface and rewrites the window uniform for a new
/// physical size -- deliberately the *only* two things this does.
/// `device`, `ui`'s atlas, buffers and bind groups are untouched, so a
/// call here (as opposed to a fresh `AndroidRenderer::new`) never
/// invalidates a glyph the CPU-side cache already placed in the atlas.
/// See `android::view::IrisViewPeer::surface_changed`'s doc comment for
/// why that distinction matters -- it is what keeps text on screen
/// across an IME resize.
pub fn resize(&mut self, width: u32, height: u32) {
self.config.width = width;
self.config.height = height;
self.surface.configure(&self.device, &self.config);
let logical = iris_core::util::Vec2::new(
width as f32 / self.content_scale,
height as f32 / self.content_scale,
);
self.ui.resize(logical, &self.queue);
let size = iris_core::util::Vec2::new(width as f32, height as f32);
self.ui.resize(size, &self.queue);
}
}
+117 -50
View File
@@ -70,19 +70,31 @@ pub struct AndroidUiState {
/// because `dumpsys gfxinfo` cannot see a `SurfaceView`'s own
/// GPU-drawn frames at all. See `iris_core::FrameReport`'s own doc.
pub frame_report: FrameReport,
/// `DisplayMetrics.density` (`new_peer`'s doc comment), read once at
/// view construction: physical pixels per dp on this device. Neither
/// this crate nor `default::` had ever divided by it before RUST.md's
/// P0 box's phone report ("text is far too small") -- `window_size`
/// below and `surface_changed`'s call into `UiRenderState::resize` both
/// report *logical* (physical / `content_scale`) dimensions now, which
/// 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
/// stays at the real physical resolution (`AndroidRenderer`'s own
/// `config.width/height`) for a sharp framebuffer; only the *logical*
/// coordinate system layout, hit-testing and the window uniform agree
/// on is scaled. Touch coordinates (`on_touch_event`) are divided by
/// this too, so they land in the same space layout is using.
/// `DisplayMetrics.density` (`new_peer`'s doc comment): physical pixels
/// per dp on this device, read once at view construction and carried
/// on `UiRenderState::density` (`render.set_density`, `new_peer`) from
/// then on -- every `Len::dp` in the widget tree resolves against it at
/// layout time (`Len::dp`'s field doc, IRIS_TODO.md's
/// "density-independent length unit" item, 2026-09-06).
///
/// **Everything else in this module is physical pixels, matching the
/// real wgpu surface/swapchain resolution** -- window size, touch
/// coordinates, insets. That is a correction from an earlier version
/// of this comment, which had `window_size`/`surface_changed`'s
/// `UiRenderState::resize` call divide by `content_scale` into a
/// *logical* coordinate space instead, as a global stopgap for
/// RUST.md's P0 box's phone report ("text is far too small"). That
/// stopgap fixed the size but not the *sharpness*: dividing to logical
/// units meant a `16.0`-sized glyph rasterised at 16 physical px and
/// then implicitly upscaled ~3x by the NDC mapping onto the real
/// physical framebuffer -- the exact "blurry ... glyphs drawn at
/// logical size and stretched by the scale" Iris reported next.
/// Resolving `dp` at layout time replaces it: a widget author writes
/// `dp(16)` for a size that should look the same physical size on any
/// density, and everything downstream (layout, hit-testing, the window
/// uniform, and the font size handed to the text shaper) works in the
/// display's own physical pixels throughout, so nothing is
/// rasterised at one resolution and displayed at another.
pub content_scale: f32,
/// The last insets `render()` saw -- compared each frame so
/// `AndroidAppState::on_insets_changed` fires only when they actually
@@ -154,21 +166,26 @@ pub trait AndroidAppState: HasAndroidUiState {
/// (RUST.md's P0 box: "the status-bar inset is not applied" reported
/// the two top buttons sitting under it, because nothing read `.top`
/// at all), and again on a rotation or the keyboard opening/closing.
/// `insets` is in the same *logical* units `content_scale` converts
/// everything else to (physical / `content_scale`), so a widget can add
/// it to a layout size directly. The default does nothing -- most
/// screens have no chrome that sits under a system bar.
/// `insets` is in the same physical-pixel units everything else in the
/// tree now uses (`AndroidUiState::content_scale`'s field comment), so
/// a widget can add it to a layout size directly -- `dp(...) +
/// abs(insets.top)` if the widget wants a density-independent size
/// plus the system bar's own (already-physical) height. The default
/// does nothing -- most screens have no chrome that sits under a
/// system bar.
#[allow(unused_variables)]
fn on_insets_changed(&mut self, rsc: &mut AndroidRsc<Self>, insets: LogicalInsets) {}
fn on_insets_changed(&mut self, rsc: &mut AndroidRsc<Self>, insets: WindowInsets) {}
}
/// `insets::Insets`, converted from physical to logical units -- see
/// `AndroidUiState::content_scale`'s field comment. A distinct type from
/// `insets::Insets` (rather than dividing in place) so a reader at the call
/// site can tell which unit a value is already in without checking where it
/// came from.
/// `insets::Insets` as `f32`, for the widget-facing callback above -- a
/// distinct type from `insets::Insets` so a caller of `on_insets_changed`
/// is not coupled to that module's own (`i32`, JNI-shaped) representation.
/// Both are physical pixels; this used to divide by `content_scale` into a
/// separate *logical* unit (hence the old name, `LogicalInsets`), back when
/// the rest of layout was logical too -- see `AndroidUiState::content_scale`'s
/// field comment for why that stopgap is gone.
#[derive(Clone, Copy, Default, Debug, PartialEq)]
pub struct LogicalInsets {
pub struct WindowInsets {
pub left: f32,
pub top: f32,
pub right: f32,
@@ -176,14 +193,14 @@ pub struct LogicalInsets {
pub ime_bottom: f32,
}
impl LogicalInsets {
fn from_physical(insets: Insets, content_scale: f32) -> Self {
impl WindowInsets {
fn from_physical(insets: Insets) -> Self {
Self {
left: insets.left as f32 / content_scale,
top: insets.top as f32 / content_scale,
right: insets.right as f32 / content_scale,
bottom: insets.bottom as f32 / content_scale,
ime_bottom: insets.ime_bottom as f32 / content_scale,
left: insets.left as f32,
top: insets.top as f32,
right: insets.right as f32,
bottom: insets.bottom as f32,
ime_bottom: insets.ime_bottom as f32,
}
}
}
@@ -343,10 +360,9 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
let ui_state = self.state.android_state();
let current_insets = ui_state.insets();
if current_insets != ui_state.last_insets {
let content_scale = ui_state.content_scale;
let logical = LogicalInsets::from_physical(current_insets, content_scale);
let physical = WindowInsets::from_physical(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();
@@ -504,16 +520,10 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
) -> bool {
self.drain_tasks();
let action = event.action_masked(&mut ctx.env);
// Device (physical) pixels, same as every other Android coordinate
// -- divided so a touch lands in the same *logical* space layout
// now uses (`AndroidUiState::content_scale`'s field comment).
// Without this, `window_size()` reporting logical dims while touch
// 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;
// Device (physical) pixels, same space layout now uses throughout
// -- see `AndroidUiState::content_scale`'s field comment.
let x = event.x(&mut ctx.env);
let y = event.y(&mut ctx.env);
let ui_state = self.state.android_state_mut();
match action {
MotionAction::Down => {
@@ -564,7 +574,6 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
height: i32,
) {
self.drain_tasks();
let window = holder.surface(&mut ctx.env).to_native_window(&mut ctx.env);
// The layout engine's own notion of the canvas size is separate
// from the wgpu surface's -- winit's backend sets it from
// `WindowEvent::Resized`, and there is no equivalent automatic
@@ -573,11 +582,56 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
// nothing but the clear colour: the widget tree laid out against
// whatever size `UiRenderState::new` starts at instead of the
// surface's real one.
self.render.resize((width as u32, height as u32));
// Drop the old renderer (and the surface it owns) before building
// one from the new window -- see `AndroidRenderer`'s doc comment.
//
// **Physical pixels, matching `AndroidRenderer`'s own
// `size()`/`resize()`/`new()`** -- `AndroidUiState::content_scale`'s
// field comment. This call sets `UiRenderState::output_size`, which
// every `rel`/`rest` length resolves against and every `abs`
// pixel-region compares to directly; a `dp(56)` height now folds
// in the density at `Len::apply_rest` time instead of this call
// dividing the whole window into a separate logical space, which
// is what used to make every `abs`-unit size (a fixed `.height(56)`
// in particular) mean something different from a `rest`-based one.
self.render.resize((width as f32, height as f32));
// **Reuse the existing renderer (device, atlas, buffers, bind
// groups) when one is already live -- only reconfigure the
// surface.** `surfaceChanged` fires on *every* size or format
// change, not only on a genuinely new `Surface`/window: showing
// the IME under `adjustResize` resizes the same `SurfaceView` and
// is reported through this exact callback. Rebuilding the whole
// `AndroidRenderer` here used to mean a fresh `UiRenderNode::new`
// -- a brand-new, empty glyph atlas and fresh GPU buffers -- while
// `iris_core`'s CPU-side glyph cache (`primitive/text.rs`) kept the
// atlas coordinates it had already handed out against the *old*
// atlas. Every glyph then drew from a UV rectangle that pointed
// into a texture that had just been recreated empty, so text
// vanished on the first keyboard open while rects (which never go
// through the atlas) kept drawing -- exactly the "rectangles stay,
// glyphs disappear" Iris reported. Confirmed by reading this path
// end to end (no fresh-atlas rebuild anywhere in `resize()` below,
// only in `AndroidRenderer::new`) before changing anything, per
// AGENTS.md's "verify before finishing".
//
// `AndroidRenderer::resize` only reconfigures the wgpu surface and
// rewrites the window uniform -- device, atlas, buffers and bind
// groups are untouched, so the glyph cache's coordinates stay
// valid. A genuinely new surface (after `surface_destroyed`, e.g.
// backgrounding) still goes through `AndroidRenderer::new` below,
// since `renderer` is `None` in that case.
let already_live = self.state.android_state().renderer.is_some();
if already_live {
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
// default uncaptured-error handler on a bind-group-layout
// validation failure -- exactly what aborted the P0 bench APK on
@@ -588,6 +642,11 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
// the one place in the app that can turn it into something a
// person can read, since `ctx.view`/`ctx.env` (needed to reach the
// Java side) are only in scope inside a `ViewPeer` callback.
//
// `content_scale` reaches `AndroidRenderer` only for the
// Diagnostics page's report text now -- window size and the
// shader's window uniform are physical pixels throughout (see the
// `resize` call above), not divided by it.
let content_scale = self.state.android_state().content_scale;
match AndroidRenderer::new(window, width as u32, height as u32, content_scale) {
Ok(renderer) => {
@@ -740,6 +799,7 @@ pub fn new_peer<'local, State: AndroidAppState>(
.resources(&mut env)
.display_metrics(&mut env)
.density(&mut env);
log::info!("iris: new_peer content_scale={content_scale}");
let vm = env.get_java_vm().unwrap();
let global_view = env.new_global_ref(&view.0).unwrap();
let redraw: Arc<dyn RequestRedraw> = Arc::new(AndroidRedrawHandle::new(vm, global_view));
@@ -751,15 +811,22 @@ pub fn new_peer<'local, State: AndroidAppState>(
state: Default::default(),
_state: PhantomData,
};
// See `TextData::density`'s field doc for why this is set alongside
// `render.set_density` below rather than read from there.
rsc.ui.text.density = content_scale;
let shared = Rc::new(RefCell::new(Shared::default()));
let ui_state = AndroidUiState::new(shared.clone(), content_scale);
let mut state = State::new(ui_state, &mut rsc);
let platform_vm = env.get_java_vm().unwrap();
let platform_view = env.new_global_ref(&view.0).unwrap();
state.platform_ready(&mut rsc, platform_vm, platform_view);
let mut render = UiRenderState::new();
// Every `Len::dp` in the tree resolves against this from now on -- see
// `UiRenderState::density`'s field doc and `Len::dp`'s.
render.set_density(content_scale);
let peer = IrisViewPeer {
rsc,
render: UiRenderState::new(),
render,
state,
task_recv,
};
+5 -3
View File
@@ -19,8 +19,7 @@ impl Input {
pub fn event(&mut self, event: &WindowEvent, scale_factor: f32) -> bool {
match event {
WindowEvent::CursorMoved { position, .. } => {
self.cursor.pos =
Vec2::new(position.x as f32, position.y as f32) / scale_factor;
self.cursor.pos = Vec2::new(position.x as f32, position.y as f32) / scale_factor;
self.cursor.exists = true;
}
WindowEvent::MouseInput { state, button, .. } => {
@@ -79,7 +78,10 @@ impl DefaultUiState {
let window = self.renderer.window();
let size = window.inner_size();
let scale_factor = window.scale_factor() as f32;
Vec2::new(size.width as f32 / scale_factor, size.height as f32 / scale_factor)
Vec2::new(
size.width as f32 / scale_factor,
size.height as f32 / scale_factor,
)
}
pub fn cursor_state(&self) -> &CursorState {
+4 -1
View File
@@ -163,7 +163,10 @@ impl UiRenderer {
// found on Iris's phone, just never noticed here because this
// crate's own dev monitors happen to run at 1.0.
let scale_factor = window.scale_factor() as f32;
let logical_size = Vec2::new(size.width as f32 / scale_factor, size.height as f32 / scale_factor);
let logical_size = Vec2::new(
size.width as f32 / scale_factor,
size.height as f32 / scale_factor,
);
let ui = UiRenderNode::new(&device, &queue, &config, logical_size)
.expect("Could not create iris render node!");
+427 -4
View File
@@ -1,5 +1,6 @@
use crate::prelude::*;
use std::{
collections::VecDeque,
ops::{BitOr, Deref, DerefMut},
rc::Rc,
time::{Duration, Instant},
@@ -491,7 +492,25 @@ impl DragArbiter {
} else if dy.abs() > DRAG_SLOP && dy.abs() >= dx.abs() {
self.state = ArbiterState::Panning;
self.last = pos;
DragOutcome::Pan(dy)
// `dy` here is the *whole* drag since `press_start`,
// not since the last frame -- nothing panned while
// `Undecided` was withholding the slop, so applying it
// in full on this one frame is a visible jump the
// instant `DRAG_SLOP` is crossed (IRIS_TODO.md's
// "scrolling down sometimes jitters the text," root-
// caused by tracing `List`'s per-frame offset against
// a synthetic monotonic drag: the offset held flat for
// every `Undecided` frame, then stepped by several
// frames' worth of motion at once on the frame slop
// was crossed, before resuming ordinary per-frame
// deltas). Only the excess past the slop threshold is
// real, undecided motion the reader hasn't seen
// reflected yet -- so only that excess is applied now,
// the same way Android's own touch handling consumes
// `ViewConfiguration.getScaledTouchSlop()` once from
// the first scroll past it rather than replaying the
// whole pre-threshold drag in one step.
DragOutcome::Pan(dy - DRAG_SLOP.copysign(dy))
} else if now.duration_since(self.origin_at) >= LONG_PRESS
&& dx.abs() <= DRAG_SLOP
&& dy.abs() <= DRAG_SLOP
@@ -510,6 +529,390 @@ impl DragArbiter {
pub fn release(&mut self) {
self.state = ArbiterState::Idle;
}
/// Whether the arbiter's current gesture (if any) has committed to
/// panning -- what a caller checks at release time to decide whether
/// to hand the tracked velocity to [`crate::widget::List::fling`], per
/// IRIS_TODO.md's "swiping has no momentum": a fling must only follow
/// a pan, never a text selection that happened to end with the finger
/// still moving.
pub fn is_panning(&self) -> bool {
matches!(self.state, ArbiterState::Panning)
}
}
/// How far back a [`VelocityTracker`] looks when estimating a fling's
/// initial speed -- Android's own `VelocityTracker` defaults to a similar
/// short window so a gesture's last flick dominates over its slower start.
const VELOCITY_WINDOW: Duration = Duration::from_millis(100);
/// Tracks a drag's speed along one axis from its last ~100ms of motion, so
/// a release can be handed a realistic initial velocity for
/// [`AndroidFlingSpline`]/[`FlingCalculator`] rather than a single frame's
/// noisy last delta. Fed one timestamped pan delta per frame
/// (`add_sample`, the same `dy`/`-dy` quantity `DragArbiter::update`'s
/// `Pan` outcome already carries) and answers `velocity()` in units per
/// second, matching whatever unit the deltas were in.
#[derive(Default)]
pub struct VelocityTracker {
/// `(when, delta)` pairs, oldest first, trimmed to `VELOCITY_WINDOW`
/// on every `add_sample` -- so this never grows past however many
/// frames land in that window.
samples: VecDeque<(Instant, f32)>,
}
impl VelocityTracker {
pub fn new() -> Self {
Self::default()
}
/// Forget everything -- called on a fresh press, so a new gesture's
/// velocity is never contaminated by the tail of the previous one.
pub fn reset(&mut self) {
self.samples.clear();
}
/// Record one frame's motion. `delta` is this frame's movement since
/// the last sample, not a cumulative position.
pub fn add_sample(&mut self, delta: f32, at: Instant) {
self.samples.push_back((at, delta));
while let Some(&(when, _)) = self.samples.front() {
if at.duration_since(when) > VELOCITY_WINDOW {
self.samples.pop_front();
} else {
break;
}
}
}
/// The estimated speed, in units-per-second, over whatever samples
/// currently fall inside the tracking window: total motion divided by
/// the elapsed time between the oldest and newest sample still held.
/// `0.0` with fewer than two samples (no time span to divide by).
pub fn velocity(&self) -> f32 {
if self.samples.len() < 2 {
return 0.0;
}
let total: f32 = self.samples.iter().map(|&(_, d)| d).sum();
let span = self
.samples
.back()
.unwrap()
.0
.duration_since(self.samples.front().unwrap().0)
.as_secs_f32();
if span <= 0.0 { 0.0 } else { total / span }
}
}
/// Android's fling deceleration curve, ported from AOSP's
/// `android.widget.OverScroller.SplineOverScroller` (the same curve
/// Compose's `androidx.compose.ui.gestures.AndroidFlingSpline` and
/// `androidx.compose.foundation.gestures.FlingCalculator` reuse) so a
/// fling here travels the same distance a Compose `LazyColumn`'s own
/// `ScrollableDefaults.flingBehavior()` would for the same initial
/// velocity -- RUST.md's "Benchmark v2" box asked the two apps' fling
/// phase to be comparable, and IRIS_TODO.md's "swiping has no momentum"
/// asked for the same physics a reader's muscle memory already expects
/// from every other Android scroll view.
///
/// The curve is a cubic-Bezier-derived spline sampled into two lookup
/// tables at start-up (`SPLINE`, built once via [`std::sync::OnceLock`]):
/// `SPLINE_POSITION[i]`/`SPLINE_TIME[i]` give the fraction of total
/// distance/time elapsed at the `i`th of 100 even steps along the curve's
/// own parameter. A lookup at an arbitrary time fraction interpolates
/// between the two bracketing samples.
mod android_fling_spline {
use std::sync::OnceLock;
const NB_SAMPLES: usize = 100;
/// Where the two cubic tension lines cross (AOSP's own constant name
/// and value, `SplineOverScroller.INFLEXION`).
pub(super) const INFLEXION: f32 = 0.35;
const START_TENSION: f32 = 0.5;
const END_TENSION: f32 = 1.0;
const P1: f32 = START_TENSION * INFLEXION;
const P2: f32 = 1.0 - END_TENSION * (1.0 - INFLEXION);
pub(super) struct Spline {
position: [f32; NB_SAMPLES + 1],
time: [f32; NB_SAMPLES + 1],
}
fn build() -> Spline {
let mut position = [0.0f32; NB_SAMPLES + 1];
let mut time = [0.0f32; NB_SAMPLES + 1];
let (mut x_min, mut y_min) = (0.0f32, 0.0f32);
for i in 0..NB_SAMPLES {
let alpha = i as f32 / NB_SAMPLES as f32;
let mut x_max = 1.0f32;
let (mut x, mut coef);
loop {
x = x_min + (x_max - x_min) / 2.0;
coef = 3.0 * x * (1.0 - x);
let tx = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x;
if (tx - alpha).abs() < 1e-5 {
break;
}
if tx > alpha {
x_max = x;
} else {
x_min = x;
}
}
position[i] = coef * ((1.0 - x) * P1 + x * P2) + x * x * x;
let mut y_max = 1.0f32;
let (mut y, mut coef_y);
loop {
y = y_min + (y_max - y_min) / 2.0;
coef_y = 3.0 * y * (1.0 - y);
let dy = coef_y * ((1.0 - y) * START_TENSION + y * END_TENSION) + y * y * y;
if (dy - alpha).abs() < 1e-5 {
break;
}
if dy > alpha {
y_max = y;
} else {
y_min = y;
}
}
time[i] = coef_y * ((1.0 - y) * P1 + y * P2) + y * y * y;
}
position[NB_SAMPLES] = 1.0;
time[NB_SAMPLES] = 1.0;
Spline { position, time }
}
static SPLINE: OnceLock<Spline> = OnceLock::new();
/// The fraction of total distance covered at `time_fraction` (0..=1
/// of the fling's total duration). Finds the bracketing samples in
/// `SPLINE_TIME` and interpolates linearly between their matching
/// `SPLINE_POSITION` entries, exactly as AOSP's `SplineOverScroller
/// .flingPosition` does.
pub(super) fn distance_fraction(time_fraction: f32) -> f32 {
let spline = SPLINE.get_or_init(build);
let t = time_fraction.clamp(0.0, 1.0);
let index = ((t * NB_SAMPLES as f32) as usize).min(NB_SAMPLES - 1);
let t_inf = spline.time[index];
let t_sup = spline.time[index + 1];
let d_inf = spline.position[index];
let d_sup = spline.position[index + 1];
let span = t_sup - t_inf;
if span <= 0.0 {
d_inf
} else {
d_inf + (d_sup - d_inf) * (t - t_inf) / span
}
}
}
/// AOSP `SplineOverScroller`'s two other physical constants: the default
/// `ViewConfiguration.getScrollFriction()` and the deceleration rate a
/// friction of `0.84` per frame at 60Hz corresponds to
/// (`ln(0.78)/ln(0.9)`, `SplineOverScroller.DECELERATION_RATE`).
const FLING_FRICTION: f32 = 0.015;
fn deceleration_rate() -> f32 {
(0.78f32.ln()) / (0.9f32.ln())
}
const GRAVITY_EARTH: f32 = 9.80665;
/// Turns an initial fling velocity into a total travel distance and
/// duration, following AOSP `SplineOverScroller`'s own closed-form
/// formulas (`getSplineFlingDistance`/the duration half of `fling()`) --
/// ported the same way Compose's `FlingCalculator` is, including its
/// `density`-dependent physical coefficient (`computeDeceleration`,
/// `GravityEarth * 39.37 * density * 160 * friction`). Density and
/// velocity/distance units cancel algebraically as long as velocity and
/// the returned distance share one pixel space (physical or logical) --
/// [`crate::widget::List::fling`] relies on exactly that cancellation to
/// avoid needing a display density of its own, since iris's `List`
/// already works in logical (density-independent) pixels throughout.
pub struct FlingCalculator {
physical_coefficient: f32,
}
impl FlingCalculator {
pub fn new(density: f32) -> Self {
Self {
physical_coefficient: GRAVITY_EARTH * 39.37 * density * 160.0 * FLING_FRICTION,
}
}
fn deceleration_for(&self, velocity: f32) -> f32 {
(android_fling_spline::INFLEXION * velocity.abs()
/ (FLING_FRICTION * self.physical_coefficient))
.ln()
}
/// Total signed distance the fling travels before settling, in the
/// same pixel units `velocity` was given in.
pub fn distance(&self, velocity: f32) -> f32 {
if velocity == 0.0 {
return 0.0;
}
let l = self.deceleration_for(velocity);
let rate = deceleration_rate();
let magnitude =
FLING_FRICTION * self.physical_coefficient * (rate / (rate - 1.0) * l).exp();
magnitude.copysign(velocity)
}
/// How long the fling takes to settle.
pub fn duration(&self, velocity: f32) -> Duration {
if velocity == 0.0 {
return Duration::ZERO;
}
let l = self.deceleration_for(velocity);
let rate = deceleration_rate();
Duration::from_secs_f32((l / (rate - 1.0)).exp())
}
/// The signed distance covered by `elapsed` into a fling of this
/// `velocity` that started at `t0` -- what a per-frame ticker
/// (`List::tick_fling`) calls to find how far to have scrolled by now.
/// Clamped to the full `distance()` once `elapsed` reaches
/// `duration()`, so a caller need not special-case "past the end."
pub fn position_at(&self, velocity: f32, elapsed: Duration) -> f32 {
let duration = self.duration(velocity);
if duration.is_zero() {
return 0.0;
}
let fraction = (elapsed.as_secs_f32() / duration.as_secs_f32()).min(1.0);
self.distance(velocity) * android_fling_spline::distance_fraction(fraction)
}
}
#[cfg(test)]
mod velocity_tracker_tests {
use super::*;
use std::sync::LazyLock;
// A single fixed base rather than a fresh `Instant::now()` per call --
// computing it once per test keeps every sample's spacing exact
// instead of at the mercy of however long the test itself takes to
// run between calls, the same reasoning `drag_arbiter_tests::t` uses.
static BASE: LazyLock<Instant> = LazyLock::new(Instant::now);
fn t(ms: u64) -> Instant {
*BASE + Duration::from_millis(ms)
}
#[test]
fn fewer_than_two_samples_reports_zero() {
let mut v = VelocityTracker::new();
assert_eq!(v.velocity(), 0.0);
v.add_sample(10.0, t(0));
assert_eq!(v.velocity(), 0.0);
}
#[test]
fn a_steady_drag_reports_its_own_speed() {
// 5px every 10ms, 11 samples spanning 100ms, sums to 55px over
// 0.1s -- 550px/s by this tracker's own "sum of deltas over the
// span between the oldest and newest held sample" definition.
let mut v = VelocityTracker::new();
for i in 0..=10 {
v.add_sample(5.0, t(i * 10));
}
assert!((v.velocity() - 550.0).abs() < 1.0, "got {}", v.velocity());
}
#[test]
fn only_the_last_100ms_of_samples_count() {
// An old, fast burst well outside the window followed by a slow,
// steady drag should report the recent speed, not the average of
// both -- otherwise a flick that trails off would still fling at
// its earlier, faster speed. The burst sits 110ms before the last
// sample, just past the 100ms window, so it is evicted.
let mut v = VelocityTracker::new();
v.add_sample(1000.0, t(0)); // will be 110ms old by the last sample
for i in 1..=11 {
v.add_sample(1.0, t(i * 10)); // 1px/10ms = 100px/s
}
assert!(
(v.velocity() - 110.0).abs() < 5.0,
"old burst leaked into the window: got {}",
v.velocity()
);
}
#[test]
fn reset_forgets_prior_samples() {
let mut v = VelocityTracker::new();
v.add_sample(500.0, t(0));
v.add_sample(500.0, t(10));
assert!(v.velocity() != 0.0);
v.reset();
assert_eq!(v.velocity(), 0.0);
}
}
#[cfg(test)]
mod fling_calculator_tests {
use super::*;
#[test]
fn zero_velocity_flings_nowhere() {
let calc = FlingCalculator::new(1.0);
assert_eq!(calc.distance(0.0), 0.0);
assert_eq!(calc.duration(0.0), Duration::ZERO);
}
#[test]
fn distance_grows_with_velocity_and_keeps_its_sign() {
let calc = FlingCalculator::new(2.75); // a typical phone's density
let d_slow = calc.distance(2000.0);
let d_fast = calc.distance(12000.0);
assert!(d_slow > 0.0);
assert!(d_fast > d_slow);
assert_eq!(calc.distance(-12000.0), -d_fast);
}
/// Summing the spline's own per-frame position deltas across the
/// whole fling has to land within 1% of the closed-form `distance()`
/// -- this is the guarantee that `List::tick_fling`'s per-frame reads
/// of `position_at` actually add up to the total the fling promised,
/// not merely that the two formulas look plausible independently.
#[test]
fn integrating_position_at_matches_the_closed_form_distance() {
let calc = FlingCalculator::new(1.0);
for velocity in [1500.0f32, 5000.0, 12000.0, -12000.0] {
let total = calc.distance(velocity);
let duration = calc.duration(velocity);
let final_position = calc.position_at(velocity, duration);
let err = (final_position - total).abs() / total.abs();
assert!(
err < 0.01,
"velocity {velocity}: position_at(duration)={final_position} vs distance()={total}, err={err}"
);
}
}
#[test]
fn position_at_is_monotonic_and_clamped_past_the_end() {
let calc = FlingCalculator::new(1.0);
let velocity = 12000.0f32;
let duration = calc.duration(velocity);
let total = calc.distance(velocity);
let mut last = 0.0;
let mut t = Duration::ZERO;
while t < duration {
let p = calc.position_at(velocity, t);
assert!(p >= last - 0.01, "position went backwards at {t:?}");
last = p;
t += Duration::from_millis(16);
}
// Well past the end, it stays pinned at the total -- a caller
// must be able to ask "where would this fling be" without first
// checking whether it has already settled.
assert_eq!(
calc.position_at(velocity, duration + Duration::from_secs(5)),
total
);
}
}
#[cfg(test)]
@@ -534,9 +937,13 @@ mod drag_arbiter_tests {
fn a_vertical_drag_pans_immediately() {
let mut a = DragArbiter::new();
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
// The transition frame applies only the motion past `DRAG_SLOP`
// (20 - 8 = 12), not the full 20px since `press_start` -- see the
// `Pan` arm's own comment for why replaying the whole withheld
// drag in one step is the scroll-jitter bug this guards against.
assert_eq!(
a.update(Vec2::new(0.0, 20.0), t(10)),
DragOutcome::Pan(20.0)
DragOutcome::Pan(12.0)
);
// Subsequent frames keep panning, by the delta since last frame.
assert_eq!(
@@ -545,6 +952,22 @@ mod drag_arbiter_tests {
);
}
/// Direct regression test for the fix: a slow drag that crosses
/// `DRAG_SLOP` by only a fraction of a pixel must not still produce a
/// visible jump -- the amount applied on the crossing frame should
/// itself shrink toward zero as the crossing gets closer to exactly
/// `DRAG_SLOP`, rather than always dumping the whole pre-threshold
/// distance at once.
#[test]
fn crossing_the_slop_by_a_little_pans_by_a_little() {
let mut a = DragArbiter::new();
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
assert_eq!(
a.update(Vec2::new(0.0, DRAG_SLOP + 0.5), t(10)),
DragOutcome::Pan(0.5)
);
}
#[test]
fn a_horizontal_drag_with_nothing_selected_does_not_select() {
let mut a = DragArbiter::new();
@@ -603,7 +1026,7 @@ mod drag_arbiter_tests {
a.press_start(Vec2::new(0.0, 0.0), t(0), true);
assert_eq!(
a.update(Vec2::new(0.0, 20.0), t(10)),
DragOutcome::Pan(20.0)
DragOutcome::Pan(12.0)
);
}
@@ -646,7 +1069,7 @@ mod drag_arbiter_tests {
a.press_start(Vec2::new(0.0, 700.0), t(0), false);
assert_eq!(
a.update(Vec2::new(0.0, 720.0), t(10)),
DragOutcome::Pan(20.0)
DragOutcome::Pan(12.0)
);
assert!(!a.is_idle());
}
+287 -8
View File
@@ -102,7 +102,7 @@
use crate::prelude::*;
use iris_core::util::HashMap;
use std::collections::VecDeque;
use std::{collections::VecDeque, sync::Arc, time::Instant};
/// A stable identifier for a loaded row, reused across pages so that a row
/// already measured and drawn is not treated as new when data is inserted
@@ -213,6 +213,39 @@ pub struct List {
/// row is evicted (`pop_front`/`pop_back`) so this cannot grow past
/// however many rows are currently loaded.
heights: HashMap<RowKey, f32>,
/// A fling in progress, or `None` if the list is at rest -- see
/// `fling`/`tick_fling`/`is_scrolling`, IRIS_TODO.md's "swiping has no
/// momentum."
fling: Option<Fling>,
/// What `tick_fling` re-arms every frame a fling is still running, so
/// the list keeps animating without needing a caller to poll it --
/// set once via `set_redraw_handle` by whoever owns the surface this
/// list draws into (the same handle `iris::task::Tasks::redraw_handle`
/// hands out elsewhere). `None` for a list that never flings
/// (headless tests, a caller driving `tick_fling` by hand as
/// `bench_client.rs`'s scripted phases do).
redraw: Option<Arc<dyn RequestRedraw>>,
/// Whether the last `draw` found no more content above the topmost
/// visible row (its top edge at or past the viewport's own top, with
/// no `prev_slot`) -- what `tick_fling` clamps a fling moving toward
/// the start against. Stale (from whatever the last draw found) on a
/// list that hasn't drawn yet; `false` by default, matching "assume
/// there is more content until a draw proves otherwise."
at_start: bool,
/// The mirror of `at_start` for the newest end.
at_end: bool,
}
/// One in-flight fling: the physics answer (`FlingCalculator`) plus how
/// much of its total distance has already been applied to the anchor, so
/// `tick_fling` only ever moves the list by this frame's *incremental*
/// delta -- matching every other place in this widget that scrolls by
/// writing `anchor.offset`.
struct Fling {
calc: FlingCalculator,
velocity: f32,
started_at: Instant,
applied: f32,
}
impl List {
@@ -226,6 +259,10 @@ impl List {
snap_end: true,
viewport_len: 0.0,
last_viewport_len: 0.0,
fling: None,
redraw: None,
at_start: false,
at_end: false,
pending_tap: None,
extents: HashMap::default(),
heights: HashMap::default(),
@@ -361,6 +398,97 @@ impl List {
}
}
/// Give this list a way to ask for another frame on its own, so a
/// fling keeps animating without a caller polling it every tick --
/// see the `redraw` field's doc. Pass the same handle
/// `iris::task::Tasks::redraw_handle` hands a `spawn`ed task; a list
/// that never calls this can still `fling`, but has to be driven by a
/// caller-owned loop instead (`bench_client.rs`'s scripted phases do
/// exactly that, since they need to await settling rather than let it
/// run in the background).
pub fn set_redraw_handle(&mut self, handle: Arc<dyn RequestRedraw>) {
self.redraw = Some(handle);
}
/// Start a fling at `velocity_px_per_s` (this widget's own pixel
/// space, same sign convention as `scroll`'s `amt`: positive continues
/// moving later content into view). Cancels any fling already in
/// progress. A caller with a live touch/press must cancel this on the
/// next touch-down (`cancel_fling`) -- `AndroidFlingSpline`'s curve
/// has no idea a finger came back down, and Android's own `Scroller`
/// relies on the view calling `abortAnimation` for the same reason.
///
/// Density cancels out of the underlying spline as long as velocity
/// and the distance it produces share one pixel space (see
/// `FlingCalculator`'s own doc) -- `List` works entirely in logical
/// pixels, so `1.0` here is not a placeholder for "unknown density,"
/// it is the correct density for a self-consistent unit system.
pub fn fling(&mut self, velocity_px_per_s: f32) {
if velocity_px_per_s == 0.0 || self.anchor.is_none() {
self.fling = None;
return;
}
self.fling = Some(Fling {
calc: FlingCalculator::new(1.0),
velocity: velocity_px_per_s,
started_at: Instant::now(),
applied: 0.0,
});
}
/// Whether a fling is currently animating. What a caller's own
/// per-frame loop polls to know when to stop driving `tick_fling`
/// (`bench_client.rs`'s fling phase) or to decide whether the list is
/// "moving on its own" for any other purpose.
pub fn is_scrolling(&self) -> bool {
self.fling.is_some()
}
/// Cancel any fling in progress with no further movement -- the next
/// touch-down's job, per `fling`'s own doc.
pub fn cancel_fling(&mut self) {
self.fling = None;
}
/// Advance an in-flight fling to `now`, applying this call's share of
/// its total travel via `scroll` and re-arming this list's own redraw
/// handle (if it has one) for another frame. Returns whether the
/// fling is still going after this call -- `false` either because it
/// settled on its own spline-decided schedule or because it reached
/// `at_start`/`at_end` (the module doc's clamp: a fling must not carry
/// the list past content that does not exist, unlike an ordinary
/// touch-pan, which this widget already leaves unclamped by design).
///
/// Safe to call even with no fling active (a no-op returning `false`),
/// so a caller does not need to check `is_scrolling` first.
pub fn tick_fling(&mut self, now: Instant) -> bool {
let Some(f) = &mut self.fling else {
return false;
};
let elapsed = now.saturating_duration_since(f.started_at);
let target = f.calc.position_at(f.velocity, elapsed);
let delta = target - f.applied;
f.applied = target;
let settled_on_schedule = elapsed >= f.calc.duration(f.velocity);
let velocity = f.velocity;
self.scroll(delta);
// Clamp: a fling moving toward the start that has already reached
// it (or one moving toward the end that has already reached that)
// stops rather than continuing to spend its remaining distance on
// a part of the list that will never scroll further.
let hit_bound = (velocity < 0.0 && self.at_start) || (velocity > 0.0 && self.at_end);
if settled_on_schedule || hit_bound {
self.fling = None;
return false;
}
if let Some(redraw) = &self.redraw {
redraw.request_redraw();
}
true
}
/// Snap to the newest content (last item, or the `more_after`
/// sentinel if set), bottom-aligned to the viewport. O(1).
pub fn jump_to_end(&mut self) {
@@ -606,9 +734,10 @@ impl List {
let axis = self.axis;
let output_len = painter.output_size().axis(axis);
let container_len = painter.region().axis(axis).len();
let density = painter.density();
let resolve = move |used: Size| -> f32 {
used.axis(axis)
.apply_rest()
.apply_rest(density)
.within_len(container_len)
.to_abs(output_len)
};
@@ -724,26 +853,33 @@ impl Widget for List {
};
let (mut top, mut bottom) = self.place(painter, anchor.slot, placement);
let mut idx = anchor.slot;
let mut idx_top = anchor.slot;
while top > 0.0 {
let Some(prev) = self.prev_slot(idx) else {
let Some(prev) = self.prev_slot(idx_top) else {
break;
};
let (t, _) = self.place(painter, prev, Placement::Bottom(top));
top = t;
idx = prev;
idx_top = prev;
}
idx = anchor.slot;
let mut idx_bottom = anchor.slot;
while bottom < self.viewport_len {
let Some(next) = self.next_slot(idx) else {
let Some(next) = self.next_slot(idx_bottom) else {
break;
};
let (_, b) = self.place(painter, next, Placement::Top(bottom));
bottom = b;
idx = next;
idx_bottom = next;
}
// What `tick_fling` clamps a fling against -- see `at_start`'s
// field doc. `top`/`bottom` are the extreme edges actually placed
// this frame, and `prev_slot`/`next_slot` returning `None` is what
// "no more content" means everywhere else in this widget.
self.at_start = self.prev_slot(idx_top).is_none() && top >= 0.0;
self.at_end = self.next_slot(idx_bottom).is_none() && bottom <= self.viewport_len;
self.update_snap_end();
Size::REST
}
@@ -1177,4 +1313,147 @@ mod tests {
);
}
}
/// Enough rows, tall enough, that a fling toward the start has real
/// room to travel before `at_start` clamps it -- shared by the fling
/// tests below.
fn build_flingable_list(rsc: &mut TestRsc) -> (WeakWidget<List>, StrongWidget, UiRenderState) {
let mut list = List::new(Axis::Y);
push_rows(rsc, &mut list, &(0..200).collect::<Vec<_>>(), 20.0);
let (list_weak, root) = add_list(rsc, list);
let mut render = UiRenderState::new();
render.resize((100.0, 600.0));
render.update(&root, rsc);
(list_weak, root, render)
}
#[test]
fn fling_moves_the_list_and_then_settles() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
// A fling toward the start: negative velocity, matching `scroll`'s
// sign convention (`Selection::drag` calls `scroll(-dy)` for a
// downward finger motion revealing older content).
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(-8000.0);
assert!(rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
let start = Instant::now();
let mut last_still_scrolling = true;
for step in 0..600 {
let now = start + std::time::Duration::from_millis(step * 16);
last_still_scrolling = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now);
render.update(&root, &mut rsc);
if !last_still_scrolling {
break;
}
}
assert!(
!last_still_scrolling,
"fling never settled within 600 steps"
);
assert!(!rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
}
#[test]
fn fling_distance_is_positive_toward_the_end() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
// Start scrolled away from the newest end so there is room for an
// end-ward fling to actually move.
rsc.ui.widgets.get_mut(&list_weak).unwrap().jump_to_start();
render.update(&root, &mut rsc);
let before = rsc.ui.widgets.get(&list_weak).unwrap().extents[&0];
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(8000.0);
let start = Instant::now();
for step in 0..600 {
let now = start + std::time::Duration::from_millis(step * 16);
let still = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now);
render.update(&root, &mut rsc);
if !still {
break;
}
}
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
// Row 0 either scrolled out of the loaded extents (flung well past
// it) or moved upward (smaller top) -- either way, real motion
// happened toward the end rather than staying put.
if let Some(after) = list_ref.extents.get(&0) {
assert!(
after.top < before.top,
"fling toward the end did not move content up"
);
}
}
#[test]
fn cancel_fling_stops_it_with_no_further_movement() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(-8000.0);
let start = Instant::now();
rsc.ui
.widgets
.get_mut(&list_weak)
.unwrap()
.tick_fling(start + std::time::Duration::from_millis(16));
render.update(&root, &mut rsc);
assert!(rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
rsc.ui.widgets.get_mut(&list_weak).unwrap().cancel_fling();
assert!(!rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
let before = rsc.ui.widgets.get(&list_weak).unwrap().extents[&199];
// A tick after cancelling must be a no-op -- this is what a fresh
// touch-down relies on to stop a fling in its tracks.
let still = rsc
.ui
.widgets
.get_mut(&list_weak)
.unwrap()
.tick_fling(start + std::time::Duration::from_millis(200));
render.update(&root, &mut rsc);
assert!(!still);
let after = rsc.ui.widgets.get(&list_weak).unwrap().extents[&199];
assert_eq!((before.top, before.bottom), (after.top, after.bottom));
}
#[test]
fn fling_toward_the_start_stops_at_the_first_row() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
// An enormous velocity that would travel far past all 200 rows if
// unclamped -- this is exactly what IRIS_TODO.md's "way faster...
// better for stress testing" fling asks for.
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(-50_000.0);
let start = Instant::now();
for step in 0..2000 {
let now = start + std::time::Duration::from_millis(step * 16);
let still = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now);
render.update(&root, &mut rsc);
if !still {
break;
}
}
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
assert!(
list_ref.at_start,
"fling should have clamped at the first row"
);
let first = list_ref.extents[&0];
assert!(
first.top >= -0.5,
"clamped fling overshot the first row's top: {}",
first.top
);
}
}
+4 -3
View File
@@ -17,14 +17,15 @@ impl Widget for Aligned {
// already-resolved region double-applies that composition and is
// wrong for any widget nested below the root.
let used = painter.widget(&self.inner);
let density = painter.density();
let region = match self.align.tuple() {
(Some(x), Some(y)) => used.to_uivec2().align(RegionAlign { x, y }),
(Some(x), Some(y)) => used.to_uivec2(density).align(RegionAlign { x, y }),
(Some(x), None) => {
let x = used.x.apply_rest().align(x);
let x = used.x.apply_rest(density).align(x);
UiRegion::new(x, UiSpan::FULL)
}
(None, Some(y)) => {
let y = used.y.apply_rest().align(y);
let y = used.y.apply_rest(density).align(y);
UiRegion::new(UiSpan::FULL, y)
}
(None, None) => UiRegion::FULL,
+10 -9
View File
@@ -9,12 +9,12 @@ pub struct MaxSize {
impl MaxSize {
/// Caps a reported length at `max`, comparing in pixels since `Len`'s
/// rel/abs/rest components are not otherwise comparable.
fn clamp(len: Len, max: Option<Len>, output: f32) -> Len {
fn clamp(len: Len, max: Option<Len>, output: f32, density: f32) -> Len {
let Some(max) = max else {
return len;
};
let len_px = len.apply_rest().to_abs(output);
let max_px = max.apply_rest().to_abs(output);
let len_px = len.apply_rest(density).to_abs(output);
let max_px = max.apply_rest(density).to_abs(output);
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
/// than the size this widget reports for it -- see the identical
/// requirement noted on `Sized::draw`.
fn clamp_region(offered_px: f32, max: Option<Len>, output: f32) -> UiSpan {
fn clamp_region(offered_px: f32, max: Option<Len>, output: f32, density: f32) -> UiSpan {
let Some(max) = max else {
return UiSpan::FULL;
};
let max_scalar = max.apply_rest();
let max_scalar = max.apply_rest(density);
let max_px = max_scalar.to_abs(output);
if offered_px > max_px {
max_scalar.align(AxisAlign::Neg)
@@ -41,15 +41,16 @@ impl MaxSize {
impl Widget for MaxSize {
fn draw(&mut self, painter: &mut Painter) -> Size {
let output = painter.output_size();
let density = painter.density();
let offered = painter.px_size();
let region = UiRegion {
x: Self::clamp_region(offered.x, self.x, output.x),
y: Self::clamp_region(offered.y, self.y, output.y),
x: Self::clamp_region(offered.x, self.x, output.x, density),
y: Self::clamp_region(offered.y, self.y, output.y, density),
};
let used = painter.widget_within(&self.inner, region);
Size {
x: Self::clamp(used.x, self.x, output.x),
y: Self::clamp(used.y, self.y, output.y),
x: Self::clamp(used.x, self.x, output.x, density),
y: Self::clamp(used.y, self.y, output.y, density),
}
}
}
+57 -44
View File
@@ -7,9 +7,12 @@ pub struct Pad {
impl Widget for Pad {
fn draw(&mut self, painter: &mut Painter) -> Size {
let used = painter.widget_within(&self.inner, self.padding.region());
let width = self.padding.left + self.padding.right;
let height = self.padding.top + self.padding.bottom;
let density = painter.density();
let used = painter.widget_within(&self.inner, self.padding.region(density));
let width =
self.padding.left.apply_rest(density).abs + self.padding.right.apply_rest(density).abs;
let height =
self.padding.top.apply_rest(density).abs + self.padding.bottom.apply_rest(density).abs;
Size {
x: used.x + Len::abs(width),
y: used.y + Len::abs(height),
@@ -17,23 +20,29 @@ impl Widget for Pad {
}
}
/// Each side is a `Len`, not a bare `f32`, so `.pad(dp(10))` resolves
/// against the display's density the same way any other size does -- see
/// `Len::dp`'s field doc. `.pad(10)` (a bare number) still works via
/// `From<T: UiNum>` below, unchanged: it becomes an `abs` (physical-pixel)
/// `Len`, exactly as a bare number always has meant elsewhere in this
/// crate.
pub struct Padding {
pub left: f32,
pub right: f32,
pub top: f32,
pub bottom: f32,
pub left: Len,
pub right: Len,
pub top: Len,
pub bottom: Len,
}
impl Padding {
pub const ZERO: Self = Self {
left: 0.0,
right: 0.0,
top: 0.0,
bottom: 0.0,
left: Len::ZERO,
right: Len::ZERO,
top: Len::ZERO,
bottom: Len::ZERO,
};
pub fn uniform(amt: impl UiNum) -> Self {
let amt = amt.to_f32();
pub fn uniform(amt: impl Into<Len>) -> Self {
let amt = amt.into();
Self {
left: amt,
right: amt,
@@ -41,80 +50,84 @@ impl Padding {
bottom: amt,
}
}
pub fn region(&self) -> UiRegion {
pub fn region(&self, density: f32) -> UiRegion {
let mut region = UiRegion::FULL;
region.x.start.abs += self.left;
region.y.start.abs += self.top;
region.x.end.abs -= self.right;
region.y.end.abs -= self.bottom;
region.x.start.abs += self.left.apply_rest(density).abs;
region.y.start.abs += self.top.apply_rest(density).abs;
region.x.end.abs -= self.right.apply_rest(density).abs;
region.y.end.abs -= self.bottom.apply_rest(density).abs;
region
}
pub fn x(amt: impl UiNum) -> Self {
let amt = amt.to_f32();
pub fn x(amt: impl Into<Len>) -> Self {
let amt = amt.into();
Self {
left: amt,
right: amt,
top: 0.0,
bottom: 0.0,
top: Len::ZERO,
bottom: Len::ZERO,
}
}
pub fn y(amt: impl UiNum) -> Self {
let amt = amt.to_f32();
pub fn y(amt: impl Into<Len>) -> Self {
let amt = amt.into();
Self {
left: 0.0,
right: 0.0,
left: Len::ZERO,
right: Len::ZERO,
top: amt,
bottom: amt,
}
}
pub fn top(amt: impl UiNum) -> Self {
pub fn top(amt: impl Into<Len>) -> Self {
let mut s = Self::ZERO;
s.top = amt.to_f32();
s.top = amt.into();
s
}
pub fn bottom(amt: impl UiNum) -> Self {
pub fn bottom(amt: impl Into<Len>) -> Self {
let mut s = Self::ZERO;
s.bottom = amt.to_f32();
s.bottom = amt.into();
s
}
pub fn left(amt: impl UiNum) -> Self {
pub fn left(amt: impl Into<Len>) -> Self {
let mut s = Self::ZERO;
s.left = amt.to_f32();
s.left = amt.into();
s
}
pub fn right(amt: impl UiNum) -> Self {
pub fn right(amt: impl Into<Len>) -> Self {
let mut s = Self::ZERO;
s.right = amt.to_f32();
s.right = amt.into();
s
}
pub fn with_top(mut self, amt: impl UiNum) -> Self {
self.top = amt.to_f32();
pub fn with_top(mut self, amt: impl Into<Len>) -> Self {
self.top = amt.into();
self
}
pub fn with_bottom(mut self, amt: impl UiNum) -> Self {
self.bottom = amt.to_f32();
pub fn with_bottom(mut self, amt: impl Into<Len>) -> Self {
self.bottom = amt.into();
self
}
pub fn with_left(mut self, amt: impl UiNum) -> Self {
self.left = amt.to_f32();
pub fn with_left(mut self, amt: impl Into<Len>) -> Self {
self.left = amt.into();
self
}
pub fn with_right(mut self, amt: impl UiNum) -> Self {
self.right = amt.to_f32();
pub fn with_right(mut self, amt: impl Into<Len>) -> Self {
self.right = amt.into();
self
}
}
impl<T: UiNum> From<T> for Padding {
/// Covers both a bare number (`.pad(8)`, via `Len`'s own `From<N: UiNum>`
/// blanket -- an `abs`/physical-pixel `Len`) and a `Len` directly
/// (`.pad(dp(10))`) with the one impl, since `Len: Into<Len>` is the
/// reflexive case of the same bound.
impl<T: Into<Len>> From<T> for Padding {
fn from(amt: T) -> Self {
Self::uniform(amt.to_f32())
Self::uniform(amt.into())
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ impl Widget for Scroll {
self.content_len = used
.axis(axis)
.apply_rest()
.apply_rest(painter.density())
.within_len(container_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
// translation; that translation is only valid if what got painted
// is already the reported size, anchored the same way both times.
let density = painter.density();
let mut region = UiRegion::FULL;
if let Some(x) = self.x {
region.x = x.apply_rest().align(AxisAlign::Neg);
region.x = x.apply_rest(density).align(AxisAlign::Neg);
}
if let Some(y) = self.y {
region.y = y.apply_rest().align(AxisAlign::Neg);
region.y = y.apply_rest(density).align(AxisAlign::Neg);
}
let used = painter.widget_within(&self.inner, region);
Size {
+16 -10
View File
@@ -4,12 +4,18 @@ use std::marker::PhantomData;
pub struct Span {
pub children: Vec<StrongWidget>,
pub dir: Dir,
pub gap: f32,
/// A `Len` (not a bare `f32`) so `dp(4)` resolves against the display's
/// density the same way any other size in the tree does -- see
/// `Len::dp`'s field doc. Only the `abs` component (folded from `dp` at
/// draw time, `Widget::draw` below) is meaningful here; `rel`/`rest`
/// were never supported for a gap and still are not.
pub gap: Len,
}
impl Widget for Span {
fn draw(&mut self, painter: &mut Painter) -> Size {
let axis = self.dir.axis;
let gap = self.gap.apply_rest(painter.density()).abs;
// Phase 1: draw each child once, at the ambient (unmodified, full)
// region a size-only query used to see before this migration, to
@@ -25,7 +31,7 @@ impl Widget for Span {
.map(|child| painter.widget(child).axis(axis))
.collect();
let gap_total = self.gap * self.children.len().saturating_sub(1) as f32;
let gap_total = gap * self.children.len().saturating_sub(1) as f32;
let total = lens.iter().fold(Len::abs(gap_total), |s, &l| s + l);
// Phase 2: place each child for real, using the lengths just
@@ -54,7 +60,7 @@ impl Widget for Span {
child_region.flip(axis);
}
let used = painter.widget_within(child, child_region);
start.abs += self.gap;
start.abs += gap;
let ortho = used.axis(!axis);
if ortho.rel > 0.0 || ortho.rest > 0.0 {
@@ -82,12 +88,12 @@ impl Span {
Self {
children: Vec::new(),
dir,
gap: 0.0,
gap: Len::ZERO,
}
}
pub fn gap(mut self, gap: impl UiNum) -> Self {
self.gap = gap.to_f32();
pub fn gap(mut self, gap: impl Into<Len>) -> Self {
self.gap = gap.into();
self
}
@@ -103,7 +109,7 @@ impl Span {
pub struct SpanBuilder<State, const LEN: usize, Wa: WidgetArrLike<State, LEN, Tag>, Tag> {
pub children: Wa,
pub dir: Dir,
pub gap: f32,
pub gap: Len,
_pd: PhantomData<(State, Tag)>,
}
@@ -129,13 +135,13 @@ impl<State, const LEN: usize, Wa: WidgetArrLike<State, LEN, Tag>, Tag>
Self {
children,
dir,
gap: 0.0,
gap: Len::ZERO,
_pd: PhantomData,
}
}
pub fn gap(mut self, gap: impl UiNum) -> Self {
self.gap = gap.to_f32();
pub fn gap(mut self, gap: impl Into<Len>) -> Self {
self.gap = gap.into();
self
}
}
+2 -1
View File
@@ -141,7 +141,8 @@ impl<'a> TextEditCtx<'a> {
fn layout(&mut self) -> &Layout<UiColor> {
let attrs = self.text.view.attrs.clone();
let width = self.text.view.wrap_width();
self.text.view.buf.shape(self.data, &attrs, width);
let density = self.data.density;
self.text.view.buf.shape(self.data, &attrs, width, density);
self.text.view.buf.layout()
}
+1 -1
View File
@@ -39,7 +39,7 @@ where
.label("Message")
.add(rsc);
let bar: WeakWidget = (field.pad(12).width(rest(1)),)
let bar: WeakWidget = (field.pad(dp(12)).width(rest(1)),)
.span(Dir::RIGHT)
.background(rect(UiColor::new(40, 40, 46, 255)))
.add(rsc);
+2 -2
View File
@@ -174,8 +174,8 @@ where
(header, field.width(rest(1)))
.span(Dir::DOWN)
.gap(4)
.pad(10)
.gap(dp(4))
.pad(dp(10))
.add_strong(rsc)
.any()
}
+26 -1
View File
@@ -41,6 +41,12 @@ pub struct Selection {
/// pan wanting the same touch gesture). See `drag` below, and
/// `iris::sense::DragArbiter`'s own doc for the decision itself.
arbiter: DragArbiter,
/// Tracks the last ~100ms of this gesture's pan deltas (in the same
/// signed units `list.scroll` takes), so a release that turns out to
/// have been panning can hand `List::fling` a realistic initial
/// velocity instead of one frame's noisy last delta --
/// IRIS_TODO.md's "swiping has no momentum."
velocity: VelocityTracker,
}
impl Default for Selection {
@@ -55,6 +61,7 @@ impl Selection {
rows: BTreeMap::new(),
anchor: None,
arbiter: DragArbiter::new(),
velocity: VelocityTracker::new(),
}
}
@@ -178,9 +185,21 @@ impl Selection {
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
// the previous gesture -- `List::fling`'s own doc, and
// Android's `Scroller::abortAnimation` for the same reason.
list(ui).cancel_fling();
self.arbiter.update(pos_window, now)
}
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;
}
@@ -198,13 +217,19 @@ impl Selection {
_ if self.arbiter.is_idle() => {
let already_selected = self.has_selection(ui);
self.arbiter.press_start(pos_window, now, already_selected);
self.velocity.reset();
list(ui).cancel_fling();
self.arbiter.update(pos_window, now)
}
_ => self.arbiter.update(pos_window, now),
};
match outcome {
DragOutcome::Undecided => {}
DragOutcome::Pan(dy) => list(ui).scroll(-dy),
DragOutcome::Pan(dy) => {
let amt = -dy;
self.velocity.add_sample(amt, now);
list(ui).scroll(amt);
}
DragOutcome::SelectStart => {
// Grep-able on "iris selection" the way the frame report is
// on "iris frame report" -- selection has no accessibility