iris: a press only reaches the widget the pointer is on

Iris's 2026-09-08 report, both halves, and her own diagnosis of the
second: "tapping outside of something that a fling is currently active
for should have no code in common with the fling that could influence
it."

`run_sensors` runs a widget one frame after the pointer leaves it
(`ActivationState::End`, which is not `Off`) so `HoverEnd` can fire, and
`should_run` derived the press and wheel senses from raw button state
without consulting `hover`. That farewell frame carried a `PressStart` to
a widget the finger was nowhere near -- and a press on already-coasting
content is a catch, which commits to a pan with no `DRAG_SLOP`, so the
widget captured the pointer and swallowed the whole gesture. Its hover
was stale because a gesture that ends while captured returns from the
capture branch, which never reaches the loop that updates it.

Measured before the fix on the real screen: a fence flicked sideways,
then a finger down on a row 500px above it dragged 160px down the screen
-- the list moved by zero, the fence moved by zero, and the fence held
the pointer throughout. After: the list follows the finger and the
fence's fling carries on coasting, which is what she asked for and falls
out of the fix rather than being arranged.

`should_run` now requires `hover.is_on()` for every non-hover sense.
`Drop`/`Cancel` are unaffected -- they are delivered deliberately to a
widget that is not under the pointer, with an explicit `On`.

Also: the composer is clipped to its own bar rather than inside its
padding (`.masked_by(rect(BAR_FILL))` in place of a `.masked()` +
`.background()` pair) -- "the box should be clipped rather than the inset
text". A long message was being sliced mid-glyph 12dp in from the bar's
edge, leaving a band of bare surface above the cut.

New: `Scroll::is_scrolling`, the name `List` already uses; the phone
rig's `--message TEXT` and `--ime PX`, since the composer's overflowing
and keyboard-open states cannot otherwise be looked at headlessly.

Tests fail on the old code, one per layer:
`a_press_does_not_reach_a_widget_the_pointer_has_just_left` (sensors, no
screen) and `a_drag_away_from_a_coasting_fence_scrolls_the_list_and_
leaves_it_coasting` (the report itself, layer 1).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-08 16:23:32 -04:00
1 parent cc8148cbec
commit 5e34dba2fd
7 files changed
+438 -22

No files matched your search

+79
View File
@@ -1529,3 +1529,82 @@ This does not reopen the 2026-09-07 platform-fonts decision. Body and
monospace text still come from the platform's own collection; an icon is monospace text still come from the platform's own collection; an icon is
the opposite case, a small closed set of codepoints no system font is the opposite case, a small closed set of codepoints no system font is
guaranteed to have, and it is the same division the Compose app makes. guaranteed to have, and it is the same division the Compose app makes.
## 2026-09-08: a press only reaches what the pointer is actually on
Iris's report -- "if I try to scroll vertically while a horizontal scroll
animation is still active, it stays locked to the horizontal scroll. It
should let it keep going and instead only affect vertical scrolling" --
and her own diagnosis of it, which was the right one: "it seems like iris
is set up so the animation stuff is global which it definitely should not
be. Tapping outside of something that a fling is currently active for
should have no code in common with the fling that could influence it."
It was global, and it was in `sense::should_run`. `run_sensors` runs a
widget one frame *after* the pointer leaves it (`ActivationState::End`,
which is not `Off`) so a `HoverEnd` can fire, and `should_run` derived
`PressStart`/`Pressing`/`PressEnd`/`Scroll` from the raw button and wheel
state without consulting `hover` at all. So that farewell frame carried a
press to a widget the finger was nowhere near.
That alone would have been a stray event; what made it eat the gesture is
the catch added on 2026-09-07 (`PressState::scrolling`), which commits a
press on already-moving content to a pan immediately, with no `DRAG_SLOP`
-- so the widget captured the pointer on that frame and every later sample
went to it. And the widget's hover was stale in the first place because a
gesture that ends while captured returns from `run_sensors`' capture
branch, which never reaches the loop that would have updated it.
Measured on the real screen before the fix: a fence flicked sideways, then
a finger put down on a row **500px above it** and dragged 160px down the
screen. The list moved by zero, the fence moved by zero, and the fence
held the pointer for the whole gesture -- the report, exactly.
- **`should_run` now requires `hover.is_on()` for every non-hover sense.**
Press and wheel both, since a wheel event reaching a widget the cursor
has just left is the same fault with a different sense. `Drop` and
`Cancel` are unaffected: they are delivered deliberately to a widget
that is *not* under the pointer, and `run_sensors` hands both an
explicit `On`.
- **`Scroll::is_scrolling`** (new): whether a fling is coasting in this
area, the same question and the same name `List::is_scrolling` already
answers for the other scrolling widget.
Nothing about the fling, the arbiter or the catch changed. A press outside
a coasting area now has no code in common with it, so the horizontal fling
keeps coasting through a vertical drag on its own -- which is the second
half of what Iris asked for, and it falls out of the fix rather than being
arranged. A press *inside* a coasting area is still a catch on either
axis, which is what Compose does ("Compose does catch no matter what axis
if you tap in the horizontal area").
Two tests, one per layer:
`sense_tests::a_press_does_not_reach_a_widget_the_pointer_has_just_left`
is the mechanism with two stacked scroll areas and no screen, and
`fence_fling.rs`'s
`a_drag_away_from_a_coasting_fence_scrolls_the_list_and_leaves_it_coasting`
is the report itself over the real transcript. Both fail on the old code.
## 2026-09-08: the composer is clipped to its bar, not inside its padding
Iris: "the message input box doesn't clip correctly ... the box should be
clipped rather than the inset text."
The composer was `.masked().background(rect(...))` -- two boxes, one
inside the other. The mask sat *inside* the `dp(FIELD_PAD_DP)` padding, so
a message longer than the six lines shown was cut through the middle of a
glyph 12dp in from the bar's edge, with a band of bare surface above the
cut. Measured at the phone's own size and density (1080x2424 at 2.55): the
bar's top edge at y=1995.6 and the text sliced at y=2026.2.
It is `.masked_by(rect(BAR_FILL))` now: the same rect is the surface drawn
behind the field *and* the shape the field is clipped to, so the two
cannot fall out of step -- the idiom `row.rs` already uses to cut a code
fence to its own rounded panel. Text now disappears under the bar's edge
at 1995.6. The padding still holds text off the edge at the end the
content is anchored to, which is the end anybody is reading.
The composer's overflowing and keyboard-open states had no way to be
looked at headlessly, since that window has no keyboard: the phone rig
takes `--message TEXT` and `--ime PX` for them
(`transcript-fixture/examples/phone.rs`, through `RUN_HEADLESS_ARGS`).
+29 -4
View File
@@ -637,15 +637,40 @@ pub fn should_run(
cursor: &CursorState, cursor: &CursorState,
hover: ActivationState, hover: ActivationState,
) -> Option<CursorSense> { ) -> Option<CursorSense> {
// Every sense below that is about the *pointer* rather than about
// hovering needs the pointer to actually be on this widget, and
// `hover` is the only thing here that knows: `run_sensors` runs a
// widget one more time after the pointer has left it (`ActivationState
// ::End`, which is not `Off`) so that a `HoverEnd` can fire, and
// deriving a press from raw button state alone handed that frame a
// `PressStart` too. A widget the finger is nowhere near then opened a
// gesture and, if it went straight to panning, captured the pointer --
// which is the whole of Iris's 2026-09-08 "if I try to scroll
// vertically while a horizontal scroll animation is still active, it
// stays locked to the horizontal scroll". Measured: a fence flicked
// sideways is left `hover == On` (the capture branch above returns
// before the loop that would have updated it), so the *next* touch
// down anywhere on the screen decayed it to `End`, ran the fence's
// `Scroll::drag` with a `PressStart`, and -- the press being a catch
// of its own fling, which commits with no slop -- captured the whole
// gesture 500px away from the fence. The list under the finger moved
// by nothing at all.
//
// The rule is the set's, not one member's: press *and* scroll, since
// a wheel event reaching a widget the cursor has just left is the same
// fault with a different sense. `Drop` and `Cancel` are exempt because
// they are delivered deliberately to a widget that is *not* under the
// pointer, and `run_sensors` hands both of those an `On` anyway.
let on_this = hover.is_on();
for sense in senses.iter() { for sense in senses.iter() {
if match sense { if match sense {
CursorSense::PressStart(button) => cursor.buttons.select(button).is_start(), CursorSense::PressStart(button) => on_this && cursor.buttons.select(button).is_start(),
CursorSense::Pressing(button) => cursor.buttons.select(button).is_on(), CursorSense::Pressing(button) => on_this && cursor.buttons.select(button).is_on(),
CursorSense::PressEnd(button) => cursor.buttons.select(button).is_end(), CursorSense::PressEnd(button) => on_this && cursor.buttons.select(button).is_end(),
CursorSense::HoverStart => hover.is_start(), CursorSense::HoverStart => hover.is_start(),
CursorSense::Hovering => hover.is_on(), CursorSense::Hovering => hover.is_on(),
CursorSense::HoverEnd => hover.is_end(), CursorSense::HoverEnd => hover.is_end(),
CursorSense::Scroll => cursor.scroll_delta != Vec2::ZERO, CursorSense::Scroll => on_this && cursor.scroll_delta != Vec2::ZERO,
// Never derived here -- `Drop` only ever fires through // Never derived here -- `Drop` only ever fires through
// `CursorSenses::should_run`'s own special case, ahead of this // `CursorSenses::should_run`'s own special case, ahead of this
// loop, for the one widget `run_sensors`' capture branch is // loop, for the one widget `run_sensors`' capture branch is
+147
View File
@@ -616,3 +616,150 @@ fn a_drag_pans_whichever_nested_scroll_area_owns_its_axis() {
); );
} }
} }
/// Iris's 2026-09-08 report: "if I try to scroll vertically while a
/// horizontal scroll animation is still active, it stays locked to the
/// horizontal scroll", with her own diagnosis -- "tapping outside of
/// something that a fling is currently active for should have no code in
/// common with the fling that could influence it."
///
/// She was right that it was global state, and this is where it lived.
/// `run_sensors` runs a widget one more frame *after* the pointer has
/// left it, so a `HoverEnd` can fire ([`ActivationState::End`], which is
/// not `Off`) -- and `should_run` derived a press from the button alone,
/// so that farewell frame also carried a `PressStart`. A widget nowhere
/// near the finger therefore opened a gesture, and a `Scroll` catching
/// its own fling commits with no slop, so it captured the pointer and the
/// whole gesture went to it.
///
/// Two areas side by side here rather than one, because "the press went
/// to the wrong widget" and "the press went nowhere" are different
/// failures and only the second area can tell them apart.
#[test]
fn a_press_does_not_reach_a_widget_the_pointer_has_just_left() {
let mut rsc = SenseRsc {
ui: UiData::default(),
events: EventManager::default(),
};
// Two 1000px-tall scroll areas, stacked: the top half of the window
// is the first, the bottom half the second. Each area's own handle is
// taken as its chain is built (`with_id`, the same way the nested-axes
// test above does it), since what is under test is `scrollable()`'s
// real registration rather than a `Scroll` assembled by hand.
let seen: [Rc<Cell<Option<WeakWidget<Scroll>>>>; 2] = Default::default();
let half = |slot: &Rc<Cell<Option<WeakWidget<Scroll>>>>| {
let record = slot.clone();
rect(UiColor::WHITE)
.height(Len::abs(1000.0))
.scrollable()
.with_id(move |_rsc, id| {
record.set(Some(id));
id
})
.height(Len::rel(0.5))
};
let root = (half(&seen[0]), half(&seen[1]))
.span(Dir::DOWN)
.add_strong(&mut rsc)
.any();
let (top_w, bottom_w) = (seen[0].get().unwrap(), seen[1].get().unwrap());
let win: Vec2 = (100.0, 200.0).into();
let mut render = UiRenderState::new();
render.resize((win.x, win.y));
render.update(&root, &mut rsc);
// The second frame is the first that knows how long the content is --
// see `a_finger_drag_over_a_scroll_area_pans_it`.
for w in [&top_w, &bottom_w] {
rsc.ui.widgets.get_mut(w).unwrap().scroll(0.0);
}
render.update(&root, &mut rsc);
let mut state = ();
// Flick the top area and let go: it is left flinging, and -- because
// the release goes through `run_sensors`' capture branch, which
// returns before the loop that would have updated anybody's hover --
// its sensor is left `On` with the pointer no longer on it. Both
// halves of the real gesture, since both are what the bug needs.
let base = Instant::now();
let mut t = 0;
let sample = |render: &mut UiRenderState,
rsc: &mut SenseRsc,
state: &mut (),
y: f32,
button: ActivationState,
at_ms: u64| {
let mut c = cursor_at((50.0, y).into());
c.buttons.left = button;
c.time = base + std::time::Duration::from_millis(at_ms);
render.run_sensors(rsc, state, c, win);
};
sample(
&mut render,
&mut rsc,
&mut state,
50.0,
ActivationState::Start,
t,
);
for y in [44.0, 32.0, 14.0] {
t += 8;
sample(&mut render, &mut rsc, &mut state, y, ActivationState::On, t);
}
t += 8;
sample(
&mut render,
&mut rsc,
&mut state,
14.0,
ActivationState::End,
t,
);
assert!(
rsc.ui.widgets.get(&top_w).unwrap().is_scrolling(),
"the flick must leave the top area coasting -- the press below is \
only dangerous while something is still moving",
);
let flung_to = rsc.ui.widgets.get(&top_w).unwrap().amt();
// Now press and drag in the *bottom* area: the top area's hover
// decays to `End` on this very sample, which is the frame that used
// to carry a `PressStart` to it.
t += 8;
sample(
&mut render,
&mut rsc,
&mut state,
150.0,
ActivationState::Start,
t,
);
t += 8;
sample(
&mut render,
&mut rsc,
&mut state,
150.0 - (DRAG_SLOP + 40.0),
ActivationState::On,
t,
);
let moved = rsc.ui.widgets.get(&bottom_w).unwrap().amt();
assert!(
(moved - 40.0).abs() < 0.01,
"the area actually under the finger should have panned by the 40px \
past the slop, got {moved}"
);
assert_eq!(
rsc.ui.widgets.get(&top_w).unwrap().amt(),
flung_to,
"the area the pointer had left must not have seen the press at all -- \
a catch would have stopped its fling on the touch-down"
);
assert_eq!(
pointer_input(&mut rsc).holder(),
Some(bottom_w.id()),
"the gesture belongs to the widget under the finger",
);
}
+9
View File
@@ -258,6 +258,15 @@ impl Scroll {
self.amt self.amt
} }
/// Whether a fling is coasting here right now -- the same question
/// `List::is_scrolling` answers for the other scrolling widget, under
/// the same name so there is one word for it. What a caller polls to
/// know whether this area is moving on its own (a test, and
/// [`PressState::scrolling`]'s own condition).
pub fn is_scrolling(&self) -> bool {
self.fling.is_flinging()
}
/// Which way this area pans. For a caller that found the widget /// Which way this area pans. For a caller that found the widget
/// rather than built it -- a test walking what is drawn, a scroll /// rather than built it -- a test walking what is drawn, a scroll
/// indicator asking which edge to sit on. /// indicator asking which edge to sit on.
+39
View File
@@ -11,6 +11,12 @@
//! screenshot here and one from the phone is the renderer, never the //! screenshot here and one from the phone is the renderer, never the
//! data. //! data.
//! //!
//! `--message TEXT` (through `RUN_HEADLESS_ARGS`) starts with that text
//! already in the composer, `\n` for a newline -- the composer's grown
//! and overflowing states are otherwise unreachable here, since this
//! window has no keyboard to type into (UI_RULES.md's "check the states
//! you can't see by default").
//!
//! No server: `transcript-fixture` embeds the transcript. Colour, //! No server: `transcript-fixture` embeds the transcript. Colour,
//! spacing, type and anything a person has to *see* is answered here; //! spacing, type and anything a person has to *see* is answered here;
//! anything with an assertion behind it belongs in `tests/ //! anything with an assertion behind it belongs in `tests/
@@ -19,6 +25,33 @@
use iris::prelude::*; use iris::prelude::*;
use winit::{dpi::PhysicalSize, window::WindowAttributes}; use winit::{dpi::PhysicalSize, window::WindowAttributes};
/// The `--ime PX` argument: the bottom inset a keyboard would report,
/// applied after the first frame the way Android's `on_insets_changed`
/// does. The composer's keyboard-open layout is otherwise unreachable
/// here, and it is where its mask went wrong before (see
/// `ActiveData::own_mask`).
fn ime_argv() -> Option<f32> {
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if arg == "--ime" {
return args.next()?.parse().ok();
}
}
None
}
/// The `--message TEXT` argument, with `\n` taken as a newline so a
/// multi-line message survives one shell word.
fn message_argv() -> Option<String> {
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if arg == "--message" {
return Some(args.next()?.replace("\\n", "\n"));
}
}
None
}
fn main() { fn main() {
DefaultApp::<Client>::run(); DefaultApp::<Client>::run();
} }
@@ -47,6 +80,12 @@ impl DefaultAppState for Client {
) -> Self { ) -> Self {
let screen = match transcript_fixture::open(rsc, &mut ui_state) { let screen = match transcript_fixture::open(rsc, &mut ui_state) {
Ok(opened) => { Ok(opened) => {
if let Some(message) = message_argv() {
opened.screen.composer.field.edit(rsc).set(&message);
}
if let Some(inset) = ime_argv() {
opened.screen.composer.set_bottom_inset(rsc, inset);
}
// A fling coasts only while something asks for the next // A fling coasts only while something asks for the next
// frame; on the desktop that is the window's own redraw // frame; on the desktop that is the window's own redraw
// request (`List::fling`'s doc). // request (`List::fling`'s doc).
@@ -36,6 +36,16 @@ fn fence_scroll_in(h: &Harness, top: f32, bottom: f32) -> Option<(WidgetId, Pixe
}) })
} }
fn is_scrolling(h: &Harness, id: WidgetId) -> bool {
h.rsc
.ui
.widgets
.get_dyn(id)
.and_then(|w| w.as_any().downcast_ref::<Scroll>())
.expect("the fence's scroll area is still drawn")
.is_scrolling()
}
fn amt(h: &Harness, id: WidgetId) -> f32 { fn amt(h: &Harness, id: WidgetId) -> f32 {
h.rsc h.rsc
.ui .ui
@@ -121,3 +131,100 @@ fn a_flick_across_a_code_fence_keeps_moving_after_the_finger_leaves() {
h.frame(t); h.frame(t);
assert_eq!(last, amt(&h, fence_scroll), "the fling never settled"); assert_eq!(last, amt(&h, fence_scroll), "the fling never settled");
} }
/// Iris's 2026-09-08 report: "if I try to scroll vertically while a
/// horizontal scroll animation is still active, it stays locked to the
/// horizontal scroll. It should let it keep going and instead only affect
/// vertical scrolling."
///
/// Her own diagnosis was the right one -- "tapping outside of something
/// that a fling is currently active for should have no code in common
/// with the fling that could influence it" -- and
/// `sense_tests::a_press_does_not_reach_a_widget_the_pointer_has_just_left`
/// is the mechanism in isolation. This is the same thing over the real
/// screen, which is where it was found: the finger goes down on an
/// ordinary row 500px above a coasting fence, and what must move is the
/// list, while the fence carries on coasting untouched.
#[test]
fn a_drag_away_from_a_coasting_fence_scrolls_the_list_and_leaves_it_coasting() {
use client_core::transcript_fold::{TranscriptItem, TranscriptRow};
let mut h = Harness::new(phone_size(), PHONE_SCALE);
let opened = transcript_fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
let screen = opened.screen;
h.frame(0);
h.frame(PHONE_FRAME_MS);
let fence = TranscriptRow::Single(TranscriptItem::AssistantMsg {
seq: 9_000_000,
text: format!(
"```\n{}\n```",
(1..=200)
.map(|i| format!("word{i}"))
.collect::<Vec<_>>()
.join(" ")
),
settled: true,
});
screen.push_row(&mut h.rsc, &fence);
(screen.list)(&mut h.rsc).jump_to_end();
h.frame(100);
h.frame(108);
let key = transcript_ui::row::row_key(&fence.key());
let (top, bottom) = (screen.list)(&mut h.rsc)
.extent(key)
.expect("the fence row is on screen");
let (fence_scroll, box_) = fence_scroll_in(&h, top, bottom)
.expect("the pushed fence draws a horizontal scroll area of its own");
let y = (box_.top_left.y + box_.bot_right.y) / 2.0;
// Flick the fence sideways and let go, exactly as above.
h.touch(TouchAction::Down, Vec2::new(900.0, y), 200);
for (i, x) in [860.0, 800.0, 720.0, 620.0].into_iter().enumerate() {
h.touch(TouchAction::Move, Vec2::new(x, y), 208 + 8 * i as u64);
}
h.touch(TouchAction::Up, Vec2::new(620.0, y), 240);
h.frame(248);
assert!(
is_scrolling(&h, fence_scroll),
"the fence has to still be coasting for this to be the reported case",
);
// A row well clear of the fence, taken by its own extent rather than
// by a coordinate: the gaps between rows are pointer-transparent, so a
// y picked by hand lands on nothing often enough to make a green run
// meaningless.
let probe = box_.top_left.y - 500.0;
let row = (screen.list)(&mut h.rsc)
.key_at(probe)
.expect("a row that far up the screen");
let (row_top, row_bottom) = (screen.list)(&mut h.rsc).extent(row).expect("its extent");
let from = (row_top + row_bottom) / 2.0;
let list_before = (screen.list)(&mut h.rsc).anchor_position_display();
let fence_before = amt(&h, fence_scroll);
h.touch(TouchAction::Down, Vec2::new(540.0, from), 256);
let mut t = 264;
for i in 1..=8 {
h.touch(
TouchAction::Move,
Vec2::new(540.0, from + 20.0 * i as f32),
t,
);
t += 8;
}
h.touch(TouchAction::Up, Vec2::new(540.0, from + 160.0), t);
assert_ne!(
list_before,
(screen.list)(&mut h.rsc).anchor_position_display(),
"the drag was nowhere near the fence, so it belongs to the list",
);
assert!(
amt(&h, fence_scroll) > fence_before,
"the fence's fling must carry on through a gesture that was never \
its own: {fence_before} -> {}",
amt(&h, fence_scroll),
);
}
+28 -18
View File
@@ -33,6 +33,10 @@ const MAX_LINES: f32 = 6.0;
const APPROX_LINE_HEIGHT_DP: f32 = 24.0; const APPROX_LINE_HEIGHT_DP: f32 = 24.0;
const FIELD_PAD_DP: f32 = 12.0; const FIELD_PAD_DP: f32 = 12.0;
/// The bar's own surface -- both what is drawn behind the field and what
/// the field is clipped to, see `build_composer`.
const BAR_FILL: UiColor = UiColor::new(40, 40, 46, 255);
/// `field` is exposed so the caller can read its content on submit /// `field` is exposed so the caller can read its content on submit
/// (`field.edit(rsc).text()`) and clear it afterward /// (`field.edit(rsc).text()`) and clear it afterward
/// (`field.edit(rsc).set("")`). /// (`field.edit(rsc).set("")`).
@@ -83,31 +87,37 @@ where
.label("Message") .label("Message")
.add(rsc); .add(rsc);
// One widget: an opaque bar sized to its own content (`.background`'s // One widget: an opaque bar sized to its own content, wrapping the
// `Stack{child: 1}`, the header row's own idiom) wrapping the padded, // padded, height-capped field -- not a background rect and a field
// height-capped field -- not a background rect and a field drawn as // drawn as two independent siblings, which is what let the two
// two independent siblings, which is what let the two disagree on // disagree on where the bar actually was.
// where the bar actually was. //
// `.scrollable().masked()`: the finger pan (`Scroll::drag`) plus the // `.masked_by(rect(BAR_FILL))` is that bar *and* the clip, in one:
// clip that keeps six lines' worth of a longer message inside the // the rect is drawn behind the field and is itself what the field is
// bar. The mask is the caller's job rather than `Scroll`'s own: // cut to (`Masked::shape`), so the surface and the edge content
// `.masked()` is the one mechanism for clipping and this is one more // disappears at cannot fall out of step. It replaces a
// use of it (tabs-ui's message area is the other). A `Scroll` nested // `.masked().background(rect(...))` pair, which clipped one box
// under another masked area used to abort here; since 2026-09-07 the // inside the other: the mask sat *inside* the `dp(FIELD_PAD_DP)`
// inner mask chains to the outer one (`Mask::parent`) and the content // padding, so a message longer than the six lines shown was sliced
// is clipped by both. // through the middle of a glyph 12dp in from the bar's edge, leaving
// Without it the overflow paints *above* the bar, over the // a band of bare surface above the cut. Iris, 2026-09-08: "the
// transcript: measured before this change at 58px of stray text for a // message input box doesn't clip correctly ... the box should be
// 475px message in a 417px box. // clipped rather than the inset text." The padding still holds the
// text off the edge at the end the content is anchored to; what
// scrolls past the other end now passes under the bar's own edge,
// the way `row.rs` already cuts a code fence to its panel.
//
// Without any mask at all the overflow paints *above* the bar, over
// the transcript: measured at 58px of stray text for a 475px message
// in a 417px box.
let content = field let content = field
// `scrollable_to_end`: what is being typed is at the end, so a // `scrollable_to_end`: what is being typed is at the end, so a
// message longer than the six lines shown holds that end. // message longer than the six lines shown holds that end.
.scrollable_to_end(Axis::Y) .scrollable_to_end(Axis::Y)
.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))
.width(rest(1)) .width(rest(1))
.background(rect(UiColor::new(40, 40, 46, 255))) .masked_by(rect(BAR_FILL))
.add(rsc); .add(rsc);
let outer_pad: WeakWidget<Pad> = content.pad(Padding::ZERO).add(rsc); let outer_pad: WeakWidget<Pad> = content.pad(Padding::ZERO).add(rsc);