Skip to content

Commit 35b9fae

Browse files
author
Roy Lin
committed
feat: route self-drawn move interactions
1 parent 617bedd commit 35b9fae

13 files changed

Lines changed: 698 additions & 18 deletions

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -294,10 +294,10 @@ independently.
294294
exposure, published only after host commit, and discarded on rejection
295295
- retained resize, fractional-scale, damage, occlusion, redraw, delayed
296296
acknowledgement, and surface-loss replay without changing semantic identity
297-
- raw pointer, keyboard, Tab-focus, hover, press, cancellation, wheel, and
298-
scheduled long-press routing over `PlatformElementId`; event-loop deadlines,
299-
release-time fallback, stable action bubbling, and reducer rollback share one
300-
staged interaction session
297+
- raw pointer, keyboard, Tab-focus, hover, press, cancellation, wheel,
298+
scheduled long press, and incremental captured move routing over
299+
`PlatformElementId`; event-loop deadlines, release-time fallback, stable
300+
action bubbling, and reducer rollback share one staged interaction session
301301
- an identical-frame fast path that performs no layout, scene, host, or
302302
presentation work, plus semantic-only commits that skip pixel presentation
303303
- a software Graphics presenter and interactive shared
@@ -315,7 +315,7 @@ independently.
315315
| M3 · Layout and Scene | Current | Generic calculator rectangle slice landed; full flex, stacking, redraw scheduling, cross-platform fingerprints, and thin-host presentation remain |
316316
| M4 · Text and interaction cutover | Planned | Shaping, glyphs, GUI-owned input, IME, accessibility bridges, overlays, and complete calculator scenarios |
317317
| M5 · Default cutover | Planned | Make self-drawn content the default, then delete the three legacy widget renderers |
318-
| H0-H5 · Thin platform hosts | H0 complete; H1 in progress | Atomic frames, lifecycle recovery, stable-id raw input/reducers, scheduled long press, zero-toolkit firewalls, and an interactive calculator landed; the Graphics raw-surface edge remains |
318+
| H0-H5 · Thin platform hosts | H0 complete; H1 in progress | Atomic frames, lifecycle recovery, stable-id raw input/reducers, scheduled long press, captured move, zero-toolkit firewalls, and an interactive calculator landed; the Graphics raw-surface edge remains |
319319
| T0-T5 · TSX native authoring | Proposed | Automatic JSX runtime, versioned Node-to-host session, state/event runtime, self-drawn native window, packages, and stable SDK |
320320
| M6-M8 · React Aria components | Catalog pinned; conformance planned | 51/51 families mapped, eight public parts explicitly missing, Button scene smoke only; full software and three-OS self-drawn evidence required |
321321

docs/platform-hosts.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,9 @@ src/platform_runtime/
213213
|- input.rs pointer capture, hover, press, and cancellation
214214
|- keyboard_input.rs keyboard activation, Tab focus, and wheel routing
215215
|- long_press_input.rs event-loop deadlines and terminal hold recognition
216+
|- move_input.rs captured incremental pointer and keyboard movement
217+
|- long_press_tests.rs scheduled hold ordering and recovery gates
218+
|- move_tests.rs move capture, identity, rollback, and reconcile gates
216219
|- presenter.rs raw-surface prepare/publish contract and recorder
217220
|- reference_presenter.rs
218221
| transactional software Graphics evidence
@@ -371,10 +374,14 @@ Landed evidence:
371374
deadline, cancels and restarts across pointer boundaries, falls back to
372375
release-time recognition, and routes the terminal action through the same
373376
rollback-aware reducer path
377+
- callback-driven and style-only move starts on the first non-zero pointer
378+
delta, remains captured outside its hit region, retains incremental delta and
379+
pointer identity, and ends on release, cancellation, or terminal long press;
380+
arrow keys route a handled one-unit lifecycle through the same action batch
374381
- reducer errors restore the staged interaction state and sequence before the
375382
event is exposed as successful; successful frame reconciliation preserves
376383
focused stable ids, while rejected frames do not touch them
377-
- 26 focused runtime/software tests and four recursive feature/source
384+
- 33 focused runtime/software tests and four recursive feature/source
378385
firewall tests pass without any legacy renderer or OS toolkit dependency
379386
- `self_drawn_calculator` reproduces layout fingerprint
380387
`16529597026056060935`, scene fingerprint `2100550662756266801`, and

docs/react-aria-native.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,9 @@ The first shared interaction milestone is available in the portable runtime:
123123

124124
- `SelfDrawnWindowRuntime` builds its hit/focus/action index from the atomically
125125
committed Native IR and layout snapshot. Raw pointer, keyboard, Tab-focus,
126-
hover, press, cancellation, wheel, scheduled long press, bubbling, and
127-
reducer dispatch retain stable `PlatformElementId` identity without
128-
importing a widget blueprint or platform content-control runtime.
126+
hover, press, cancellation, wheel, scheduled long press, incremental move,
127+
bubbling, and reducer dispatch retain stable `PlatformElementId` identity
128+
without importing a widget blueprint or platform content-control runtime.
129129
- `SelfDrawnActionInvocation` carries the committed frame revision, monotonic
130130
event sequence, target/current-target pair, modality and input context, plus
131131
static action payloads. Reducer failure restores the staged interaction
@@ -135,6 +135,11 @@ The first shared interaction milestone is available in the portable runtime:
135135
event-loop timer without exporting a toolkit timer into portable state.
136136
Deadline recognition and release-time fallback share the same ordering and
137137
reducer rollback, while pointer leave/re-entry cancels and restarts the hold.
138+
- Self-drawn move routing starts on the first non-zero delta, keeps the
139+
initiating pointer captured outside its original hit region, preserves
140+
incremental position across keyed frames, and reference-counts concurrent
141+
pointer state. Release, cancellation, and terminal long press end the move;
142+
arrow keys emit a handled one-unit lifecycle before `KeyDown`.
138143

139144
- `NativeInputModality` represents keyboard, mouse, touch, pen, virtual, and
140145
unknown input.
@@ -568,7 +573,7 @@ props:
568573
| Priority | Area | Required outcome |
569574
| --- | --- | --- |
570575
| P0 | Self-drawn component accounting | Keep all 51 React Aria 1.19.0 families in the executable matrix, implement the eight recorded public-part gaps, and require every upstream catalog delta to update code, matrix, tests, and milestones together. |
571-
| P0 | Shared self-drawn interaction | Extend the landed stable-id pointer, keyboard, Tab-focus, hover, press, scheduled generic long press, cancellation, wheel, bubbling, and reducer path with move/drag, collection long-press selection mode, focus-scope restoration, overlay gestures, text editing/IME, and accessibility activation. |
576+
| P0 | Shared self-drawn interaction | Extend the landed stable-id pointer, keyboard, Tab-focus, hover, press, scheduled generic long press, incremental captured move, cancellation, wheel, bubbling, and reducer path with drag-and-drop source/target negotiation, collection long-press selection mode, focus-scope restoration, overlay gestures, text editing/IME, and accessibility activation. |
572577
| P0 | Native input conformance | WinUI's complete 98-case V1 manifest passes real OS automation. Populate the AppKit and GTK4 manifests with platform-run mouse, pen, touch where applicable, keyboard, assistive activation, disabled, cancellation, and keyed-rerender fixtures for every role currently marked native; then close or retain evidence-backed menu/item exceptions. |
573578
| P1 | Event propagation | Add platform-run conformance fixtures for conditional `Stop`/`Continue` across nested native controls. |
574579
| P1 | Focus management | Add platform-run conformance fixtures for post-mount `autoFocus`, nested containment, and restoration. |

docs/roadmap.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -490,10 +490,14 @@ Landed evidence:
490490
callback-driven targets, resets across pointer leave/re-entry, recognizes at
491491
the deadline or on release as a scheduling fallback, and atomically emits
492492
`LongPressEnd`, `PressCancel`, then terminal `LongPress`
493+
- move starts only after the first non-zero pointer delta, stays captured
494+
outside the original hit region, preserves initiating pointer identity and
495+
incremental deltas across keyed frames, reference-counts concurrent moves,
496+
and shares reducer rollback; arrow keys emit a handled one-unit lifecycle
493497
- the shared 410x620 calculator preserves its reviewed layout and scene
494498
fingerprints, routes eight fake-host events through four reducer actions,
495499
commits the resulting frames, and reaches display value `10`
496-
- 26 focused runtime/software tests plus four recursive H1 firewall tests are
500+
- 33 focused runtime/software tests plus four recursive H1 firewall tests are
497501
included in `just verify`
498502

499503
Remaining H1 work:
@@ -502,8 +506,9 @@ Remaining H1 work:
502506
pinned Graphics commit `8748fab` owns only a surface-independent texture and
503507
readback today, so its safe host-owned surface attachment/recovery contract
504508
must land before GUI can implement this edge without duplicating `wgpu`
505-
- richer move/drag, text editing, IME, overlay gestures, and
506-
component-specific interaction conformance remain explicit M4 and M6-M8 work
509+
- drag-and-drop source/target negotiation, text editing, IME, overlay gestures,
510+
and component-specific interaction conformance remain explicit M4 and M6-M8
511+
work
507512

508513
Gates:
509514

src/platform_runtime/input.rs

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ use crate::platform_host::{
66
};
77

88
use super::interaction::{
9-
ActivePress, LastClick, LongPressTracking, PointerInteraction, RoutedSemanticEvent,
10-
SelfDrawnEventContext, SelfDrawnInputDispatch, SelfDrawnInteractionChange,
9+
ActivePress, LastClick, LongPressTracking, PointerInteraction, PointerMoveTracking,
10+
RoutedSemanticEvent, SelfDrawnEventContext, SelfDrawnInputDispatch, SelfDrawnInteractionChange,
1111
SelfDrawnInteractionSession,
1212
};
1313
use super::interaction_tree::SelfDrawnInteractionTree;
@@ -81,6 +81,20 @@ impl SelfDrawnInteractionSession {
8181

8282
match event.phase {
8383
PlatformPointerPhase::Entered | PlatformPointerPhase::Moved => {
84+
if event.phase == PlatformPointerPhase::Moved {
85+
if let Some(active) = pointer.active_press.as_mut() {
86+
if self.update_active_move(
87+
active,
88+
tree,
89+
frame_revision,
90+
event_sequence,
91+
&context,
92+
&mut routed,
93+
) {
94+
routed.target = Some(active.target.clone());
95+
}
96+
}
97+
}
8498
self.transition_hover(
8599
&mut pointer,
86100
hit_target.clone(),
@@ -161,6 +175,12 @@ impl SelfDrawnInteractionSession {
161175
);
162176
let long_press_threshold_micros =
163177
tree.long_press_threshold_micros(&target, context.modality);
178+
let movement =
179+
tree.tracks_movement(&target)
180+
.then_some(PointerMoveTracking {
181+
last_position: event.position,
182+
did_move: false,
183+
});
164184
let long_press = long_press_threshold_micros.map(|threshold| {
165185
let mut long_press_context = context.clone();
166186
long_press_context.click_count = click_count;
@@ -201,6 +221,7 @@ impl SelfDrawnInteractionSession {
201221
long_press_threshold_micros,
202222
long_press,
203223
long_press_recognized: false,
224+
movement,
204225
});
205226
routed.target = Some(target);
206227
}
@@ -229,6 +250,17 @@ impl SelfDrawnInteractionSession {
229250
event_sequence,
230251
&mut routed,
231252
);
253+
if !recognized_now && !active.long_press_recognized {
254+
context.click_count = active.click_count;
255+
self.end_active_move(
256+
&mut active,
257+
tree,
258+
frame_revision,
259+
event_sequence,
260+
&context,
261+
&mut routed,
262+
);
263+
}
232264
if !recognized_now
233265
&& !active.long_press_recognized
234266
&& over_target
@@ -494,11 +526,19 @@ impl SelfDrawnInteractionSession {
494526
context: &SelfDrawnEventContext,
495527
routed: &mut RoutedInput,
496528
) {
529+
let mut context = context.clone();
530+
context.click_count = active.click_count;
531+
self.end_active_move(
532+
&mut active,
533+
tree,
534+
frame_revision,
535+
event_sequence,
536+
&context,
537+
routed,
538+
);
497539
if active.long_press_recognized {
498540
return;
499541
}
500-
let mut context = context.clone();
501-
context.click_count = active.click_count;
502542
self.end_active_long_press(
503543
&mut active,
504544
tree,

src/platform_runtime/interaction.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ pub struct SelfDrawnElementInteraction {
207207
pub hovered: bool,
208208
pub pressed: bool,
209209
pub long_pressed: bool,
210+
pub moving: bool,
210211
pub focused: bool,
211212
pub focus_visible: bool,
212213
pub focus_within: bool,
@@ -257,6 +258,7 @@ pub(super) struct SelfDrawnInteractionSession {
257258
pub(super) hover_counts: BTreeMap<PlatformElementId, u32>,
258259
pub(super) pressed_counts: BTreeMap<PlatformElementId, u32>,
259260
pub(super) long_pressed_counts: BTreeMap<PlatformElementId, u32>,
261+
pub(super) moving_counts: BTreeMap<PlatformElementId, u32>,
260262
pub(super) event_sequence: u64,
261263
}
262264

@@ -275,6 +277,7 @@ pub(super) struct ActivePress {
275277
pub(super) long_press_threshold_micros: Option<u64>,
276278
pub(super) long_press: Option<LongPressTracking>,
277279
pub(super) long_press_recognized: bool,
280+
pub(super) movement: Option<PointerMoveTracking>,
278281
}
279282

280283
#[derive(Debug, Clone)]
@@ -283,6 +286,12 @@ pub(super) struct LongPressTracking {
283286
pub(super) context: SelfDrawnEventContext,
284287
}
285288

289+
#[derive(Debug, Clone)]
290+
pub(super) struct PointerMoveTracking {
291+
pub(super) last_position: PlatformPoint,
292+
pub(super) did_move: bool,
293+
}
294+
286295
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
287296
pub(super) struct KeyboardPressKey {
288297
pub(super) device: PlatformInputDeviceId,
@@ -347,6 +356,7 @@ impl SelfDrawnInteractionSession {
347356
self.rebuild_hover_counts();
348357
self.rebuild_pressed_counts();
349358
self.rebuild_long_pressed_counts();
359+
self.rebuild_moving_counts();
350360
if let Some(focused) = &self.focused {
351361
if let Some(state) = self.states.get_mut(focused) {
352362
state.focused = true;
@@ -560,6 +570,19 @@ impl SelfDrawnInteractionSession {
560570
}
561571
}
562572

573+
pub(super) fn begin_move(
574+
&mut self,
575+
id: &PlatformElementId,
576+
changes: &mut Vec<SelfDrawnInteractionChange>,
577+
) {
578+
let count = self.moving_counts.entry(id.clone()).or_default();
579+
let was_zero = *count == 0;
580+
*count = count.saturating_add(1);
581+
if was_zero {
582+
self.change_state(id, changes, |state| state.moving = true);
583+
}
584+
}
585+
563586
pub(super) fn end_hover(
564587
&mut self,
565588
id: &PlatformElementId,
@@ -605,6 +628,21 @@ impl SelfDrawnInteractionSession {
605628
}
606629
}
607630

631+
pub(super) fn end_move(
632+
&mut self,
633+
id: &PlatformElementId,
634+
changes: &mut Vec<SelfDrawnInteractionChange>,
635+
) {
636+
let Some(count) = self.moving_counts.get_mut(id) else {
637+
return;
638+
};
639+
*count = count.saturating_sub(1);
640+
if *count == 0 {
641+
self.moving_counts.remove(id);
642+
self.change_state(id, changes, |state| state.moving = false);
643+
}
644+
}
645+
608646
pub(super) fn next_interaction_deadline_micros(&self) -> Option<u64> {
609647
self.pointers
610648
.values()
@@ -701,6 +739,30 @@ impl SelfDrawnInteractionSession {
701739
}
702740
}
703741
}
742+
743+
fn rebuild_moving_counts(&mut self) {
744+
self.moving_counts.clear();
745+
for press in self
746+
.pointers
747+
.values()
748+
.filter_map(|pointer| pointer.active_press.as_ref())
749+
.filter(|press| {
750+
press
751+
.movement
752+
.as_ref()
753+
.is_some_and(|movement| movement.did_move)
754+
})
755+
{
756+
*self.moving_counts.entry(press.target.clone()).or_default() += 1;
757+
}
758+
for (id, count) in &self.moving_counts {
759+
if *count > 0 {
760+
if let Some(state) = self.states.get_mut(id) {
761+
state.moving = true;
762+
}
763+
}
764+
}
765+
}
704766
}
705767

706768
fn is_false(value: &bool) -> bool {

src/platform_runtime/interaction_tree.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ pub(super) struct SelfDrawnInteractionNode {
2727
pub(super) tab_index: i32,
2828
pub(super) tree_order: usize,
2929
tracks_pointer_interaction: bool,
30+
tracks_movement: bool,
3031
long_press_mode: SelfDrawnLongPressMode,
3132
long_press_threshold_micros: u64,
3233
}
@@ -189,6 +190,11 @@ impl SelfDrawnInteractionTree {
189190
accepted.then_some(node.long_press_threshold_micros)
190191
}
191192

193+
pub(super) fn tracks_movement(&self, target: &PlatformElementId) -> bool {
194+
self.node(target)
195+
.is_some_and(|node| node.available && node.tracks_movement)
196+
}
197+
192198
fn interaction_target(&self, target: &PlatformElementId) -> Option<PlatformElementId> {
193199
self.ancestors_inclusive(target).into_iter().find(|id| {
194200
self.node(id).is_some_and(|node| {
@@ -224,8 +230,19 @@ impl SelfDrawnInteractionTree {
224230
available && !is_focus_scope(&props) && role_is_focusable(element.role, &props);
225231
let interaction_requirements = interaction_requirements_from_web(&props.web);
226232
let style_requires_long_press = interaction_requirements.long_press;
233+
let has_move_event = ["onMoveStart", "onMove", "onMoveEnd"]
234+
.into_iter()
235+
.any(|name| {
236+
props
237+
.web
238+
.events
239+
.get(name)
240+
.is_some_and(|action| !action.is_empty())
241+
});
242+
let tracks_movement = interaction_requirements.movement || has_move_event;
227243
let tracks_pointer_interaction = interaction_requirements.press
228244
|| interaction_requirements.long_press
245+
|| tracks_movement
229246
|| interaction_requirements.hover;
230247
let has_long_press_event = ["onLongPressStart", "onLongPressEnd", "onLongPress"]
231248
.into_iter()
@@ -261,6 +278,7 @@ impl SelfDrawnInteractionTree {
261278
focusable,
262279
tree_order,
263280
tracks_pointer_interaction,
281+
tracks_movement,
264282
long_press_mode,
265283
long_press_threshold_micros,
266284
},

src/platform_runtime/keyboard_input.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,15 @@ impl SelfDrawnInteractionSession {
3737
let focused = self.focused.clone();
3838
routed.target = focused.clone();
3939
if let Some(target) = focused {
40+
self.route_keyboard_move(
41+
&target,
42+
&key,
43+
tree,
44+
frame_revision,
45+
event_sequence,
46+
&mut context,
47+
&mut routed,
48+
);
4049
let tracks_press = tree
4150
.source(&target)
4251
.is_some_and(|source| source.tracks_press());

0 commit comments

Comments
 (0)