iris: a scroll area no longer opens at the end of content it has not measured

Scroll::content_len was 0.0 both for 'nothing here' and for 'not drawn
yet', so the first frame's clamp found a scroll range of zero, read
amt == len as 'sitting at the end' and set snap_end -- and the next
frame, now knowing the real length, jumped to it. On a phone that put a
code fence at the end of its longest line, mid-word, before anybody
touched it. It is an Option now, and the clamp does not answer a question
it cannot yet answer.

Which edge an area opens at is also a caller's decision rather than a
default: scrollable_on starts at the beginning (what is read),
scrollable_to_end pins to the end while content grows (what is typed --
the composer), both through one Scroll::new(inner, axis, at_end).

And tool.rs's raw_block pans sideways again: the 2026-09-06 'a
scrollable_on(Axis::X) around a non-editable Text draws nothing' defect
does not reproduce, most likely fixed by the shaped-mask work, so a long
command is readable rather than clipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-08 13:57:16 -04:00
1 parent 94d8373289
commit cce4b28324
7 files changed
+153 -26

No files matched your search

+16 -2
View File
@@ -801,18 +801,32 @@ has the fuller account.
`reposition` fault f5b8893 fixed -- it survives that commit. The `reposition` fault f5b8893 fixed -- it survives that commit. The
workaround costs the group the 4dp inset its Compose counterpart holds workaround costs the group the 4dp inset its Compose counterpart holds
its cards off the edge by, so this is worth fixing. its cards off the edge by, so this is worth fixing.
- [ ] **`scrollable_on(Axis::X)` on a non-editable `Text` draws nothing.** - [x] **`scrollable_on(Axis::X)` on a non-editable `Text` draws nothing.**
The panel is drawn and the text inside it is not. A markdown fence does The panel is drawn and the text inside it is not. A markdown fence does
the same to a `TextEdit` and is fine, so it is the widget kind rather the same to a `TextEdit` and is fine, so it is the widget kind rather
than the chain. `tool.rs`'s `raw_block` is `masked()` only until this is than the chain. `tool.rs`'s `raw_block` is `masked()` only until this is
fixed, which means a long command is clipped rather than pannable. fixed, which means a long command is clipped rather than pannable.
**Not reproducible on 2026-09-08**: `raw_block` was changed to
`.scrollable_on(Axis::X).pad(..).masked_by(..)` and the command draws
normally (`IRIS_TOOLS_EXPANDED=1 iris/run-headless.sh transcript --shot`,
the `rm -rf target` card). Something between 09-06 and 09-08 fixed it --
the shaped-mask work (`.masked_by`, 38bf630) is the likeliest, since the
old chain was `.masked()` *inside* the padding. Left ticked with the
original symptom recorded rather than deleted, in case it comes back.
- [ ] **No overflow ellipsis.** `TextAttrs` can wrap or not wrap; there is - [ ] **No overflow ellipsis.** `TextAttrs` can wrap or not wrap; there is
no "one line, ellipsised" the way `maxLines = 1` + `TextOverflow. no "one line, ellipsised" the way `maxLines = 1` + `TextOverflow.
Ellipsis` gives Compose. A tool card's summary is clipped instead, so Ellipsis` gives Compose. A tool card's summary is clipped instead, so
nothing on screen says it was cut. Whichever end is cut has to be a nothing on screen says it was cut. Whichever end is cut has to be a
choice when this lands: a path is identified by its tail, a command by choice when this lands: a path is identified by its tail, a command by
its head. its head.
- [ ] **A drawn chevron.** `Chevron.kt` draws its own strokes precisely - [ ] **A drawn chevron.** *Still open, and now worse: the bundled fonts
were removed on 2026-09-07 in favour of the platform collection, so the
mark is a codepoint the phone's own faces may not have -- Iris's
2026-09-08 screenshot shows an empty box where it should be, and the
desktop render draws it as a small dot. UI_RULES: "don't rely on
characters the platform might not have." iris has rects, text and
textures, so the smallest honest fix is a generated texture rather than
a new primitive.* `Chevron.kt` draws its own strokes precisely
because a chevron from a font is a glyph a system font may not have -- because a chevron from a font is a glyph a system font may not have --
and the bundled `NotoSans-Regular.ttf` indeed has no U+25B8/25BE/25B4, and the bundled `NotoSans-Regular.ttf` indeed has no U+25B8/25BE/25B4,
while `NotoSansMono-Regular.ttf` does. `tool.rs` sets the mark in the while `NotoSansMono-Regular.ttf` does. `tool.rs` sets the mark in the
+4 -1
View File
@@ -51,7 +51,10 @@ impl DefaultAppState for State {
} }
let span = rsc.ui.widgets.add_strong(span); let span = rsc.ui.widgets.add_strong(span);
let span_weak = span.weak(); let span_weak = span.weak();
let root = rsc.ui.widgets.add_strong(Scroll::new(span.any(), Axis::Y)); let root = rsc
.ui
.widgets
.add_strong(Scroll::new(span.any(), Axis::Y, true));
ui_state.set_root(root.any()); ui_state.set_root(root.any());
Self { Self {
ui_state, ui_state,
+59 -3
View File
@@ -50,7 +50,10 @@ fn scrolled_rects(
span.push(row.any()); span.push(row.any());
} }
let span = rsc.ui.widgets.add_strong(span); let span = rsc.ui.widgets.add_strong(span);
let scroll = rsc.ui.widgets.add_strong(Scroll::new(span.any(), Axis::Y)); let scroll = rsc
.ui
.widgets
.add_strong(Scroll::new(span.any(), Axis::Y, true));
let weak = scroll.weak(); let weak = scroll.weak();
(weak, scroll.any(), rects) (weak, scroll.any(), rects)
} }
@@ -324,7 +327,10 @@ fn a_scroll_measures_the_box_it_was_offered_not_the_window() {
x: None, x: None,
y: Some(Len::abs(1000.0)), y: Some(Len::abs(1000.0)),
}); });
let scroll = rsc.ui.widgets.add_strong(Scroll::new(tall.any(), Axis::Y)); let scroll = rsc
.ui
.widgets
.add_strong(Scroll::new(tall.any(), Axis::Y, true));
let scroll_w = scroll.weak(); let scroll_w = scroll.weak();
let scroll_id = scroll.id(); let scroll_id = scroll.id();
let capped = rsc.ui.widgets.add_strong(MaxSize { let capped = rsc.ui.widgets.add_strong(MaxSize {
@@ -387,7 +393,10 @@ fn a_panned_widgets_own_hit_box_moves_exactly_once() {
y: Some(Len::abs(1000.0)), y: Some(Len::abs(1000.0)),
}); });
let tall_w = tall.weak(); let tall_w = tall.weak();
let scroll = rsc.ui.widgets.add_strong(Scroll::new(tall.any(), Axis::Y)); let scroll = rsc
.ui
.widgets
.add_strong(Scroll::new(tall.any(), Axis::Y, true));
let scroll_w = scroll.weak(); let scroll_w = scroll.weak();
let root = scroll.any(); let root = scroll.any();
@@ -907,3 +916,50 @@ fn a_plain_mask_still_clips_to_a_square_box() {
"a square clip must reject a point outside it", "a square clip must reject a point outside it",
); );
} }
/// A scroll area created to be *read* opens at the beginning of its
/// content, however many frames it takes to learn how long that content
/// is.
///
/// The bug this pins: `content_len` was `0.0` both for "nothing here" and
/// for "not drawn yet", so the first frame's clamp found a range of zero,
/// read `amt == len` as "sitting at the end", and set `snap_end` -- and
/// the frame after, now knowing the real length, jumped to it. On screen
/// that was a code fence opening at the end of its longest line, in the
/// middle of a word (`iris/run-headless.sh phone`, 2026-09-08).
#[test]
fn a_scroll_area_opens_at_the_start_of_content_it_has_not_measured_yet() {
for (name, at_end, want) in [("read", false, 0.0), ("written", true, 4900.0)] {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any();
let tall = rsc.ui.widgets.add_strong(Sized {
inner: fill,
x: None,
y: Some(Len::abs(5000.0)),
});
let scroll = rsc
.ui
.widgets
.add_strong(Scroll::new(tall.any(), Axis::Y, at_end));
let weak = scroll.weak();
let root = scroll.any();
let mut render = UiRenderState::new();
render.resize((800.0, 100.0));
// Twice: the first draw is the one that measures the content, and
// the defect only showed on the second. The touch in between is
// what asks for that second draw -- an unchanged frame draws
// nothing at all, which is the point of the frame before it.
render.update(&root, &mut rsc);
let _ = rsc.ui.widgets.get_mut(&weak);
render.update(&root, &mut rsc);
let amt = rsc.ui.widgets.get(&weak).unwrap().amt();
assert!(
(amt - want).abs() < 0.01,
"an area to be {name} should have opened at {want}, got {amt}"
);
}
}
+47 -16
View File
@@ -8,7 +8,18 @@ pub struct Scroll {
amt: f32, amt: f32,
snap_end: bool, snap_end: bool,
container_len: f32, container_len: f32,
content_len: f32, /// How long the content is along `axis`, as of the last draw --
/// `None` until this widget has drawn once.
///
/// An `Option` rather than a `0.0` that stands in for both, because
/// the two answers led somewhere different and the code could not
/// tell them apart: on the first frame the clamp in `update_amt`
/// computed a scroll range of zero, concluded from `amt == len` that
/// the area was sitting at its end, and set `snap_end` -- so the next
/// frame, now knowing the real length, jumped to it. A code fence
/// therefore opened at the end of its longest line, mid-word
/// (`iris/run-headless.sh phone`, 2026-09-08).
content_len: Option<f32>,
/// Touch panning, from the same `DragGesture` `List` is driven by /// Touch panning, from the same `DragGesture` `List` is driven by
/// (`transcript-ui::Selection::drag`) rather than a second copy of its /// (`transcript-ui::Selection::drag`) rather than a second copy of its
/// wiring: arbitration, `DRAG_SLOP` and pointer capture all live in /// wiring: arbitration, `DRAG_SLOP` and pointer capture all live in
@@ -48,13 +59,19 @@ impl Widget for Scroll {
let container_len = painter.px_size().axis(axis); let container_len = painter.px_size().axis(axis);
self.container_len = container_len; self.container_len = container_len;
if self.snap_end { if self.snap_end
self.amt = self.content_len - self.container_len; && let Some(content_len) = self.content_len
{
self.amt = content_len - self.container_len;
} }
self.update_amt(); self.update_amt();
let mut region = UiRegion::FULL; let mut region = UiRegion::FULL;
region.axis_mut(axis).end = region.axis(axis).start.offset(self.content_len); // The container's own length until the content has been measured:
// a zero-length region on the first frame would place the child's
// primitives against a box of no size.
let offered = self.content_len.unwrap_or(container_len);
region.axis_mut(axis).end = region.axis(axis).start.offset(offered);
let region = region.offset(Vec2::from_axis(axis, -self.amt, 0.0)); let region = region.offset(Vec2::from_axis(axis, -self.amt, 0.0));
let used = painter.widget_within(&self.inner, region); let used = painter.widget_within(&self.inner, region);
@@ -62,10 +79,11 @@ impl Widget for Scroll {
// A child reporting `rel` means "this fraction of what I was // A child reporting `rel` means "this fraction of what I was
// offered", and what it was offered is this scroll area -- so the // offered", and what it was offered is this scroll area -- so the
// container, again, is what that resolves against. // container, again, is what that resolves against.
self.content_len = used self.content_len = Some(
.axis(axis) used.axis(axis)
.apply_rest(painter.density()) .apply_rest(painter.density())
.to_abs(container_len); .to_abs(container_len),
);
// The **content's** size, not the container's. A parent that can // 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 // grow (the composer's bar) should hug the text until its own cap
@@ -80,14 +98,18 @@ impl Widget for Scroll {
} }
impl Scroll { impl Scroll {
pub fn new(inner: StrongWidget, axis: Axis) -> Self { /// `at_end` starts the area pinned to the end of its content and
/// keeps it there while the content grows -- see
/// `WidgetLike::scrollable_to_end`. `false` starts at the beginning,
/// which is what anything being *read* wants.
pub fn new(inner: StrongWidget, axis: Axis, at_end: bool) -> Self {
Self { Self {
inner, inner,
axis, axis,
amt: 0.0, amt: 0.0,
snap_end: true, snap_end: at_end,
container_len: 0.0, container_len: 0.0,
content_len: 0.0, content_len: None,
gesture: DragGesture::on(axis), gesture: DragGesture::on(axis),
} }
} }
@@ -148,10 +170,19 @@ impl Scroll {
} }
} }
/// Clamp `amt` into the range the content allows, and re-read whether
/// this area is sitting at its end.
///
/// Both are skipped until the content has been measured: with no
/// length there is no range to clamp into, and "at the end" is a
/// question that cannot be answered yet -- answering it anyway is
/// what `content_len`'s doc describes.
pub fn update_amt(&mut self) { pub fn update_amt(&mut self) {
self.amt = self.amt.max(0.0); let Some(content_len) = self.content_len else {
let len = (self.content_len - self.container_len).max(0.0); return;
self.amt = self.amt.min(len); };
let len = (content_len - self.container_len).max(0.0);
self.amt = self.amt.clamp(0.0, len);
self.snap_end = self.amt == len; self.snap_end = self.amt == len;
} }
@@ -182,8 +213,8 @@ mod tests {
let mut ui = UiData::default(); let mut ui = UiData::default();
let inner = ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any(); let inner = ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any();
let id = inner.id(); let id = inner.id();
let mut s = Scroll::new(inner, Axis::Y); let mut s = Scroll::new(inner, Axis::Y, true);
s.content_len = 1000.0; s.content_len = Some(1000.0);
s.container_len = 100.0; s.container_len = 100.0;
s.amt = 400.0; s.amt = 400.0;
s.snap_end = false; s.snap_end = false;
+22 -1
View File
@@ -87,13 +87,34 @@ widget_trait! {
self.scrollable_on(Axis::Y) self.scrollable_on(Axis::Y)
} }
/// `scrollable_on`, but starting pinned to the **end** of its content
/// and staying there while the content grows -- what a composer wants,
/// where the newest line is the one being written.
///
/// Explicit, because the other kind is not a variation on it: a code
/// fence opened at the end of its longest line, which is the middle
/// of a word (seen in `iris/run-headless.sh phone`, 2026-09-08). The
/// two behaviours are one mechanism with the starting edge passed in,
/// and both names say which they are rather than one of them being a
/// default nobody reads.
fn scrollable_to_end(self, axis: Axis) -> impl WidgetIdFn<Rsc, Scroll> where Rsc: HasEvents {
self.scroll_area(axis, true)
}
/// `scrollable` along `axis`. A code fence pans across its own long /// `scrollable` along `axis`. A code fence pans across its own long
/// lines exactly the way a transcript pans down its rows, so the two /// lines exactly the way a transcript pans down its rows, so the two
/// are one function with the axis passed in rather than a second copy /// are one function with the axis passed in rather than a second copy
/// -- `DragArbiter::on` is the other half. /// -- `DragArbiter::on` is the other half.
fn scrollable_on(self, axis: Axis) -> impl WidgetIdFn<Rsc, Scroll> where Rsc: HasEvents { fn scrollable_on(self, axis: Axis) -> impl WidgetIdFn<Rsc, Scroll> where Rsc: HasEvents {
self.scroll_area(axis, false)
}
/// The one implementation behind [`Self::scrollable_on`] and
/// [`Self::scrollable_to_end`] -- see the latter for what `at_end`
/// decides.
fn scroll_area(self, axis: Axis, at_end: bool) -> impl WidgetIdFn<Rsc, Scroll> where Rsc: HasEvents {
move |state| { move |state| {
Scroll::new(self.add_strong(state), axis) Scroll::new(self.add_strong(state), axis, at_end)
.on(CursorSense::Scroll, move |ctx, rsc| { .on(CursorSense::Scroll, move |ctx, rsc| {
let delta = ctx.data.scroll_delta.axis(axis) * 50.0; let delta = ctx.data.scroll_delta.axis(axis) * 50.0;
ctx.widget(rsc).scroll(delta); ctx.widget(rsc).scroll(delta);
+3 -1
View File
@@ -100,7 +100,9 @@ where
// transcript: measured before this change at 58px of stray text for a // transcript: measured before this change at 58px of stray text for a
// 475px message in a 417px box. // 475px message in a 417px box.
let content = field let content = field
.scrollable() // `scrollable_to_end`: what is being typed is at the end, so a
// message longer than the six lines shown holds that end.
.scrollable_to_end(Axis::Y)
.masked() .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))
+2 -2
View File
@@ -272,9 +272,9 @@ where
.wrap(false) .wrap(false)
.add(rsc); .add(rsc);
field field
.masked() .scrollable_on(Axis::X)
.pad(dp(RAW_PAD_DP)) .pad(dp(RAW_PAD_DP))
.background(rect(VERBATIM_BACKGROUND).radius(dp(RAW_RADIUS_DP))) .masked_by(rect(VERBATIM_BACKGROUND).radius(dp(RAW_RADIUS_DP)))
.width(rest(1)) .width(rest(1))
.add_strong(rsc) .add_strong(rsc)
.any() .any()