Skip to main content

fret_ui/
action.rs

1use crate::UiHost;
2use fret_core::{
3    AppWindowId, Axis, CursorIcon, InternalDragKind, KeyCode, Modifiers, MouseButton, Point,
4    PointerId, PointerType, Rect, UiServices,
5};
6use fret_runtime::{
7    ActionId, CommandId, DefaultAction, DragHost, DragKindId, DragSession, Effect, Model,
8    ModelStore, PlatformTextInputQuery, PlatformTextInputQueryResult, TickId, TimerToken,
9    Utf16Range, WeakModel,
10};
11use std::any::{Any, TypeId};
12use std::sync::Arc;
13
14/// Context passed to component-owned action handlers.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct ActionCx {
17    pub window: AppWindowId,
18    pub target: crate::GlobalElementId,
19}
20
21/// Why an element was activated.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum ActivateReason {
24    Pointer,
25    Keyboard,
26}
27
28/// Result of a component-owned `Pressable` pointer down hook.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum PressablePointerDownResult {
31    /// Continue with the default `Pressable` pointer down behavior (focus, capture, pressed state).
32    Continue,
33    /// Skip the default behavior but allow the event to keep propagating.
34    SkipDefault,
35    /// Skip the default behavior and stop propagation at this pressable.
36    SkipDefaultAndStopPropagation,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum PressablePointerUpResult {
41    /// Continue with the default `Pressable` pointer-up behavior (activate when pressed+hovered).
42    Continue,
43    /// Skip the activation step (but still run default cleanup like releasing capture).
44    SkipActivate,
45}
46
47/// Why an overlay is requesting dismissal.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum DismissReason {
50    Escape,
51    OutsidePress {
52        pointer: Option<OutsidePressCx>,
53    },
54    /// Focus moved outside the dismissable layer subtree (Radix `onFocusOutside` outcome).
55    FocusOutside,
56    /// The trigger (or another registered subtree) was scrolled.
57    ///
58    /// This is used for Radix-aligned tooltip semantics: a tooltip should close when its trigger
59    /// is inside the scroll target that received a wheel/scroll gesture.
60    Scroll,
61}
62
63/// Context passed to overlay dismissal handlers.
64///
65/// This mirrors the DOM/Radix contract where `onInteractOutside` / `onPointerDownOutside` /
66/// `onFocusOutside` may "prevent default" to keep the overlay open.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub struct DismissRequestCx {
69    pub reason: DismissReason,
70    default_prevented: bool,
71}
72
73impl DismissRequestCx {
74    pub fn new(reason: DismissReason) -> Self {
75        Self {
76            reason,
77            default_prevented: false,
78        }
79    }
80
81    pub fn prevent_default(&mut self) {
82        self.default_prevented = true;
83    }
84
85    pub fn default_prevented(&self) -> bool {
86        self.default_prevented
87    }
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub(crate) struct DismissibleLastDismissRequest {
92    pub tick_id: TickId,
93    pub reason: Option<DismissReason>,
94    pub default_prevented: bool,
95}
96
97impl Default for DismissibleLastDismissRequest {
98    fn default() -> Self {
99        Self {
100            tick_id: TickId(0),
101            reason: None,
102            default_prevented: false,
103        }
104    }
105}
106
107/// Context passed to auto-focus handlers.
108///
109/// This mirrors the DOM/Radix contract where `onOpenAutoFocus` / `onCloseAutoFocus` may "prevent
110/// default" to take full control of focus movement.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub struct AutoFocusRequestCx {
113    default_prevented: bool,
114}
115
116impl AutoFocusRequestCx {
117    pub fn new() -> Self {
118        Self {
119            default_prevented: false,
120        }
121    }
122
123    pub fn prevent_default(&mut self) {
124        self.default_prevented = true;
125    }
126
127    pub fn default_prevented(&self) -> bool {
128        self.default_prevented
129    }
130}
131
132impl Default for AutoFocusRequestCx {
133    fn default() -> Self {
134        Self::new()
135    }
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub struct OutsidePressCx {
140    pub pointer_id: PointerId,
141    pub pointer_type: PointerType,
142    pub button: MouseButton,
143    pub modifiers: Modifiers,
144    pub click_count: u8,
145}
146
147/// Pointer down payload for component-owned pointer handlers.
148#[derive(Debug, Clone, Copy, PartialEq)]
149pub struct PointerDownCx {
150    pub pointer_id: PointerId,
151    /// Pointer position in the target widget's untransformed layout space (ADR 0238).
152    pub position: Point,
153    /// Pointer position in the target element's local coordinate space (origin at `(0, 0)`).
154    ///
155    /// This is derived as `position - host.bounds().origin` (ADR 0238).
156    pub position_local: Point,
157    /// Pointer position in window-local logical pixels (pre-mapping).
158    ///
159    /// This is best-effort: events may arrive before the runtime has recorded a window snapshot.
160    pub position_window: Option<Point>,
161    pub tick_id: TickId,
162    /// Pixels-per-point (a.k.a. window scale factor) for `position`.
163    ///
164    /// This is required for DPI-stable interactions (e.g. viewport tools, gizmos).
165    pub pixels_per_point: f32,
166    pub button: MouseButton,
167    pub modifiers: Modifiers,
168    /// See `PointerEvent::{Down,Up}.click_count` for normalization rules.
169    pub click_count: u8,
170    pub pointer_type: PointerType,
171    /// `true` when the pointer-down hit-test target is (or is inside) a text input element subtree
172    /// (`TextInput`, `TextArea`, or `TextInputRegion`).
173    ///
174    /// This is a mechanism-provided classification intended for component policy decisions like
175    /// Embla-style "do not arm drag when interacting with focus nodes".
176    pub hit_is_text_input: bool,
177    /// `true` when the pointer-down hit-test target is (or is inside) a pressable element subtree
178    /// (`Pressable`).
179    ///
180    /// This is a mechanism-provided classification intended for policy-level decisions like
181    /// "click-to-focus unless interacting with an embedded button".
182    pub hit_is_pressable: bool,
183    /// The deepest pressable element in the pointer-down hit-test chain (if any).
184    ///
185    /// This can be used by composite widgets to implement DOM-style policies like
186    /// "click-to-focus unless the event target is inside a button" without requiring selector
187    /// mechanisms in the component layer.
188    pub hit_pressable_target: Option<crate::GlobalElementId>,
189    /// `true` when `hit_pressable_target` is a strict descendant of the current action target.
190    ///
191    /// This excludes ambient ancestor pressables and the current target itself, so wrapper
192    /// policies can suppress forwarding only for genuinely nested interactive descendants.
193    pub hit_pressable_target_in_descendant_subtree: bool,
194}
195
196/// Pointer move payload for component-owned pointer handlers.
197#[derive(Debug, Clone, Copy, PartialEq)]
198pub struct PointerMoveCx {
199    pub pointer_id: PointerId,
200    /// Pointer position in the target widget's untransformed layout space (ADR 0238).
201    pub position: Point,
202    /// Pointer position in the target element's local coordinate space (origin at `(0, 0)`).
203    ///
204    /// This is derived as `position - host.bounds().origin` (ADR 0238).
205    pub position_local: Point,
206    /// Pointer position in window-local logical pixels (pre-mapping).
207    ///
208    /// This is best-effort: events may arrive before the runtime has recorded a window snapshot.
209    pub position_window: Option<Point>,
210    pub tick_id: TickId,
211    /// Pixels-per-point (a.k.a. window scale factor) for `position`.
212    pub pixels_per_point: f32,
213    /// Best-effort pointer velocity snapshot in window-local logical pixels per second (ADR 0243).
214    ///
215    /// Notes:
216    /// - This is derived from the UI runtime's pointer motion snapshots, not from per-element
217    ///   state. It may be `None` when monotonic timestamps are unavailable.
218    /// - Components that need deterministic velocity for tests should treat `None` as "unknown"
219    ///   and fall back to policy defaults.
220    pub velocity_window: Option<Point>,
221    pub buttons: fret_core::MouseButtons,
222    pub modifiers: Modifiers,
223    pub pointer_type: PointerType,
224}
225
226/// Wheel payload for component-owned wheel handlers.
227#[derive(Debug, Clone, Copy, PartialEq)]
228pub struct WheelCx {
229    pub pointer_id: PointerId,
230    /// Pointer position in the target widget's untransformed layout space (ADR 0238).
231    pub position: Point,
232    /// Pointer position in the target element's local coordinate space (origin at `(0, 0)`).
233    ///
234    /// This is derived as `position - host.bounds().origin` (ADR 0238).
235    pub position_local: Point,
236    /// Pointer position in window-local logical pixels (pre-mapping).
237    ///
238    /// This is best-effort: events may arrive before the runtime has recorded a window snapshot.
239    pub position_window: Option<Point>,
240    pub tick_id: TickId,
241    /// Pixels-per-point (a.k.a. window scale factor) for `position`.
242    pub pixels_per_point: f32,
243    /// Wheel delta mapped into the target widget's untransformed layout space (ADR 0238).
244    pub delta: Point,
245    /// Wheel delta in window-local logical pixels (pre-mapping).
246    pub delta_window: Option<Point>,
247    pub modifiers: Modifiers,
248    pub pointer_type: PointerType,
249}
250
251/// Pinch (magnify) gesture payload for component-owned pinch handlers.
252#[derive(Debug, Clone, Copy, PartialEq)]
253pub struct PinchGestureCx {
254    pub pointer_id: PointerId,
255    /// Pointer position in the target widget's untransformed layout space (ADR 0238).
256    pub position: Point,
257    /// Pointer position in the target element's local coordinate space (origin at `(0, 0)`).
258    ///
259    /// This is derived as `position - host.bounds().origin` (ADR 0238).
260    pub position_local: Point,
261    /// Pointer position in window-local logical pixels (pre-mapping).
262    ///
263    /// This is best-effort: events may arrive before the runtime has recorded a window snapshot.
264    pub position_window: Option<Point>,
265    pub tick_id: TickId,
266    /// Pixels-per-point (a.k.a. window scale factor) for `position`.
267    pub pixels_per_point: f32,
268    /// Positive for magnification (zoom in) and negative for shrinking (zoom out).
269    ///
270    /// This may be NaN depending on the platform backend; callers should guard accordingly.
271    pub delta: f32,
272    pub modifiers: Modifiers,
273    pub pointer_type: PointerType,
274}
275
276/// Pointer cancel payload for component-owned pointer handlers.
277#[derive(Debug, Clone, Copy, PartialEq)]
278pub struct PointerCancelCx {
279    pub pointer_id: PointerId,
280    /// When provided by the platform, this is the last known pointer position (logical pixels).
281    pub position: Option<Point>,
282    /// When provided by the platform, this is the last known pointer position in element-local
283    /// logical pixels.
284    ///
285    /// Derived as `position - host.bounds().origin` (ADR 0238).
286    pub position_local: Option<Point>,
287    /// When provided by the platform, this is the last known pointer position in window-local
288    /// logical pixels (pre-mapping).
289    pub position_window: Option<Point>,
290    pub tick_id: TickId,
291    /// Pixels-per-point (a.k.a. window scale factor) for `position`.
292    pub pixels_per_point: f32,
293    pub buttons: fret_core::MouseButtons,
294    pub modifiers: Modifiers,
295    pub pointer_type: PointerType,
296    pub reason: fret_core::PointerCancelReason,
297}
298
299/// Pointer up payload for component-owned pointer handlers.
300#[derive(Debug, Clone, Copy, PartialEq)]
301pub struct PointerUpCx {
302    pub pointer_id: PointerId,
303    /// Pointer position in the target widget's untransformed layout space (ADR 0238).
304    pub position: Point,
305    /// Pointer position in the target element's local coordinate space (origin at `(0, 0)`).
306    ///
307    /// This is derived as `position - host.bounds().origin` (ADR 0238).
308    pub position_local: Point,
309    /// Pointer position in window-local logical pixels (pre-mapping).
310    ///
311    /// This is best-effort: events may arrive before the runtime has recorded a window snapshot.
312    pub position_window: Option<Point>,
313    pub tick_id: TickId,
314    /// Pixels-per-point (a.k.a. window scale factor) for `position`.
315    pub pixels_per_point: f32,
316    /// Best-effort pointer velocity snapshot in window-local logical pixels per second (ADR 0243).
317    pub velocity_window: Option<Point>,
318    pub button: MouseButton,
319    pub modifiers: Modifiers,
320    /// Whether this pointer-up completes a "true click" (press + release without exceeding click
321    /// slop).
322    ///
323    /// See `PointerEvent::Up.is_click` for normalization rules.
324    pub is_click: bool,
325    /// See `PointerEvent::{Down,Up}.click_count` for normalization rules.
326    pub click_count: u8,
327    pub pointer_type: PointerType,
328    /// The deepest pressable element in the pointer-down hit-test chain (if any).
329    ///
330    /// This is populated from the pressable/pointer-region state recorded on pointer down, and is
331    /// intended for policy decisions like suppressing row selection when the click started inside
332    /// a nested button.
333    pub down_hit_pressable_target: Option<crate::GlobalElementId>,
334    /// `true` when `down_hit_pressable_target` was a strict descendant of the current action
335    /// target on pointer down.
336    pub down_hit_pressable_target_in_descendant_subtree: bool,
337}
338
339/// Key down payload for component-owned key handlers.
340#[derive(Debug, Clone, Copy, PartialEq, Eq)]
341pub struct KeyDownCx {
342    pub key: KeyCode,
343    pub modifiers: Modifiers,
344    pub repeat: bool,
345    /// Whether the focused text input is currently in an active IME composition session.
346    ///
347    /// When `true`, components should generally avoid treating key presses as command/selection
348    /// navigation shortcuts (see `cmdk`'s `isComposing` guard).
349    pub ime_composing: bool,
350}
351
352/// Object-safe host surface for action handlers.
353///
354/// This intentionally exposes only non-generic operations so handlers can be stored in element
355/// state and invoked by the runtime without coupling to `H: UiHost` (see ADR 0074).
356pub trait UiActionHost {
357    fn models_mut(&mut self) -> &mut ModelStore;
358    fn push_effect(&mut self, effect: Effect);
359    fn request_redraw(&mut self, window: AppWindowId);
360    fn next_timer_token(&mut self) -> TimerToken;
361    fn next_clipboard_token(&mut self) -> fret_runtime::ClipboardToken;
362    fn next_share_sheet_token(&mut self) -> fret_runtime::ShareSheetToken;
363
364    /// Publish router navigation availability for the given window.
365    ///
366    /// This is an object-safe hook used by ecosystem layers (for example `fret-router-ui`) to
367    /// keep cross-surface command gating (menus, command palette, shortcuts) in sync without
368    /// requiring generic access to host globals.
369    fn set_router_command_availability(
370        &mut self,
371        _window: AppWindowId,
372        _can_back: bool,
373        _can_forward: bool,
374    ) {
375    }
376
377    /// Record a transient, per-element event for the current dispatch cycle.
378    ///
379    /// This is a mechanism-only escape hatch intended for cases where an action hook needs to
380    /// communicate a one-shot signal to the next declarative render pass without allocating a
381    /// dedicated model. The event is keyed by `(ActionCx.target, key)` and is typically consumed
382    /// by declarative authoring code via an element-scoped "take" API.
383    ///
384    /// Notes:
385    /// - Hosts that are not running inside a UI tree event dispatch may leave this as a no-op.
386    /// - Callers should treat `key` as a stable, deterministic identifier (e.g. a const hash).
387    fn record_transient_event(&mut self, _cx: ActionCx, _key: u64) {}
388
389    /// Mark the nearest view-cache root for `cx.target` as dirty (GPUI-style `notify`).
390    ///
391    /// Notes:
392    /// - This is intentionally optional: hosts that are not running inside a UI tree event
393    ///   dispatch can leave this as a no-op.
394    /// - When view caching is enabled, this forces a rerender (skips reuse) for the nearest cache
395    ///   root so declarative UI that depends on non-model state can still update deterministically.
396    fn notify(&mut self, _cx: ActionCx) {}
397
398    /// Record best-effort diagnostics metadata for an upcoming command dispatch.
399    ///
400    /// This is a mechanism-only hook intended to help explain pointer-triggered `Effect::Command`
401    /// dispatches in `fretboard diag` without changing the effect schema.
402    ///
403    /// Hosts that do not support diagnostics can leave this as a no-op.
404    fn record_pending_command_dispatch_source(
405        &mut self,
406        _cx: ActionCx,
407        _command: &CommandId,
408        _reason: ActivateReason,
409    ) {
410    }
411
412    /// Record a transient payload for a parameterized action dispatch (ADR 0312).
413    ///
414    /// This is a best-effort, window-scoped pending store with a small tick TTL. Payload is
415    /// intentionally *not* embedded into the element tree; it is passed through a separate,
416    /// transient channel to keep the IR data-first and to preserve future DSL/frontend options.
417    ///
418    /// Hosts that do not support payload actions can leave this as a no-op.
419    fn record_pending_action_payload(
420        &mut self,
421        _cx: ActionCx,
422        _action: &ActionId,
423        _payload: Box<dyn Any + Send + Sync>,
424    ) {
425    }
426
427    /// Consume the most recent pending payload for a given action, if still available (ADR 0312).
428    ///
429    /// Recommended handler semantics when `None`:
430    /// - treat as "not handled" (payload missing/expired/mismatched),
431    /// - keep behavior diagnosable (best-effort) rather than panicking.
432    ///
433    /// Hosts that do not support payload actions can return `None`.
434    fn consume_pending_action_payload(
435        &mut self,
436        _window: AppWindowId,
437        _action: &ActionId,
438    ) -> Option<Box<dyn Any + Send + Sync>> {
439        None
440    }
441
442    fn dispatch_command(&mut self, window: Option<AppWindowId>, command: CommandId) {
443        self.push_effect(Effect::Command { window, command });
444    }
445}
446
447/// Extra runtime-provided operations available to non-pointer action hooks.
448///
449/// This is used by keyboard hooks and other global hooks that need to move focus as a policy
450/// decision (e.g. menu submenu focus transfer).
451pub trait UiFocusActionHost: UiActionHost {
452    fn request_focus(&mut self, target: crate::GlobalElementId);
453}
454
455/// Host operations for internal (app-owned) drag sessions.
456///
457/// This is intentionally object-safe so drag flows can be authored via stored action hooks.
458/// Payload should typically live in models/globals (not in the drag session payload) to avoid
459/// generic APIs in this surface.
460pub trait UiDragActionHost: UiActionHost {
461    fn begin_drag_with_kind(
462        &mut self,
463        pointer_id: PointerId,
464        kind: DragKindId,
465        source_window: AppWindowId,
466        start: Point,
467    );
468
469    fn begin_cross_window_drag_with_kind(
470        &mut self,
471        pointer_id: PointerId,
472        kind: DragKindId,
473        source_window: AppWindowId,
474        start: Point,
475    );
476
477    fn drag(&self, pointer_id: PointerId) -> Option<&DragSession>;
478    fn drag_mut(&mut self, pointer_id: PointerId) -> Option<&mut DragSession>;
479    fn cancel_drag(&mut self, pointer_id: PointerId);
480}
481
482pub trait UiActionHostExt: UiActionHost {
483    fn read_weak_model<T: Any, R>(
484        &mut self,
485        model: &WeakModel<T>,
486        f: impl FnOnce(&T) -> R,
487    ) -> Option<R> {
488        let model = model.upgrade()?;
489        self.models_mut().read(&model, f).ok()
490    }
491
492    fn update_model<T: Any, R>(
493        &mut self,
494        model: &Model<T>,
495        f: impl FnOnce(&mut T) -> R,
496    ) -> Option<R> {
497        self.models_mut().update(model, f).ok()
498    }
499
500    fn update_weak_model<T: Any, R>(
501        &mut self,
502        model: &WeakModel<T>,
503        f: impl FnOnce(&mut T) -> R,
504    ) -> Option<R> {
505        let model = model.upgrade()?;
506        self.update_model(&model, f)
507    }
508}
509
510impl<T> UiActionHostExt for T where T: UiActionHost + ?Sized {}
511
512/// Extra runtime-provided operations available during pointer event hooks.
513///
514/// This is intentionally separate from `UiActionHost` because pointer capture and cursor updates
515/// are mediated by the UI runtime (`UiTree`), not by the app host (`UiHost`).
516pub trait UiPointerActionHost: UiFocusActionHost + UiDragActionHost {
517    fn bounds(&self) -> fret_core::Rect;
518    fn capture_pointer(&mut self);
519    fn release_pointer_capture(&mut self);
520    fn set_cursor_icon(&mut self, icon: CursorIcon);
521    /// Suppress a runtime default action for the current event dispatch.
522    ///
523    /// This is primarily used to prevent "focus on pointer down" while still allowing propagation
524    /// and other policies (overlays, global shortcuts, outside-press) to observe the event.
525    fn prevent_default(&mut self, action: DefaultAction);
526
527    /// Request a node-level invalidation for the current pointer region / pressable.
528    ///
529    /// This is intentionally separate from `notify()`: it enables paint-only updates (e.g. hover
530    /// chrome) under view-cache reuse without forcing a rerender.
531    fn invalidate(&mut self, _invalidation: crate::widget::Invalidation) {}
532}
533
534pub struct UiActionHostAdapter<'a, H: UiHost> {
535    pub app: &'a mut H,
536}
537
538impl<'a, H: UiHost> UiActionHost for UiActionHostAdapter<'a, H> {
539    fn models_mut(&mut self) -> &mut ModelStore {
540        self.app.models_mut()
541    }
542
543    fn push_effect(&mut self, effect: Effect) {
544        self.app.push_effect(effect);
545    }
546
547    fn request_redraw(&mut self, window: AppWindowId) {
548        self.app.request_redraw(window);
549    }
550
551    fn next_timer_token(&mut self) -> TimerToken {
552        self.app.next_timer_token()
553    }
554
555    fn next_clipboard_token(&mut self) -> fret_runtime::ClipboardToken {
556        self.app.next_clipboard_token()
557    }
558
559    fn next_share_sheet_token(&mut self) -> fret_runtime::ShareSheetToken {
560        self.app.next_share_sheet_token()
561    }
562
563    fn set_router_command_availability(
564        &mut self,
565        window: AppWindowId,
566        can_back: bool,
567        can_forward: bool,
568    ) {
569        self.app.with_global_mut(
570            fret_runtime::WindowCommandAvailabilityService::default,
571            |svc, _app| {
572                svc.set_router_availability(window, can_back, can_forward);
573            },
574        );
575    }
576
577    fn record_transient_event(&mut self, cx: ActionCx, key: u64) {
578        crate::elements::record_transient_event(&mut *self.app, cx.window, cx.target, key);
579    }
580
581    fn record_pending_command_dispatch_source(
582        &mut self,
583        cx: ActionCx,
584        command: &CommandId,
585        reason: ActivateReason,
586    ) {
587        let kind = match reason {
588            ActivateReason::Pointer => fret_runtime::CommandDispatchSourceKindV1::Pointer,
589            ActivateReason::Keyboard => fret_runtime::CommandDispatchSourceKindV1::Keyboard,
590        };
591        let source = fret_runtime::CommandDispatchSourceV1 {
592            kind,
593            element: Some(cx.target.0),
594            test_id: None,
595        };
596        self.app.with_global_mut(
597            fret_runtime::WindowPendingCommandDispatchSourceService::default,
598            |svc, app| {
599                svc.record(cx.window, app.tick_id(), command.clone(), source);
600            },
601        );
602    }
603}
604
605impl<'a, H: UiHost> UiDragActionHost for UiActionHostAdapter<'a, H> {
606    fn begin_drag_with_kind(
607        &mut self,
608        pointer_id: PointerId,
609        kind: DragKindId,
610        source_window: AppWindowId,
611        start: Point,
612    ) {
613        DragHost::begin_drag_with_kind(&mut *self.app, pointer_id, kind, source_window, start, ());
614    }
615
616    fn begin_cross_window_drag_with_kind(
617        &mut self,
618        pointer_id: PointerId,
619        kind: DragKindId,
620        source_window: AppWindowId,
621        start: Point,
622    ) {
623        DragHost::begin_cross_window_drag_with_kind(
624            &mut *self.app,
625            pointer_id,
626            kind,
627            source_window,
628            start,
629            (),
630        );
631    }
632
633    fn drag(&self, pointer_id: PointerId) -> Option<&DragSession> {
634        DragHost::drag(&*self.app, pointer_id)
635    }
636
637    fn drag_mut(&mut self, pointer_id: PointerId) -> Option<&mut DragSession> {
638        DragHost::drag_mut(&mut *self.app, pointer_id)
639    }
640
641    fn cancel_drag(&mut self, pointer_id: PointerId) {
642        DragHost::cancel_drag(&mut *self.app, pointer_id);
643    }
644}
645
646/// Internal drag event payload for component-owned internal drag handlers.
647#[derive(Debug, Clone, Copy, PartialEq)]
648pub struct InternalDragCx {
649    pub pointer_id: PointerId,
650    pub position: Point,
651    /// Pointer position in window-local logical pixels (pre-mapping).
652    ///
653    /// This is best-effort: events may arrive before the runtime has recorded a window snapshot.
654    pub position_window: Option<Point>,
655    pub tick_id: TickId,
656    pub kind: InternalDragKind,
657    pub modifiers: Modifiers,
658}
659
660pub type OnInternalDrag =
661    Arc<dyn Fn(&mut dyn UiDragActionHost, ActionCx, InternalDragCx) -> bool + 'static>;
662
663#[derive(Default)]
664pub(crate) struct InternalDragActionHooks {
665    pub on_internal_drag: Option<OnInternalDrag>,
666}
667
668pub type OnExternalDrag =
669    Arc<dyn Fn(&mut dyn UiActionHost, ActionCx, &fret_core::ExternalDragEvent) -> bool + 'static>;
670
671#[derive(Default)]
672pub(crate) struct ExternalDragActionHooks {
673    pub on_external_drag: Option<OnExternalDrag>,
674}
675
676pub type OnActivate = Arc<dyn Fn(&mut dyn UiActionHost, ActionCx, ActivateReason) + 'static>;
677
678/// Span activation payload for `SelectableText` interactive spans.
679#[derive(Debug, Clone, PartialEq, Eq)]
680pub struct SelectableTextSpanActivation {
681    pub tag: Arc<str>,
682    pub range: std::ops::Range<usize>,
683}
684
685pub type OnSelectableTextActivateSpan = Arc<
686    dyn Fn(&mut dyn UiActionHost, ActionCx, ActivateReason, SelectableTextSpanActivation) + 'static,
687>;
688
689#[derive(Default)]
690pub(crate) struct SelectableTextActionHooks {
691    pub on_activate_span: Option<OnSelectableTextActivateSpan>,
692}
693pub type OnPressablePointerDown = Arc<
694    dyn Fn(&mut dyn UiPointerActionHost, ActionCx, PointerDownCx) -> PressablePointerDownResult
695        + 'static,
696>;
697pub type OnPressablePointerMove =
698    Arc<dyn Fn(&mut dyn UiPointerActionHost, ActionCx, PointerMoveCx) -> bool + 'static>;
699pub type OnPressablePointerUp = Arc<
700    dyn Fn(&mut dyn UiPointerActionHost, ActionCx, PointerUpCx) -> PressablePointerUpResult
701        + 'static,
702>;
703pub type OnPressableClipboardWriteCompleted = Arc<
704    dyn Fn(
705            &mut dyn UiActionHost,
706            ActionCx,
707            fret_core::ClipboardToken,
708            &fret_core::ClipboardWriteOutcome,
709        ) -> bool
710        + 'static,
711>;
712
713#[derive(Default)]
714pub(crate) struct PressableActionHooks {
715    pub on_activate: Option<OnActivate>,
716    pub on_pointer_down: Option<OnPressablePointerDown>,
717    pub on_pointer_move: Option<OnPressablePointerMove>,
718    pub on_pointer_up: Option<OnPressablePointerUp>,
719    pub on_clipboard_write_completed: Option<OnPressableClipboardWriteCompleted>,
720}
721
722pub type OnHoverChange = Arc<dyn Fn(&mut dyn UiActionHost, ActionCx, bool) + 'static>;
723
724#[derive(Default)]
725pub(crate) struct PressableHoverActionHooks {
726    pub on_hover_change: Option<OnHoverChange>,
727}
728
729pub type OnDismissRequest =
730    Arc<dyn Fn(&mut dyn UiActionHost, ActionCx, &mut DismissRequestCx) + 'static>;
731
732pub type OnOpenAutoFocus =
733    Arc<dyn Fn(&mut dyn UiFocusActionHost, ActionCx, &mut AutoFocusRequestCx) + 'static>;
734
735pub type OnCloseAutoFocus =
736    Arc<dyn Fn(&mut dyn UiFocusActionHost, ActionCx, &mut AutoFocusRequestCx) + 'static>;
737
738/// Pointer move observer hook for `DismissibleLayer`.
739///
740/// This is intentionally `UiActionHost` (not `UiPointerActionHost`) so dismissible roots can
741/// observe pointer movement without participating in hit-testing or capture.
742pub type OnDismissiblePointerMove =
743    Arc<dyn Fn(&mut dyn UiActionHost, ActionCx, PointerMoveCx) -> bool + 'static>;
744
745#[derive(Default)]
746pub(crate) struct DismissibleActionHooks {
747    pub on_dismiss_request: Option<OnDismissRequest>,
748    pub on_pointer_move: Option<OnDismissiblePointerMove>,
749}
750
751pub type OnTextInputRegionTextInput =
752    Arc<dyn Fn(&mut dyn UiActionHost, ActionCx, &str) -> bool + 'static>;
753
754pub type OnTextInputRegionIme =
755    Arc<dyn Fn(&mut dyn UiActionHost, ActionCx, &fret_core::ImeEvent) -> bool + 'static>;
756
757pub type OnTextInputRegionClipboardReadText =
758    Arc<dyn Fn(&mut dyn UiActionHost, ActionCx, fret_core::ClipboardToken, &str) -> bool + 'static>;
759
760pub type OnTextInputRegionClipboardReadFailed = Arc<
761    dyn Fn(
762            &mut dyn UiActionHost,
763            ActionCx,
764            fret_core::ClipboardToken,
765            &fret_core::ClipboardAccessError,
766        ) -> bool
767        + 'static,
768>;
769
770pub type OnTextInputRegionSetSelection =
771    Arc<dyn Fn(&mut dyn UiActionHost, ActionCx, u32, u32) -> bool + 'static>;
772
773pub type OnTextInputRegionPlatformTextInputQuery = Arc<
774    dyn Fn(
775            &mut dyn UiActionHost,
776            ActionCx,
777            &mut dyn UiServices,
778            Rect,
779            f32,
780            &crate::element::TextInputRegionProps,
781            &PlatformTextInputQuery,
782        ) -> Option<PlatformTextInputQueryResult>
783        + 'static,
784>;
785
786pub type OnTextInputRegionPlatformTextInputReplaceTextInRangeUtf16 = Arc<
787    dyn Fn(
788            &mut dyn UiActionHost,
789            ActionCx,
790            &mut dyn UiServices,
791            Rect,
792            f32,
793            &crate::element::TextInputRegionProps,
794            Utf16Range,
795            &str,
796        ) -> bool
797        + 'static,
798>;
799
800pub type OnTextInputRegionPlatformTextInputReplaceAndMarkTextInRangeUtf16 = Arc<
801    dyn Fn(
802            &mut dyn UiActionHost,
803            ActionCx,
804            &mut dyn UiServices,
805            Rect,
806            f32,
807            &crate::element::TextInputRegionProps,
808            Utf16Range,
809            &str,
810            Option<Utf16Range>,
811            Option<Utf16Range>,
812        ) -> bool
813        + 'static,
814>;
815
816#[derive(Default)]
817pub(crate) struct TextInputRegionActionHooks {
818    pub on_text_input: Option<OnTextInputRegionTextInput>,
819    pub on_ime: Option<OnTextInputRegionIme>,
820    pub on_clipboard_read_text: Option<OnTextInputRegionClipboardReadText>,
821    pub on_clipboard_read_failed: Option<OnTextInputRegionClipboardReadFailed>,
822    pub on_set_selection: Option<OnTextInputRegionSetSelection>,
823    pub on_platform_text_input_query: Option<OnTextInputRegionPlatformTextInputQuery>,
824    pub on_platform_text_input_replace_text_in_range_utf16:
825        Option<OnTextInputRegionPlatformTextInputReplaceTextInRangeUtf16>,
826    pub on_platform_text_input_replace_and_mark_text_in_range_utf16:
827        Option<OnTextInputRegionPlatformTextInputReplaceAndMarkTextInRangeUtf16>,
828}
829
830pub type OnPointerDown =
831    Arc<dyn Fn(&mut dyn UiPointerActionHost, ActionCx, PointerDownCx) -> bool + 'static>;
832
833pub type OnPointerMove =
834    Arc<dyn Fn(&mut dyn UiPointerActionHost, ActionCx, PointerMoveCx) -> bool + 'static>;
835
836pub type OnWheel = Arc<dyn Fn(&mut dyn UiPointerActionHost, ActionCx, WheelCx) -> bool + 'static>;
837
838pub type OnPinchGesture =
839    Arc<dyn Fn(&mut dyn UiPointerActionHost, ActionCx, PinchGestureCx) -> bool + 'static>;
840
841pub type OnPointerUp =
842    Arc<dyn Fn(&mut dyn UiPointerActionHost, ActionCx, PointerUpCx) -> bool + 'static>;
843
844pub type OnPointerCancel =
845    Arc<dyn Fn(&mut dyn UiPointerActionHost, ActionCx, PointerCancelCx) -> bool + 'static>;
846
847#[derive(Default)]
848pub(crate) struct PointerActionHooks {
849    pub on_pointer_down: Option<OnPointerDown>,
850    pub on_pointer_move: Option<OnPointerMove>,
851    pub on_wheel: Option<OnWheel>,
852    pub on_pinch_gesture: Option<OnPinchGesture>,
853    pub on_pointer_up: Option<OnPointerUp>,
854    pub on_pointer_cancel: Option<OnPointerCancel>,
855}
856
857pub type OnKeyDown = Arc<dyn Fn(&mut dyn UiFocusActionHost, ActionCx, KeyDownCx) -> bool + 'static>;
858
859#[derive(Default)]
860pub(crate) struct KeyActionHooks {
861    pub on_key_down_capture: Option<OnKeyDown>,
862    pub on_key_down: Option<OnKeyDown>,
863    /// Key down hook that only fires when the current node is the focus target.
864    ///
865    /// Use this for widgets that want to handle navigation keys only when they are focused,
866    /// without intercepting keys bubbling from focused descendants.
867    pub on_key_down_focused: Option<OnKeyDown>,
868}
869
870pub type OnCommand = Arc<dyn Fn(&mut dyn UiFocusActionHost, ActionCx, CommandId) -> bool + 'static>;
871
872#[derive(Default)]
873pub(crate) struct CommandActionHooks {
874    pub on_command: Option<OnCommand>,
875}
876
877#[derive(Clone)]
878pub(crate) struct ActionRouteOwnerHooks {
879    pub owner: TypeId,
880    pub on_command: Option<OnCommand>,
881    pub on_command_availability: Option<OnCommandAvailability>,
882}
883
884/// Owner-scoped action route hooks.
885///
886/// This lane is separate from the legacy generic command hook slot so app-facing typed action
887/// surfaces can coexist on the same element without overwriting one another.
888#[derive(Default, Clone)]
889pub(crate) struct ActionRouteHooks {
890    owners: Vec<ActionRouteOwnerHooks>,
891}
892
893impl ActionRouteHooks {
894    fn owner_mut(&mut self, owner: TypeId) -> &mut ActionRouteOwnerHooks {
895        if let Some(index) = self.owners.iter().position(|hooks| hooks.owner == owner) {
896            return &mut self.owners[index];
897        }
898        self.owners.push(ActionRouteOwnerHooks {
899            owner,
900            on_command: None,
901            on_command_availability: None,
902        });
903        self.owners
904            .last_mut()
905            .expect("action route owner slot must exist after insertion")
906    }
907
908    pub(crate) fn set_on_command(&mut self, owner: TypeId, handler: OnCommand) {
909        self.owner_mut(owner).on_command = Some(handler);
910    }
911
912    pub(crate) fn add_on_command(&mut self, owner: TypeId, handler: OnCommand) {
913        let hooks = self.owner_mut(owner);
914        hooks.on_command = match hooks.on_command.clone() {
915            None => Some(handler),
916            Some(prev) => {
917                let next = handler.clone();
918                Some(Arc::new(move |host, cx, command| {
919                    prev(host, cx, command.clone()) || next(host, cx, command)
920                }))
921            }
922        };
923    }
924
925    pub(crate) fn clear_on_command(&mut self, owner: TypeId) {
926        self.owner_mut(owner).on_command = None;
927    }
928
929    pub(crate) fn on_command_handlers(&self) -> Vec<OnCommand> {
930        self.owners
931            .iter()
932            .filter_map(|hooks| hooks.on_command.clone())
933            .collect()
934    }
935
936    pub(crate) fn set_on_command_availability(
937        &mut self,
938        owner: TypeId,
939        handler: OnCommandAvailability,
940    ) {
941        self.owner_mut(owner).on_command_availability = Some(handler);
942    }
943
944    pub(crate) fn add_on_command_availability(
945        &mut self,
946        owner: TypeId,
947        handler: OnCommandAvailability,
948    ) {
949        let hooks = self.owner_mut(owner);
950        hooks.on_command_availability = match hooks.on_command_availability.clone() {
951            None => Some(handler),
952            Some(prev) => {
953                let next = handler.clone();
954                Some(Arc::new(move |host, cx, command| {
955                    let availability = prev(host, cx.clone(), command.clone());
956                    if availability != crate::widget::CommandAvailability::NotHandled {
957                        return availability;
958                    }
959                    next(host, cx, command)
960                }))
961            }
962        };
963    }
964
965    pub(crate) fn clear_on_command_availability(&mut self, owner: TypeId) {
966        self.owner_mut(owner).on_command_availability = None;
967    }
968
969    pub(crate) fn on_command_availability_handlers(&self) -> Vec<OnCommandAvailability> {
970        self.owners
971            .iter()
972            .filter_map(|hooks| hooks.on_command_availability.clone())
973            .collect()
974    }
975}
976
977pub trait UiCommandAvailabilityActionHost {
978    fn models_mut(&mut self) -> &mut fret_runtime::ModelStore;
979}
980
981#[derive(Debug, Clone)]
982pub struct CommandAvailabilityActionCx {
983    pub window: fret_core::AppWindowId,
984    pub target: crate::GlobalElementId,
985    pub node: fret_core::NodeId,
986    pub focus: Option<fret_core::NodeId>,
987    pub focus_in_subtree: bool,
988    pub input_ctx: fret_runtime::InputContext,
989}
990
991pub type OnCommandAvailability = Arc<
992    dyn Fn(
993            &mut dyn UiCommandAvailabilityActionHost,
994            CommandAvailabilityActionCx,
995            CommandId,
996        ) -> crate::widget::CommandAvailability
997        + 'static,
998>;
999
1000#[derive(Default)]
1001pub(crate) struct CommandAvailabilityActionHooks {
1002    pub on_command_availability: Option<OnCommandAvailability>,
1003}
1004
1005pub type OnTimer = Arc<dyn Fn(&mut dyn UiFocusActionHost, ActionCx, TimerToken) -> bool + 'static>;
1006
1007#[derive(Default)]
1008pub(crate) struct TimerActionHooks {
1009    pub on_timer: Option<OnTimer>,
1010}
1011
1012#[derive(Debug, Clone)]
1013pub struct RovingTypeaheadCx {
1014    pub input: char,
1015    pub current: Option<usize>,
1016    pub len: usize,
1017    pub disabled: Arc<[bool]>,
1018    pub wrap: bool,
1019    pub tick: u64,
1020}
1021
1022pub type OnRovingActiveChange = Arc<dyn Fn(&mut dyn UiActionHost, ActionCx, usize) + 'static>;
1023
1024pub type OnRovingTypeahead =
1025    Arc<dyn Fn(&mut dyn UiActionHost, ActionCx, RovingTypeaheadCx) -> Option<usize> + 'static>;
1026
1027#[derive(Debug, Clone)]
1028pub struct RovingNavigateCx {
1029    pub key: KeyCode,
1030    pub modifiers: Modifiers,
1031    pub repeat: bool,
1032    pub axis: Axis,
1033    pub current: Option<usize>,
1034    pub len: usize,
1035    pub disabled: Arc<[bool]>,
1036    pub wrap: bool,
1037}
1038
1039#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1040pub enum RovingNavigateResult {
1041    NotHandled,
1042    Handled { target: Option<usize> },
1043}
1044
1045pub type OnRovingNavigate = Arc<
1046    dyn Fn(&mut dyn UiActionHost, ActionCx, RovingNavigateCx) -> RovingNavigateResult + 'static,
1047>;
1048
1049#[derive(Default)]
1050pub(crate) struct RovingActionHooks {
1051    pub on_active_change: Option<OnRovingActiveChange>,
1052    pub on_typeahead: Option<OnRovingTypeahead>,
1053    pub on_navigate: Option<OnRovingNavigate>,
1054    pub on_key_down: Vec<OnKeyDown>,
1055}