diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 430adae..a6ceeb3 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -5,6 +5,29 @@ they can be judged and reversed later. Detail lives in RUST.md (and IRIS.md for iris API changes); this file is only the summary. Newest first. Items marked **DEFERRED** are ones the agent chose not to decide alone. +## 2026-09-06 (stale-primitives and touch-scroll pass) + +- **A vertical drag inside a focused composer now scrolls rather than + selects.** Android's own `EditText` does this -- a vertical drag scrolls + the field, and only a long press starts a selection -- so the platform + decided it. What it costs: you can no longer drag straight down inside + the composer to select several lines of what you typed; use a long press + and then drag, or drag sideways. Say if that trade is wrong for you. +- **`Scroll` gets a finger pan but no fling.** `List` flings; a scroll area + does not, because it has no per-frame tick to animate one and the areas + it wraps are at most a screenful (Android does not fling a six-line text + box either). Easy to add later if a scroll area ever wraps something long. +- **The composer still does not scroll its overflowed text**, though the + mechanism it needs is now in place. Wrapping the field in `.scrollable()` + was tried and reverted the same day: `Scroll` measures its content and + container against the *window*, so inside the `MaxSize` that caps the + composer at six lines the two are in different spaces and the field pans + itself entirely out of the bar (measured on the emulator with 474 + characters in it -- the bar collapsed to its padding). Fixing that means + `Scroll` measuring against its own offered box, which is a change to a + widget the transcript and the bench shell both use, so it is its own + piece of work rather than a rider on this one. + ## 2026-09-06 (defect pass) - **The keyboard-open diagnostics overlay is gone; the capture only diff --git a/docs/IRIS.md b/docs/IRIS.md index 9b03367..784a255 100644 --- a/docs/IRIS.md +++ b/docs/IRIS.md @@ -8,6 +8,48 @@ capability that moved. Small and trivial changes do not go here. An entry gives the date, what changed, why, and a short before/after where it helps judge the change without the session that made it. Newest first. +## 2026-09-06: `Scroll` pans on a finger drag, and a vertical drag in a focused text field no longer selects + +Three related public changes, all in aid of IRIS_TODO.md's "the composer +has no touch-drag scroll". + +**`Scroll::drag(render, id, sense, pos_window, now)` is new**, and +`WidgetLike::scrollable()` now registers it alongside the wheel handler it +already registered -- so anything built with `.scrollable()` pans on a +finger drag with no extra wiring at the call site. It goes through the same +`sense::DragGesture` that `transcript-ui::Selection::drag` drives `List` +with (arbitration, `DRAG_SLOP`, velocity, pointer capture), rather than a +second copy of that widget's wiring: `DragGesture` owns the mechanics and +each caller decides only what a committed pan *means*. `Scroll::amt()` is +new too, the read-only pan position a test or a scroll indicator needs. + +There is deliberately **no fling** on `Scroll`. Unlike `List` it has no +per-frame tick to animate one with (`List::set_redraw_handle`/`tick_fling`), +and the areas it wraps today are at most a screenful, where Android does not +fling either. The released velocity is dropped rather than approximated. + +**A vertical drag inside an already-focused `TextEdit` no longer extends a +selection.** `iris::attr`'s `on_press` used to treat a focused field as the +plain `click_or_drag` case -- every `Pressing` frame updated the selection. +It now applies the same `DRAG_SLOP` rule the *unfocused* branch already +applied: a press that moves past the slop vertically abandons its pending +selection for the rest of the gesture, so the scroll area around the field +gets the drag instead. Horizontal drag-to-select is unchanged, and a long +press still starts a selection. This is Android's own `EditText` behaviour +(a vertical drag scrolls; only a long press selects), and it is what makes +"swipe up over the composer to scroll the transcript" work without dragging +a highlight through the message you were typing. + +**`UiRenderState::orphaned_primitives()` is new**, and `update` now +`debug_assert!`s (debug builds only) that nothing is orphaned. An orphan is +a primitive still bound for the GPU that no live `ActiveData` names -- a +copy nothing can move, clip or free. That was the doubled `Compacted:` row +on the phone; see the same date's commit `76b1f99` and docs/RUST.md. The +per-frame guard is a count comparison (O(active widgets)); the walk that +names the offenders only runs when the counts disagree, because the walk is +O(primitives) and made a debug build on a phone too slow to finish a +benchmark run. + ## 2026-09-06: a tap on a text field always leaves a caret `TextEditCtx::select` used to compare the tap position against the diff --git a/docs/IRIS_TODO.md b/docs/IRIS_TODO.md index b2135ea..ec3a621 100644 --- a/docs/IRIS_TODO.md +++ b/docs/IRIS_TODO.md @@ -201,12 +201,19 @@ agent takes them without colliding with that pass's `bench_client.rs`/ a capped/scrollable height, bottom padding tied to the IME/nav-bar inset) -- structurally in place and unit-tested, but its own visual correctness cannot be screenshotted until text actually renders. -- [ ] **The composer has no touch-drag scroll for overflowing text.** The - 2026-09-06 rebuild caps the field at ~6 lines and wraps it in - `.scrollable()` for a wheel/trackpad scroll, but a real finger drag over - text that has overflowed the cap does not scroll it -- `Scroll`'s touch - handling is a follow-up, the same shape `List`'s own touch-drag pan - needed before I3/I5. +- [~] **The composer has no touch-drag scroll for overflowing text.** + **The mechanism is in, the composer is not, 2026-09-06.** `Scroll::drag` + takes its pan from the same `sense::DragGesture` `List` uses and + `WidgetLike::scrollable()` registers it beside the wheel handler, so + every scroll area in the codebase now pans on a finger (no fling -- see + IRIS.md). A vertical drag inside a focused field no longer extends a + selection, matching Android's `EditText`. But the composer field is + **not** wrapped in `.scrollable()` -- the note above was describing + intent, not the code -- and wrapping it was tried and reverted: `Scroll` + measures against the window rather than its own offered box, so inside + the `MaxSize` that caps the field at six lines it pans itself out of the + bar entirely (emulator, 474 characters, the bar collapsed to its + padding). docs/RUST.md's plan box has the numbers and the next step. ## From the phone, 2026-09-06, 11:39 (build delivered 02:07, commit 543f6d9) @@ -247,8 +254,17 @@ agent ticks it here with the evidence. field the composer may still read as pixels or dp; a stale value from before the first `on_insets_changed`. Reproduce with the phone's screen size and density on the emulator before guessing. -- [ ] **"Swiping still gets caught by the grey bar but keeps working - after I go past it."** Not closeable from the emulator, annotated +- [~] **"Swiping still gets caught by the grey bar but keeps working + after I go past it."** Improved 2026-09-06 by the focused-field rule + below, still needs her phone to close. `attr.rs`'s `on_press` treated an + already-focused composer as the plain drag-to-select case, so a swipe + starting inside it dragged a highlight through the typed text for the + whole gesture; it now abandons that the moment the press passes + `DRAG_SLOP` vertically (Android `EditText`'s own rule), which removes one + of the two things that made the bar feel like it caught the swipe. The + residual `DRAG_SLOP` measured from the boundary crossing, described + below, is unchanged. Original note follows. + Not closeable from the emulator, annotated 2026-09-06 after the `DragGesture` merge. `attr.rs`'s `on_press` never calls `capture_pointer` and never consumes a `Pressing` frame past `DRAG_SLOP` (it just stops watching), so once the finger's *current* diff --git a/docs/RUST.md b/docs/RUST.md index 5862732..5be05dc 100644 --- a/docs/RUST.md +++ b/docs/RUST.md @@ -226,20 +226,90 @@ closes it. at the top of `update` that it is empty between frames. Her build has both. -- [ ] **Stale primitives, the phone's half** — the `Compacted:` row - drawn twice, overlapping, and once more below the composer - (`docs/bench/iris-phone-v2-2026-09-06.md`). Everything the box - above lists as ruled out is ruled out; what is left needs the - phone, and it is worth noting the row is drawn *below the - composer*, i.e. outside the list's own clip, which points at - `List`'s row placement rather than at `Span::draw`'s two-phase - placement the earlier passes assumed. The `iris surface:`/`iris - insets:` log lines added 2026-09-06 are in the build she is - holding, so her next `adb logcat | grep -i iris` says what the - frame it happens on was doing. -- [ ] **Composer touch-drag scroll** for overflowed text — now that - dragging is a default-input `DragGesture`, `Scroll` should get its - touch pan from the same mechanism `List` uses, not a copy. +- [x] **Stale primitives, the phone's half** — **root-caused and fixed + 2026-09-06, commit `76b1f99`.** It was neither `Span::draw` nor + `List`: `UiRenderState::draw_inner` *read* `needs_redraw` without + consuming it, and used it to skip the whole `if let Some(active)` + block — **including the `remove(id, false)` that frees a redrawn + widget's previous primitives**. So a widget that was both already + active and marked dirty, and was reached by an **ancestor's** draw + rather than by `redraw_updates` picking it first (the order a + `HashSet` makes arbitrary, which is why it was intermittent), wrote + a second full set of primitives and then had `active.insert` + overwrite the only handles that could ever have freed the first + set. Those instances stay in the layer's buffer for the life of the + process, with a leaked move slot and leaked mask refs, redrawn every + frame at whatever region they last had — and `List` sets no mask, so + a row measured at `GENEROUS_PADDING` leaves its ghost outside the + list's own box, which is the copy below the composer. + `Painter::draw_twice` (`List::place`'s measurement pass) reaches + `draw_inner` twice for one id in one frame and so hits the same + fault with no ancestor involved. + **Fix**: consume the mark at the top of `draw_inner` — this call *is* + the redraw it asked for — and free the old primitives on the dirty + path too. + **Why the earlier passes could not see it**: `replacing_the_last_row_ + many_times_does_not_leak_primitives` counts *widgets*, and the + orphan's owner is very much alive; it is an earlier set of that same + widget's primitives that is stranded. + **Guard**: `UiRenderState::orphaned_primitives()` names every live + instance no `ActiveData` owns, and `update` `debug_assert!`s it empty + every frame in debug builds. The per-frame form is a *count* + comparison (`primitive_counts_agree`, O(active widgets)); the + O(primitives) walk only runs to build the failure message, because + running it per frame made a debug build on the emulator too slow to + finish a bench run at all (260s timeout, no report). + **Test**: `an_ancestor_redrawing_a_dirty_row_leaves_no_stale_copy` + (`iris/src/widget/list.rs`), which fails on the pre-fix code with + `1 primitive(s) survived their own widget's redraw`. + **Emulator evidence, 2026-09-06** (this checkout's `ai-app-2` AVD, + `build-apk.sh debug --abi x86_64 --features "transcript-screen bench + force-gles"`, a **debug** build so the guard is live): a complete + `run-bench.sh` run — 3,142 frames over 147s across the fling, the + 400-event stream (which is 400 `apply` calls including the fixture's + compaction event), the typing and the keyboard phases — with the + assert firing zero times and `logcat` showing no abort. That is the + whole of the phone's reported scenario exercised with the invariant + checked on every frame. +- [~] **Composer touch-drag scroll** for overflowed text — **the + mechanism is done, the composer is not.** `Scroll::drag` + (`iris/src/widget/position/scroll.rs`) takes its pan from the same + `sense::DragGesture` `List` is driven by, and + `WidgetLike::scrollable()` registers it beside the wheel handler, so + every `.scrollable()` in the codebase pans on a finger with nothing + added at the call site. No fling (`Scroll` has no per-frame tick and + the areas it wraps are at most a screenful) — see IRIS.md and + DECISIONS.md. A vertical drag inside a *focused* field no longer + extends a selection either (`attr.rs`'s `on_press` now applies the + same `DRAG_SLOP` rule its unfocused branch already did), which is + Android `EditText`'s own behaviour and what lets the scroll area + around a field win the gesture. + **Tests**: four in `scroll.rs` (pan past the slop, a tap inside it, + a horizontal drag, the end clamp) plus + `a_finger_drag_over_a_scroll_area_pans_it` in `sense_tests.rs`, which + drives the whole path — `scrollable()`'s registration, `run_sensors`' + dispatch, `Scroll::drag`, arbitration and pointer capture — and fails + with `got 0` if the registration is removed. + **What is left, with the measurement**: wrapping the composer's field + in `.scrollable().masked()` was tried and reverted the same day. + `Scroll` resolves `content_len`/`container_len` against + `Painter::output_size` — the whole window — so inside the `MaxSize` + that caps the composer at six lines the two are in different spaces + and the field pans itself entirely out of the bar: measured on the + emulator with 474 characters in it (`iris text render: ... + size=(1016.7, 623.7)` against a 441px cap) the bar collapsed to its + padding with no text in it. Making `Scroll` measure against its own + offered box is the next step, and it touches a widget the transcript + and the bench shell both use. + One real bug **was** found and fixed on the way (`ActiveData::mask` + stored the mask a widget *set* rather than the one it was drawn + *under*, and `redraw` feeds that field straight back in as the + inherited mask — so a targeted redraw of any `Masked` handed it its + own mask and aborted on `set_mask`'s nested-mask assert; that is a + real abort on the emulator, `assertion failed: self.mask == + MaskIdx::NONE`, reproduced as + `redrawing_a_masked_widget_does_not_nest_its_own_mask` in + `layout_tests.rs`). - [ ] **Streaming re-layout** (IRIS_TODO.md's last section) — after the above, since they make the stream phase unrepresentative today. - [x] **client-core prerequisites for P1, in parallel** (pure Rust, diff --git a/docs/bench/iris-phone-v2-2026-09-06.md b/docs/bench/iris-phone-v2-2026-09-06.md index 6e4bfa3..1fff670 100644 --- a/docs/bench/iris-phone-v2-2026-09-06.md +++ b/docs/bench/iris-phone-v2-2026-09-06.md @@ -20,6 +20,14 @@ overlapping, and once more below the composer bar: primitives of a replaced/removed row surviving in the GPU buffers, the same shape as the header drawn twice after a keyboard resize. +**Root-caused and fixed 2026-09-06** (commit `76b1f99`): the diagnosis in +that sentence was right and the location was not -- `UiRenderState:: +draw_inner` read the `needs_redraw` mark without consuming it and skipped +the branch that frees a redrawn widget's old primitives. docs/RUST.md's +"Stale primitives, the phone's half" box has the full account, the guard +(`orphaned_primitives`, `debug_assert`ed every frame) and the emulator run +that exercises it. + ``` iris bench report per phase: diff --git a/iris/core/src/render/primitive.rs b/iris/core/src/render/primitive.rs index 38a4397..f4cfc3c 100644 --- a/iris/core/src/render/primitive.rs +++ b/iris/core/src/render/primitive.rs @@ -278,6 +278,14 @@ impl Primitives { } } + /// How many instances are still bound for the GPU -- the O(1) half of + /// the orphan check, so the O(primitives) walk below only runs on a + /// frame that already looks wrong. See + /// [`crate::UiRenderState::orphaned_primitives`]. + pub fn live_count(&self) -> usize { + (self.instances.len() - self.free.len()) + (self.images.len() - self.image_free.len()) + } + /// Every instance that is still bound for the GPU, as `(inst_idx, /// owner, is_image)` -- everything except the slots already handed to /// [`Self::free`] and waiting for [`Self::apply_free`] to compact them diff --git a/iris/core/src/ui/render_state.rs b/iris/core/src/ui/render_state.rs index 67c0e70..929d3a7 100644 --- a/iris/core/src/ui/render_state.rs +++ b/iris/core/src/ui/render_state.rs @@ -140,11 +140,7 @@ impl UiRenderState { self.redraw_updates(rsc); } #[cfg(debug_assertions)] - debug_assert!( - self.orphaned_primitives().is_empty(), - "{}", - self.orphan_report(rsc), - ); + debug_assert!(self.primitive_counts_agree(), "{}", self.orphan_report(rsc),); } fn redraw_all(&mut self, root: Option<&StrongWidget>, rsc: &mut dyn UiRsc) { @@ -291,6 +287,16 @@ impl UiRenderState { } }; + // The mask this widget was drawn *under*, kept aside because + // `Painter::set_mask` overwrites `painter.mask` with the widget's + // own new one -- and `ActiveData::mask`'s only consumer is + // `redraw`, which feeds it back in as the *inherited* mask. Storing + // the set one instead handed a `Masked` its own mask on every + // targeted redraw, tripping `set_mask`'s nested-mask assert: + // `assertion failed: self.mask == MaskIdx::NONE`, an abort the + // first time the composer's scroll area was redrawn on the + // emulator. + let inherited_mask = mask; let mut painter = Painter { state: self, region, @@ -314,7 +320,7 @@ impl UiRenderState { state: _, rsc: _, region, - mask, + mask: _, move_slot, textures, primitives, @@ -331,7 +337,7 @@ impl UiRenderState { textures, primitives, children, - mask, + mask: inherited_mask, layer, size, move_slot, @@ -534,6 +540,19 @@ impl UiRenderState { orphans } + /// Whether every primitive still bound for the GPU is owned by a live + /// widget, decided by counting rather than by walking: an orphan is a + /// live instance no `ActiveData` names, so it can only ever make the + /// live count exceed the owned one. O(active widgets) -- a few dozen -- + /// against [`Self::orphaned_primitives`]'s O(primitives), which on a + /// transcript is tens of thousands and made a debug build on a phone + /// too slow to finish a benchmark run. + fn primitive_counts_agree(&self) -> bool { + let live: usize = self.layers.iter().map(|(_, p)| p.live_count()).sum(); + let owned: usize = self.active.values().map(|a| a.primitives.len()).sum(); + live == owned + } + /// The message [`Self::update`]'s orphan assert prints -- built here /// rather than inline so the (allocating, O(primitives)) work only /// happens on the failing path. diff --git a/iris/src/attr.rs b/iris/src/attr.rs index 6f100ba..8fc18f4 100644 --- a/iris/src/attr.rs +++ b/iris/src/attr.rs @@ -129,8 +129,39 @@ fn on_press( sense: CursorSense, ) { if state.is_focused(id) { - let recent = matches!(sense, CursorSense::PressStart(_)) && state.recent_click(); - id.edit(rsc).select(pos, size, sense.is_dragging(), recent); + // Already focused, so there is no keyboard to withhold -- but a + // vertical drag still is not a selection. Android's own `EditText` + // scrolls its overflowed text on a vertical drag and starts a + // selection only from a long press; a scroll area wrapping this + // field (`Scroll::drag`) is what actually pans, and it needs the + // first frames of the gesture not to have selected anything behind + // it before it crosses `DRAG_SLOP` and takes pointer capture. + // `press_origin` carries the same meaning here as in the unfocused + // branch below -- "this gesture is still eligible", cleared the + // moment it becomes a drag -- so there is one flag, not two. + match sense { + CursorSense::PressStart(_) => { + let recent = state.recent_click(); + id.edit(rsc).text.press_origin = Some(pos); + id.edit(rsc).select(pos, size, false, recent); + } + CursorSense::Pressing(_) | CursorSense::PressEnd(_) => { + let mut ctx = id.edit(rsc); + let Some(origin) = ctx.text.press_origin else { + return; + }; + let (dx, dy) = (pos.x - origin.x, pos.y - origin.y); + if dy.abs() > DRAG_SLOP && dy.abs() >= dx.abs() { + ctx.text.press_origin = None; + return; + } + if matches!(sense, CursorSense::PressEnd(_)) { + ctx.text.press_origin = None; + } + ctx.select(pos, size, true, false); + } + _ => {} + } return; } diff --git a/iris/src/layout_tests.rs b/iris/src/layout_tests.rs index 58f4599..757939f 100644 --- a/iris/src/layout_tests.rs +++ b/iris/src/layout_tests.rs @@ -147,6 +147,37 @@ fn hit_testing_follows_a_scrolled_widget() { ); } +/// `ActiveData::mask` is the mask a widget was drawn **under**, not the one +/// it set for itself -- `redraw` feeds it straight back in as the inherited +/// mask, so storing the set one hands a `Masked` its own mask the second +/// time round and trips `Painter::set_mask`'s nested-mask assert. That was +/// an abort (`assertion failed: self.mask == MaskIdx::NONE`) the first time +/// the composer's new scroll area was redrawn on the emulator; a targeted +/// redraw of a `Masked` is what any real screen does whenever anything +/// inside it changes. +#[test] +fn redrawing_a_masked_widget_does_not_nest_its_own_mask() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let (_scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 8); + let masked = rsc.ui.widgets.add_strong(Masked { inner: inner_root }); + let masked_id = masked.id(); + let root = masked.any(); + let mut render = UiRenderState::new(); + render.resize((800.0, 600.0)); + render.update(&root, &mut rsc); + + render.redraw(masked_id, &mut rsc); + render.redraw(masked_id, &mut rsc); + + assert_eq!( + render.active.get(&masked_id).unwrap().mask, + MaskIdx::NONE, + "a `Masked` at the root is drawn under no mask of its own" + ); +} + #[test] fn a_mask_stays_put_while_its_scrolled_content_moves() { let mut rsc = TestRsc { diff --git a/iris/src/sense_tests.rs b/iris/src/sense_tests.rs index 5007b3f..5c75a14 100644 --- a/iris/src/sense_tests.rs +++ b/iris/src/sense_tests.rs @@ -246,3 +246,73 @@ fn capturing_one_widget_starves_every_other_widget_of_events() { "while a's drag holds capture, b must see no hover at all" ); } + +/// IRIS_TODO.md's "the composer has no touch-drag scroll": `Scroll` only +/// answered a wheel, so a finger drag over overflowed text did nothing. +/// End-to-end over the real wiring -- `scrollable()`'s own registration, +/// `run_sensors`' dispatch, `Scroll::drag`, `DragGesture`'s arbitration and +/// pointer capture -- rather than only `Scroll::drag`'s own unit tests in +/// `scroll.rs`, because the registration is exactly the half those cannot +/// see. +#[test] +fn a_finger_drag_over_a_scroll_area_pans_it() { + let mut rsc = SenseRsc { + ui: UiData::default(), + events: EventManager::default(), + }; + + // 1000px of content in a 100px window: room to pan. + let scroll_strong = rect(UiColor::WHITE) + .height(Len::abs(1000.0)) + .scrollable() + .add_strong(&mut rsc); + let scroll = scroll_strong.weak(); + let root = scroll_strong.any(); + + let mut render = UiRenderState::new(); + render.resize((100.0, 100.0)); + render.update(&root, &mut rsc); + // `Scroll` reads its content length back from the draw it just did, so + // the frame after is the first one that knows there is anything to pan + // -- the one-frame lag LAYOUT.md section 4 documents. `scroll(0.0)` is + // how `layout_tests.rs` asks for that second frame, and it also drops + // `snap_end`, leaving this parked at the start of the content. + rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0); + render.update(&root, &mut rsc); + assert_eq!(rsc.ui.widgets.get(&scroll).unwrap().amt(), 0.0); + + let mut state = (); + let mut down = cursor_at((50.0, 80.0).into()); + down.buttons.left = ActivationState::Start; + render.run_sensors(&mut rsc, &mut state, down, (100.0, 100.0).into()); + assert_eq!( + rsc.ui.widgets.get(&scroll).unwrap().amt(), + 0.0, + "the touch-down alone must not move anything" + ); + + // Inside the slop: still a tap as far as anything can tell. + let mut nudge = cursor_at((50.0, 80.0 - (DRAG_SLOP - 1.0)).into()); + nudge.buttons.left = ActivationState::On; + render.run_sensors(&mut rsc, &mut state, nudge, (100.0, 100.0).into()); + assert_eq!( + rsc.ui.widgets.get(&scroll).unwrap().amt(), + 0.0, + "a press inside DRAG_SLOP must not scroll" + ); + + // Past it, upward: the content follows the finger up, which for this + // widget means more `amt`. + let mut drag = cursor_at((50.0, 80.0 - (DRAG_SLOP + 40.0)).into()); + drag.buttons.left = ActivationState::On; + render.run_sensors(&mut rsc, &mut state, drag, (100.0, 100.0).into()); + let after = rsc.ui.widgets.get(&scroll).unwrap().amt(); + assert!( + (after - 40.0).abs() < 0.01, + "expected the 40px past the slop to pan it, got {after}" + ); + + // And the gesture holds the pointer, so the rest of it reaches this + // widget even once the finger leaves its box. + assert_eq!(render.captured_pointer(), Some(scroll.id())); +} diff --git a/iris/src/widget/position/scroll.rs b/iris/src/widget/position/scroll.rs index e35f8d2..f1e9588 100644 --- a/iris/src/widget/position/scroll.rs +++ b/iris/src/widget/position/scroll.rs @@ -1,4 +1,6 @@ use crate::prelude::*; +use crate::sense::{DragGesture, GestureOutcome}; +use std::time::Instant; pub struct Scroll { inner: StrongWidget, @@ -7,6 +9,12 @@ pub struct Scroll { snap_end: bool, container_len: f32, content_len: 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 + /// `sense.rs` and only what a committed pan *means* is decided here. + /// See [`Self::drag`]. + gesture: DragGesture, } impl Widget for Scroll { @@ -60,6 +68,59 @@ impl Scroll { snap_end: true, container_len: 0.0, content_len: 0.0, + gesture: DragGesture::new(), + } + } + + /// Feed one frame of a touch gesture over this scroll area through. + /// Wired by `WidgetLike::scrollable`; a caller building a `Scroll` by + /// hand registers the same senses and calls this. + /// + /// `id` is this widget's own id, which `DragGesture` takes pointer + /// capture on once the gesture commits -- so the rest of the drag + /// reaches here even after the finger has left this area, and, just as + /// importantly, stops reaching whatever is *inside* it. That is what + /// resolves a vertical drag over a focused text field: the field sees + /// the first few frames, iris::attr's `on_press` gives up its pending + /// selection the moment they pass `DRAG_SLOP` vertically, and this + /// takes the gesture over. Android's own `EditText` behaves the same + /// way -- a vertical drag scrolls, and only a long press selects. + /// + /// No fling: unlike `List`, `Scroll` has no per-frame tick to animate + /// one with (`List::set_redraw_handle`/`tick_fling`), and the areas + /// this wraps today -- a six-line composer, a diagnostics pane -- are + /// at most a screenful, where Android does not fling either. The + /// released velocity is deliberately dropped rather than approximated. + pub fn drag( + &mut self, + render: &UiRenderState, + id: WidgetId, + sense: CursorSense, + pos_window: Vec2, + now: Instant, + ) { + // `already_selected: false` -- a scroll area has no selection of + // its own to extend, so a horizontal drag stays `Undecided` and a + // vertical one past the slop pans, which is the whole contract + // here. A caller that *does* own a selection (the transcript's + // `Selection`) drives `DragGesture` itself instead. + match self + .gesture + .handle(render, id, sense, pos_window, now, false) + { + // `scroll(dy)`, not `scroll(-dy)` -- `Selection::drag` passes + // `-dy` to `List::scroll` because a `List`'s anchor offset and + // this widget's `amt` run in *opposite* directions (offset is + // where the anchored edge sits; `amt` is how far the content + // has been pulled up past the top), even though `List::scroll`'s + // own doc claims to mirror this one's convention. The rule that + // holds for both, and the one to check a sign against, is that + // the content follows the finger. + GestureOutcome::Pan(dy) => self.scroll(dy), + GestureOutcome::Undecided + | GestureOutcome::SelectStart + | GestureOutcome::SelectExtend + | GestureOutcome::Released(_) => {} } } @@ -70,8 +131,179 @@ impl Scroll { self.snap_end = self.amt == len; } + /// How far the content has been pulled past the container's leading + /// edge, in pixels -- 0 at the start of the content. Read-only, for a + /// caller that needs to observe a pan (a test, a scroll indicator). + pub fn amt(&self) -> f32 { + self.amt + } + pub fn scroll(&mut self, amt: f32) { self.amt -= amt; self.update_amt(); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::sense::{CursorButton, DRAG_SLOP}; + use iris_core::UiData; + use std::time::Duration; + + /// A scroll area with 1000px of content in a 100px box, already + /// settled somewhere in the middle so a drag has room in both + /// directions. + fn area() -> (UiData, Scroll, WidgetId) { + 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; + s.container_len = 100.0; + s.amt = 400.0; + s.snap_end = false; + (ui, s, id) + } + + fn press( + s: &mut Scroll, + render: &UiRenderState, + id: WidgetId, + sense: CursorSense, + y: f32, + t: Instant, + ) { + s.drag(render, id, sense, Vec2::new(0.0, y), t); + } + + #[test] + fn a_vertical_finger_drag_pans_the_content_with_the_finger() { + let (_ui, mut s, id) = area(); + let render = UiRenderState::new(); + let t = Instant::now(); + press( + &mut s, + &render, + id, + CursorSense::PressStart(CursorButton::Left), + 0.0, + t, + ); + // Finger down by well past the slop: the content follows it down, + // which for this widget means *less* `amt`. + press( + &mut s, + &render, + id, + CursorSense::Pressing(CursorButton::Left), + DRAG_SLOP + 30.0, + t + Duration::from_millis(20), + ); + assert!( + (s.amt - 370.0).abs() < 0.01, + "expected the 30px past the slop to be applied downward, got amt={}", + s.amt + ); + // ...and the next frame's motion is a plain per-frame delta. + press( + &mut s, + &render, + id, + CursorSense::Pressing(CursorButton::Left), + DRAG_SLOP + 50.0, + t + Duration::from_millis(40), + ); + assert!((s.amt - 350.0).abs() < 0.01, "amt={}", s.amt); + } + + /// The half the change had no reason to touch: a press that never + /// leaves the slop is a tap, and must move nothing at all -- otherwise + /// every tap on a scrollable field nudges its text. + #[test] + fn a_press_that_stays_inside_the_slop_does_not_scroll() { + let (_ui, mut s, id) = area(); + let render = UiRenderState::new(); + let t = Instant::now(); + press( + &mut s, + &render, + id, + CursorSense::PressStart(CursorButton::Left), + 0.0, + t, + ); + for (i, y) in [1.0, -2.0, DRAG_SLOP - 0.5].into_iter().enumerate() { + press( + &mut s, + &render, + id, + CursorSense::Pressing(CursorButton::Left), + y, + t + Duration::from_millis(10 * (i as u64 + 1)), + ); + } + press( + &mut s, + &render, + id, + CursorSense::PressEnd(CursorButton::Left), + DRAG_SLOP - 0.5, + t + Duration::from_millis(50), + ); + assert!( + (s.amt - 400.0).abs() < 0.01, + "a tap scrolled: amt={}", + s.amt + ); + } + + /// A horizontal drag is not this widget's gesture: it must stay put + /// rather than pick up the vertical noise in a sideways swipe. + #[test] + fn a_horizontal_drag_does_not_scroll() { + let (_ui, mut s, id) = area(); + let render = UiRenderState::new(); + let t = Instant::now(); + s.drag( + &render, + id, + CursorSense::PressStart(CursorButton::Left), + Vec2::new(0.0, 0.0), + t, + ); + s.drag( + &render, + id, + CursorSense::Pressing(CursorButton::Left), + Vec2::new(120.0, 3.0), + t + Duration::from_millis(20), + ); + assert!((s.amt - 400.0).abs() < 0.01, "amt={}", s.amt); + } + + /// Panning stops at the ends of the content rather than running off, + /// which is `update_amt`'s clamp -- checked through `drag` so the two + /// cannot drift apart. + #[test] + fn a_pan_past_the_end_clamps_instead_of_running_off() { + let (_ui, mut s, id) = area(); + let render = UiRenderState::new(); + let t = Instant::now(); + s.drag( + &render, + id, + CursorSense::PressStart(CursorButton::Left), + Vec2::new(0.0, 0.0), + t, + ); + s.drag( + &render, + id, + CursorSense::Pressing(CursorButton::Left), + Vec2::new(0.0, 5000.0), + t + Duration::from_millis(20), + ); + assert!((s.amt - 0.0).abs() < 0.01, "amt={}", s.amt); + } +} diff --git a/iris/src/widget/trait_fns.rs b/iris/src/widget/trait_fns.rs index effebc8..941f273 100644 --- a/iris/src/widget/trait_fns.rs +++ b/iris/src/widget/trait_fns.rs @@ -1,5 +1,6 @@ use super::*; use crate::prelude::*; +use std::time::Instant; // these methods should "not require any context" (require unit) because they're in core widget_trait! { @@ -90,6 +91,20 @@ widget_trait! { let delta = ctx.data.scroll_delta.y * 50.0; ctx.widget(rsc).scroll(delta); }) + // A finger drag, through the same `DragGesture` the + // transcript's `List` is panned by -- `Scroll::drag`'s doc + // has the arbitration and why there is no fling. The wheel + // above and this are the two inputs of one scroll, so they + // are registered together rather than left to each caller. + .on( + CursorSense::click_or_drag() | CursorSense::unclick(), + |ctx, rsc| { + let id = ctx.widget.id(); + let (sense, pos) = (ctx.data.sense, ctx.data.cursor.pos); + ctx.widget(rsc) + .drag(ctx.data.render, id, sense, pos, Instant::now()); + }, + ) .add(state) } } diff --git a/iris/transcript-ui/src/composer.rs b/iris/transcript-ui/src/composer.rs index 6ed9686..9780add 100644 --- a/iris/transcript-ui/src/composer.rs +++ b/iris/transcript-ui/src/composer.rs @@ -88,6 +88,18 @@ where // height-capped field -- not a background rect and a field drawn as // two independent siblings, which is what let the two disagree on // where the bar actually was. + // **Not** `.scrollable()` here yet, though IRIS_TODO.md's "the composer + // has no touch-drag scroll" wants it: `Scroll` resolves its own + // `content_len`/`container_len` against `Painter::output_size` -- the + // whole window -- so inside a `MaxSize` that has clamped the offered + // region to six lines the two are in different spaces and the field + // pans itself entirely out of the bar. Measured on this checkout's + // emulator 2026-09-06 with 474 characters in the field (`iris text + // render: ... size=(1016.7, 623.7)`, a 441px cap): the bar collapsed to + // its padding with no text in it. `Scroll::drag` -- the finger pan the + // TODO actually asks for -- is in place and exercised by the bench + // shell's report pane; what is left is `Scroll` measuring against its + // own offered box rather than the window. See docs/RUST.md. let content = field .pad(dp(FIELD_PAD_DP)) .max_height(dp(APPROX_LINE_HEIGHT_DP * MAX_LINES + FIELD_PAD_DP * 2.0))