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
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<f32>,
|
||||
/// 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)
|
||||
self.content_len = Some(
|
||||
used.axis(axis)
|
||||
.apply_rest(painter.density())
|
||||
.to_abs(container_len);
|
||||
.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;
|
||||
|
||||
@@ -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<Rsc, Scroll> 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<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| {
|
||||
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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user