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:
1 parent
94d8373289
commit
cce4b28324
7 files changed
+152
-25
No files matched your search
+16
-2
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in new issue
Block a user