iris: a Scroll measures and places its content in the same frame

Follows Iris on the previous commit: "nothing in the framework should
ever self heal because it should not be drawn incorrectly in the first
place. If you need 2 draws to get something into the correct position
then that should happen within the same frame. Layout should never be
frame dependent, it should be a pure function of the state."

So `Scroll::draw` no longer places its child against last frame's
content length and asks for a corrective frame. It draws the child once
at that length purely to measure it, then places it at the length just
measured, with the end-pin and the clamp applied only to the second
placement -- the measure-then-place idiom `Span::draw` and `List::place`
already use. Last frame's length survives as a hint that keeps the
common case cheap: when the content's length did not change the two
regions are identical, so the first call is `draw_inner`'s O(1) `mov`
and the second returns at its first line. Nothing drawn depends on the
hint.

Reverts the frame-loop change from the previous commit (a frame that
left anything dirty asked for another), which existed only to deliver
that corrective frame and would have made any widget marking itself
dirty spin at full rate.

Knock-on: an end-anchored Scroll now sits at its end on its first drawn
frame rather than its second, since the end-pin no longer waits for a
length. Two layout tests that scroll down from what they assumed was the
top now build their area with `at_end: false`, which is what they meant.

`List::clamp_to_content` is the only next-frame correction left. Its
comment cited Scroll's lag as precedent, which no longer exists; it now
says it is a deviation from the rule, and docs/IRIS_TODO.md carries it.

Verified: the layer-1 test draws no settling frame and still passes; on
the emulator the caret's bottom is 1509 against a bar edge of 1535, 26px
inside a 31px padding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-08 17:12:26 -04:00
1 parent ba57086361
commit a00376994e
9 files changed
+181 -131

No files matched your search

+16 -11
View File
@@ -5,20 +5,25 @@ 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
marked **DEFERRED** are ones the agent chose not to decide alone.
## 2026-09-08 (later: a scroll area that grew redraws once)
## 2026-09-08 (later: a scroll area measures and places in one frame)
From Iris's phone report about the composer's padding while typing
newlines. IRIS.md's entry has the account.
newlines, and the rule she stated when she read the first fix: layout is
a pure function of the state, nothing self-heals, and two draws to place
something happen in the same frame. IRIS.md's entry has the account.
- **A `Scroll` whose measured content length differs from the length it
offered its child asks for one more draw** (`Painter::draw_again`),
rather than the stale box being the last one drawn. Pays one extra
draw of the scroll's subtree when the content's length changes --
including its first frame, where the offered length is a placeholder --
and nothing on an ordinary scroll tick.
- **A frame that leaves any widget dirty now requests another frame** on
both backends. Previously only an animation did, so any use of
`draw_again` depended on some later input to deliver its correction.
- **`Scroll::draw` draws its child twice** -- once at last frame's length
to measure it, once at the measured length to place it -- instead of
placing against the stale length and leaving a wrong frame on screen.
The second draw is free unless the content's length changed.
- **An end-anchored `Scroll` is at its end on its first drawn frame**, a
consequence of the above. Two layout tests now build their area with
`at_end: false`, which is what they meant: they scroll down from the
top.
- **`List::clamp_to_content`'s next-frame correction is left in place**
and written down in docs/IRIS_TODO.md instead of fixed here, because
`List::place` is a larger piece of machinery and deserves its own
before/after on the phone.
## 2026-09-08 (every crate to its latest version, wgpu 28 -> 30)
+49 -32
View File
@@ -12,48 +12,65 @@ things still stay out.
An entry gives the date, what changed, why, and a short before/after where
it helps judge the change without the session that made it. Newest first.
## 2026-09-08 (later): a `Scroll` whose content grew asks to be drawn again
## 2026-09-08 (later): a `Scroll` measures and places its content in one frame
Iris's phone: "when typing with the keyboard up and entering enough
newlines ... the text drops down close to the bottom and seems to ignore
the padding. If I close (and optionally reopen) the keyboard it seems to
fix itself."
`Scroll::draw` offers its child **last** frame's content length rather
than measuring afresh, deliberately: an ordinary scroll tick then hands
the child the same box it already has, which is what makes a tick an O(1)
move instead of a redraw (LAYOUT.md sections 2 and 4). The comment there
said the lag "self-corrects the next frame". It does not, because nothing
asks for that frame: a keystroke dirties the *field*, the frame it
requests draws the field in a box one line short of its new text, and the
tree is clean afterwards -- so the stale placement is simply the last one
drawn. The composer's text is centred in its box, so a box one line short
put half a line past each end, and the caret's line box landed a full
12dp below the bar's inside edge, flush against its bottom. Closing the
keyboard rewrote the bar's inset, which dirtied it, which is why it
"fixed itself".
`Scroll::draw` used to place its child against **last** frame's content
length. Every newline therefore drew the field in a box one line short of
its text, and since that text is centred in its box it hung half a line
past each end -- putting the caret's line box a full 12dp below the bar's
inside edge, flush with its bottom, with the padding eaten. The comment
there said the lag "self-corrects the next frame". There was no next
frame: a keystroke dirties the field, not the scroll area, and after that
frame the tree is clean, so the stale placement was simply the last one
drawn -- until the keyboard closed, whose inset rewrite dirtied the bar
and forced the redraw. That is the "it fixes itself" half of the report.
Two halves to the fix, and the second is the general one:
The rule Iris stated when she saw the first fix, and which the code now
follows: **layout is a pure function of the state, never of how many
frames have been drawn.** Nothing should heal itself, because nothing
should be drawn wrong in the first place; where two draws are genuinely
needed to place something, both happen in the same frame.
- **`Scroll::draw` calls `Painter::draw_again` when what it just measured
differs from what it offered** (by more than half a pixel). Costs one
extra draw when the content's length actually changes, and nothing on
an ordinary scroll tick, so the O(1)-move path is untouched. It
converges: the next draw offers the measured length and measures the
same thing again.
- **A frame that leaves anything dirty now asks for another frame**, on
both backends (`android::view`'s `post_frame_callback`,
`default`'s `request_redraw`). `draw_again` sets its mark *during* the
update, after the input path's own "does anything need redrawing?"
check has run, so before this nothing asked -- which quietly applied to
`List::clamp_to_content`'s use of the same mechanism too.
So `Scroll::draw` now draws its child twice: once at last frame's length
purely to measure it, then once at the length it just measured, with the
end-pin and the clamp applied only to that second placement. The same
measure-then-place idiom `Span::draw` and `List::place` already use.
**The second draw is free unless the content's length actually changed**
-- an ordinary scroll tick offers the same size at a new offset, so the
first call is `draw_inner`'s O(1) `mov` and the second, with an identical
region, returns at its first line. Growing a bottom-anchored area is
still O(1) in the sense that mattered; what it is not is free to place
its child against a length already known to be wrong. Last frame's length
survives only as a *hint* that keeps the common case cheap; nothing drawn
depends on it.
Two consequences worth knowing:
- **An end-anchored `Scroll` now sits at its end on its first drawn
frame**, not its second. It could not before: the end-pin needs the
content's length, which was a frame behind, so a fresh area showed its
start and jumped. Two layout tests that scrolled *down* from what they
assumed was the top now build their area with `at_end: false`, which is
what they always meant.
- **`List::clamp_to_content` is now the only place left that corrects on
the next frame** -- it finds a fling has run past the content's end and
marks itself for a redraw. Same defect, larger machinery; recorded in
docs/IRIS_TODO.md rather than folded into this change.
Covered by `a_newline_leaves_the_caret_inside_the_composers_padding`
(layer 1, `transcript-fixture/tests/phone_screen.rs`), which fails on the
old code with the caret exactly on the bar's edge. `phone.rs` grew a
`--typed TEXT` argument beside `--message`: the two are different cases,
since one lays the composer out from scratch and the other grows one
already drawn, and only the second reproduces this.
(layer 1, `transcript-fixture/tests/phone_screen.rs`), which draws no
settling frame on purpose and fails on the old code with the caret
exactly on the bar's edge. On the emulator the caret's bottom moved from
1535 -- the bar's own bottom edge -- to 1509, 26px inside a 31px padding;
the remainder is parley's line box standing ~6px taller than its line
height. `phone.rs` grew a `--typed TEXT` argument beside `--message`,
since laying the composer out from scratch and growing one already drawn
are different cases and only the second reproduces this.
## 2026-09-08: what a cancel means, what a row's box is, and one fling for every scroll area
+18
View File
@@ -7,6 +7,24 @@ order and what "done" looks like. Tick and date them in place.
## Fix
- [ ] **`List::clamp_to_content` still corrects on the next frame
(2026-09-08).** Iris's rule, stated while the composer's caret was
being fixed: "nothing in the framework should ever self heal because
it should not be drawn incorrectly in the first place. If you need 2
draws to get something into the correct position then that should
happen within the same frame. Layout should never be frame dependent,
it should be a pure function of the state." `Scroll::draw` was brought
to that rule the same day (it measures its content and places it
again in the one frame, IRIS.md's entry). `List::clamp_to_content` is
the one place left that has not been: it discovers a fling has run
past the content's end, calls `scroll(gap)` and `Painter::draw_again`,
and asks its own `RequestRedraw` handle for a frame -- so one frame is
drawn with the content past its end and the next one snaps it back.
The fix is the same shape as `Scroll`'s: re-place inside the draw that
found the gap. Not done in the same change because `List::place` is a
larger piece of machinery than `Scroll::draw` and this deserves its
own before/after on the phone.
- [x] **`request_device` asked for compute-shader limits it never uses
(2026-09-05).** `Limits::default()` (both `iris/src/android/render.rs`
and `iris/src/default/render.rs`) requests desktop-tier compute limits