Merge remote-tracking branch 'origin/rustify' into worktree-agent-afe80868604fef704

# Conflicts:
#	IRIS.md
#	RUST.md
This commit is contained in:
iris committed 2026-09-05 12:59:39 -04:00
commit 62199aa3a7
7 files changed
+485 -78

No files matched your search

+43
View File
@@ -30,6 +30,49 @@ let (screen, tree) = transcript_ui::build_tree(rsc, rows);
some_widget_ptr(rsc).set(tree); 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) ## 2026-09-05: `SpanStyle`, per-range text styling (RUST.md's I5)
A `TextBuffer` used to have exactly one style (`TextAttrs`: colour, size, A `TextBuffer` used to have exactly one style (`TextAttrs`: colour, size,
+19 -11
View File
@@ -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 `app/ui-sandbox.sh --delay` (this crate deliberately fetches nothing
itself, `transcript-ui/src/lib.rs`'s doc), a new cdylib + Gradle itself, `transcript-ui/src/lib.rs`'s doc), a new cdylib + Gradle
module, then the bench script pointed at it. module, then the bench script pointed at it.
- [ ] **Touch-drag panning over a row's own rendered text.** `row.rs` - [x] **Touch-drag panning over a row's own rendered text — done,
registers `CursorSense::click_or_drag()` on each row's `TextEdit` for 2026-09-05.** `row.rs` used to register `CursorSense::click_or_drag()`
cross-row selection; `TextEdit::draw`'s `painter.child_layer()` on each row's `TextEdit` for cross-row selection; `TextEdit::draw`'s
(`iris/src/widget/text/edit.rs:87`) means that registration wins `painter.child_layer()` (`iris/src/widget/text/edit.rs:87`) meant that
`core/src/sense.rs::run_sensors`'s per-layer arbitration on every registration won `core/src/sense.rs::run_sensors`'s per-layer
frame it is pressed, not just the frame the press started, so a list arbitration on every frame it was pressed, not just the frame the
pan gesture registered on `List` itself never gets a turn while a press started, so a list pan gesture registered on `List` itself never
row is under the finger. Fix: a small press distance/time arbiter got a turn while a row was under the finger. Fixed with
deciding pan vs. select before either commits, or gate text-drag- `iris::sense::DragArbiter` (recorded in `IRIS.md`), one small state
selection behind a long-press so an ordinary swipe always pans first. machine per list deciding pan vs. select the way Android does (a
`lib.rs`'s module doc has the full diagnosis. 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 - [ ] **Row-level accessibility names.** The composer carries
`.label("Message")`; transcript rows do not carry a `.label()` of `.label("Message")`; transcript rows do not carry a `.label()` of
their own yet, so `Widgets::named()` (I4) does not include them — their own yet, so `Widgets::named()` (I4) does not include them —
+77 -32
View File
@@ -36,12 +36,10 @@ session spending an afternoon on them again.
## Where things stand (2026-09-05) ## Where things stand (2026-09-05)
- **In flight, 2026-09-05 (session cleared mid-work, picked up again):** - **Both of the previous note's in-flight pieces are now done, 2026-09-05.**
the I5 touch-drag pan-vs-select gap, as a `DragArbiter` in Design choices for both are summarised in `DECISIONS.md` at the repo
`iris/src/sense.rs` wired into `transcript-ui`'s selection. Design root, which is the file Iris reads for choices made without her. Next:
choices are summarised in `DECISIONS.md` at the repo root, which is the I5's Android integration and the bench numbers.
file Iris reads for choices made without her. Next after it: I5's
Android integration and the bench numbers.
- **E4 done, 2026-09-05.** `iris/desktop-app`: a winit window with a - **E4 done, 2026-09-05.** `iris/desktop-app`: a winit window with a
session list beside `transcript-ui`'s screen (`build_tree`), against a session list beside `transcript-ui`'s screen (`build_tree`), against a
real `ai-server` through `client-core`, enrolled from the same 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 `app/ui-sandbox.sh` -- see E4's own box for the commands, the
screenshot, and a real streaming-duplication bug the screenshot found screenshot, and a real streaming-duplication bug the screenshot found
and a regression test now covers. 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 - **Done**: E0 (toolchain), E1 (Masonry on android-view, which found the
keyboard gap — now explained, see below), E2 (a transcript in Masonry, keyboard gap — now explained, see below), E2 (a transcript in Masonry,
which found that Masonry has no touch-scroll on Android at all — see 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 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 the one emulator here held by another session, but the real blocker is
that the integration work itself is unbuilt, not the emulator being 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 busy. Full accounting, every citation, and the dated IRIS_TODO.md items
reachable, for a specific, diagnosed reason (it competes with this box's are in I5's own box below. **Update, 2026-09-05, same day**: touch-drag
own row-level drag-select for the same gesture) rather than an absent panning over a row's own rendered text, which was not yet reachable for
primitive. Full accounting, every citation, and the dated a specific, diagnosed reason (it competed with this box's own
IRIS_TODO.md items are in I5's own box below. 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** - **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 (`android-shell/` — a JNI-bridge crate on `client-core` — plus a new
Gradle module `app/shellApp/`, left deliberately separate from 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 include it) -- a small, real gap, recorded as an IRIS_TODO.md
item rather than silently left, since AGENTS.md's bench scripts item rather than silently left, since AGENTS.md's bench scripts
depend on exactly this for driving a screen by name. depend on exactly this for driving a screen by name.
7. **Measurable frames / the render-number pass condition -- not 7. **Measurable frames / the render-number pass condition -- the
attempted, and unlike E2 the reason is not an absent gesture gesture-conflict half is now fixed (2026-09-05); the emulator
path.** `List` demonstrably scrolls (I3's flat draws/moves, half is still not attempted, and unlike E2 that's not an absent
programmatic `scroll()`) and mouse-wheel scrolling is wired here gesture path.** `List` demonstrably scrolls (I3's flat
(`lib.rs`'s `CursorSense::Scroll` on `list`). What is *not* draws/moves, programmatic `scroll()`) and mouse-wheel scrolling
reachable yet is a **touch-drag pan starting on a row's own is wired here (`lib.rs`'s `CursorSense::Scroll` on `list`). What
text**: `row.rs` registers `CursorSense::click_or_drag()` on each was *not* reachable at first was a **touch-drag pan starting on a
row's `TextEdit` for selection, and `TextEdit::draw` calls row's own text**: `row.rs` registered `CursorSense::
`painter.child_layer()` (`iris/src/widget/text/edit.rs:87`), so click_or_drag()` on each row's `TextEdit` for selection, and
`core/src/sense.rs`'s `run_sensors` (which stops at the first `TextEdit::draw` calls `painter.child_layer()`
layer, checked innermost-first, that consumed the gesture) gives (`iris/src/widget/text/edit.rs:87`), so `core/src/sense.rs`'s
that row first refusal on *every* frame it is pressed, not just `run_sensors` (which stops at the first layer, checked
the frame the press started -- a row's drag-select wins the same innermost-first, that consumed the gesture) gave that row first
gesture a list-level pan would want. `lib.rs`'s own module doc refusal on *every* frame it was pressed, not just the frame the
states this precisely, with the fix named (a press distance/time press started -- a row's drag-select won the same gesture a
arbiter deciding pan vs. select before either commits, or gating list-level pan would want. This is a genuine, diagnosed
text-drag-selection behind a long-press). This is a genuine, architecture gap this box's *own* two features created by both
diagnosed architecture gap this box's *own* two features created wanting the same gesture -- not a missing primitive the way
by both wanting the same gesture -- not a missing primitive the Masonry's absent `on_pointer_event` drag handling was.
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):** **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 own right -- closer in size to E2/E3 than to "run one more
script" -- not something this pass's remaining time could script" -- not something this pass's remaining time could
responsibly rush and still report honestly. 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. - **Row-level accessibility names** -- behaviour 6 above.
- **A tappable link and a code-span background chip** -- behaviour 2. - **A tappable link and a code-span background chip** -- behaviour 2.
- **`Selection`'s anchor-row shortcut** -- behaviour 1. - **`Selection`'s anchor-row shortcut** -- behaviour 1.
+239
View File
@@ -2,6 +2,7 @@ use crate::prelude::*;
use std::{ use std::{
ops::{BitOr, Deref, DerefMut}, ops::{BitOr, Deref, DerefMut},
rc::Rc, rc::Rc,
time::{Duration, Instant},
}; };
#[derive(Clone, Copy, PartialEq)] #[derive(Clone, Copy, PartialEq)]
@@ -357,3 +358,241 @@ impl BitOr<CursorSense> for CursorSenses {
self 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
);
}
}
+14 -15
View File
@@ -28,21 +28,20 @@
//! also knew how to make an HTTPS request would be untestable without a //! 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. //! server and unable to be driven by `run-headless.sh` with synthetic rows.
//! //!
//! **Known gap, real and diagnosed rather than untested**: a touch-drag //! **Gap closed, 2026-09-05**: a touch-drag that starts on a row's
//! that starts on a row's rendered text currently begins a cross-row //! rendered text used to always begin a cross-row *selection* (`row.rs`'s
//! *selection* (`row.rs`'s `CursorSense::click_or_drag()` on each row's //! `CursorSense::click_or_drag()` on each row's `TextEdit`), never a
//! `TextEdit`), not a *scroll* of the list -- both want the same gesture //! *scroll* of the list, because both wanted the same gesture over the
//! over the same screen region, and `core/src/sense.rs`'s `run_sensors` //! same screen region and `core/src/sense.rs`'s `run_sensors` gave the
//! gives the widget that registered it in the *inner* layer (a row's own //! widget in the *inner* layer (a row's own `TextEdit`) first refusal
//! `TextEdit`, which calls `painter.child_layer()`, //! every frame it was pressed. `row.rs` now routes every row's drag
//! `iris/src/widget/text/edit.rs:87`) first refusal every frame it is //! through one shared `iris::sense::DragArbiter`
//! pressed, not just the frame the press started. `List` itself scrolls //! (`Selection::drag`, `selection.rs`), which decides pan vs. select the
//! correctly when driven programmatically (I3's benchmark) and via the //! way Android itself does -- see `DragArbiter`'s own doc and
//! mouse wheel (wired below, `CursorSense::Scroll`), but a real touch pan //! `DECISIONS.md` for the exact rule. `List` scrolls correctly when
//! starting on top of a message's own text is not currently reachable -- //! driven programmatically (I3's benchmark), via the mouse wheel (wired
//! see IRIS_TODO.md's dated entry and RUST.md's I5 box for what a fix //! below, `CursorSense::Scroll`), and now via a touch pan starting on a
//! looks like (a press distance/time arbiter deciding pan vs. select //! row's own text too.
//! before either commits).
pub mod composer; pub mod composer;
pub mod markdown; pub mod markdown;
+31 -19
View File
@@ -20,7 +20,7 @@ use crate::markdown::render_markdown;
use crate::selection::Selection; use crate::selection::Selection;
use client_core::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow}; use client_core::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
use iris::prelude::*; 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 /// 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 /// 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 /// Build one `TextEdit` from a sender label plus markdown source, register
/// it with `selection` under `key`, and wire the pointer handlers that /// it with `selection` under `key`, and wire the pointer handlers that
/// drive `Selection::begin`/`extend` -- shared by every row variant below, /// drive `Selection::drag` -- shared by every row variant below, since a
/// since a selectable row is always "one TextEdit plus this wiring" /// selectable row is always "one TextEdit plus this wiring" regardless of
/// regardless of what folded it. /// 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: HasEvents>( fn build_text_row<Rsc: HasEvents>(
rsc: &mut Rsc, rsc: &mut Rsc,
list: WeakWidget<List>,
selection: Rc<RefCell<Selection>>, selection: Rc<RefCell<Selection>>,
key: RowKey, key: RowKey,
sender: Option<&str>, sender: Option<&str>,
@@ -125,18 +127,24 @@ where
selection.borrow_mut().register(key, field); selection.borrow_mut().register(key, field);
field field
.on(CursorSense::click_or_drag(), move |ctx, rsc| { // `| CursorSense::unclick()` on top of the usual click-or-drag set
let sel = selection.clone(); // -- the arbiter inside `Selection::drag` needs the release too,
match ctx.data.sense { // to go back to idle for the next press (`DragArbiter::release`).
CursorSense::PressStart(_) => { .on(
sel.borrow_mut() CursorSense::click_or_drag() | CursorSense::unclick(),
.begin(rsc, key, ctx.data.pos, ctx.data.size) move |ctx, rsc| {
} selection.borrow_mut().drag(
_ => sel rsc,
.borrow_mut() list,
.extend(rsc, key, ctx.data.pos, ctx.data.size), key,
} ctx.data.pos,
}) ctx.data.size,
ctx.data.cursor.pos,
ctx.data.sense,
Instant::now(),
);
},
)
.add(rsc); .add(rsc);
// `.add` (weak), not `.add_strong` -- `header` is about to be embedded // `.add` (weak), not `.add_strong` -- `header` is about to be embedded
@@ -165,6 +173,7 @@ where
fn build_single<Rsc: HasEvents>( fn build_single<Rsc: HasEvents>(
rsc: &mut Rsc, rsc: &mut Rsc,
list: WeakWidget<List>,
selection: Rc<RefCell<Selection>>, selection: Rc<RefCell<Selection>>,
key: RowKey, key: RowKey,
item: &TranscriptItem, item: &TranscriptItem,
@@ -173,7 +182,7 @@ where
Rsc::State: FocusHost, Rsc::State: FocusHost,
{ {
let (sender, markdown_src) = item_content(item); 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 /// A run of adjacent tool calls: collapsed to a one-line summary by
@@ -215,6 +224,7 @@ where
fn build_content<Rsc: HasEvents>( fn build_content<Rsc: HasEvents>(
rsc: &mut Rsc, rsc: &mut Rsc,
list: WeakWidget<List>,
selection: Rc<RefCell<Selection>>, selection: Rc<RefCell<Selection>>,
key: RowKey, key: RowKey,
expanded: bool, expanded: bool,
@@ -225,11 +235,12 @@ where
Rsc::State: FocusHost, Rsc::State: FocusHost,
{ {
let text = if expanded { full } else { summary }; 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( let content = build_content(
rsc, rsc,
list,
selection.clone(), selection.clone(),
key, key,
false, false,
@@ -252,6 +263,7 @@ where
*expanded.borrow_mut() = !was_expanded; *expanded.borrow_mut() = !was_expanded;
let content = build_content( let content = build_content(
rsc, rsc,
list,
selection.clone(), selection.clone(),
key, key,
!was_expanded, !was_expanded,
@@ -279,7 +291,7 @@ where
match row { match row {
FoldedRow::Single(item) => { FoldedRow::Single(item) => {
let key = row_key(&item.key()); let key = row_key(&item.key());
(key, build_single(rsc, selection, key, item)) (key, build_single(rsc, list, selection, key, item))
} }
FoldedRow::Tools(calls) => { FoldedRow::Tools(calls) => {
let key = row_key(&calls[0].key()); let key = row_key(&calls[0].key());
+62 -1
View File
@@ -31,11 +31,16 @@
//! since that branch never goes through the approximation. //! since that branch never goes through the approximation.
use iris::prelude::*; use iris::prelude::*;
use std::collections::BTreeMap; use std::{collections::BTreeMap, time::Instant};
pub struct Selection { pub struct Selection {
rows: BTreeMap<RowKey, WeakWidget<TextEdit>>, rows: BTreeMap<RowKey, WeakWidget<TextEdit>>,
anchor: Option<(RowKey, Vec2)>, 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 { impl Default for Selection {
@@ -49,6 +54,7 @@ impl Selection {
Self { Self {
rows: BTreeMap::new(), rows: BTreeMap::new(),
anchor: None, 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<List>,
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 /// The concatenated selected text, in row order, `None` if nothing is
/// selected -- what a copy command reads. Joins with a blank line /// selected -- what a copy command reads. Joins with a blank line
/// between rows, matching how the transcript itself separates them. /// between rows, matching how the transcript itself separates them.