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

+239
View File
@@ -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<CursorSense> 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
);
}
}
+14 -15
View File
@@ -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;
+31 -19
View File
@@ -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: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<List>,
selection: Rc<RefCell<Selection>>,
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: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<List>,
selection: Rc<RefCell<Selection>>,
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: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<List>,
selection: Rc<RefCell<Selection>>,
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());
+62 -1
View File
@@ -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<RowKey, WeakWidget<TextEdit>>,
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<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
/// selected -- what a copy command reads. Joins with a blank line
/// between rows, matching how the transcript itself separates them.