diff --git a/IRIS.md b/IRIS.md index db6c377..15d6dcf 100644 --- a/IRIS.md +++ b/IRIS.md @@ -30,6 +30,49 @@ let (screen, tree) = transcript_ui::build_tree(rsc, rows); some_widget_ptr(rsc).set(tree); ``` + +## 2026-09-05: `DragArbiter`, pan-vs-select for one shared touch gesture (RUST.md's I5) + +New public type, `iris::sense::DragArbiter`. Why: a widget author who +registers both a list-level pan and a row-level drag-to-select on the same +touch gesture has no way to arbitrate between them — `core/src/sense.rs`'s +`run_sensors` always gives the innermost layer first refusal, so the inner +one wins every frame it is pressed, not just the frame the press started +(this is exactly what left transcript-ui's touch-drag panning unreachable +until now). `DragArbiter` is one small state machine, one instance per +gesture surface (a whole list, not per row), that a caller drives with its +own `press_start`/`update`/`release` calls and a caller-supplied `Instant` +(so it is unit-testable without a real clock or a render harness). It +decides the way Android itself does: an ordinary vertical drag pans +immediately; a stationary press held `LONG_PRESS` (500ms) starts a +selection, which any further drag then extends; a horizontal drag while +something is already selected extends it immediately, skipping the wait. + +```rust +// One per list, held alongside whatever state coordinates the rows: +let mut arbiter = DragArbiter::new(); + +// On press-down: +arbiter.press_start(pos, Instant::now(), already_selected); +// Every frame the button/finger stays down: +match arbiter.update(pos, Instant::now()) { + DragOutcome::Pan(dy) => list.scroll(-dy), + DragOutcome::SelectStart => selection.begin(...), + DragOutcome::SelectExtend => selection.extend(...), + DragOutcome::Undecided => {} +} +// On release: +arbiter.release(); +``` + +`transcript-ui`'s `Selection::drag` (`transcript-ui/src/selection.rs`) is +the reference caller: every row's `CursorSense::click_or_drag() | +CursorSense::unclick()` handler routes through one `Selection`-owned +arbiter instead of calling `begin`/`extend` directly, so a drag that starts +on a row's own rendered text now pans the list correctly instead of +always starting a selection. 8 new unit tests in `iris/src/sense.rs`'s +`drag_arbiter_tests` module. + ## 2026-09-05: `SpanStyle`, per-range text styling (RUST.md's I5) A `TextBuffer` used to have exactly one style (`TextAttrs`: colour, size, diff --git a/IRIS_TODO.md b/IRIS_TODO.md index 78db53a..6b2a51b 100644 --- a/IRIS_TODO.md +++ b/IRIS_TODO.md @@ -184,17 +184,25 @@ order and what "done" looks like. Tick and date them in place. `app/ui-sandbox.sh --delay` (this crate deliberately fetches nothing itself, `transcript-ui/src/lib.rs`'s doc), a new cdylib + Gradle module, then the bench script pointed at it. - - [ ] **Touch-drag panning over a row's own rendered text.** `row.rs` - registers `CursorSense::click_or_drag()` on each row's `TextEdit` for - cross-row selection; `TextEdit::draw`'s `painter.child_layer()` - (`iris/src/widget/text/edit.rs:87`) means that registration wins - `core/src/sense.rs::run_sensors`'s per-layer arbitration on every - frame it is pressed, not just the frame the press started, so a list - pan gesture registered on `List` itself never gets a turn while a - row is under the finger. Fix: a small press distance/time arbiter - deciding pan vs. select before either commits, or gate text-drag- - selection behind a long-press so an ordinary swipe always pans first. - `lib.rs`'s module doc has the full diagnosis. + - [x] **Touch-drag panning over a row's own rendered text — done, + 2026-09-05.** `row.rs` used to register `CursorSense::click_or_drag()` + on each row's `TextEdit` for cross-row selection; `TextEdit::draw`'s + `painter.child_layer()` (`iris/src/widget/text/edit.rs:87`) meant that + registration won `core/src/sense.rs::run_sensors`'s per-layer + arbitration on every frame it was pressed, not just the frame the + press started, so a list pan gesture registered on `List` itself never + got a turn while a row was under the finger. Fixed with + `iris::sense::DragArbiter` (recorded in `IRIS.md`), one small state + machine per list deciding pan vs. select the way Android does (a + vertical drag pans immediately; a stationary press held `LONG_PRESS` + (500ms) starts a selection which further drag extends; a horizontal + drag while something is already selected extends immediately) — + `transcript-ui/src/selection.rs`'s `Selection::drag` is the one place + every row's drag now routes through. 8 new unit tests + (`iris/src/sense.rs`'s `drag_arbiter_tests`); `cargo fmt/clippy/test + --workspace` and `cargo ndk` (both `iris` and `transcript-ui`) all + clean; `run-headless.sh` screenshot byte-identical to before the + change (38578 bytes). See RUST.md's I5 box, "Gap closed, 2026-09-05". - [ ] **Row-level accessibility names.** The composer carries `.label("Message")`; transcript rows do not carry a `.label()` of their own yet, so `Widgets::named()` (I4) does not include them — diff --git a/RUST.md b/RUST.md index 3590b81..b7ff10c 100644 --- a/RUST.md +++ b/RUST.md @@ -36,12 +36,10 @@ session spending an afternoon on them again. ## Where things stand (2026-09-05) -- **In flight, 2026-09-05 (session cleared mid-work, picked up again):** - the I5 touch-drag pan-vs-select gap, as a `DragArbiter` in - `iris/src/sense.rs` wired into `transcript-ui`'s selection. Design - choices are summarised in `DECISIONS.md` at the repo root, which is the - file Iris reads for choices made without her. Next after it: I5's - Android integration and the bench numbers. +- **Both of the previous note's in-flight pieces are now done, 2026-09-05.** + Design choices for both are summarised in `DECISIONS.md` at the repo + root, which is the file Iris reads for choices made without her. Next: + I5's Android integration and the bench numbers. - **E4 done, 2026-09-05.** `iris/desktop-app`: a winit window with a session list beside `transcript-ui`'s screen (`build_tree`), against a real `ai-server` through `client-core`, enrolled from the same @@ -49,6 +47,9 @@ session spending an afternoon on them again. `app/ui-sandbox.sh` -- see E4's own box for the commands, the screenshot, and a real streaming-duplication bug the screenshot found and a regression test now covers. +- **The I5 touch-drag pan-vs-select gap is closed, 2026-09-05**, as a + `DragArbiter` in `iris/src/sense.rs` wired into `transcript-ui`'s + selection -- see I5's own box below, "Gap closed, 2026-09-05". - **Done**: E0 (toolchain), E1 (Masonry on android-view, which found the keyboard gap — now explained, see below), E2 (a transcript in Masonry, which found that Masonry has no touch-scroll on Android at all — see @@ -90,11 +91,14 @@ session spending an afternoon on them again. Compose baseline, `ui-trace` tap-by-name on a row) — `emu list` showed the one emulator here held by another session, but the real blocker is that the integration work itself is unbuilt, not the emulator being - busy. Touch-drag panning over a row's own rendered text is also not yet - reachable, for a specific, diagnosed reason (it competes with this box's - own row-level drag-select for the same gesture) rather than an absent - primitive. Full accounting, every citation, and the dated - IRIS_TODO.md items are in I5's own box below. + busy. Full accounting, every citation, and the dated IRIS_TODO.md items + are in I5's own box below. **Update, 2026-09-05, same day**: touch-drag + panning over a row's own rendered text, which was not yet reachable for + a specific, diagnosed reason (it competed with this box's own + row-level drag-select for the same gesture, not an absent primitive), + is now closed — a `DragArbiter` in `iris/src/sense.rs`, wired into + `transcript-ui`'s selection — see the box's "Gap closed" note. Android + integration is the one item left before this box can tick `[x]`. - **E3 done, 2026-09-05, and unlike E1/E2 it is committed to this repo** (`android-shell/` — a JNI-bridge crate on `client-core` — plus a new Gradle module `app/shellApp/`, left deliberately separate from @@ -2357,26 +2361,68 @@ silently on real hardware. include it) -- a small, real gap, recorded as an IRIS_TODO.md item rather than silently left, since AGENTS.md's bench scripts depend on exactly this for driving a screen by name. - 7. **Measurable frames / the render-number pass condition -- not - attempted, and unlike E2 the reason is not an absent gesture - path.** `List` demonstrably scrolls (I3's flat draws/moves, - programmatic `scroll()`) and mouse-wheel scrolling is wired here - (`lib.rs`'s `CursorSense::Scroll` on `list`). What is *not* - reachable yet is a **touch-drag pan starting on a row's own - text**: `row.rs` registers `CursorSense::click_or_drag()` on each - row's `TextEdit` for selection, and `TextEdit::draw` calls - `painter.child_layer()` (`iris/src/widget/text/edit.rs:87`), so - `core/src/sense.rs`'s `run_sensors` (which stops at the first - layer, checked innermost-first, that consumed the gesture) gives - that row first refusal on *every* frame it is pressed, not just - the frame the press started -- a row's drag-select wins the same - gesture a list-level pan would want. `lib.rs`'s own module doc - states this precisely, with the fix named (a press distance/time - arbiter deciding pan vs. select before either commits, or gating - text-drag-selection behind a long-press). This is a genuine, - diagnosed architecture gap this box's *own* two features created - by both wanting the same gesture -- not a missing primitive the - way Masonry's absent `on_pointer_event` drag handling was. + 7. **Measurable frames / the render-number pass condition -- the + gesture-conflict half is now fixed (2026-09-05); the emulator + half is still not attempted, and unlike E2 that's not an absent + gesture path.** `List` demonstrably scrolls (I3's flat + draws/moves, programmatic `scroll()`) and mouse-wheel scrolling + is wired here (`lib.rs`'s `CursorSense::Scroll` on `list`). What + was *not* reachable at first was a **touch-drag pan starting on a + row's own text**: `row.rs` registered `CursorSense:: + click_or_drag()` on each row's `TextEdit` for selection, and + `TextEdit::draw` calls `painter.child_layer()` + (`iris/src/widget/text/edit.rs:87`), so `core/src/sense.rs`'s + `run_sensors` (which stops at the first layer, checked + innermost-first, that consumed the gesture) gave that row first + refusal on *every* frame it was pressed, not just the frame the + press started -- a row's drag-select won the same gesture a + list-level pan would want. This is a genuine, diagnosed + architecture gap this box's *own* two features created by both + wanting the same gesture -- not a missing primitive the way + Masonry's absent `on_pointer_event` drag handling was. + + **Gap closed, 2026-09-05, same day.** `iris::sense::DragArbiter` + (`iris/src/sense.rs`, new public type, recorded in `IRIS.md`) is + one small state machine, one instance per gesture surface (a + whole list, not per row), driven with a caller-supplied `Instant` + so it needs no render harness to test. It decides the way + Android itself does, recorded in `DECISIONS.md`: an ordinary + vertical drag pans immediately; a stationary press held + `LONG_PRESS` (500ms) starts a selection, which any further drag + then extends; a horizontal drag while something is already + selected extends it immediately, skipping the wait. + `transcript-ui/src/selection.rs`'s new `Selection::drag` is the + one place every row's `CursorSense::click_or_drag() | + CursorSense::unclick()` handler now goes through (`row.rs`, + `build_text_row`), replacing the direct `begin`/`extend` calls + each row used to make on its own -- one arbiter shared across + every row is what keeps the decision consistent as a drag + crosses row boundaries, per `DragArbiter`'s own doc. `Pan(dy)` + calls the list's own `List::scroll` (the same method I3's + mouse-wheel handler and its own benchmark already use), so this + is not a second scroll mechanism. 8 new unit tests in + `iris/src/sense.rs`'s `drag_arbiter_tests` (vertical drag pans + immediately and keeps panning by per-frame delta; small jitter + under `DRAG_SLOP` stays undecided; a held press starts a + selection after `LONG_PRESS` and further drag extends it, even + vertical drag, once selecting; a horizontal drag with nothing yet + selected stays undecided rather than guessing; a horizontal drag + with something already selected extends immediately; a vertical + drag still pans even with a prior selection; release resets to + idle). Verification: `cargo fmt --all -- --check`, `cargo clippy + --workspace --all-targets` (zero warnings), `cargo test + --workspace` (28 pre-existing + 9 `transcript-ui` + **8 new** + `drag_arbiter_tests`, all passing), `cargo ndk -t x86_64 -P 26 + build/clippy` for both `-p iris` and `-p transcript-ui --lib` + (clean), and `run-headless.sh transcript --shot ... -- -p + transcript-ui` -- byte-identical to this box's original + screenshot (38578 bytes, `cmp` confirms identical), confirming no + visual regression from the rewiring. **What this did not + attempt**: the emulator-side confirmation (a real touch swipe + over a row's text panning on-device) -- that still needs I5's own + Android integration, the one item named just above and in "What + remains" below; this pass only had the winit/host-side gesture + path to drive, since no cdylib exists yet for this screen. **Verification, exact commands and results (2026-09-05, this VM):** @@ -2435,7 +2481,6 @@ silently on real hardware. own right -- closer in size to E2/E3 than to "run one more script" -- not something this pass's remaining time could responsibly rush and still report honestly. - - **Touch-drag panning over a row's own text** -- behaviour 7 above. - **Row-level accessibility names** -- behaviour 6 above. - **A tappable link and a code-span background chip** -- behaviour 2. - **`Selection`'s anchor-row shortcut** -- behaviour 1. diff --git a/iris/src/sense.rs b/iris/src/sense.rs index a6ed6e1..e9f9e26 100644 --- a/iris/src/sense.rs +++ b/iris/src/sense.rs @@ -2,6 +2,7 @@ use crate::prelude::*; use std::{ ops::{BitOr, Deref, DerefMut}, rc::Rc, + time::{Duration, Instant}, }; #[derive(Clone, Copy, PartialEq)] @@ -357,3 +358,241 @@ impl BitOr for CursorSenses { self } } + +/// How long a stationary press has to be held before it is treated as a +/// long-press rather than the start of a pan. +pub const LONG_PRESS: Duration = Duration::from_millis(500); +/// How far a press has to move, in pixels, before it counts as a drag +/// rather than jitter -- for both the pan-vs-select axis test and the +/// "did this actually move" long-press guard. +pub const DRAG_SLOP: f32 = 8.0; + +/// What a [`DragArbiter`] decided a frame's drag should mean. `Undecided` +/// means neither a pan nor a selection has committed yet, so the caller +/// should do nothing observable this frame. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum DragOutcome { + Undecided, + /// Scroll the enclosing list by this many window-space pixels along + /// the drag axis (the delta since the arbiter's last decided frame). + Pan(f32), + /// A selection should begin at the arbiter's press origin. + SelectStart, + /// A selection already underway should extend to the current position. + SelectExtend, +} + +#[derive(Clone, Copy, PartialEq)] +enum ArbiterState { + Idle, + Undecided { already_selected: bool }, + Panning, + Selecting, +} + +/// Decides, one shared instance per gesture surface (a transcript's whole +/// row list here), whether a touch drag that starts on a row's own +/// selectable text is panning the list or extending a text selection -- +/// RUST.md's I5 finding that both wanted the same `CursorSense:: +/// click_or_drag()` gesture, with the inner text layer winning every frame +/// regardless of which one the reader meant. Decided the way Android +/// itself decides it, so a reader's existing muscle memory carries over: +/// +/// - An ordinary vertical drag pans -- checked first, and immediately, +/// so a swipe never waits on the long-press timer. +/// - A stationary press held past [`LONG_PRESS`] starts a selection. +/// Every drag frame after that extends it, whichever direction it goes. +/// - A drag that starts **horizontally** while something is already +/// selected extends that selection right away, skipping the long-press +/// wait -- the "drag the selection handle" gesture a reader reaches for +/// once text is already highlighted. +/// +/// Pure state, no rendering or widget access, so it is unit-testable +/// exactly like the rest of this module (`sense_tests.rs`'s style) with a +/// caller-supplied `Instant` rather than a real clock. +pub struct DragArbiter { + state: ArbiterState, + origin: Vec2, + origin_at: Instant, + last: Vec2, +} + +impl Default for DragArbiter { + fn default() -> Self { + Self { + state: ArbiterState::Idle, + origin: Vec2::ZERO, + origin_at: Instant::now(), + last: Vec2::ZERO, + } + } +} + +impl DragArbiter { + pub fn new() -> Self { + Self::default() + } + + /// A fresh press-down at `pos`. `already_selected` is whatever the + /// caller's selection state was *before* this press -- it decides + /// whether an early horizontal move extends that selection instead of + /// waiting for a long-press. + pub fn press_start(&mut self, pos: Vec2, now: Instant, already_selected: bool) { + self.origin = pos; + self.origin_at = now; + self.last = pos; + self.state = ArbiterState::Undecided { already_selected }; + } + + /// The press continues (still down) at `pos`. Call once per frame + /// while the button/finger is down; returns what this frame means. + pub fn update(&mut self, pos: Vec2, now: Instant) -> DragOutcome { + match self.state { + ArbiterState::Idle => DragOutcome::Undecided, + ArbiterState::Panning => { + let dy = pos.y - self.last.y; + self.last = pos; + DragOutcome::Pan(dy) + } + ArbiterState::Selecting => { + self.last = pos; + DragOutcome::SelectExtend + } + ArbiterState::Undecided { already_selected } => { + let dx = pos.x - self.origin.x; + let dy = pos.y - self.origin.y; + if already_selected && dx.abs() > DRAG_SLOP && dx.abs() > dy.abs() { + self.state = ArbiterState::Selecting; + self.last = pos; + DragOutcome::SelectExtend + } else if dy.abs() > DRAG_SLOP && dy.abs() >= dx.abs() { + self.state = ArbiterState::Panning; + self.last = pos; + DragOutcome::Pan(dy) + } else if now.duration_since(self.origin_at) >= LONG_PRESS + && dx.abs() <= DRAG_SLOP + && dy.abs() <= DRAG_SLOP + { + self.state = ArbiterState::Selecting; + self.last = pos; + DragOutcome::SelectStart + } else { + DragOutcome::Undecided + } + } + } + } + + /// The press was released -- back to idle for the next one. + pub fn release(&mut self) { + self.state = ArbiterState::Idle; + } +} + +#[cfg(test)] +mod drag_arbiter_tests { + use super::*; + + fn t(ms: u64) -> Instant { + // A fixed base plus an offset, rather than `Instant::now()` per + // call -- keeps every test's timing deterministic instead of at + // the mercy of how long the test itself took to run. + Instant::now() - Duration::from_secs(3600) + Duration::from_millis(ms) + } + + #[test] + fn small_jitter_stays_undecided() { + let mut a = DragArbiter::new(); + a.press_start(Vec2::new(0.0, 0.0), t(0), false); + assert_eq!(a.update(Vec2::new(1.0, 1.0), t(10)), DragOutcome::Undecided); + } + + #[test] + fn a_vertical_drag_pans_immediately() { + let mut a = DragArbiter::new(); + a.press_start(Vec2::new(0.0, 0.0), t(0), false); + assert_eq!( + a.update(Vec2::new(0.0, 20.0), t(10)), + DragOutcome::Pan(20.0) + ); + // Subsequent frames keep panning, by the delta since last frame. + assert_eq!( + a.update(Vec2::new(0.0, 35.0), t(20)), + DragOutcome::Pan(15.0) + ); + } + + #[test] + fn a_horizontal_drag_with_nothing_selected_does_not_select() { + let mut a = DragArbiter::new(); + a.press_start(Vec2::new(0.0, 0.0), t(0), false); + // Horizontal movement alone, with no prior selection, is not any + // of the three named gestures -- it stays undecided rather than + // guessing (it will resolve to a long-press-selection if the + // finger then stops moving, or nothing if it lifts). + assert_eq!( + a.update(Vec2::new(20.0, 0.0), t(10)), + DragOutcome::Undecided + ); + } + + #[test] + fn a_long_press_without_moving_starts_a_selection() { + let mut a = DragArbiter::new(); + a.press_start(Vec2::new(5.0, 5.0), t(0), false); + assert_eq!(a.update(Vec2::new(5.0, 5.0), t(10)), DragOutcome::Undecided); + assert_eq!( + a.update(Vec2::new(6.0, 5.0), t(LONG_PRESS.as_millis() as u64 + 1)), + DragOutcome::SelectStart + ); + } + + #[test] + fn after_a_long_press_any_further_drag_extends() { + let mut a = DragArbiter::new(); + a.press_start(Vec2::new(0.0, 0.0), t(0), false); + assert_eq!( + a.update(Vec2::new(0.0, 0.0), t(LONG_PRESS.as_millis() as u64 + 1)), + DragOutcome::SelectStart + ); + // Even a vertical move now extends the selection rather than + // panning -- once a selection has started, it owns the gesture + // until release. + assert_eq!( + a.update(Vec2::new(0.0, 40.0), t(600)), + DragOutcome::SelectExtend + ); + } + + #[test] + fn a_horizontal_drag_on_already_selected_text_extends_immediately() { + let mut a = DragArbiter::new(); + a.press_start(Vec2::new(0.0, 0.0), t(0), true); + assert_eq!( + a.update(Vec2::new(20.0, 2.0), t(10)), + DragOutcome::SelectExtend + ); + } + + #[test] + fn a_vertical_drag_still_pans_even_with_a_prior_selection() { + let mut a = DragArbiter::new(); + a.press_start(Vec2::new(0.0, 0.0), t(0), true); + assert_eq!( + a.update(Vec2::new(0.0, 20.0), t(10)), + DragOutcome::Pan(20.0) + ); + } + + #[test] + fn release_resets_to_idle() { + let mut a = DragArbiter::new(); + a.press_start(Vec2::new(0.0, 0.0), t(0), false); + a.update(Vec2::new(0.0, 20.0), t(10)); + a.release(); + assert_eq!( + a.update(Vec2::new(0.0, 999.0), t(20)), + DragOutcome::Undecided + ); + } +} diff --git a/iris/transcript-ui/src/lib.rs b/iris/transcript-ui/src/lib.rs index 07a8827..24baaa2 100644 --- a/iris/transcript-ui/src/lib.rs +++ b/iris/transcript-ui/src/lib.rs @@ -28,21 +28,20 @@ //! also knew how to make an HTTPS request would be untestable without a //! server and unable to be driven by `run-headless.sh` with synthetic rows. //! -//! **Known gap, real and diagnosed rather than untested**: a touch-drag -//! that starts on a row's rendered text currently begins a cross-row -//! *selection* (`row.rs`'s `CursorSense::click_or_drag()` on each row's -//! `TextEdit`), not a *scroll* of the list -- both want the same gesture -//! over the same screen region, and `core/src/sense.rs`'s `run_sensors` -//! gives the widget that registered it in the *inner* layer (a row's own -//! `TextEdit`, which calls `painter.child_layer()`, -//! `iris/src/widget/text/edit.rs:87`) first refusal every frame it is -//! pressed, not just the frame the press started. `List` itself scrolls -//! correctly when driven programmatically (I3's benchmark) and via the -//! mouse wheel (wired below, `CursorSense::Scroll`), but a real touch pan -//! starting on top of a message's own text is not currently reachable -- -//! see IRIS_TODO.md's dated entry and RUST.md's I5 box for what a fix -//! looks like (a press distance/time arbiter deciding pan vs. select -//! before either commits). +//! **Gap closed, 2026-09-05**: a touch-drag that starts on a row's +//! rendered text used to always begin a cross-row *selection* (`row.rs`'s +//! `CursorSense::click_or_drag()` on each row's `TextEdit`), never a +//! *scroll* of the list, because both wanted the same gesture over the +//! same screen region and `core/src/sense.rs`'s `run_sensors` gave the +//! widget in the *inner* layer (a row's own `TextEdit`) first refusal +//! every frame it was pressed. `row.rs` now routes every row's drag +//! through one shared `iris::sense::DragArbiter` +//! (`Selection::drag`, `selection.rs`), which decides pan vs. select the +//! way Android itself does -- see `DragArbiter`'s own doc and +//! `DECISIONS.md` for the exact rule. `List` scrolls correctly when +//! driven programmatically (I3's benchmark), via the mouse wheel (wired +//! below, `CursorSense::Scroll`), and now via a touch pan starting on a +//! row's own text too. pub mod composer; pub mod markdown; diff --git a/iris/transcript-ui/src/row.rs b/iris/transcript-ui/src/row.rs index a72efaa..c082397 100644 --- a/iris/transcript-ui/src/row.rs +++ b/iris/transcript-ui/src/row.rs @@ -20,7 +20,7 @@ use crate::markdown::render_markdown; use crate::selection::Selection; use client_core::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow}; use iris::prelude::*; -use std::{cell::RefCell, rc::Rc}; +use std::{cell::RefCell, rc::Rc, time::Instant}; /// The paragraph size every row's `TextEdit` is built at; markdown headings /// inside a row scale relative to a fixed set of sizes rather than this one @@ -100,11 +100,13 @@ fn tool_call_markdown(tool: &str, input: &str, output: &str) -> String { /// Build one `TextEdit` from a sender label plus markdown source, register /// it with `selection` under `key`, and wire the pointer handlers that -/// drive `Selection::begin`/`extend` -- shared by every row variant below, -/// since a selectable row is always "one TextEdit plus this wiring" -/// regardless of what folded it. +/// drive `Selection::drag` -- shared by every row variant below, since a +/// selectable row is always "one TextEdit plus this wiring" regardless of +/// what folded it. `list` is threaded through so that same drag can pan +/// the list instead of selecting, per `Selection::drag`'s own doc. fn build_text_row( rsc: &mut Rsc, + list: WeakWidget, selection: Rc>, key: RowKey, sender: Option<&str>, @@ -125,18 +127,24 @@ where selection.borrow_mut().register(key, field); field - .on(CursorSense::click_or_drag(), move |ctx, rsc| { - let sel = selection.clone(); - match ctx.data.sense { - CursorSense::PressStart(_) => { - sel.borrow_mut() - .begin(rsc, key, ctx.data.pos, ctx.data.size) - } - _ => sel - .borrow_mut() - .extend(rsc, key, ctx.data.pos, ctx.data.size), - } - }) + // `| CursorSense::unclick()` on top of the usual click-or-drag set + // -- the arbiter inside `Selection::drag` needs the release too, + // to go back to idle for the next press (`DragArbiter::release`). + .on( + CursorSense::click_or_drag() | CursorSense::unclick(), + move |ctx, rsc| { + selection.borrow_mut().drag( + rsc, + list, + key, + ctx.data.pos, + ctx.data.size, + ctx.data.cursor.pos, + ctx.data.sense, + Instant::now(), + ); + }, + ) .add(rsc); // `.add` (weak), not `.add_strong` -- `header` is about to be embedded @@ -165,6 +173,7 @@ where fn build_single( rsc: &mut Rsc, + list: WeakWidget, selection: Rc>, key: RowKey, item: &TranscriptItem, @@ -173,7 +182,7 @@ where Rsc::State: FocusHost, { let (sender, markdown_src) = item_content(item); - build_text_row(rsc, selection, key, sender, &markdown_src) + build_text_row(rsc, list, selection, key, sender, &markdown_src) } /// A run of adjacent tool calls: collapsed to a one-line summary by @@ -215,6 +224,7 @@ where fn build_content( rsc: &mut Rsc, + list: WeakWidget, selection: Rc>, key: RowKey, expanded: bool, @@ -225,11 +235,12 @@ where Rsc::State: FocusHost, { let text = if expanded { full } else { summary }; - build_text_row(rsc, selection, key, Some("Tools"), text) + build_text_row(rsc, list, selection, key, Some("Tools"), text) } let content = build_content( rsc, + list, selection.clone(), key, false, @@ -252,6 +263,7 @@ where *expanded.borrow_mut() = !was_expanded; let content = build_content( rsc, + list, selection.clone(), key, !was_expanded, @@ -279,7 +291,7 @@ where match row { FoldedRow::Single(item) => { let key = row_key(&item.key()); - (key, build_single(rsc, selection, key, item)) + (key, build_single(rsc, list, selection, key, item)) } FoldedRow::Tools(calls) => { let key = row_key(&calls[0].key()); diff --git a/iris/transcript-ui/src/selection.rs b/iris/transcript-ui/src/selection.rs index 499d0e6..c1c91d7 100644 --- a/iris/transcript-ui/src/selection.rs +++ b/iris/transcript-ui/src/selection.rs @@ -31,11 +31,16 @@ //! since that branch never goes through the approximation. use iris::prelude::*; -use std::collections::BTreeMap; +use std::{collections::BTreeMap, time::Instant}; pub struct Selection { rows: BTreeMap>, anchor: Option<(RowKey, Vec2)>, + /// One arbiter shared by every row's drag handler -- RUST.md's I5 + /// gesture conflict (a row's own `click_or_drag()` and a list-level + /// pan wanting the same touch gesture). See `drag` below, and + /// `iris::sense::DragArbiter`'s own doc for the decision itself. + arbiter: DragArbiter, } impl Default for Selection { @@ -49,6 +54,7 @@ impl Selection { Self { rows: BTreeMap::new(), anchor: None, + arbiter: DragArbiter::new(), } } @@ -133,6 +139,61 @@ impl Selection { } } + /// Whether any row currently has a non-empty selection -- what a fresh + /// press consults so `drag` knows whether an early horizontal move is + /// "start dragging the selection handle" rather than an ordinary tap. + fn has_selection(&self, ui: &mut impl UiRsc) -> bool { + self.rows + .values() + .any(|w| w.edit(ui).text.selected_text().is_some()) + } + + /// One row's `CursorSense::click_or_drag()` handler, for every row, + /// routes its raw pointer data through here rather than calling + /// `begin`/`extend` directly -- this is the single place that decides + /// whether the gesture pans `list` or extends a selection, so the + /// decision is made once per gesture rather than independently by + /// whichever row happens to be under the finger this frame (see + /// `DragArbiter`'s own doc for why one shared instance, not one per + /// row, is what makes that consistent as a drag crosses row + /// boundaries). + /// + /// `pos_row`/`size` are row-local, as `begin`/`extend` want; + /// `pos_window` is in window space, since a pan's delta has to stay + /// meaningful even when this frame's event landed on a different row + /// than the last one. + #[allow(clippy::too_many_arguments)] + pub fn drag( + &mut self, + ui: &mut impl UiRsc, + list: WeakWidget, + key: RowKey, + pos_row: Vec2, + size: Vec2, + pos_window: Vec2, + sense: CursorSense, + now: Instant, + ) { + let outcome = match sense { + CursorSense::PressStart(_) => { + let already_selected = self.has_selection(ui); + self.arbiter.press_start(pos_window, now, already_selected); + self.arbiter.update(pos_window, now) + } + CursorSense::PressEnd(_) => { + self.arbiter.release(); + return; + } + _ => self.arbiter.update(pos_window, now), + }; + match outcome { + DragOutcome::Undecided => {} + DragOutcome::Pan(dy) => list(ui).scroll(-dy), + DragOutcome::SelectStart => self.begin(ui, key, pos_row, size), + DragOutcome::SelectExtend => self.extend(ui, key, pos_row, size), + } + } + /// The concatenated selected text, in row order, `None` if nothing is /// selected -- what a copy command reads. Joins with a blank line /// between rows, matching how the transcript itself separates them.