iris: generalize drag arbitration into a default-input DragGesture, with pointer capture and Drop

Iris asked (2026-09-06) that dragging be part of iris's default input
system rather than duplicated per app: "anything that provides good
performance and can be generalized well is part of iris rather than the
app." DragArbiter and VelocityTracker (both already in iris::sense) are
now bundled into a new DragGesture, which also takes exclusive pointer
capture (UiRenderState::capture_pointer/release_pointer/captured_pointer)
the moment a gesture commits to panning or selecting, and delivers a new
CursorSense::Drop -- not PressEnd -- to the captured widget when the
button lifts, wherever on screen that happens to be.

This directly targets the phone bench's "finger flings do nothing":
per-widget hit testing silently drops a gesture the instant the pointer
moves off every registered region, which a fast pan/fling does routinely
(crossing several virtualised rows, or ending off the loaded content
entirely) -- so PressEnd, and the velocity/fling-start decision hanging
off it, was frequently never delivered at all. Capture targets List's own
stable id (List::key_at resolves the row-under-pointer from its
extents), not a row's, since List retires rows mid-drag as content
scrolls.

transcript-ui::Selection::drag now only decides pan-vs-select from
DragGesture's outcome; row.rs's per-row registration is only ever a
gesture's first frame, with lib.rs registering the List-level
continuation once. New tests: sense_tests.rs's two pointer-capture
regressions, list.rs's replacing_the_last_row_many_times_does_not_leak_primitives
(a P0 stale-primitives diagnostic -- passes, pinning the widget-arena
layer as not the leak). MainActivity.java opts into edge-to-edge
(Window::setDecorFitsSystemWindows(false), API 30+, no new dependency)
so window insets are redelivered on every change including a pure IME
toggle -- the named-but-untried fix for the phone bench's "keyboard:
could not be shown" and the emulator's identical non-confirmation.

cargo fmt/clippy/test clean across the iris workspace.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-06 02:10:48 -04:00
1 parent 71a3fae655
commit e12c708246
8 files changed
+571 -86

No files matched your search

+44
View File
@@ -20,6 +20,21 @@ pub struct UiRenderState {
resized: bool,
draw_started: HashSet<WidgetId>,
/// The widget currently holding exclusive pointer input, if any --
/// `iris::sense::SensorUi::run_sensors` reads and clears this every
/// call. Interior mutability (a `Mutex`, not a bare `Cell`, since a
/// `CursorData` reaching this through an async `task_on` handler needs
/// `Send`/`Sync`) because `run_sensors` takes `&self` (widgets are
/// dispatched to, not owned, at that layer) and this render state is
/// the one structure both backends (winit, android-view) already hold
/// across frames, the same way `old_root`/`resized` are -- see
/// `iris::sense`'s pointer-capture doc for why a drag needs this: once
/// a gesture has committed to panning or selecting, every later sample
/// of it must reach the same widget even if the finger has moved off
/// whatever hit region first noticed the press. Never held across an
/// await or another lock -- every access here is a single get/set.
captured: std::sync::Mutex<Option<WidgetId>>,
/// `Widget::draw` calls and `Primitives::region_mut` rewrites since the
/// last `take_counters`. LAYOUT.md section 8's pass conditions are
/// stated in terms of these two: an unchanged frame must cost 0 of
@@ -45,6 +60,7 @@ impl UiRenderState {
old_root: None,
resized: false,
draw_started: Default::default(),
captured: Default::default(),
draw_count: 0,
region_mut_count: 0,
mov_count: 0,
@@ -357,6 +373,13 @@ impl UiRenderState {
active.textures.clear();
rsc.ui_mut().textures.free();
if undraw {
// A captured widget that goes away mid-gesture (List's
// virtualisation retiring a row, a rebuild) must not leave
// the pointer permanently captured by an id nothing will
// ever draw again -- `captured`'s own path out.
if *self.captured.lock().unwrap() == Some(id) {
*self.captured.lock().unwrap() = None;
}
// Permanent removal: retire this widget's own move slot
// (the self-ownership ref taken when it was allocated) and
// the up-link ref it held on its parent's slot -- read from
@@ -429,6 +452,27 @@ impl UiRenderState {
self.active.len()
}
/// Give `id` exclusive pointer input from the next `run_sensors` call
/// on -- see `captured`'s field doc. Overwrites any previous capture
/// (a gesture that starts a new one has already decided the old one
/// is over).
pub fn capture_pointer(&self, id: WidgetId) {
*self.captured.lock().unwrap() = Some(id);
}
/// Release exclusive pointer input, if any is held -- called once
/// `run_sensors` has delivered the terminal `Drop` to the capturing
/// widget, or by that widget itself if it decides the gesture is over
/// some other way.
pub fn release_pointer(&self) {
*self.captured.lock().unwrap() = None;
}
/// The widget currently holding exclusive pointer input, if any.
pub fn captured_pointer(&self) -> Option<WidgetId> {
*self.captured.lock().unwrap()
}
pub fn debug(&self, widgets: &Widgets, label: &str) -> impl Iterator<Item = &ActiveData> {
self.active.iter().filter_map(move |(&id, inst)| {
let l = widgets.label(id);