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>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-06 17:17:42 -04:00
1 parent d73db97629
commit 167862ca1b
14 files changed
+567 -39

No files matched your search

+25
View File
@@ -5,6 +5,31 @@ they can be judged and reversed later. Detail lives in RUST.md (and IRIS.md
for iris API changes); this file is only the summary. Newest first. Items for iris API changes); this file is only the summary. Newest first. Items
marked **DEFERRED** are ones the agent chose not to decide alone. marked **DEFERRED** are ones the agent chose not to decide alone.
## 2026-09-06 (composer scroll and the streaming block model)
- **A streamed message becomes a column of per-block widgets.** Decided by
the design agent; recorded here because it is the shape of every message
on screen. A transcript row is one `TextEdit` today, so a streamed delta
re-shapes the entire message through parley on every event -- the stream
phase is the one place iris is behind Compose on your phone (p50 18.2ms
vs 13.4ms). A row becomes a column of one widget per markdown block
(paragraph, heading, fence, list, table) and a delta replaces only the
last block, keeping every earlier block's layout. **Rejected:** splitting
parley's layout at block boundaries inside one text widget (couples
iris's text widget to markdown structure, and parley has no incremental
API), and caching shaped runs per paragraph inside `TextEdit` (a second
cache with its own invalidation beside the glyph cache). Chosen because
P1's markdown block model is needed anyway, so the split happens once, in
`client-core`, and iris stays a text renderer. **Status: designed, not
built** -- this pass spent its budget on the composer's three layout
defects; docs/RUST.md has the design and the pass conditions.
- **The composer's overflowing text now scrolls on a finger**, capped at
six lines and clipped to the bar. Reverses the "still does not scroll"
item below.
- **A widget may not report a `dp` length** (see IRIS.md). A rule for
widget authors, enforced by a `debug_assert!`; nothing changes for app
code.
## 2026-09-06 (stale-primitives and touch-scroll pass) ## 2026-09-06 (stale-primitives and touch-scroll pass)
- **A vertical drag inside a focused composer now scrolls rather than - **A vertical drag inside a focused composer now scrolls rather than
+44
View File
@@ -8,6 +8,50 @@ capability that moved. Small and trivial changes do not go here.
An entry gives the date, what changed, why, and a short before/after where An entry gives the date, what changed, why, and a short before/after where
it helps judge the change without the session that made it. Newest first. it helps judge the change without the session that made it. Newest first.
## 2026-09-06: a reported `Size` may not carry `dp`; `Len::fold_dp`
**New: `Len::fold_dp(density) -> Len`** -- the same fold `apply_rest` does
(`dp` becomes physical pixels), but staying a `Len` so `rest` survives.
**New rule, and it is a rule about every widget, not about the two that
broke it**: a `Len` a widget *reports* from `draw` must not carry an
unresolved `dp`. `dp` is an input unit -- a number the widget author wrote
-- and the containers that consume a reported length read `abs`, `rel` and
`rest` straight off it (`Span`'s placement arithmetic, `Pad`'s addition),
so a reported `dp` is silently worth **zero**. `MaxSize` and `Sized` both
returned the caller's declared `Len` as written; a `.max_height(dp(168))`
therefore gave its child a slot of nothing the moment the cap actually
applied, which is what made the composer's bar collapse. Both put their
declared lengths through `fold_dp` now, and
`UiRenderState::draw_inner` `debug_assert!`s the invariant after every
`Widget::draw`, so a widget that gets this wrong says so at the mistake
rather than laying out at zero somewhere else.
Nothing changes for a caller: `.max_height(dp(48))` is written the same
way. It is only widget *authors* who now have a rule to follow, and a
debug build that enforces it.
## 2026-09-06: `Painter::set_mask` reuses one slot; `ActiveData` gains two fields
**`Painter::set_mask(region)` allocates its widget's mask slot once and
rewrites it in place** on every later draw, instead of pushing a new one
each time. It has to: `draw_inner`'s unchanged-region fast path does not
revisit a descendant whose own region did not change, so those descendants
go on referencing whichever slot they were first drawn under. Pushing a
fresh slot per draw left the composer's field clipped to a box the bar had
long since moved away from -- four live mask entries, none of them the
`Masked`'s current region -- and it drew nothing at all. Same call, same
signature; only the lifetime changed.
**`ActiveData` gains `own_mask` and `move_applied`** (both public, since
`ActiveData` is). `own_mask` is the slot above, `MaskIdx::NONE` for a
widget that sets no mask. `move_applied` is how much of a widget's own
move-slot delta its `region` already accounts for: `mov` shifts both,
`Painter::reposition` shifts only the slot, and `resolved_region` -- and so
every hit test -- has to subtract it. Without that a widget that had been
panned had its *own* hit box at twice the pan while its descendants were
correct, which made the composer's field untappable after a finger drag.
## 2026-09-06: `Scroll` pans on a finger drag, and a vertical drag in a focused text field no longer selects ## 2026-09-06: `Scroll` pans on a finger drag, and a vertical drag in a focused text field no longer selects
Three related public changes, all in aid of IRIS_TODO.md's "the composer Three related public changes, all in aid of IRIS_TODO.md's "the composer
+26 -13
View File
@@ -201,19 +201,32 @@ agent takes them without colliding with that pass's `bench_client.rs`/
a capped/scrollable height, bottom padding tied to the IME/nav-bar a capped/scrollable height, bottom padding tied to the IME/nav-bar
inset) -- structurally in place and unit-tested, but its own visual inset) -- structurally in place and unit-tested, but its own visual
correctness cannot be screenshotted until text actually renders. correctness cannot be screenshotted until text actually renders.
- [~] **The composer has no touch-drag scroll for overflowing text.** - [x] **The composer has no touch-drag scroll for overflowing text.**
**The mechanism is in, the composer is not, 2026-09-06.** `Scroll::drag` **Done 2026-09-06.** `field.scrollable().masked()` in
takes its pan from the same `sense::DragGesture` `List` uses and `transcript-ui/src/composer.rs`: a finger drag inside the bar pans the
`WidgetLike::scrollable()` registers it beside the wheel handler, so message, the bar stays capped at six lines, and a vertical drag in the
every scroll area in the codebase now pans on a finger (no fling -- see focused field no longer extends a selection (Android `EditText`'s own
IRIS.md). A vertical drag inside a focused field no longer extends a behaviour). Verified on this checkout's emulator with the
selection, matching Android's `EditText`. But the composer field is `transcript-screen bench force-gles` debug build -- six repetitions of a
**not** wrapped in `.scrollable()` -- the note above was describing 13-word sentence typed in, then
intent, not the code -- and wrapping it was tried and reverted: `Scroll` `ui-trace record --do "swipe 540 1200 540 1460 300"`: the field's
measures against the window rather than its own offered box, so inside `Message` box moved `31,1041..1048,1509` -> `31,1131..1048,1651` (the
the `MaxSize` that caps the field at six lines it pans itself out of the content panned down with the finger) with its **height unchanged at
bar entirely (emulator, 474 characters, the bar collapsed to its 468px** (the bar did not grow), and the two screenshots either side show
padding). docs/RUST.md's plan box has the numbers and the next step. different text in the same band.
Three real defects had to be fixed first, each with a headless
regression test in `iris/src/layout_tests.rs` and each confirmed to fail
without its fix (docs/RUST.md's plan box has the measurements):
a `MaxSize` reporting its cap as an unresolved `dp` (`Len::fold_dp`), a
`Masked` allocating a fresh mask slot per draw (`ActiveData::own_mask`),
and a panned widget's own hit box moving twice (`move_applied`).
`Scroll` itself turned out to measure the right number by a misleading
route -- it is written against `painter.px_size()` now, and the claim
below that it "measures against the window" was wrong.
**Still open, and pre-existing:** the bar's own grey background is not
drawn on this build (the `Stack{StackSize::Child(1)}` behind the field),
so the message reads as white text over the transcript. Present in the
build *before* this change too, so it is not the scroll area's doing.
## From the phone, 2026-09-06, 11:39 (build delivered 02:07, commit 543f6d9) ## From the phone, 2026-09-06, 11:39 (build delivered 02:07, commit 543f6d9)
+77
View File
@@ -43,6 +43,83 @@ gated on her verdict**, so this pass works the P0 defects and the pure
prerequisites in this order. Each item is ticked here by the agent that prerequisites in this order. Each item is ticked here by the agent that
closes it. closes it.
### Task A, closed 2026-09-06: the composer scrolls on a finger
`iris/transcript-ui/src/composer.rs` is `field.scrollable().masked()` now.
Verified on this checkout's emulator -- the evidence and the numbers are
in docs/IRIS_TODO.md's ticked "composer has no touch-drag scroll" item.
**The premise the task was given under was wrong, and that is worth
recording**: `Scroll` did *not* measure against the window. Its
`used.within_len(container).to_abs(output_size)` came to exactly
`abs + rel * container_px` -- the right number by a route that reads as if
the window were the container, which is what cost a session. It is
`painter.px_size()` and `to_abs(container_len)` now: same arithmetic,
stated the way the invariant is. `Scroll` also still reports its
**content's** size upward, deliberately -- reporting the container makes
the answer a function of itself (the bar is sized *from* that report, so
it collapses to nothing and never recovers; measured in the headless
harness before the shape was settled).
What actually broke the composer was three separate defects, each now
carrying a headless regression test in `iris/src/layout_tests.rs` that was
confirmed to fail without its fix:
1. **A `MaxSize` reported its cap as an unresolved `dp`.**
`Span::draw` places a child from the `abs`/`rel` of the length it
reported, so `dp(168)` was worth **zero** and the bar got a slot of
nothing the instant its content passed six lines; the `Scroll` inside
then measured its container at **-63px** (the padding subtracted from
nothing) and panned the whole message out of view. Emulator log, before
the fix: `container=-63 content=415.8 amt=478.8`. Fixed by
`Len::fold_dp` (new), used by `MaxSize` and `Sized` on the way out, and
guarded for every widget by a `debug_assert!` in
`UiRenderState::draw_inner` that a reported `Size` carries no `dp`.
Test: `a_dp_cap_is_reported_in_pixels_so_a_span_can_place_it`.
2. **A `Masked` allocated a fresh mask slot on every draw.**
`draw_inner`'s unchanged-region fast path means its descendants are
mostly *not* redrawn with it, so they kept clipping against the slot
they were first drawn under -- measured on the composer's tree at
**four live mask entries, none of them the widget's current box**, and
the field drew nothing at all. The slot is allocated once and rewritten
in place now (`ActiveData::own_mask`, `Painter::set_mask`), with its
path out in `remove`'s `undraw` branch. Test:
`a_masked_widget_keeps_one_mask_slot_that_is_always_its_own_region`.
3. **A panned widget's own hit box moved twice.** `mov` updates
`active.region` *and* accumulates the same delta on the widget's move
slot, and `resolved_region` added both -- so after a finger pan the
composer's field was untappable, while its descendants were fine (which
is why `hit_testing_follows_a_scrolled_widget`, which checks a
descendant, never saw it). `ActiveData::move_applied` records the part
of the slot's delta `region` already accounts for. Test:
`a_panned_widgets_own_hit_box_moves_exactly_once` (fails at exactly
2x the pan without it).
Still open, and **pre-existing** (present in the build before this change,
so not the scroll area's doing): the composer bar's grey background is not
drawn on the `transcript-screen bench` build, so the message reads as
white text over the transcript. `Stack{StackSize::Child(1)}` is the thing
to look at.
Rig fix on the way past: `iris/android-app/run-bench.sh` polled logcat for
`"iris bench report:"`, which `copy_report` also logs at startup
("nothing to copy -- run the benchmark first"), so it returned instantly
and printed a report that had never been run. It polls for the report's
own first line now.
### Bench, before Task B (emulator, 2026-09-06)
`iris/android-app/build-apk.sh debug --abi x86_64 --features
"transcript-screen bench force-gles"` + `run-bench.sh`, this checkout's
AVD. Emulator absolutes transfer nothing; the before/after ratio on the
same emulator does.
stream: 202 frames over 21.0s
late: 197 (97.5%)
total p50 61.5ms p90 211.7ms p99 342.6ms
worst 403.6ms
- [x] **Merge the `DragGesture` work** -- done 2026-09-06 (merge commit - [x] **Merge the `DragGesture` work** -- done 2026-09-06 (merge commit
`f802de9`, `git merge --no-ff worktree-agent-a754368325fa06839`, `f802de9`, `git merge --no-ff worktree-agent-a754368325fa06839`,
clean, no conflicts across the 8 files `e12c708` touched). Targets clean, no conflicts across the 8 files `e12c708` touched). Targets
+7 -2
View File
@@ -51,9 +51,14 @@ ui-trace record -s "$SERIAL" -d 3000 --do "tap 'Run benchmark'" -o /tmp/run-benc
# phase, ~61s of typing, 10s of keyboard toggles, roughly 2.5 minutes end # phase, ~61s of typing, 10s of keyboard toggles, roughly 2.5 minutes end
# to end) but device speed varies. 260s cap rather than v1's 90s -- v2 is # to end) but device speed varies. 260s cap rather than v1's 90s -- v2 is
# a longer script than v1's swipe-loop-only run. # a longer script than v1's swipe-loop-only run.
# The report's own first line, not the bare "iris bench report:" prefix:
# `copy_report` logs that prefix too ("nothing to copy -- run the benchmark
# first", which the app emits at startup), so polling for the prefix
# returned instantly and the script printed a report that was never run.
REPORT_LINE="iris bench report: iris bench report"
i=0 i=0
while [ "$i" -lt 260 ]; do while [ "$i" -lt 260 ]; do
LINE=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null | grep "iris bench report:" || true) LINE=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null | grep "$REPORT_LINE" || true)
if [ -n "$LINE" ]; then if [ -n "$LINE" ]; then
break break
fi fi
@@ -66,4 +71,4 @@ if [ -z "$LINE" ]; then
fi fi
# -A 60 rather than v1's -A 6 -- v2's report has a per-phase block (four # -A 60 rather than v1's -A 6 -- v2's report has a per-phase block (four
# phases, four lines each) on top of the frames/bench sections v1 had. # phases, four lines each) on top of the frames/bench sections v1 had.
adb -s "$SERIAL" logcat -d -s iris-android-app:I | grep -A 60 "iris bench report:" adb -s "$SERIAL" logcat -d -s iris-android-app:I | grep -A 60 "$REPORT_LINE"
+23
View File
@@ -147,6 +147,29 @@ impl Len {
} }
} }
/// The same fold as [`Self::apply_rest`] but staying a `Len`, so
/// `rest` survives: `dp` becomes physical pixels and every other
/// component is left alone.
///
/// **A `Len` a widget *reports* must have been through this.** `dp` is
/// an input unit -- a number the widget author wrote -- and the
/// containers that consume a reported length read `abs`/`rel`/`rest`
/// directly (`Span::draw`'s placement arithmetic, `Pad`'s addition),
/// so a reported `dp` is silently worth zero. That is what made the
/// composer's bar collapse to nothing the moment its content grew past
/// `MaxSize`'s cap: the cap was `dp(168)` and was returned unresolved,
/// so the bar was given a slot of 0 and the field inside it was panned
/// out of a container measured at -63px. `UiRenderState::draw_inner`
/// debug-asserts the invariant after every `Widget::draw`.
pub fn fold_dp(&self, density: f32) -> Self {
Self {
abs: self.abs + self.dp * density,
dp: 0.0,
rel: self.rel,
rest: self.rest,
}
}
pub fn abs(abs: impl UiNum) -> Self { pub fn abs(abs: impl UiNum) -> Self {
Self { Self {
abs: abs.to_f32(), abs: abs.to_f32(),
+33 -1
View File
@@ -1,4 +1,6 @@
use crate::{LayerId, MaskIdx, MoveIdx, PrimitiveHandle, Size, TextureHandle, UiRegion, WidgetId}; use crate::{
LayerId, MaskIdx, MoveIdx, PrimitiveHandle, Size, TextureHandle, UiRegion, WidgetId, util::Vec2,
};
/// important non rendering data for retained drawing /// important non rendering data for retained drawing
#[derive(Debug)] #[derive(Debug)]
@@ -9,7 +11,22 @@ pub struct ActiveData {
pub textures: Vec<TextureHandle>, pub textures: Vec<TextureHandle>,
pub primitives: Vec<PrimitiveHandle>, pub primitives: Vec<PrimitiveHandle>,
pub children: Vec<WidgetId>, pub children: Vec<WidgetId>,
/// The mask this widget was drawn **under** (its parent's), not the
/// one it set for itself -- see `own_mask` for that.
pub mask: MaskIdx, pub mask: MaskIdx,
/// The mask slot this widget allocated for *itself* with
/// `Painter::set_mask`, or `MaskIdx::NONE`. Kept across redraws and
/// rewritten in place, the way `move_slot` is: a `Masked` that pushed
/// a fresh slot each draw left every already-drawn descendant --
/// which `draw_inner`'s unchanged-region fast path does not revisit --
/// clipping to the *old* slot's region, so a composer whose bar had
/// since been placed at the bottom of the screen was still being
/// clipped to a box at the top of it and drew nothing (measured
/// 2026-09-06: four mask entries live, none of them the widget's
/// current region). Its path out is the `undraw` branch of
/// `UiRenderState::remove`, which drops the self-ownership ref taken
/// when the slot was allocated.
pub own_mask: MaskIdx,
pub layer: LayerId, pub layer: LayerId,
/// What `Widget::draw` returned the last time this widget was actually /// What `Widget::draw` returned the last time this widget was actually
/// drawn -- read by a parent placing this widget again without /// drawn -- read by a parent placing this widget again without
@@ -21,4 +38,19 @@ pub struct ActiveData {
/// so a retained child's `parent` link never goes stale). See /// so a retained child's `parent` link never goes stale). See
/// LAYOUT.md section 2. /// LAYOUT.md section 2.
pub move_slot: MoveIdx, pub move_slot: MoveIdx,
/// How much of this widget's own `move_slot` delta is already folded
/// into `region` above, in window pixels. The two mechanisms that
/// write that slot disagree about this and cannot be told apart from
/// the slot alone: `UiRenderState::mov` shifts `region` and the delta
/// together (the *offered* region genuinely moved), while
/// `Painter::reposition` writes only the delta (`region` stays the
/// offered box and the delta says where inside it the content was
/// placed). So anything that wants the widget's real position --
/// `resolved_region`, and through it every hit test -- must subtract
/// this from the chain sum. Without it a panned widget's own hit box
/// sits at twice the pan while its descendants' are correct, which is
/// how it went unnoticed: the composer's field became untappable
/// after a finger pan (2026-09-06). Reset to zero whenever the widget
/// is really redrawn, since `draw_inner` zeroes the slot then too.
pub move_applied: Vec2,
} }
+27 -2
View File
@@ -13,6 +13,10 @@ pub struct Painter<'a> {
pub(super) region: UiRegion, pub(super) region: UiRegion,
pub(super) mask: MaskIdx, pub(super) mask: MaskIdx,
pub(super) move_slot: MoveIdx, pub(super) move_slot: MoveIdx,
/// This widget's own mask slot, reused across redraws -- see
/// `ActiveData::own_mask`. `MaskIdx::NONE` until `set_mask` is called
/// for the first time in this widget's life.
pub(super) own_mask: MaskIdx,
pub(super) textures: Vec<TextureHandle>, pub(super) textures: Vec<TextureHandle>,
pub(super) primitives: Vec<PrimitiveHandle>, pub(super) primitives: Vec<PrimitiveHandle>,
pub(super) children: Vec<WidgetId>, pub(super) children: Vec<WidgetId>,
@@ -48,12 +52,32 @@ impl<'a> Painter<'a> {
self.primitive_at(primitive, region.within(&self.region)); self.primitive_at(primitive, region.within(&self.region));
} }
/// Clip everything this widget draws, itself and its descendants, to
/// `region`. One per widget: a second call would need the two to be
/// intersected, which nothing here does.
///
/// The slot is allocated once and **rewritten in place** on every
/// later draw rather than pushed again, because a descendant whose own
/// region did not change is not redrawn (`draw_inner`'s fast path) and
/// so keeps pointing at whichever slot it was drawn under. See
/// `ActiveData::own_mask` for what pushing a fresh one cost.
pub fn set_mask(&mut self, region: UiRegion) { pub fn set_mask(&mut self, region: UiRegion) {
assert!(self.mask == MaskIdx::NONE); assert!(self.mask == MaskIdx::NONE);
self.mask = self.rsc.ui_mut().masks.push(Mask { let mask = Mask {
region, region,
move_idx: self.move_slot, move_idx: self.move_slot,
}); };
if self.own_mask == MaskIdx::NONE {
let slot = self.rsc.ui_mut().masks.push(mask);
// The one ref this widget holds on its own slot, so the slot
// outlives any single frame's primitives; released in
// `UiRenderState::remove`'s `undraw` branch.
self.rsc.ui_mut().masks.push_ref(slot);
self.own_mask = slot;
} else {
*self.rsc.ui_mut().masks.get_mut(self.own_mask) = mask;
}
self.mask = self.own_mask;
} }
/// Draws a widget within this widget's region, returning the size it /// Draws a widget within this widget's region, returning the size it
@@ -86,6 +110,7 @@ impl<'a> Painter<'a> {
self.mask, self.mask,
None, None,
None, None,
crate::render::MaskIdx::NONE,
self.rsc, self.rsc,
); );
self.state self.state
+46 -1
View File
@@ -156,6 +156,7 @@ impl UiRenderState {
MaskIdx::NONE, MaskIdx::NONE,
None, None,
None, None,
MaskIdx::NONE,
rsc, rsc,
); );
} }
@@ -190,10 +191,12 @@ impl UiRenderState {
mask: MaskIdx, mask: MaskIdx,
old_children: Option<Vec<WidgetId>>, old_children: Option<Vec<WidgetId>>,
old_move_slot: Option<MoveIdx>, old_move_slot: Option<MoveIdx>,
old_own_mask: MaskIdx,
rsc: &mut dyn UiRsc, rsc: &mut dyn UiRsc,
) { ) {
let mut old_children = old_children.unwrap_or_default(); let mut old_children = old_children.unwrap_or_default();
let mut old_move_slot = old_move_slot; let mut old_move_slot = old_move_slot;
let mut own_mask = old_own_mask;
// Consumed here, not merely read: this call *is* the redraw the mark // Consumed here, not merely read: this call *is* the redraw the mark
// asked for, and leaving the mark set is what stranded a widget's // asked for, and leaving the mark set is what stranded a widget's
// primitives. `Painter::draw_twice` calls this twice for the same id // primitives. `Painter::draw_twice` calls this twice for the same id
@@ -207,6 +210,7 @@ impl UiRenderState {
// the doubled `Compacted:` row in docs/bench/iris-phone-v2-2026-09-06.md. // the doubled `Compacted:` row in docs/bench/iris-phone-v2-2026-09-06.md.
// The same shape reaches any dirty widget an ancestor redraws first. // The same shape reaches any dirty widget an ancestor redraws first.
let dirty = rsc.widgets_mut().needs_redraw.remove(&id); let dirty = rsc.widgets_mut().needs_redraw.remove(&id);
let output_size = self.output_size;
if let Some(active) = self.active.get_mut(&id) if let Some(active) = self.active.get_mut(&id)
&& !dirty && !dirty
{ {
@@ -235,6 +239,13 @@ impl UiRenderState {
*r = r.outside(&from).within(&region); *r = r.outside(&from).within(&region);
self.region_mut_count += 1; self.region_mut_count += 1;
} }
// Same bookkeeping `mov` does below and for the same
// reason: `region` moves, this widget's own slot delta
// does not, so the part of that delta `region` accounts
// for grows by exactly this step. See
// `ActiveData::move_applied`.
active.move_applied +=
region.top_left().to_abs(output_size) - from.top_left().to_abs(output_size);
active.region = region; active.region = region;
return; return;
} }
@@ -242,6 +253,7 @@ impl UiRenderState {
let active = self.remove(id, false, rsc).unwrap(); let active = self.remove(id, false, rsc).unwrap();
old_children = active.children; old_children = active.children;
old_move_slot = Some(active.move_slot); old_move_slot = Some(active.move_slot);
own_mask = active.own_mask;
} else if dirty && self.active.contains_key(&id) { } else if dirty && self.active.contains_key(&id) {
// Dirty and already drawn: none of the fast paths above may be // Dirty and already drawn: none of the fast paths above may be
// taken (the widget's own content changed, so its old primitives // taken (the widget's own content changed, so its old primitives
@@ -250,6 +262,7 @@ impl UiRenderState {
let active = self.remove(id, false, rsc).unwrap(); let active = self.remove(id, false, rsc).unwrap();
old_children = active.children; old_children = active.children;
old_move_slot = Some(active.move_slot); old_move_slot = Some(active.move_slot);
own_mask = active.own_mask;
} }
// draw widget // draw widget
@@ -302,6 +315,7 @@ impl UiRenderState {
region, region,
mask, mask,
move_slot, move_slot,
own_mask,
layer, layer,
id, id,
textures: Vec::new(), textures: Vec::new(),
@@ -313,6 +327,16 @@ impl UiRenderState {
let mut widget = painter.rsc.widgets().get_dyn_dynamic(id); let mut widget = painter.rsc.widgets().get_dyn_dynamic(id);
painter.state.draw_count += 1; painter.state.draw_count += 1;
let size = widget.draw(&mut painter); let size = widget.draw(&mut painter);
// A reported length is consumed by containers that read `abs`,
// `rel` and `rest` straight off it (`Span`'s placement, `Pad`'s
// addition), so an unresolved `dp` in one is silently worth zero
// -- see `Len::fold_dp`, which is what a widget reporting a
// caller-declared size has to put it through.
debug_assert!(
size.x.dp == 0.0 && size.y.dp == 0.0,
"widget {id:?} reported an unresolved `dp` size ({size:?}); \
report `Len::fold_dp(painter.density())` instead"
);
drop(widget); drop(widget);
painter.state.draw_started.remove(&id); painter.state.draw_started.remove(&id);
@@ -322,6 +346,7 @@ impl UiRenderState {
region, region,
mask: _, mask: _,
move_slot, move_slot,
own_mask,
textures, textures,
primitives, primitives,
children, children,
@@ -341,6 +366,8 @@ impl UiRenderState {
layer, layer,
size, size,
move_slot, move_slot,
own_mask,
move_applied: Vec2::ZERO,
}; };
// remove old children that weren't kept // remove old children that weren't kept
@@ -368,6 +395,7 @@ impl UiRenderState {
let from_px = from.top_left().to_abs(self.output_size); let from_px = from.top_left().to_abs(self.output_size);
let to_px = to.top_left().to_abs(self.output_size); let to_px = to.top_left().to_abs(self.output_size);
let delta = to_px - from_px; let delta = to_px - from_px;
active.move_applied += delta;
let entry = rsc.ui_mut().move_offsets.get_mut(slot); let entry = rsc.ui_mut().move_offsets.get_mut(slot);
entry.delta[0] += delta.x; entry.delta[0] += delta.x;
entry.delta[1] += delta.y; entry.delta[1] += delta.y;
@@ -402,6 +430,12 @@ impl UiRenderState {
let Some(active) = self.active.get(&id) else { let Some(active) = self.active.get(&id) else {
return; return;
}; };
debug_assert!(
active.move_applied == Vec2::ZERO,
"widget {id:?} is both moved by its parent's own layout (`mov`) and repositioned \
within it; the two write the same slot with different conventions -- see \
`ActiveData::move_applied`"
);
let from = active let from = active
.size .size
.to_uivec2(self.density) .to_uivec2(self.density)
@@ -443,6 +477,11 @@ impl UiRenderState {
// the parent's own `ActiveData` may already be gone by the // the parent's own `ActiveData` may already be gone by the
// time a deep descendant is retired (see LAYOUT.md // time a deep descendant is retired (see LAYOUT.md
// section 2's lifecycle note). // section 2's lifecycle note).
if active.own_mask != MaskIdx::NONE {
// The self-ownership ref `Painter::set_mask` took when
// it allocated this widget's own mask slot.
rsc.ui_mut().masks.remove(active.own_mask);
}
let parent_slot = rsc.ui_mut().move_offsets[active.move_slot.idx()].parent; let parent_slot = rsc.ui_mut().move_offsets[active.move_slot.idx()].parent;
rsc.ui_mut().move_offsets.remove(active.move_slot); rsc.ui_mut().move_offsets.remove(active.move_slot);
if parent_slot != MoveOffset::NONE_PARENT { if parent_slot != MoveOffset::NONE_PARENT {
@@ -628,7 +667,12 @@ impl UiRenderState {
/// section 2b. /// section 2b.
pub fn resolved_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option<UiRegion> { pub fn resolved_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option<UiRegion> {
let active = self.active.get(&id.id())?; let active = self.active.get(&id.id())?;
let delta = self.resolve_move_chain(active.move_slot, rsc); // The chain sum is what the shader adds to this widget's
// *primitives*, which were written before any of those moves.
// `region`, unlike them, has already been shifted by whatever
// part of this widget's own slot `mov` put there -- see
// `ActiveData::move_applied`, which is exactly that part.
let delta = self.resolve_move_chain(active.move_slot, rsc) - active.move_applied;
Some(active.region.offset(UiVec2::abs(delta))) Some(active.region.offset(UiVec2::abs(delta)))
} }
@@ -691,6 +735,7 @@ impl UiRenderState {
active.mask, active.mask,
Some(active.children), Some(active.children),
Some(active.move_slot), Some(active.move_slot),
active.own_mask,
rsc, rsc,
); );
// If this widget's own reported size changed, its parent's layout // If this widget's own reported size changed, its parent's layout
+209
View File
@@ -299,3 +299,212 @@ fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() {
"expected the bar near the bottom of the shorter window: {after_px:?}" "expected the bar near the bottom of the shorter window: {after_px:?}"
); );
} }
/// `Scroll` used to be documented as resolving its own lengths against
/// `Painter::output_size` -- the window -- which read as if a scroll area
/// smaller than the screen could not work, and cost a session's
/// investigation before the composer was wired up (docs/RUST.md,
/// 2026-09-06). It measures `painter.px_size()` now, so this pins the
/// three numbers that follow from the offered box: what it reports
/// upward, what its capping parent reports, and how far it can pan.
#[test]
fn a_scroll_measures_the_box_it_was_offered_not_the_window() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let tall = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(),
x: None,
y: Some(Len::abs(1000.0)),
});
let scroll = rsc.ui.widgets.add_strong(Scroll::new(tall.any(), Axis::Y));
let scroll_w = scroll.weak();
let scroll_id = scroll.id();
let capped = rsc.ui.widgets.add_strong(MaxSize {
inner: scroll.any(),
x: None,
y: Some(Len::abs(100.0)),
});
let capped_id = capped.id();
let root = capped.any();
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
// Two passes: the first offers the content a zero-length region
// (nothing measured yet) and learns the real content length from what
// comes back -- see `scrolling_moves_in_o1_without_a_redraw` for why
// that warm-up is deliberate rather than a bug.
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(0.0);
render.update(&root, &mut rsc);
// Reports the *content*, so the cap above it has something to cap;
// reporting the container instead would make the answer a function of
// itself, since the container is sized from this very number.
assert_eq!(
render.active.get(&scroll_id).unwrap().size.y,
Len::abs(1000.0)
);
assert_eq!(
render.active.get(&capped_id).unwrap().size.y,
Len::abs(100.0),
"the cap, not the content and not the window"
);
// Panning is bounded by content minus *container*: 900, not the 400
// a 600px window would give.
rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(-10_000.0);
assert!(
(rsc.ui.widgets.get_mut(&scroll_w).unwrap().amt() - 900.0).abs() < 0.01,
"amt={}",
rsc.ui.widgets.get_mut(&scroll_w).unwrap().amt()
);
}
/// The half `hit_testing_follows_a_scrolled_widget` could not see: it
/// checks a *descendant* of the widget `Scroll` actually moves, whose own
/// `region` is stale and is corrected entirely by the move chain. The
/// moved widget itself had its `region` updated *and* the chain delta
/// added on top, so its hit box sat at twice the pan -- which is why a
/// finger pan of the composer left its field untappable. See
/// `ActiveData::move_applied`.
#[test]
fn a_panned_widgets_own_hit_box_moves_exactly_once() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let tall = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(),
x: None,
y: Some(Len::abs(1000.0)),
});
let tall_w = tall.weak();
let scroll = rsc.ui.widgets.add_strong(Scroll::new(tall.any(), Axis::Y));
let scroll_w = scroll.weak();
let root = scroll.any();
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(0.0);
render.update(&root, &mut rsc);
let before = render.window_region(&tall_w, &rsc).unwrap();
rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(-37.0);
render.update(&root, &mut rsc);
let after = render.window_region(&tall_w, &rsc).unwrap();
assert!(
(after.top_left.y - (before.top_left.y - 37.0)).abs() < 0.01,
"the pan was applied twice: before={before:?} after={after:?}"
);
}
/// A `Masked` used to allocate a **new** mask slot on every draw, and
/// `draw_inner`'s unchanged-region fast path means its descendants are
/// mostly *not* redrawn with it -- so they went on referencing the slot
/// they were first drawn under, whose region had since stopped being the
/// widget's. Measured 2026-09-06 on the composer's tree: four live mask
/// entries, none of them the `Masked`'s current box, and the field it was
/// meant to clip drew nothing at all on the emulator. The slot is
/// allocated once and rewritten in place now (`ActiveData::own_mask`), so
/// this pins both halves: one entry, and that entry is the widget's own
/// region.
#[test]
fn a_masked_widget_keeps_one_mask_slot_that_is_always_its_own_region() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (_scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 8);
let masked = rsc.ui.widgets.add_strong(Masked { inner: inner_root });
let masked_id = masked.id();
// Placed at the bottom of a `Span::DOWN` behind a `rest(1)` sibling,
// which is what moves the bar away from the provisional slot it is
// first drawn at -- the move that left the stale mask behind.
let filler = rsc.ui.widgets.add_strong(Rect::new(UiColor::BLACK));
let filler = rsc.ui.widgets.add_strong(Sized {
inner: filler.any(),
x: None,
y: Some(rest(1)),
});
let capped = rsc.ui.widgets.add_strong(MaxSize {
inner: masked.any(),
x: None,
y: Some(Len::abs(60.0)),
});
let mut span = Span::empty(Dir::DOWN);
span.push(filler.any());
span.push(capped.any());
let root = rsc.ui.widgets.add_strong(span).any();
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
for _ in 0..3 {
render.update(&root, &mut rsc);
render.redraw(masked_id, &mut rsc);
}
assert_eq!(
rsc.ui.masks.iter().count(),
1,
"one `Masked` must own exactly one mask slot, however often it is redrawn"
);
let mask = *rsc.ui.masks.iter().next().unwrap();
assert_eq!(
mask.region,
render.active.get(&masked_id).unwrap().region,
"the mask a descendant clips against must be this widget's current box"
);
}
/// A `dp` cap that has done its job must be reported in pixels. `Span`
/// places a child using the `abs`/`rel` of the length it reported, so a
/// `MaxSize` handing back the caller's own `dp(168)` gave the composer's
/// bar a slot of **zero** the moment its content grew past six lines --
/// and the `Scroll` inside then measured its container at -63px (the
/// padding, subtracted from nothing) and panned the whole message out of
/// view. Measured on this checkout's emulator, 2026-09-06:
/// `container=-63 content=415.8 amt=478.8`. See `Len::fold_dp`.
#[test]
fn a_dp_cap_is_reported_in_pixels_so_a_span_can_place_it() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let tall = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(),
x: None,
y: Some(Len::abs(1000.0)),
});
let capped = rsc.ui.widgets.add_strong(MaxSize {
inner: tall.any(),
x: None,
y: Some(Len::dp(100.0)),
});
let capped_w = capped.weak();
let filler = rsc.ui.widgets.add_strong(Rect::new(UiColor::BLACK));
let filler = rsc.ui.widgets.add_strong(Sized {
inner: filler.any(),
x: None,
y: Some(rest(1)),
});
let mut span = Span::empty(Dir::DOWN);
span.push(filler.any());
span.push(capped.any());
let root = rsc.ui.widgets.add_strong(span).any();
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
render.set_density(2.5);
render.update(&root, &mut rsc);
render.update(&root, &mut rsc);
let box_px = render.window_region(&capped_w, &rsc).unwrap();
let height = box_px.bot_right.y - box_px.top_left.y;
assert!(
(height - 250.0).abs() < 0.01,
"expected the 100dp cap at density 2.5 to be a 250px slot, got {height} ({box_px:?})"
);
}
+8 -1
View File
@@ -15,7 +15,14 @@ impl MaxSize {
}; };
let len_px = len.apply_rest(density).to_abs(output); let len_px = len.apply_rest(density).to_abs(output);
let max_px = max.apply_rest(density).to_abs(output); let max_px = max.apply_rest(density).to_abs(output);
if len_px > max_px { max } else { len } // `fold_dp`, not the caller's `max` as written: a reported `Len`
// may not carry an unresolved `dp` -- see `Len::fold_dp` for the
// collapsed composer bar this caused.
if len_px > max_px {
max.fold_dp(density)
} else {
len
}
} }
/// The span (in this widget's own local, `UiRegion::FULL`-relative /// The span (in this widget's own local, `UiRegion::FULL`-relative
+25 -5
View File
@@ -33,10 +33,20 @@ impl Widget for Scroll {
// length itself (read below from what was actually drawn) is never // length itself (read below from what was actually drawn) is never
// stale, so this self-corrects the next frame and never leaves the // stale, so this self-corrects the next frame and never leaves the
// scroll range wrong for long. See LAYOUT.md section 4. // scroll range wrong for long. See LAYOUT.md section 4.
//
// Every length here is resolved against the box this widget was
// **offered** (`px_size`), never `output_size`: a `Scroll` is
// routinely smaller than the window -- the composer's field is
// capped at six lines by a `MaxSize` around it -- and measuring
// the window instead would make the pan range, and so where the
// content sits, a function of the screen rather than of the box.
// (What the previous arithmetic here computed came to the same
// number by a longer route, through a `within_len` against a
// window-relative scalar; it read as if the window were the
// container and cost a session working out that it was not.)
let axis = self.axis; let axis = self.axis;
let output_len = painter.output_size().axis(axis); let container_len = painter.px_size().axis(axis);
let container_len = painter.region().axis(axis).len(); self.container_len = container_len;
self.container_len = container_len.to_abs(output_len);
if self.snap_end { if self.snap_end {
self.amt = self.content_len - self.container_len; self.amt = self.content_len - self.container_len;
@@ -49,12 +59,22 @@ impl Widget for Scroll {
let used = painter.widget_within(&self.inner, region); let used = painter.widget_within(&self.inner, region);
// A child reporting `rel` means "this fraction of what I was
// offered", and what it was offered is this scroll area -- so the
// container, again, is what that resolves against.
self.content_len = used self.content_len = used
.axis(axis) .axis(axis)
.apply_rest(painter.density()) .apply_rest(painter.density())
.within_len(container_len) .to_abs(container_len);
.to_abs(output_len);
// The **content's** size, not the container's. A parent that can
// grow (the composer's bar) should hug the text until its own cap
// stops it, and reporting the container instead would make this
// widget's answer a function of the answer -- the bar is sized
// from what is reported here, so it collapses to nothing and
// never recovers. What keeps the content inside the offered box
// is the mask a caller puts around it (`.scrollable().masked()`),
// not this number.
used used
} }
} }
+5 -2
View File
@@ -26,9 +26,12 @@ impl Widget for Sized {
region.y = y.apply_rest(density).align(AxisAlign::Neg); region.y = y.apply_rest(density).align(AxisAlign::Neg);
} }
let used = painter.widget_within(&self.inner, region); let used = painter.widget_within(&self.inner, region);
// `fold_dp` on the way out: a declared size is a `Len` the caller
// wrote (`.width(dp(48))`), and a *reported* one may not carry an
// unresolved `dp` -- see `Len::fold_dp`.
Size { Size {
x: self.x.unwrap_or(used.x), x: self.x.map(|x| x.fold_dp(density)).unwrap_or(used.x),
y: self.y.unwrap_or(used.y), y: self.y.map(|y| y.fold_dp(density)).unwrap_or(used.y),
} }
} }
} }
+12 -12
View File
@@ -88,19 +88,19 @@ where
// height-capped field -- not a background rect and a field drawn as // height-capped field -- not a background rect and a field drawn as
// two independent siblings, which is what let the two disagree on // two independent siblings, which is what let the two disagree on
// where the bar actually was. // where the bar actually was.
// **Not** `.scrollable()` here yet, though IRIS_TODO.md's "the composer // `.scrollable().masked()`: the finger pan (`Scroll::drag`) plus the
// has no touch-drag scroll" wants it: `Scroll` resolves its own // clip that keeps six lines' worth of a longer message inside the
// `content_len`/`container_len` against `Painter::output_size` -- the // bar. The mask is the caller's job rather than `Scroll`'s own,
// whole window -- so inside a `MaxSize` that has clamped the offered // because `Painter::set_mask` allows exactly one mask per widget and
// region to six lines the two are in different spaces and the field // a `Scroll` nested under another masked area would abort on the
// pans itself entirely out of the bar. Measured on this checkout's // second -- `.masked()` is the one mechanism for clipping and this is
// emulator 2026-09-06 with 474 characters in the field (`iris text // one more use of it (tabs-ui's message area is the other).
// render: ... size=(1016.7, 623.7)`, a 441px cap): the bar collapsed to // Without it the overflow paints *above* the bar, over the
// its padding with no text in it. `Scroll::drag` -- the finger pan the // transcript: measured before this change at 58px of stray text for a
// TODO actually asks for -- is in place and exercised by the bench // 475px message in a 417px box.
// shell's report pane; what is left is `Scroll` measuring against its
// own offered box rather than the window. See docs/RUST.md.
let content = field let content = field
.scrollable()
.masked()
.pad(dp(FIELD_PAD_DP)) .pad(dp(FIELD_PAD_DP))
.max_height(dp(APPROX_LINE_HEIGHT_DP * MAX_LINES + FIELD_PAD_DP * 2.0)) .max_height(dp(APPROX_LINE_HEIGHT_DP * MAX_LINES + FIELD_PAD_DP * 2.0))
.width(rest(1)) .width(rest(1))