Skip to content

Commit cacf558

Browse files
author
Roy Lin
committed
feat: add native number field hold stepping
1 parent 3110c20 commit cacf558

11 files changed

Lines changed: 773 additions & 25 deletions

File tree

README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,7 @@ object graph at the host boundary.
4848
IME/dead-key conformance, measured overlay collision/arrow conformance and
4949
scroll locking, remaining native focus conformance, message catalogs,
5050
currency/unit parsing and formatting, date-range/time-zone formatting,
51-
continuous NumberField press stepping, and NumberField live announcements
52-
are still in progress.
51+
and NumberField live announcements are still in progress.
5352
- `.rsx` component source modules with imports, local Rust types, hook
5453
registrations, Rust selector/reducer expressions, and a final `rsx!(...)`
5554
view.

docs/architecture.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -485,9 +485,15 @@ shared interaction profile marks handled keys and wheels on AppKit, GTK4, and
485485
WinUI so a toolkit-native numeric control cannot apply a second change. A
486486
mouse stepper `PressStart` resolves the marked sibling input and requests
487487
native focus after the platform press, while touch and virtual focus remain on
488-
their native target. Read-only and disabled fields retain focus semantics but
489-
do not produce step changes. Continuous press stepping remains a separate
490-
interaction contract.
488+
their native target. Marked stepper buttons share a cancellable hold state
489+
machine across AppKit, GTK4, and WinUI. Mouse and pen presses step immediately,
490+
start repeating after 400 milliseconds, and repeat every 60 milliseconds.
491+
Touch defers its first repeat for 600 milliseconds and emits a short tap only
492+
on release. Leaving the button, pointer cancellation, disabled/read-only state,
493+
or a step boundary stops the active timer; re-entry starts a fresh cycle.
494+
Native terminal button activation is consumed so it cannot duplicate the
495+
portable step. Read-only and disabled fields retain focus semantics but do not
496+
produce step changes.
491497
AppKit keeps logical item identity separate from the collection selection
492498
target: a row button is registered as the ListBoxItem or TreeItem responder,
493499
while activation reports the selected value to the owning collection.

docs/react-aria-native.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -288,9 +288,14 @@ The first shared interaction milestone is available in the portable runtime:
288288
model-space stepping, rejects horizontal-dominant trackpad gestures and
289289
control-wheel zoom, and can be disabled independently with
290290
`isWheelDisabled`. Mouse presses on either stepper restore focus to the input.
291+
Mouse and pen steppers fire immediately, repeat after a 400-millisecond
292+
delay, and continue every 60 milliseconds. Touch steppers defer repeating
293+
for 600 milliseconds and preserve short-tap activation on release. Leaving,
294+
cancellation, disabled/read-only updates, and step boundaries stop the
295+
current cycle; re-entry starts a new one.
291296
AppKit, GTK4, and WinUI claim handled keys and wheels before the toolkit
292-
default runs, preventing native numeric controls from applying a duplicate
293-
change.
297+
default runs and consume terminal stepper activation, preventing native
298+
controls from applying a duplicate change.
294299
- Native IR capabilities are versioned. Every host exposes a feature manifest
295300
with unsupported, portable, or native support levels, role-specific
296301
overrides, and auditable capability issues. Protocol render responses carry
@@ -330,7 +335,7 @@ existence of a platform object:
330335
| Complete press lifecycle | Button, disclosure summary, link, image-map area, ListBoxItem, and TreeItem on AppKit, GTK4, and WinUI; WinUI menu items also use the complete lifecycle. |
331336
| Long press | Shared AppKit, GTK4, and WinUI press sources emit start/end and recognize terminal long press after the configured threshold. `NSTimer`, GTK main-loop timeout, and `DispatcherQueueTimer` provide threshold-time delivery, and release-time evaluation is the fallback. |
332337
| Move | AppKit mouse/pen drag events, GTK4 `GestureDrag`, and WinUI mouse/touch/pen pointer capture use one incremental move state machine. All three normalize Arrow keys to a complete keyboard lifecycle and prevent the underlying native default. |
333-
| NumberField stepping | The shared runtime maps ArrowUp/ArrowDown/PageUp/PageDown, Home/End, and focused vertical wheels to model-space `Change` events. Wheel input rejects horizontal-dominant gestures and control-wheel zoom, honors `isWheelDisabled`, and AppKit, GTK4, and WinUI suppress handled toolkit defaults. Built-in decrement and increment buttons expose the same next values and boundary/read-only state through native Button controls, and mouse presses preserve input focus. Continuous pointer-hold stepping, automatic localized button messages, and live value announcements remain incomplete. |
338+
| NumberField stepping | The shared runtime maps ArrowUp/ArrowDown/PageUp/PageDown, Home/End, focused vertical wheels, and stepper presses to model-space `Change` events. Wheel input rejects horizontal-dominant gestures and control-wheel zoom and honors `isWheelDisabled`. Built-in decrement and increment buttons expose the same next values and boundary/read-only state through native Button controls, and mouse presses preserve input focus. AppKit, GTK4, and WinUI share cancellable pointer-hold stepping with immediate mouse/pen activation, delayed touch activation, and 60-millisecond repeats; handled toolkit defaults and terminal native clicks are suppressed. Automatic localized button messages and live value announcements remain incomplete. |
334339
| Native menu activation | AppKit and GTK4 menu items emit terminal press only because their menu models do not expose a mounted generic view event source. |
335340
| Hover and typed modality | View-backed widgets; explicit exceptions are reported for AppKit non-view wrappers/items, GTK4 menu items, and the WinUI window wrapper. |
336341
| Focus within | Portable runtime routing on AppKit, GTK4, WinUI, and headless hosts. Native blur/focus batches are linked with `relatedTarget`; direct focus callbacks remain target-only while focus-within callbacks run only when a subtree boundary is crossed. |
@@ -410,7 +415,7 @@ props:
410415
| P1 | Event propagation | Add platform-run conformance fixtures for conditional `Stop`/`Continue` across nested native controls. |
411416
| P1 | Focus management | Add platform-run conformance fixtures for post-mount `autoFocus`, nested containment, and restoration. |
412417
| P1 | Collections and selection | Complete IME/dead-key typeahead conformance and add real-platform fixtures for layout-aware page navigation. |
413-
| P1 | NumberField interaction | Add continuous press-and-hold stepping, automatic localized stepper labels, and live value announcements. Group/input/button anatomy, minimum-anchored button stepping, Arrow/Page/Home/End keyboard semantics, focused vertical-wheel stepping with horizontal/zoom rejection and `isWheelDisabled`, mouse input-focus preservation, decimal-noise cleanup, boundary disabling, and native-default suppression are implemented. |
418+
| P1 | NumberField interaction | Add automatic localized stepper labels and live value announcements. Group/input/button anatomy, minimum-anchored button and continuous press-and-hold stepping, Arrow/Page/Home/End keyboard semantics, focused vertical-wheel stepping with horizontal/zoom rejection and `isWheelDisabled`, mouse input-focus preservation, decimal-noise cleanup, boundary disabling, cancellation/re-entry, and native-default suppression are implemented. |
414419
| P1 | Internationalization | Add message formatting, currency/unit parsing and formatting, and date ranges/time zones. Reusable decimal/percent parsing and formatting, partial-input validation, locale-aware filtering, and localized NumberField model/display conversion now build on inherited locale/direction. |
415420
| P1 | Accessibility conformance | Complete OS accessibility API projection, relationships, live regions, value announcements, and role-specific native adapter coverage. |
416421
| P2 | Overlays | Complete measured boundary-driven collision and arrow projection, native scroll locking, configurable outside-interaction filters, multi-window layer coordination, and real-platform positioning conformance fixtures. |

docs/roadmap.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,9 @@ The following foundations are implemented and are not roadmap placeholders:
7979
localized decimal/percent parsing and partial-input validation, NumberField
8080
model/display normalization, grouped native stepper controls,
8181
minimum-anchored button, Arrow/Page/Home/End, and focus-gated vertical-wheel
82-
stepping with platform-default suppression, horizontal/zoom rejection, and
83-
decimal/percent/date/time formatting
82+
stepping, cancellable cross-platform pointer-hold repetition, platform-default
83+
suppression, horizontal/zoom rejection, and decimal/percent/date/time
84+
formatting
8485
- an activation-ordered mounted overlay stack with topmost Escape/outside
8586
dismissal, modal background inertness, portaled foreground layers, and
8687
shared focus containment, autofocus, and restoration

docs/rsx.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1504,8 +1504,13 @@ The accessible-label overrides are optional. Without them, the built-in
15041504
English labels include the field label. Applications should provide localized
15051505
overrides until automatic message catalogs are implemented.
15061506
`isWheelDisabled={true}` disables wheel changes without disabling keyboard or
1507-
button stepping. Mouse stepper presses preserve input focus. Continuous
1508-
press-and-hold and live value announcements are not yet part of this contract.
1507+
button stepping. Mouse stepper presses preserve input focus. Mouse and pen
1508+
steppers fire immediately, begin repeating after 400 milliseconds, and repeat
1509+
every 60 milliseconds. Touch steppers begin repeating after 600 milliseconds;
1510+
a shorter touch activates once on release. Leaving, cancellation, a disabled
1511+
or read-only update, and reaching a step boundary stop repetition. Automatic
1512+
localized stepper labels and live value announcements are not yet part of this
1513+
contract.
15091514

15101515
- `UiButton`
15111516
- `UiBadge`

src/appkit_native/interaction.rs

Lines changed: 132 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use super::*;
22
use crate::event::{
3-
keyboard_move_events, NativeInteractionProfile, NativeLongPressTimer, PointerMoveState,
4-
PointerPressState,
3+
keyboard_move_events, NativeInteractionProfile, NativeLongPressTimer,
4+
NumberFieldStepperPressState, NumberFieldStepperTimer, PointerMoveState, PointerPressState,
55
};
66
use crate::input::{NativeEventContext, NativeInputModality, NativeKeyModifiers};
77

@@ -66,6 +66,49 @@ impl AppKitLongPressTarget {
6666
}
6767
}
6868

69+
#[derive(Debug, Clone)]
70+
struct AppKitNumberFieldStepperTargetIvars {
71+
timer: NumberFieldStepperTimer,
72+
events: Rc<RefCell<Vec<NativeEvent>>>,
73+
}
74+
75+
define_class!(
76+
#[unsafe(super(NSObject))]
77+
#[thread_kind = MainThreadOnly]
78+
#[ivars = AppKitNumberFieldStepperTargetIvars]
79+
#[derive(Debug)]
80+
struct AppKitNumberFieldStepperTarget;
81+
82+
impl AppKitNumberFieldStepperTarget {
83+
#[unsafe(method(a3sGuiNumberFieldStep:))]
84+
fn fire(&self, native_timer: &NSTimer) {
85+
if let Some(event) = self.ivars().timer.try_fire(true) {
86+
self.ivars().events.borrow_mut().push(event);
87+
} else {
88+
native_timer.invalidate();
89+
}
90+
}
91+
}
92+
93+
unsafe impl NSObjectProtocol for AppKitNumberFieldStepperTarget {}
94+
);
95+
96+
impl AppKitNumberFieldStepperTarget {
97+
fn new(
98+
timer: NumberFieldStepperTimer,
99+
events: Rc<RefCell<Vec<NativeEvent>>>,
100+
mtm: MainThreadMarker,
101+
) -> Retained<Self> {
102+
let this =
103+
Self::alloc(mtm).set_ivars(AppKitNumberFieldStepperTargetIvars { timer, events });
104+
unsafe { msg_send![super(this), init] }
105+
}
106+
107+
fn as_any_object(&self) -> &AnyObject {
108+
self.as_super().as_super()
109+
}
110+
}
111+
69112
#[derive(Debug, Clone, Copy)]
70113
pub(super) struct AppKitInteractionRegistration {
71114
profile: NativeInteractionProfile,
@@ -83,16 +126,33 @@ impl AppKitInteractionRegistration {
83126
pub(super) fn apply_setter(&mut self, setter: &NativeWidgetSetter) {
84127
self.profile.apply_setter(setter);
85128
}
129+
130+
pub(super) fn is_number_field_stepper(self) -> bool {
131+
self.profile.is_number_field_stepper()
132+
}
133+
134+
pub(super) fn tracks_number_field_stepper(self) -> bool {
135+
self.profile.tracks_number_field_stepper()
136+
}
86137
}
87138

88139
#[derive(Debug)]
89140
pub(super) struct AppKitPointerPress {
90141
pub(super) node: HostNodeId,
91142
view: Retained<NSView>,
92143
press_state: Option<PointerPressState>,
144+
number_field_stepper: Option<NumberFieldStepperPressState>,
93145
move_state: PointerMoveState,
94146
}
95147

148+
impl AppKitPointerPress {
149+
pub(super) fn cancel_number_field_stepper(&mut self) {
150+
if let Some(state) = self.number_field_stepper.as_mut() {
151+
state.cancel();
152+
}
153+
}
154+
}
155+
96156
#[derive(Debug)]
97157
pub(super) struct AppKitHoverTarget {
98158
pub(super) node: HostNodeId,
@@ -282,6 +342,9 @@ impl AppKitNativeSurface {
282342
if let Some(state) = previous.press_state.as_mut() {
283343
events.extend(state.cancel(previous.node, context));
284344
}
345+
if let Some(state) = previous.number_field_stepper.as_mut() {
346+
state.cancel();
347+
}
285348
}
286349

287350
let context = pointer_context(event, &target.view);
@@ -299,18 +362,37 @@ impl AppKitNativeSurface {
299362
} else {
300363
(Vec::new(), None)
301364
};
365+
let mut number_field_stepper = target
366+
.registration
367+
.tracks_number_field_stepper()
368+
.then(NumberFieldStepperPressState::default);
369+
let (stepper_events, stepper_timer) = if let Some(state) = number_field_stepper.as_mut() {
370+
let pending = state.begin(target.node, context);
371+
let timer = state.take_timer(target.node, context);
372+
(pending, timer)
373+
} else {
374+
(Vec::new(), None)
375+
};
302376
let mut move_state = PointerMoveState::default();
303377
if target.registration.profile.tracks_movement() {
304378
move_state.begin(context);
305379
}
306-
self.events.borrow_mut().extend(pending);
380+
{
381+
let mut events = self.events.borrow_mut();
382+
events.extend(pending);
383+
events.extend(stepper_events);
384+
}
307385
if let Some(timer) = timer {
308386
self.schedule_long_press(timer);
309387
}
388+
if let Some(timer) = stepper_timer {
389+
self.schedule_number_field_stepper(timer);
390+
}
310391
*self.pointer_press.borrow_mut() = Some(AppKitPointerPress {
311392
node: target.node,
312393
view: target.view,
313394
press_state,
395+
number_field_stepper,
314396
move_state,
315397
});
316398
}
@@ -334,10 +416,22 @@ impl AppKitNativeSurface {
334416
};
335417
events.extend(pending);
336418
}
419+
let mut stepper_timer = None;
420+
if let Some(state) = active.number_field_stepper.as_mut() {
421+
if point_is_inside(&active.view, context) {
422+
events.extend(state.enter(active.node, context));
423+
stepper_timer = state.take_timer(active.node, context);
424+
} else {
425+
state.leave();
426+
}
427+
}
337428
drop(events);
338429
if let Some(timer) = timer {
339430
self.schedule_long_press(timer);
340431
}
432+
if let Some(timer) = stepper_timer {
433+
self.schedule_number_field_stepper(timer);
434+
}
341435
}
342436

343437
fn end_pointer_press(&self, event: &NSEvent) {
@@ -371,12 +465,24 @@ impl AppKitNativeSurface {
371465
.any(|event| event.kind == NativeEventKind::LongPress);
372466
self.events.borrow_mut().extend(pending);
373467
}
468+
if let Some(state) = active.number_field_stepper.as_mut() {
469+
if over_target {
470+
let boundary = state.enter(active.node, context);
471+
self.events.borrow_mut().extend(boundary);
472+
} else {
473+
state.leave();
474+
}
475+
let pending = state.release(active.node, context);
476+
self.events.borrow_mut().extend(pending);
477+
}
374478

375479
if over_target && registration.is_some_and(|registration| registration.native_press_action)
376480
{
481+
let handled =
482+
long_pressed || registration.is_some_and(|value| value.is_number_field_stepper());
377483
self.activation_contexts
378484
.borrow_mut()
379-
.insert(active.node, context.handled_activation(long_pressed));
485+
.insert(active.node, context.handled_activation(handled));
380486
} else {
381487
self.activation_contexts.borrow_mut().remove(&active.node);
382488
}
@@ -393,6 +499,9 @@ impl AppKitNativeSurface {
393499
if let Some(state) = active.press_state.as_mut() {
394500
events.extend(state.cancel(active.node, context));
395501
}
502+
if let Some(state) = active.number_field_stepper.as_mut() {
503+
state.cancel();
504+
}
396505
}
397506

398507
fn schedule_long_press(&self, timer: NativeLongPressTimer) {
@@ -417,6 +526,25 @@ impl AppKitNativeSurface {
417526
}
418527
}
419528

529+
fn schedule_number_field_stepper(&self, timer: NumberFieldStepperTimer) {
530+
let initial_delay = timer.initial_delay().as_secs_f64();
531+
let interval = timer.repeat_interval().as_secs_f64();
532+
let target = AppKitNumberFieldStepperTarget::new(timer, Rc::clone(&self.events), self.mtm);
533+
let native_timer = unsafe {
534+
NSTimer::timerWithTimeInterval_target_selector_userInfo_repeats(
535+
interval,
536+
target.as_any_object(),
537+
sel!(a3sGuiNumberFieldStep:),
538+
None,
539+
true,
540+
)
541+
};
542+
native_timer.setFireDate(&NSDate::dateWithTimeIntervalSinceNow(initial_delay));
543+
unsafe {
544+
NSRunLoop::mainRunLoop().addTimer_forMode(&native_timer, NSRunLoopCommonModes);
545+
}
546+
}
547+
420548
fn update_hover(&self, event: &NSEvent) {
421549
let target = self.interaction_target_at_event(event, |registration| {
422550
registration.profile.subscriptions.hover

src/appkit_native/update.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,22 @@ impl AppKitNativeSurface {
77
handle: &AppKitOsHandle,
88
setter: &NativeWidgetSetter,
99
) -> GuiResult<()> {
10-
if let Some(registration) = self.interaction_nodes.get_mut(&id) {
11-
registration.apply_setter(setter);
10+
let cancel_number_field_stepper =
11+
if let Some(registration) = self.interaction_nodes.get_mut(&id) {
12+
registration.apply_setter(setter);
13+
!registration.tracks_number_field_stepper()
14+
} else {
15+
false
16+
};
17+
if cancel_number_field_stepper {
18+
if let Some(active) = self
19+
.pointer_press
20+
.borrow_mut()
21+
.as_mut()
22+
.filter(|active| active.node == id)
23+
{
24+
active.cancel_number_field_stepper();
25+
}
1226
}
1327
if let Some(config) = self.text_input_configs.get_mut(&id) {
1428
apply_widget_setter(config, setter);

src/event.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ pub(crate) use move_interaction::{keyboard_move_events, PointerMoveState};
2121
pub(crate) use press::{
2222
virtual_press_events, KeyboardPressState, NativeInteractionProfile,
2323
NativeInteractionSubscriptions, NativeLongPressConfig, NativeLongPressMode,
24-
NativeLongPressTimer, PointerPressState,
24+
NativeLongPressTimer, NumberFieldStepperPressState, NumberFieldStepperTimer, PointerPressState,
2525
};
2626

2727
/// Maximum number of successful action invocations retained for diagnostics by default.

0 commit comments

Comments
 (0)