cce4b28324b1a1091378503ab7dd4eb4cd9eed62
47
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b863f9f3df |
iris: a capture cancels every other gesture, and the pointer leaves UiRenderState
Two defects Iris reported from her phone on 2026-09-08, one root cause each, both in how a gesture ends. A widget that takes pointer capture cuts every other widget off from the press completely -- no PressEnd, no Drop -- so anything else tracking it was left with an open gesture at a stale origin, and the *next* touch anywhere was measured from that origin. That is the transcript jumping on a tap after a code fence was panned sideways. CursorSense::Cancel is the missing state: delivered once to each loser of a capture race, the way Android sends ACTION_CANCEL and the web sends pointercancel. And registered click_or_drag|unclick, which never matches a Drop, so a Scroll that had captured never saw its own gesture end and stayed panning from where the finger left. That is the horizontal snap back. CursorSense::drag_senses() states the rule once for every widget driving a DragGesture instead of per call site. The pointer's own state (who holds capture, who is tracking the press) no longer lives in a Mutex on UiRenderState. It is Event::Global for the cursor senses -- owned by the event manager that runs the dispatch, reached by &mut, with a per-dispatch PointerRequests slot for handlers -- per Iris: never reach for locks first, and input-wide state belongs to the general input handler. What had forced the lock was a Data: Send bound on task_on that nothing needed; the spawned future never sees the event's data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
38bf6309cb |
iris: a mask is a shape, not a rectangle -- .masked_by, and touch obeys it
Iris, on the code fence: "the code block scrolling currently masks in an
inner rectangle. Ideally masks should have a shape associated with them,
rounded rectangle being one of them ... so that the mask becomes the
parent container with rounded edges. Make sure alpha works properly with
it, eg. on the corners where alpha should be decreased / multiplied."
`Mask` is now `{ primitive, parent }` -- the slot of a primitive already
written, plus the mask this one nests inside. The fragment stage
evaluates that primitive's own coverage at the masked pixel, through the
same `rounded_rect_coverage` a drawn rect goes through, and multiplies it
into the alpha along the whole `parent` chain. Nothing about the shape is
copied, so a rounded container's corner and its children's clipped corner
are one piece of arithmetic and cannot drift; two nested feathers dim a
pixel twice, which is the multiply she asked for.
`.masked()` is unchanged for callers: it writes an undrawn rect
(`Drawn::No`/`NOT_DRAWN` -- owned, moved, resized and freed like any
other primitive, simply never rasterized) and points at that, so square
clipping is the same mechanism rather than a special case. New
`.masked_by(shape)` draws `shape` behind the content in its own layer and
clips to the first primitive it drew, with no radius written twice; it
replaces `.masked().background(w)`, which drew both and clipped to the
box. `transcript-ui`'s `BlockFrame::Verbatim` is the first caller.
Hit-testing applies the shape (`SensorUi::run_sensors` ->
`UiRenderState::mask_admits`, coverage above one half, which is where the
drawn edge is), as well as the widget's own box -- the two ask different
questions and both have to hold. `primitive_corners` is a floor-for-floor
transliteration of the shader's `corners_of`, not `region.to_px()`: the
phone's 2.55 density puts nothing on a whole pixel, and skipping the
rounding disagrees with the pixels by up to one along each edge.
A mask's shape must be a rect, asserted by name in `set_mask_to`. A glyph
would need a CPU-side alpha plane before the hit test could agree with
the shader, and a standalone image a bind-group switch the fragment stage
cannot make. So no texture mask exists; the branch where one would go is
in both copies of `mask_coverage`. docs/LAYOUT.md's section end lists this
and the three other places the code is narrower than the design.
Tests. Layer 1, `layout_tests.rs`: the child's coverage swept across the
container's corner arc equals the container's own exactly; nested masks
multiply rather than intersect, asserted where both feathers are partial,
which is the only place the two differ; a press in a rounded-away corner
misses while one inside the curve and one on a straight edge hit; and
`a_plain_mask_still_clips_to_a_square_box`, the half this had no reason to
touch. The first version of the corner test swept the straight chord
between the arc's ends, which lies inside the circle everywhere -- it
proved nothing and said so, which is why it counts both sides now.
`iris/tests/mask_sdf.rs` is the only test here that needs a GPU: it lifts
`distance_from_rect` and `rounded_rect_coverage` out of
`iris_core::SHAPE_SHADER` by name -- lifted, not copied, since a copy
would be edited alongside the shader -- and runs them in a compute pass
over ~200k points at five radii against `iris_core::rounded_rect_coverage`.
Worst disagreement under 1e-5; the negative control (`+ 0.01` inside the
shader's smoothstep) fails it at 0.03.
Layer 2 for looking: `./run-headless.sh phone --phone --shot /tmp/mask.png
--seconds 6 -- -p transcript-fixture` draws the fixture's horizontally
scrolled code fence clipped on the curve at both top corners.
Two things found on the way and fixed here:
- The winit backend had the defect the Android one was fixed for in
|
||
|
|
203f53470c |
iris: one primitive arena all layers share, with placement in a storage buffer
A mask is about to reference a primitive already drawn and evaluate it at the masked pixel (docs/LAYOUT.md's "Masks with a shape"), which the data layout could not answer: a primitive's placement lived in its layer's *vertex* buffer, invisible to the fragment stage, and `rects`/`glyphs` were per layer too -- so a mask whose shape is a rounded container in one layer, clipping content a `Stack` put in another, would have read the wrong layer's rect with nothing on screen to say so. So the instances and the per-primitive data become one arena (`UiRenderState::primitives`), bound once per frame; a layer keeps only its draw *order*, which is what its vertex buffer now is -- one `u32` slot per instance instead of eight attributes. The vertex stage reads the placement it is drawing from `instances[slot]`; the fragment stage can read any other primitive's from the same buffer, which is what the mask work needs and the reason there is no second copy for masks. Arena slots are stable (nothing is compacted), so a `Mask` can hold one across frames. A slot freed during a redraw is therefore not reusable until every layer's order has been compacted around it -- otherwise the reused slot would draw twice, once through the stale order entry -- which is what `Primitives::freed` and `UiRenderState::apply_free` are. That compaction moved out of `UiRenderNode::update` into `UiRenderState:: update`: it is bookkeeping over `active`, not GPU work, and the harness (which has no renderer) needs it too. Same 164 tests, the `--phone` screenshot unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
a6a100edc6 |
iris: the chain bound is named for the walk, and two nits from the review
docs/REVIEW-2026-09-07.md's rule finding on `MOVE_CHAIN_LIMIT` plus both nits. `MOVE_CHAIN_LIMIT` bounds two different parent walks -- move offsets in the vertex stage and `Mask::parent` in the fragment stage -- under a name that says one, and the shader's own comment beside it already called it "the bound on the parent walk". Renamed to `PARENT_CHAIN_LIMIT` in both files at once (the constant has no other users), with the doc saying which two chains it governs. `DragGesture`'s release computed `self.velocity.velocity()` twice, once for the outcome and once for the `iris drag release:` line -- a full Lsq2 fit each. Once now, into a local both read. `transcript-ui`'s `selection.rs` called `ui.ui_mut().animate(id)` even when `List::fling` had bailed (Compose's `|v| <= 1.0`, or no anchor), so a frame was asked to advance an animation known not to exist. It is behind `is_scrolling()` now, which is the same answer `fling` itself reached. `phone_screen.rs`'s recorded flick still flings, which is the half that says the guard did not turn a working release off. Verified: `cargo test --lib -p iris` (104) and `-p transcript-fixture` (12), fmt and clippy clean, and layer 2 (`run-headless.sh phone --phone`) still renders with the mask chain intact -- code fences clipped to their rows, the list clipped at the composer -- which is what the wgsl rename needed looking at rather than compiling. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
ff1d6ea932 |
iris: a degenerate fit has no solution, and the desktop follows a display's density
Two of docs/REVIEW-2026-09-07.md's risks.
**R7.** `poly_fit_least_squares` clamped a near-zero basis-vector norm
(`1.0 / dot(..).sqrt().max(1e-6)`) where Compose's `polyFitLeastSquares`
bails: below `0.000001f` the vectors are linearly dependent and there is
no solution. Clamping reached the solve with a `q` row of zeros and a
zero on `r`'s diagonal, produced `[NaN, NaN, NaN]`, and was rescued only
by the caller's `is_finite` check -- working, but by accident, and not
what the source it is transcribed from does. It returns `Option` now and
`velocity()` answers 0 on `None`.
`a_fit_through_linearly_dependent_points_has_no_solution` reports
`Some([NaN, NaN, NaN])` with the clamp back in place. Three samples at
one instant is exactly what the input clock produced before
|
||
|
|
551c01398f |
iris: the guards against silently wrong output survive into release
docs/REVIEW-2026-09-07.md's R1. Every invariant guard added on 2026-09-07
was a `debug_assert!`, and every build anybody runs on this project is
release -- the bench APK must be (the debug `libmain.so` is 325 MB and
will not install) and Iris's phone gets release too. So a `List` drawn
without a mask painted over its surroundings again, in exactly the build
the fault was found in, with nothing saying so.
Promoted to `assert!`, each O(1) or a handful per *draw* and each
protecting against output that is wrong on screen with no other symptom:
`List::draw`'s `painter.is_masked()`, `List`'s `extents`-are-on-screen
check, `Painter::set_mask`'s doubled-call check (the second call replaces
rather than nests, i.e. an unclipped widget), `Painter::glyphs`'s atlas
generation (glyphs sampled from coordinates now holding other letters),
and `List::fling`'s finiteness (one comparison per gesture; NaN
propagates into `deceleration_for`'s `ln()` and the fling never settles).
Left as `debug_assert!` and now saying so in a comment: `List::place`'s
slot-exists precondition (once per row placed per frame, and its release
failure is the `.expect` below rather than something wrong on screen) and
`poly_fit_least_squares`'s two preconditions (run on every velocity query,
with `MIN_SAMPLE_SIZE` and the `is_finite` check giving release a defined
outcome either way). `PointerClock::sample`'s ordering assert was already
annotated in
|
||
|
|
992c472975 |
iris: iris::input/iris::frame diagnostics, and gating the four debug! lines that already drowned the ring
Iris asked for a button to copy raw input events and per-frame timings through the same report Copy report already produces. sense::log_input_event (one line per platform pointer sample, historical samples inline on Android) and diagnostics::log_frame (one line per frame: frame number, frame clock, time since last input, layout/draw durations, redraw kind, primitives on screen, animating) both land under iris::diagnostics's trace_enabled() gate, off by default since the ring is 2000 lines/256KiB and either target at 120Hz fills it in seconds. report_to_touch.py turns a report's iris::input lines back into a .touch file for harness/desktop replay, round-tripped in transcript-fixture's input_log_roundtrip test. Folds in docs/REVIEW-2026-09-07.md's D1: four older per-frame debug! lines (android::view's two render() lines, list.rs's fling tick, text/mod.rs's text render) were unconditional at Debug and, with the ring's RingLogger recording everything the app's Debug install lets through regardless of target, filled it before Copy report ever saw anything else. All four (and sense.rs's drag-release-samples line) are now behind the same gate. The same test proves both directions: tracing off leaves zero Debug lines from a replayed flick, tracing on produces the expected iris::input/iris::frame lines with real durations. Not wired to a Diagnostics-pane button: bench_client.rs is open under another agent. set_trace(bool) is the whole surface a control needs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
7e4e26a335 |
iris: resolve fontique's Android monospace generic family ourselves
fontique 0.11.1's Android backend never resolves GenericFamily::Monospace
(mono=None in the startup diagnostic, RUST.md's 2026-09-07 "Platform
fonts" gap): DEFAULT_GENERIC_FAMILIES looks up "monospace" against
name_map before fonts.xml is parsed into it, and even after parsing,
AOSP's fonts.xml names it with a <family name="monospace"> element whose
<font> children the backend's own parser never reads (a TODO left in
place) -- so the name gets a FamilyId with no font data behind it, and
family_by_name("monospace") comes back empty too. Confirmed still present
on linebender/parley's main branch, so there is no newer release to bump
to.
TextData::patch_android_monospace (Android-only, called from
TextData::default) reads fonts.xml's own "monospace" declaration for the
font filename it names, then finds which of fontique's actually-scanned
families owns a font file with that name and registers it as the
Monospace generic directly -- the same authority Compose's
Typeface.MONOSPACE resolves through, without pinning an OEM-specific
family name. Verified on this checkout's emulator:
mono=Some("Droid Sans Mono") in the startup log, and a screenshot showing
the bench-fixture's code block and tool-card values in a visibly
monospaced face beside sans body/heading text. Desktop's fontconfig
backend is unaffected.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
||
|
|
896c93a59a |
iris: drop bundled Noto Sans, match Compose's platform-font fonts
Iris's call: "remove the font for now; just match what compose does." Removes the six embedded Noto Sans/Noto Sans Mono TTFs (3.6 MB) that TextData::default used to register ahead of the platform's own fonts; fontique's system font discovery was already on by default and now runs unshadowed (Roboto/Roboto Flex on Android, fontconfig on the desktop). .so -3,748,136 bytes (11,193,608 -> 7,445,472), matching the estimate. Verified fallback still lands on visible tofu for CJK/emoji rather than blank, and flagged (not fixed) a fontique Android backend gap that leaves Monospace unresolved -- see RUST.md's "Platform fonts (2026-09-07)" and DECISIONS.md/IRIS.md's dated entries. |
||
|
|
d507ae4c96 |
iris-core: masks nest instead of aborting, and a widget can ask to be drawn again
`Painter::set_mask` refused a widget any mask of its own once an ancestor had set one -- `assertion failed: self.mask == MaskIdx::NONE` -- so clipping was one level deep wherever it was used at all. That is what stopped the transcript's `List` from being clipped to its own box: its rows already use `.masked()` themselves (a code fence, a tool card's one-line title), and giving the list one aborted on the first fence drawn. A mask now carries the mask it was set inside (`Mask::parent`) and the fragment stage walks that chain, so a pixel has to be inside every mask on it. Chained rather than intersected on the CPU because each mask moves with its own widget: a fence inside a transcript row carries the row's scroll and the list's box does not, and one region resolved when the fence was last drawn gets the second of those wrong as soon as the row is moved rather than redrawn -- which is every scroll frame. The child holds one ref on its parent's slot, released where the child's own slot is, so a chain cannot outlive what it points at. The old assert survives as the case that is still wrong: the same widget setting two masks, which since a mask now chains would be a clip loop. Also `Painter::draw_again`, for a layout that can only discover a correction to itself by laying out once -- `List::clamp_to_content`, in the commit after this -- and `Painter::is_masked`, which is how a widget that draws outside its own box can require something to be clipping it. |
||
|
|
ed04d4c735 |
iris: the keyboard reopens, the IME's height reaches the layout, and a fling actually moves
Items 1-3 of Iris's 22:16 phone report, plus the two defects that were hiding behind item 1 and only became visible once the first one was fixed. Emulator evidence and the numbers are in docs/RUST.md. **Keyboard reopen.** `attr.rs`'s already-focused branch calls `focus_gained` on a tap that stays inside `DRAG_SLOP` -- what Android's own `EditText` does, `showSoftInput` being idempotent. Dismissing the IME leaves the field focused, so the only branch that requested it never ran again. Negative control run: without this one call the second tap leaves `mInputShown=false`. Swipes across and out of the focused field still summon nothing. **IME height.** `MainActivity` sends `getInsets(ime()).bottom` and `isVisible(ime())` as two values; the height used to be sent *as* the boolean, so nothing had a number to pad by. `Insets`/`WindowInsets` carry both, `bench_client` reads the boolean for its state machine and the height for `Composer::set_bottom_inset`, and the list follows because it is `rest(1)` in the same `Span`. **Fling.** Three defects, in the order they were found: 1. `on_touch_event` read only each `MotionEvent`'s final position, so a batched 120Hz flick fed the tracker one sample and `velocity()` answered 0.0. Historical samples are replayed through the sensor pass now, `CursorState::time` carries each sample's own time (so a replay loop's speed cannot become the measured velocity -- the winit backend sets it too), the press is a sample as AOSP's own tracker does, and `iris drag release:` logs the decision for the phone's logcat. 2. Nothing advanced a fling between input events: `tick_fling`'s only caller was the benchmark's own loop, so the bench flung and a finger never did. iris has one animation mechanism now -- `Widget::tick`, `UiData::animate`/`tick_animations`, called by both backends before the draw and re-requesting a frame while it answers true. 3. With flings finally animating, one lasted 45 seconds: `List::fling` hardcoded density 1.0 against physical-pixel velocities, and `FlingCalculator`'s coefficient used the scroll friction where AOSP uses its 0.84 tuning constant -- 56x, inside an exponential. Emulator: 1.62s for v=11064, against AOSP's own 1.586s. **Two pre-existing faults found on the way.** `MOVE_CHAIN_LIMIT` was 16 and the composer's chain is 17, so every debug build aborted on a tap of the composer and every release build silently drew and hit-tested that subtree short; it is 64 in both the CPU walk and shader.wgsl, and the assert prints the chain so a cycle and a deep tree can be told apart. And `minSdk` is 29, since `getEventTimeNanos` is API 29 and a missing JNI method is a crash rather than a degraded fling. Every new invariant carries its guard: sample times non-decreasing in `on_touch_event`, and tests confirmed to fail without their fix for the press-seeded velocity, the animation registration and the AOSP magnitudes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
ba2afbaedb |
iris: a cleared glyph atlas must un-cache every RenderedText, not just empty itself
Iris's phone, 2026-09-06 22:16: after leaving the app and returning, every glyph drawn *before* the resume came back as fragments of other letters, while the diagnostics text drawn after it was perfect. The renderer rebuild does force a full redraw -- `surface_changed` calls `render.resize(...)`, which sets `UiRenderState::resized`, which makes the next `update` take `redraw_all`. What survives that is one cache further in: `TextView::render` returns its cached `RenderedText` whenever the wrap width, buffer and attrs are unchanged, so `TextData::place` is never reached, nothing is re-rasterised into the fresh atlas, and the *previous* atlas's uv_min/uv_max/layer go straight back to the GPU. Only text whose content changed after the resume re-shapes -- exactly the split in the screenshot. One mechanism rather than a per-holder invalidation path: `GlyphAtlas` carries a `generation`, bumped by `clear`; a `RenderedText` records the one it was placed against; and `TextView::render`'s cache key includes it, so clearing the atlas makes every cached render un-reusable at once. `Painter::glyphs` debug-asserts that a submitted quad's generation is the live one, catching the fault at the submission instead of on screen. Test `clearing_the_atlas_re_renders_cached_text_instead_of_reusing_it` (iris/src/widget/text/mod.rs): draw, clear the atlas, resize, draw again, and assert the atlas holds the same glyph count. Confirmed to fail without the cache-key line -- it trips the new debug_assert with "glyphs placed against atlas generation 0 submitted against 1". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
f5b88932b4 |
iris: a widget's move slot has one owner -- move_applied + repositioned
`mov` accumulates a delta onto the slot and `reposition` overwrote it, and both legitimately land on one widget in one frame: `List::place`'s Bottom-known branch offers a row a same-size box that has moved (`mov`), then corrects the placement inside it when the row's cached height no longer matches what the row reports (`reposition`). That is what a wrapped transcript row hit, and what the `move_applied == ZERO` debug assert was standing in for -- an assert against a case that happens is not a guarantee, it is a crash. The slot means `move_applied + repositioned` now, both halves recorded on `ActiveData`, so `reposition` adds the move rather than dropping it and stays idempotent. The assert it replaces is a `debug_assert_eq!` that the slot still holds that sum on entry -- i.e. that nothing but those two ever wrote it. Test: `a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement`, which draws the child at the offered position (-100px) rather than the placement (100px) without the fix. Verified against the `.wrap(true)` repro from docs/IRIS_TODO.md (draws correctly, no panic) and an emulator bench run with assertions live. |
||
|
|
3cb18ac5c2 |
iris: a one-layer glyph atlas is a GL_TEXTURE_2D, so every glyph drew as a box
The emulator was blamed for two days for what is iris's own defect on any GL adapter. `GpuTextures::new` created the atlas `texture_2d_array` with one layer; wgpu-hal picks the GL target from the descriptor alone (`gles::Texture::get_info_from_desc`, `(false, 1) => TEXTURE_2D`), so the shader's `sampler2DArray` was handed a `GL_TEXTURE_2D`, the unit was incomplete, every `textureSample` returned (0,0,0,1), and `draw_glyph`'s `color.a *= texel.a` painted the whole glyph quad. `MIN_ARRAY_LAYERS = 2`, with the account at `create_array_texture` and a `debug_assert!` there. Vulkan -- the phone's build and the desktop's default backend -- was never affected. `force-gles` now switches the desktop backend too, so the GLES path is reproducible on a machine with a real GPU in seconds rather than only through an APK: that is how this was found, with two shader probes showing the sample was exactly (0,0,0,1). |
||
|
|
c3cfc67bb3 |
iris: count text layouts, so "a delta shapes one block" is measured rather than argued
take_counters gains a fourth counter, text shapes, bumped in Painter::render_text -- which TextView::render only reaches on a cache miss, so it counts shapes and not requests. A draw counter cannot stand in for it in either direction: a widget can be redrawn without re-shaping (the layout is memoized by width) and re-shaped without any extra draw, and re-shaping is the whole thing the per-block transcript row exists to avoid. With it, a_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one asserts the number docs/DECISIONS.md's 2026-09-06 entry actually claims: one delta into a 100-paragraph reply shapes exactly one text layout, the same as into a one-paragraph one. Before the split that was necessarily O(message), since the reply was one buffer. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
e63e923d44 |
iris: a size-independent widget's hit box lands where it is drawn
draw_inner's third fast path -- offered region changed shape, widget's
output does not depend on it -- rewrites the widget's own primitives in
place and writes no move-slot delta at all.
|
||
|
|
167862ca1b |
iris: the composer scrolls on a finger -- a dp cap worth zero, a stale mask slot, a hit box moved twice
Wrapping the composer's field in .scrollable().masked() needed three layout defects fixed first, each with a headless regression test that was confirmed to fail without its fix: - MaxSize/Sized reported a caller's declared dp length unresolved, and Span places a child from the abs/rel of what it reported, so dp(168) was worth zero: the bar got a slot of nothing the moment its content passed six lines and the Scroll inside measured its container at -63px (container=-63 content=415.8 amt=478.8 on the emulator). Len::fold_dp, used on the way out, plus a debug_assert in draw_inner that a reported Size carries no dp -- the rule is about every widget, not those two. - Masked allocated a fresh mask slot per draw, and draw_inner's unchanged-region fast path does not revisit descendants, so they kept clipping against a box the bar had moved away from: four live mask entries, none of them current, and the field drew nothing. ActiveData::own_mask, allocated once and rewritten in place. - mov updates active.region and accumulates the same delta on the move slot, and resolved_region added both, so a panned widget's own hit box sat at twice the pan -- the composer's field was untappable after a drag. ActiveData::move_applied. Scroll itself measured the right number by a misleading route; it is written against painter.px_size() now and still reports its content's size, since reporting the container makes the answer a function of itself. Verified on this checkout's emulator: swipe 540 1200 -> 540 1460 moved the field's Message box 31,1041..1048,1509 -> 31,1131..1048,1651 with its height unchanged at 468px. run-bench.sh polled logcat for a prefix copy_report also logs at startup, so it printed a report that had never been run. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
fb6b459c2c |
iris: Scroll pans on a finger drag; a vertical drag in a focused field scrolls rather than selects
IRIS_TODO.md's "the composer has no touch-drag scroll". `Scroll::drag`
takes its pan from the same `sense::DragGesture` `List` is driven by --
arbitration, DRAG_SLOP, velocity and pointer capture all stay in sense.rs
and only what a committed pan *means* is decided per caller -- and
`WidgetLike::scrollable()` registers it beside the wheel handler it already
registered, so every scroll area pans on a finger with nothing added at the
call site. No fling: `Scroll` has no per-frame tick to animate one and the
areas it wraps are at most a screenful. `Scroll::amt()` exposes the pan
position.
`attr.rs`'s `on_press` treated an already-focused field as the plain
click_or_drag case, so every Pressing frame extended a selection. It now
applies the same DRAG_SLOP rule its unfocused branch already did: a press
past the slop vertically abandons its pending selection for the rest of the
gesture, so the scroll area around the field wins it. That is Android
EditText's own behaviour and it is what lets a swipe up over the composer
scroll instead of dragging a highlight through what you typed.
Also fixed, found doing it: `ActiveData::mask` stored the mask a widget
*set* rather than the one it was drawn *under*, and `redraw` feeds that
field back in as the inherited mask -- so a targeted redraw of any `Masked`
handed it its own mask and aborted on `set_mask`'s nested-mask assert. A
real abort on the emulator, `assertion failed: self.mask == MaskIdx::NONE`.
And the per-frame orphan guard from
|
||
|
|
76b1f99277 |
iris: a dirty widget redrawn by its ancestor never freed its old primitives
`draw_inner` read `needs_redraw` without consuming it, and used it to skip the whole `if let Some(active)` block -- including the `remove(id, false)` that frees a redrawn widget's previous primitives. So a widget that was both already active and marked dirty, and was reached by an *ancestor's* draw rather than by `redraw_updates` picking it first, drew a second full set of primitives and then had `active.insert` overwrite the only handles that could ever have freed the first set. Those primitives stay in the layer's instance buffer for the life of the process, with a leaked move slot and leaked mask refs, drawn every frame at whatever region they last had -- and `List` sets no mask, so a row measured at `GENEROUS_PADDING` leaves its ghost outside the list's own box. That is the doubled `Compacted:` row in docs/bench/iris-phone-v2-2026-09-06.md: overlapping copies inside the transcript and one more below the composer. Fixed by consuming the mark (`needs_redraw.remove`) at the top of `draw_inner` -- this call *is* the redraw it asked for -- and freeing the old primitives on the dirty path too. Guarded so it cannot come back silently: `UiRenderState::orphaned_primitives` walks every layer's live instances and names any whose owner is no longer active or no longer holds a handle to them, and `update` `debug_assert!`s it empty every frame (debug builds only). New regression test `an_ancestor_redrawing_a_dirty_row_leaves_no_stale_copy` in list.rs fails on the pre-fix code with "1 primitive(s) survived their own widget's redraw". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
c02152a4f4 |
iris: a tap on an empty text field left no caret, so typing was silently dropped
TextEditCtx::select compared the tap against the laid-out text's own box and cleared the selection for anything outside it. An empty field lays out to a zero-width box, so tapping the composer granted focus and opened the keyboard with no caret, and insert_str returns early without one -- every keystroke went nowhere and no glyph was ever emitted. Parley clamps a point outside the layout by itself, and a press reaching select() has already been hit-tested to the widget, so there was nothing for the 'outside' branch to mean. insert_str now debug_asserts rather than dropping input silently, and UiRenderState::draw_started -- a re-entrancy guard whose test was written after its own remove(), so it could never fire, and which grew by one entry per widget ever drawn -- is restored to what it was meant to be: inserted around Widget::draw, removed when it returns, asserted empty at the top of every update. |
||
|
|
1f379e8384 |
docs/REVIEW-2026-09-06.md: fix all ten review findings; RUST.md/IRIS_TODO.md: DragGesture merge checks
Finding 1 (the real crash): Selection::clear() drops rows and anchor, called from TranscriptScreen::apply's Rebuild arm right before List::clear() -- push_row re-registers survivors as it rebuilds each row. Fixes a WeakWidget outliving the row group_tool_runs regrouped away, which panicked the next long-press anywhere. New apply_tests test builds a real TranscriptScreen, forces the regroup, and confirms no panic. Findings 2-5: debug_assert!s on List::place's slot, List::fling and FlingCalculator's velocity finiteness, VelocityTracker::add_sample's chronological order, and FrameReport::mark_phase's non-decreasing start_index. Finding 7: bench_client.rs's battery_line guard restructured so the empty check can't be separated from its unwraps by a future edit. Findings 9/10: new List tests pinning tick_fling's per-tick deceleration and replace_back's evicted-key cleanup with a different key than the existing tests use. IRIS.md's replace_back/clear/apply entry gained the side-table-clearing note the Docs finding asked for. Also records this pass's DragGesture-merge verification in RUST.md (tap stays vs swipe doesn't, a real fling keeps moving after release, keyboard cycles confirmed via on_insets_changed) and annotates the two IRIS_TODO.md phone-report items it targets. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
9458f443ad | Merge remote-tracking branch 'origin/rustify' into worktree-agent-a754368325fa06839 | ||
|
|
e12c708246 |
iris: generalize drag arbitration into a default-input DragGesture, with pointer capture and Drop
Iris asked (2026-09-06) that dragging be part of iris's default input system rather than duplicated per app: "anything that provides good performance and can be generalized well is part of iris rather than the app." DragArbiter and VelocityTracker (both already in iris::sense) are now bundled into a new DragGesture, which also takes exclusive pointer capture (UiRenderState::capture_pointer/release_pointer/captured_pointer) the moment a gesture commits to panning or selecting, and delivers a new CursorSense::Drop -- not PressEnd -- to the captured widget when the button lifts, wherever on screen that happens to be. This directly targets the phone bench's "finger flings do nothing": per-widget hit testing silently drops a gesture the instant the pointer moves off every registered region, which a fast pan/fling does routinely (crossing several virtualised rows, or ending off the loaded content entirely) -- so PressEnd, and the velocity/fling-start decision hanging off it, was frequently never delivered at all. Capture targets List's own stable id (List::key_at resolves the row-under-pointer from its extents), not a row's, since List retires rows mid-drag as content scrolls. transcript-ui::Selection::drag now only decides pan-vs-select from DragGesture's outcome; row.rs's per-row registration is only ever a gesture's first frame, with lib.rs registering the List-level continuation once. New tests: sense_tests.rs's two pointer-capture regressions, list.rs's replacing_the_last_row_many_times_does_not_leak_primitives (a P0 stale-primitives diagnostic -- passes, pinning the widget-arena layer as not the leak). MainActivity.java opts into edge-to-edge (Window::setDecorFitsSystemWindows(false), API 30+, no new dependency) so window insets are redelivered on every change including a pure IME toggle -- the named-but-untried fix for the phone bench's "keyboard: could not be shown" and the emulator's identical non-confirmation. cargo fmt/clippy/test clean across the iris workspace. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
27ca5b2349 | Merge remote-tracking branch 'origin/rustify' into worktree-agent-a9002910a315fe719 | ||
|
|
20b12255e1 |
iris/android: composing text sync, tap-vs-swipe focus, composer rebuild, atlas reset on app-switch
Four fixes from Iris's phone report on the
|
||
|
|
4afc453faa |
Merge remote-tracking branch 'origin/rustify' into worktree-agent-a16b22e34539b810e
# Conflicts: # iris/android-app/src/bench_client.rs # iris/android-app/src/bench_jni.rs |
||
|
|
1aab61bf26 |
iris android-app: Benchmark v2 -- fling, type and keyboard phases
Implements RUST.md's "Benchmark v2" spec in bench_client.rs: fling (8 out + 8 back at 12,000px/s through List::fling, waits for !is_scrolling() capped 3s, reports travel as row index + offset via List's new anchor_position_display), stream (unchanged), type (the 600-char P0 constant, one char per 50ms into the composer's real TextEdit via .set(), then deleted), and keyboard (5 show/hide cycles via bench_jni.rs's new InputMethodManager calls, confirmed from on_insets_changed's real ime_bottom transitions rather than assumed from the JNI call returning). FrameReport gained mark_phase/phase_stats/late_at_hz (iris/core) so the report can show a per-phase block (frames, late%, p50/p90/p99, worst) against the display's real refresh rate (bench_jni's new refresh_rate_hz), matching the shape docs/bench/compose-phone-v2 uses. RING_CAPACITY bumped 4096->16384 since a full v2 run is ~3,000+ frames. Found and fixed a real deadlock while wiring this up: read_from_state (a new helper that gets a value back out of a spawned task's ctx.update, which has no return channel of its own) only worked for its first call in a chain, because nothing called redraw.request_redraw() after enqueueing later ones -- nothing then drains the task channel to run them. Every call now triggers its own redraw. Verified end to end on this checkout's x86_64 emulator (force-gles, cold boot): fling/stream/type all report populated phase blocks; keyboard's show never got a real on_insets_changed confirmation this run (see follow-up work). Full report and travel numbers go in RUST.md's P0 box next. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
d8e6bc6e9b |
iris: bundle Noto Sans for text rendering, apply density scale on both backends
Bundles Noto Sans/Noto Sans Mono (regular/bold/italic/bold-italic, OFL licensed) into iris-core and registers them ahead of the platform's own fonts in the SansSerif/Monospace generic-family fallback lists, so text no longer depends on the platform's font enumeration succeeding or resolving weight/style correctly. Iris's phone report showed bold spans rendering as blank gaps of the correct advance width -- the glyph simply wasn't rasterised -- while the emulator's system fonts happened to resolve every style; a bundled static-per-style family removes that platform-dependent step entirely. TextData::font_diagnostics() reports what was found/resolved, for the startup log and the Diagnostics page. Also applies a content/device-pixel scale that neither backend had before: UiRenderNode::new/resize now take the window size explicitly (logical units) rather than deriving it from the surface's physical config, so a 16.0 font size is 16 logical units rather than 16 raw device pixels. Wired on desktop via window.scale_factor() (input events, window_size, and the render node's own seed); the Android side (density via DisplayMetrics, touch coordinates, layout root size) is the next commit. Also adds WgpuErrorLog and a per-frame atlas-grow counter (GpuTextures::take_pages_grown), both plumbing for the Android diagnostics page in the next commit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
46246ea511 |
iris: turn the phone bind-group-layout crash into a diagnostic, drop force-gles from phone builds
UiRenderNode::new used to let a wgpu validation error reach the default
uncaptured-error handler and panic, which is what aborted the P0 bench APK
on Iris's phone in AndroidRenderer::new with only "wgpu error: Validation
Error" surviving into the truncated crash report. It now wraps creation in
wgpu error scopes and returns Result<Self, String>; the Android backend
turns a failure into the adapter's identity, the limits/downlevel flags a
layout validates against, and wgpu's own error chain, logged as one logcat
line and shown on screen (IrisView.showRendererError) instead of crashing.
Auditing every bind-group-layout entry against wgpu-core's own validation
source names the likely cause: masks_layout's move_offsets storage buffer
is visible to the vertex stage, which Vulkan grants unconditionally but
GLES gates on the driver's own vertex-stage SSBO support -- and the
delivered APK was built with force-gles, a flag meant only to force the
*emulator* onto GLES for one frame-time measurement, that build-apk.sh's
default feature list applied to every arm64 build regardless of target.
Its default no longer includes force-gles.
Testing the diagnostic (by inducing an artificial validation error) also
found and fixed a real reentrancy bug: calling Activity.setContentView
synchronously from inside a ViewPeer callback re-enters the same peer's
RefCell borrow through onFocusChanged, aborting with "RefCell already
borrowed". Deferred through the same push_dynamic_deferred_callback
mechanism raise_if_enabled already uses.
Full audit, verification, and the named hypothesis are in RUST.md's P0
box ("iris bench crash on the phone, 2026-09-06"); the API change is in
IRIS.md. Nobody on this session has the phone, so this is unconfirmed
against real hardware -- the point of (1) is that the next run says so
either way.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
||
|
|
d01c105037 |
iris: stop requesting compute-shader limits nothing uses
adapter.request_device asked for Limits::default(), which requests desktop-tier compute-shader limits unconditionally even though nothing in iris/iris-core creates a ComputePipeline or writes a @compute stage. That crashed device creation outright on a downlevel GL adapter reporting OpenGL ES 3.0 (no compute at all) -- the Android emulator's EMU_GPU=software/force-gles path, and any real GLES-3.0-only device. New iris_core::device_limits(), shared by both platform backends, zeros exactly the six max_compute_* fields rather than switching to a downlevel Limits preset -- downlevel_webgl2_defaults() also zeros max_storage_buffers_per_shader_stage, which shader.wgsl's vertex stage needs. rigs/gpu-probe's own mirrored limits were updated to match. Not verified against the actual SwiftShader-ES-3.0 crash on-device this pass: the cold boot needed would have force-restarted this checkout's emulator while another session had its own app running on it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
e2a1fadbec |
iris: FrameReport CPU/GPU split, force-gles backend switch, iris-scroll.sh rig
Splits each frame sample at queue.submit into redraw-to-submit (iris's own CPU work) and submit-to-after-present (driver/GPU wait), so RUST.md's I5 "where does iris's frame time go" question can be answered with a number per half instead of a single total. Adds a force-gles Cargo feature that switches the Android wgpu::Instance from Backends::PRIMARY to Backends::GL at compile time (no runtime env-var path exists into an already-launched Android process on this machine), for isolating SwiftShader-Vulkan vs. GLES/virgl as the software-mode gap's cause. app/iris-scroll.sh extracts transcript-bench.sh's exact 24-swipe/6-cycle gesture loop for iris's own demo app, which transcript-bench.sh cannot drive directly since it opens a session through the Compose app's own UI. Verification (this pass, on a disk-pressure-limited host running low on space): cargo fmt --all clean, no diff. cargo clippy --workspace --all-targets: no warnings from this diff (pre-existing future-incompat notices from wgpu/winit/naga only). cargo test --workspace and cargo ndk for iris-android-app --features transcript-screen were verified clean by the previous pass on this identical diff (fmt/clippy/test/ndk all clean, per that pass's own report); not re-run here because the host's disk was 93% full and a concurrent ai-server rebuild (stable toolchain moved to 1.98.1, rebuilding aws-lc-sys from scratch) had driven I/O pressure to ~60%, so a repeat cargo test --workspace sat 50+ minutes doing no useful work and was stopped rather than left to make the disk situation worse. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
7ae53ad797 |
iris: FrameReport, a per-frame wall-time report of iris's own render path
dumpsys gfxinfo cannot see a SurfaceView's own GPU-drawn frames at all
(RUST.md's I5 box), so iris needs its own equivalent of Compose's
render-report button before item 3 of the recommendation can be decided
by a number. FrameReport (iris/core/src/render/frame_report.rs) records
each frame's wall time -- from render()'s redraw start to after
queue.submit + present() -- into a fixed 4096-entry ring, and reports
total frames, janky % (>16.7ms, gfxinfo's own budget), P50/P90/P99 and
the worst. Wired into AndroidUiState and android/view.rs's render(), and
exposed as two named controls ("Frame report", "Reset frame report") on
iris-android-app's transcript screen, logged under the crate's fixed tag
so a script can grep "iris frame report" the way transcript-bench.sh
greps "ai-app render report".
6 new unit tests for the ring/percentile math. cargo fmt/clippy/test
--workspace clean; cargo ndk (iris, transcript-ui, and
iris-android-app --features transcript-screen) all clean.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
||
|
|
0af4c88d08 |
iris: SpanStyle, per-range text styling (RUST.md's I5)
A TextBuffer used to have exactly one style for its whole string, applied via parley's push_default. SpanStyle adds a second, optional layer -- a byte range plus whichever of colour/family/font size/ bold/italic/underline it overrides, pushed with parley's own push(property, range) -- so a heading, bold, inline code and a link can each carry their own look inside one wrapped, selectable TextEdit. This is the actual answer to RUST.md's E2 finding against Masonry (TextArea::edit_styles() is one StyleSet for the whole editor). PlacedGlyph gains a color field, read from parley's own per-run Style::brush, and Painter::glyphs draws each glyph in its own colour instead of one colour for the whole RenderedText. Real bug found while wiring this into a live screen (not caught by any test, since markdown's own tests only check string/range logic): spans were threaded through TextOutput::run but not the sibling TextEditOutput::run, so every editable field silently dropped them. Fixed in build.rs; see IRIS.md's entry for why both call sites are a pair to keep in sync. cargo fmt/build/clippy/test --workspace and cargo ndk (iris, iris-android excluded per its own workspace exclusion) all clean; 28 existing iris tests unaffected. Co-Authored-By: Claude Sonnet <noreply@anthropic.com> |
||
|
|
4cfe0ef6e6 |
iris: I4 -- accessibility names via AccessKit
Builds one flat AccessKit tree (iris_core::ui::access::AccessTree) from
iris's own widget tree: a synthetic Role::Window root with every named
widget as a direct child, names from the existing `.label()`, roles from
a new Widget::access_role() (default Unknown, TextEdit overrides to
TextInput/MultilineTextInput), bounds from UiRenderState::window_region
so a moved subtree reports where it actually is. Modular the way input's
sense registry is: Widgets gained one HashSet<WidgetId> ("named"),
populated only by .label()/set_label and drained by free_next (the
existing removal path), and AccessTree walks only that set -- a widget
nobody named costs it nothing. Updates only when the named set's name,
role or bounds actually changed, with a rebuild counter mirroring
take_counters (confirmed 1/0/1 across first-draw/unchanged/moved in
access_tests.rs).
Pushed through accesskit_winit on the desktop (DefaultApp::new now
creates the window hidden, builds the adapter, then shows it, per that
constructor's requirement) and accesskit_android on Android
(IrisViewPeer now implements AccessibilityNodeProvider). Both action
handlers are inert on purpose: AGENTS.md's tap-by-name is a real touch
at the node's bounds, not an AccessKit action request, so the ordinary
pointer path already answers it once bounds are right. E1's
detach-abort mitigation is carried into android/access.rs's
raise_if_enabled, which gates every QueuedEvents::raise on
AccessibilityManager.isEnabled().
tabs-ui's five switch buttons now carry .label()s matching their
on-screen text, giving both the desktop run and the emulator step real
names to find.
Verified on host: cargo fmt/build/clippy/test all clean (28 tests, 3
new), cargo ndk build+clippy clean for iris and iris-android-app,
run-headless.sh tabs --shot byte-identical to I2's prior screenshot
(27266 bytes). Not run: the emulator step (ui-trace tap-by-name against
iris-android-app), held by another session this pass -- exact commands
recorded in RUST.md's I4 box.
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
|
||
|
|
19c36e37f2 |
iris: give masks/move_offsets their own bind group, fixing O(N) image append
GpuTextures folded the masks and move_offsets storage buffers into every standalone image's own bind group (group 2), alongside that image's texture view. Since ArrBuf::update hands back a new Buffer identity whenever either buffer's length changes -- which a widget getting its first move-offset slot can trigger, unrelated to any image -- every live image's bind group had to be rebuilt whenever either buffer grew. Appending a 1,001st image to 1,000 already-settled ones cost 1,001 bind-group creates, not 1 (IRIS_TODO.md, run-bench.sh images). Moved both buffers into their own bind group (group 3 in shader.wgsl and UiRenderNode), bound once per frame in draw() rather than once per per-image bind group. GpuTextures's image bind groups now only reference the atlas array view, the image's own view and the sampler -- none of which change when masks/move_offsets resize -- so a resize touches exactly one bind group regardless of how many images are live. This also closes the "two frames to reach steady state" item, which was the same bug measured a second way. Verified: cargo build/clippy/test clean (19 tests), cargo ndk build/clippy clean, run-headless.sh tabs --shot byte-identical (27266 bytes). New run-bench.sh images numbers: cold load unchanged at 1000/0/0/0, append now 1 instead of 1001. Both Fix items in IRIS_TODO.md ticked with the before/after numbers. Co-Authored-By: Claude Sonnet <noreply@anthropic.com> |
||
|
|
e2873df92e |
iris: fix I2's render gap -- window uniform never left (0, 0) on android-view
UiRenderNode::new seeded the GPU window uniform from WindowUniform::default() rather than the surface's real size, so shader.wgsl's vertex stage divided every primitive's position by (0, 0) and produced NaN/Inf clip coordinates on both Vulkan and GLES. winit's backend never hit this because winit fires an initial WindowEvent::Resized that corrects the uniform before the first frame; android-view has no equivalent event, so the node it built never got corrected. Seed the uniform from the SurfaceConfiguration passed to UiRenderNode::new instead, which is already right on both backends at construction time. Verified on the ai-app-2 emulator (Vulkan/SwiftShader and, temporarily forced, GLES/virgl): the tabs example now draws its widgets instead of just the clear colour. Ticks I2 in RUST.md. Co-Authored-By: Claude Sonnet <noreply@anthropic.com> |
||
|
|
288853c094 |
iris: on-demand message-list/image benchmarks, and two O(N) findings
IRIS_TODO.md's "Benchmarks" item: a message list of N wrapped-text rows (first-frame cost), scrolling it, and growing an input box above which the list must move rather than re-layout -- all as a plain, harness=false `cargo bench` binary (iris/benches/message_list.rs) since UiRenderState touches no GPU or window, chosen over criterion because every scenario here reduces to a count take_counters already answers exactly, and a new dependency wasn't worth it. Scroll (200 ticks) and the input-grow case (40 lines) are flat across N=100/1,000/10,000: LAYOUT.md's O(1) move chain holds. The many-images case (d) needs a real wgpu device, so it's a headless example (iris/examples/bench_images.rs) plus a new GpuTextures/UiRenderNode counter, take_image_bind_group_creates, mirroring take_counters. It found two real non-O(1) costs, recorded as new Fix items rather than redesigned: bind-group creation takes two frames to settle after a cold load instead of one, and appending a single image to an already-loaded 1,000-image list rebuilds all 1,000 existing bind groups (masks/move_offsets buffer growth triggers rebuild_image_bind_groups unconditionally). run-bench.sh wraps both. Numbers and commands are in IRIS_TODO.md. cargo fmt --all -- --check, cargo clippy --all-targets, and cargo test --workspace (19 passed) all clean; benches are not run by cargo test. Co-Authored-By: Claude Sonnet <noreply@anthropic.com> |
||
|
|
643daf5637 |
iris: route pointer input per kind, so scroll falls through a hovered button
IRIS_TODO.md's "Input does not fall through by input type": run_sensors treated "the cursor is over this widget" and "this widget consumed the event" as the same check, so a widget registered only for click() still blocked a Scroll meant for a list underneath it. Fixed by judging consumption per input kind -- with nothing momentary happening this frame the topmost hovered widget still wins (unchanged), but once a scroll or a press/release is actually happening, only a widget whose registered senses include a matching non-hover one (via the new TypeEventManager::registered, which lists a widget's registrations without running anything) can consume it. iris/src/sense_tests.rs builds a button-over-a-list Stack with a plain HasEvents impl (no GPU or window) and checks both directions: a scroll over the button reaches the list, and a real click still reaches the button. Confirmed to fail on the pre-fix code and pass after. Co-Authored-By: Claude Sonnet <noreply@anthropic.com> |
||
|
|
1a6599e1b2 |
iris: Widget::draw reports the size it used, replacing desired_width/height
Implements LAYOUT.md end to end: one fn draw(&mut self, &mut Painter) -> Size replaces draw + desired_width/desired_height on every widget in iris/src/widget/, SizeCtx and Cache are deleted, and a moved widget (Scroll, Offset) costs one move_offsets write resolved by a shared resolve_move WGSL function in both shader stages -- O(1) regardless of how many primitives are in its subtree, measured at 500 in the new iris/src/layout_tests.rs (a plain unit test: UiRenderState touches no GPU or window). Five real bugs surfaced only by diffing iris/run-headless.sh screenshots against the pre-change tree and are written up in LAYOUT.md's "Deviations found during implementation": Aligned's provisional draw composing painter.region() a second time through widget_within; Sized/ MaxSize reporting a capped size while still painting their child unconstrained (fine under the old two-pass model, wrong once a parent like Aligned draws before knowing the final size); a widget's move_offsets parent link being unreadable from self.active while its own ActiveData is still mid-construction; Painter::reposition needing the child's *painted* footprint (its reported size, top-left anchored) rather than its offered region; and a widget's move slot needing to be reused in place across redraws, with its delta reset, rather than reallocated. All four iris/examples render pixel-identical to the pre-change tree. cargo fmt/clippy/test clean across the workspace (18 tests: 14 pre-existing plus 4 new). Co-Authored-By: Claude Sonnet <noreply@anthropic.com> |
||
|
|
e0a473e090 |
iris: replace the bindless texture array with an atlas array + per-image bind groups
The old pipeline bound every texture ever drawn (glyph atlas pages and
standalone images alike) in one binding_array<texture_2d<f32>> and asked
every device, unconditionally, for VK_EXT_descriptor_indexing -- which a
real share of Android GPUs lack and which failed outright on the Android
emulator's software Vulkan (see TEXTURES.md's "iris's binding array does
not survive real Android hardware").
Implements TEXTURES.md's "Recommended shape": the glyph atlas is now one
texture_2d_array (a layer per page, grown by doubling + GPU-side
copy_texture_to_texture); a standalone image is its own ordinary Texture
and BindGroup, drawn with its own draw() call from a separate per-layer
instance list; group 2's layout is {atlas array, one image slot, sampler,
masks}. request_device now asks for no features and no binding-array
limits at all, and UiLimits is gone.
Also fixes (by making moot) the changed=false bug the review found, where
a Patch in the same batch could cancel an earlier Push's rebuild signal,
and documents the swap_remove draw-order invariant apply_free already
relied on.
Verified: cargo fmt/build/clippy/test clean in iris/ on the pinned
nightly; minimal and tabs render correctly via run-headless.sh; a
throwaway example confirmed the standalone-image bind-group path renders;
rigs/gpu-probe, updated to the new empty feature/limit set, confirms
request_device succeeds on the ai-app-2 emulator's software Vulkan
(EMU_GPU=software) -- see TEXTURES.md's "Implemented, 2026-09-04" for the
exact command and output. RUST.md's blocking item is resolved.
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
|
||
|
|
68a7f41ed0 |
Move iris's text onto parley, with a glyph atlas
Two changes that only make sense together, because the atlas is what the new layout feeds. Parley replaces cosmic-text for layout and shaping, and its editing model replaces the hand-written one. That is the larger win in edit.rs: parley addresses text by byte offset into one string rather than by (line, index), so `select_content`, `delete_between`, `insert_inner` and `newline` become ordinary string operations, and `iter_layout_lines`, `index_x` and `cursor_pos` -- which walked runs by hand to place the caret and the selection boxes -- are deleted in favour of `Selection::geometry` and `Cursor::geometry`. Those are bidi- and wrap-correct, which the hand-written versions were not. The file loses about 130 lines and gains Home/End. The atlas is what the TODO's "text resizing (per frame) is really slow" was about. Every string used to be rasterised into its own RgbaImage and uploaded as a whole texture whenever anything changed -- so a window resize re-rasterised and re-uploaded every visible string. Now a glyph is rasterised once per font, size and subpixel phase and shared by every string containing it, and a resize re-emits quads without touching the GPU's copy. The tabs example says so directly: its `views` counter, the number of texture views bound, goes from 6 to 1. Supporting pieces: a GLYPH primitive that samples a sub-rectangle and tints it, since the existing texture primitive samples a whole texture; a Patch texture update, because re-uploading a 4 MB page per glyph is what an atlas exists to avoid; and GpuTextures now keeps its Textures, as a view cannot be written through. Two bugs found on the way. `primitives!`'s @count rule recursed with commas while matching space-separated tokens, so it only terminated for exactly two primitives -- adding a third hit the recursion limit. And Color had no Default, which parley's Brush requires. Drops cosmic-text and unicode-segmentation, and with them two nightly feature gates that nothing uses any more: portable_simd (the old glyph compositing) and gen_blocks (the deleted line iterator). Eleven gates left. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b6b0928087 |
Take winit out of iris-core, which makes it build for Android
iris-core wanted exactly one thing from winit: PhysicalSize<u32> in UiRenderNode::resize's signature, for two numbers it immediately turned into floats. That pulled a whole windowing backend into the layer below it. `resize` takes `impl Into<Vec2>` now, matching UiRenderState::resize beside it. The consequence is the reason: with winit in the graph, an Android build of the core failed in android-activity, which needs a backend feature nothing here selects and which iris should not be going through at all -- the plan is android-view. Without it, `cargo ndk -t arm64-v8a -P 26 build -p iris-core` produces an rlib in 30s with wgpu's Android backend included. So the widget, layout and render core already builds for the phone, and what remains is the surface, the input and the IME. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
12221ea025 |
Make iris ask for the frame a resize needs
`update` redrew everything when `resized` was set, but `needs_redraw` -- which is what decides whether to request a frame at all -- did not know about `resized`. A condition in one and not the other is a frame nobody asks for and a stale window. The two share one `needs_redraw_all` now. Latent on Wayland, because winit requests a redraw after a resize by itself; a resize changes neither the root nor any widget, so nothing else here would have asked. It stops being latent on Android, where the surface work will not have winit underneath it and every rotation and keyboard open is a resize. This is not a fix for the startup defect recorded in RUST.md, where the window keeps its pre-configure layout: that reproduces with this change in place, and the frame it needs is requested and drawn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
caaa733caa |
Make iris build: pin a dated nightly and migrate const-trait impls
The vendored January tree did not parse at all on a current nightly: 36 errors in iris-core, all from one syntax change. `impl const Trait for T` is now `const impl Trait for T`, with generics on the `impl`. Bounds are unaffected, and the traits were already declared `const trait` -- so the diagnosis recorded in RUST.md was wrong, and pinning back to a January nightly would only have deferred this. Everything else (the unresolved UiVec2/Vec2/impl_op imports, a Color<u8> resolving to wgpu_types::Color) cascaded from the seven files that failed to parse. The pin is dated rather than `nightly` because that is exactly the failure: a rolling channel moving under a build Dev Updater runs unattended. It carries the components and Android targets too, so a fresh clone provisions itself. Also drops two `#![feature]` gates the compiler reports as declared and unused, since the build stays warning-clean, and takes rustfmt's import order in attr.rs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4ab26f068e |
Vendor iris, the in-house UI library, at iris/
Iris's decision: it lives in this repository for now, included by path, with its history left in the iris/iris repository on the gitea remote (this is its main at 7b54aaf, byte-identical to the public GitHub copy). It gets its own repository back once it has proved itself here. RUST.md's I0 records the decision and what the first build said: the tree does not compile on the current nightly because const_trait_impl now requires traits to be declared 'const trait', which is the first item of I0b. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |