diff --git a/docs/IRIS_TODO.md b/docs/IRIS_TODO.md index 46a06e0..cb956d9 100644 --- a/docs/IRIS_TODO.md +++ b/docs/IRIS_TODO.md @@ -801,18 +801,32 @@ has the fuller account. `reposition` fault f5b8893 fixed -- it survives that commit. The workaround costs the group the 4dp inset its Compose counterpart holds 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 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 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 "one line, ellipsised" the way `maxLines = 1` + `TextOverflow. 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 choice when this lands: a path is identified by its tail, a command by 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 -- 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 diff --git a/iris/examples/bench_images.rs b/iris/examples/bench_images.rs index a0e016e..6b802c0 100644 --- a/iris/examples/bench_images.rs +++ b/iris/examples/bench_images.rs @@ -51,7 +51,10 @@ impl DefaultAppState for State { } let span = rsc.ui.widgets.add_strong(span); 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()); Self { ui_state, diff --git a/iris/src/layout_tests.rs b/iris/src/layout_tests.rs index 9e54d3c..6794c3b 100644 --- a/iris/src/layout_tests.rs +++ b/iris/src/layout_tests.rs @@ -50,7 +50,10 @@ fn scrolled_rects( span.push(row.any()); } 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(); (weak, scroll.any(), rects) } @@ -324,7 +327,10 @@ fn a_scroll_measures_the_box_it_was_offered_not_the_window() { x: None, 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_id = scroll.id(); 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)), }); 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 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 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}" + ); + } +} diff --git a/iris/src/widget/position/scroll.rs b/iris/src/widget/position/scroll.rs index 40a82d5..d27553d 100644 --- a/iris/src/widget/position/scroll.rs +++ b/iris/src/widget/position/scroll.rs @@ -8,7 +8,18 @@ pub struct Scroll { amt: f32, snap_end: bool, 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, /// Touch panning, from the same `DragGesture` `List` is driven by /// (`transcript-ui::Selection::drag`) rather than a second copy of its /// 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); self.container_len = container_len; - if self.snap_end { - self.amt = self.content_len - self.container_len; + if self.snap_end + && let Some(content_len) = self.content_len + { + self.amt = content_len - self.container_len; } self.update_amt(); 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 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 // offered", and what it was offered is this scroll area -- so the // container, again, is what that resolves against. - self.content_len = used - .axis(axis) - .apply_rest(painter.density()) - .to_abs(container_len); + self.content_len = Some( + used.axis(axis) + .apply_rest(painter.density()) + .to_abs(container_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 @@ -80,14 +98,18 @@ impl Widget for 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 { inner, axis, amt: 0.0, - snap_end: true, + snap_end: at_end, container_len: 0.0, - content_len: 0.0, + content_len: None, 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) { - self.amt = self.amt.max(0.0); - let len = (self.content_len - self.container_len).max(0.0); - self.amt = self.amt.min(len); + let Some(content_len) = self.content_len else { + return; + }; + let len = (content_len - self.container_len).max(0.0); + self.amt = self.amt.clamp(0.0, len); self.snap_end = self.amt == len; } @@ -182,8 +213,8 @@ mod tests { let mut ui = UiData::default(); let inner = ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any(); let id = inner.id(); - let mut s = Scroll::new(inner, Axis::Y); - s.content_len = 1000.0; + let mut s = Scroll::new(inner, Axis::Y, true); + s.content_len = Some(1000.0); s.container_len = 100.0; s.amt = 400.0; s.snap_end = false; diff --git a/iris/src/widget/trait_fns.rs b/iris/src/widget/trait_fns.rs index 5a59aa2..8ad5fd7 100644 --- a/iris/src/widget/trait_fns.rs +++ b/iris/src/widget/trait_fns.rs @@ -87,13 +87,34 @@ widget_trait! { 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 where Rsc: HasEvents { + self.scroll_area(axis, true) + } + /// `scrollable` along `axis`. A code fence pans across its own long /// 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 /// -- `DragArbiter::on` is the other half. fn scrollable_on(self, axis: Axis) -> impl WidgetIdFn 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 where Rsc: HasEvents { move |state| { - Scroll::new(self.add_strong(state), axis) + Scroll::new(self.add_strong(state), axis, at_end) .on(CursorSense::Scroll, move |ctx, rsc| { let delta = ctx.data.scroll_delta.axis(axis) * 50.0; ctx.widget(rsc).scroll(delta); diff --git a/iris/transcript-ui/src/composer.rs b/iris/transcript-ui/src/composer.rs index 0b0d352..bd42991 100644 --- a/iris/transcript-ui/src/composer.rs +++ b/iris/transcript-ui/src/composer.rs @@ -100,7 +100,9 @@ where // transcript: measured before this change at 58px of stray text for a // 475px message in a 417px box. 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() .pad(dp(FIELD_PAD_DP)) .max_height(dp(APPROX_LINE_HEIGHT_DP * MAX_LINES + FIELD_PAD_DP * 2.0)) diff --git a/iris/transcript-ui/src/tool.rs b/iris/transcript-ui/src/tool.rs index e9210c7..96a6222 100644 --- a/iris/transcript-ui/src/tool.rs +++ b/iris/transcript-ui/src/tool.rs @@ -272,9 +272,9 @@ where .wrap(false) .add(rsc); field - .masked() + .scrollable_on(Axis::X) .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)) .add_strong(rsc) .any()