Merge remote-tracking branch 'origin/rustify' into worktree-agent-afe80868604fef704
# Conflicts: # IRIS.md # RUST.md
This commit is contained in:
commit
62199aa3a7
7 files changed
+485
-78
No files matched your search
@@ -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
@@ -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 —
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in new issue
Block a user