Skip to main content

tessera_foundation/modifier/
interaction.rs

1//! Shared interaction configuration types for modifier APIs.
2//!
3//! ## Usage
4//!
5//! Configure clickable, toggleable, selectable, and draggable modifier
6//! behavior.
7
8use tessera_ui::{
9    BuildModifierNode, Callback, CallbackWith, FocusProperties, FocusRequester, Modifier,
10    ModifierBuildContext, PointerInput, PointerInputModifierNode, Px, PxPosition, PxSize, State,
11    accesskit, modifier::ModifierCapabilityExt as _,
12};
13
14use crate::gesture::{DragAxis, DragRecognizer, DragSettings, LongPressRecognizer, TapRecognizer};
15
16/// Context for pointer press/release callbacks.
17#[derive(Clone, PartialEq, Copy, Debug)]
18pub struct PointerEventContext {
19    /// Pointer position normalized to `[0.0, 1.0]` within the element bounds.
20    pub normalized_pos: [f32; 2],
21    /// The element size in pixels.
22    pub size: PxSize,
23}
24
25type PressCallback = CallbackWith<PointerEventContext, ()>;
26type DragCallback = CallbackWith<DragDelta, ()>;
27
28/// Arguments for the `clickable` modifier.
29#[derive(Clone)]
30pub struct ClickableArgs {
31    /// Callback invoked when the element is clicked.
32    pub on_click: Callback,
33    /// Whether the element is enabled for interaction.
34    pub enabled: bool,
35    /// Whether to block input propagation when within bounds.
36    pub block_input: bool,
37    /// Optional press callback with normalized position and element size.
38    pub on_press: Option<PressCallback>,
39    /// Optional release callback with normalized position and element size.
40    pub on_release: Option<PressCallback>,
41    /// Optional accessibility role.
42    pub role: Option<accesskit::Role>,
43    /// Optional accessibility label.
44    pub label: Option<String>,
45    /// Optional accessibility description.
46    pub description: Option<String>,
47    /// Optional external interaction state.
48    pub interaction_state: Option<State<InteractionState>>,
49    /// Optional externally managed focus requester for this clickable target.
50    pub focus_requester: Option<FocusRequester>,
51    /// Optional focus properties applied to the clickable target.
52    pub focus_properties: Option<FocusProperties>,
53    /// Optional persistent tap recognizer state for component-owned gestures.
54    pub tap_recognizer: Option<State<TapRecognizer>>,
55    /// Optional component-owned long-press recognizer. When supplied, a
56    /// recognized long press suppresses the ordinary click.
57    pub long_press_recognizer: Option<State<LongPressRecognizer>>,
58}
59
60impl Default for ClickableArgs {
61    fn default() -> Self {
62        Self {
63            on_click: Callback::noop(),
64            enabled: true,
65            block_input: true,
66            on_press: None,
67            on_release: None,
68            role: None,
69            label: None,
70            description: None,
71            interaction_state: None,
72            focus_requester: None,
73            focus_properties: None,
74            tap_recognizer: None,
75            long_press_recognizer: None,
76        }
77    }
78}
79
80/// Arguments for the `toggleable` modifier.
81#[derive(Clone)]
82pub struct ToggleableArgs {
83    /// Current boolean value.
84    pub value: bool,
85    /// Callback invoked with the new value when changed.
86    pub on_value_change: CallbackWith<bool, ()>,
87    /// Whether the control is enabled for interaction.
88    pub enabled: bool,
89    /// Optional accessibility role.
90    pub role: Option<accesskit::Role>,
91    /// Optional accessibility label.
92    pub label: Option<String>,
93    /// Optional accessibility description.
94    pub description: Option<String>,
95    /// Optional external interaction state.
96    pub interaction_state: Option<State<InteractionState>>,
97    /// Optional press callback with normalized position and element size.
98    pub on_press: Option<PressCallback>,
99    /// Optional release callback with normalized position and element size.
100    pub on_release: Option<PressCallback>,
101    /// Optional externally managed focus requester for this toggleable target.
102    pub focus_requester: Option<FocusRequester>,
103    /// Optional persistent tap recognizer state for component-owned gestures.
104    pub tap_recognizer: Option<State<TapRecognizer>>,
105}
106
107impl Default for ToggleableArgs {
108    fn default() -> Self {
109        Self {
110            value: false,
111            on_value_change: CallbackWith::default_value(),
112            enabled: true,
113            role: None,
114            label: None,
115            description: None,
116            interaction_state: None,
117            on_press: None,
118            on_release: None,
119            focus_requester: None,
120            tap_recognizer: None,
121        }
122    }
123}
124
125/// Arguments for the `selectable` modifier.
126#[derive(Clone)]
127pub struct SelectableArgs {
128    /// Whether the item is selected.
129    pub selected: bool,
130    /// Callback invoked when the selectable is activated.
131    pub on_click: Callback,
132    /// Whether the element is enabled for interaction.
133    pub enabled: bool,
134    /// Optional accessibility role.
135    pub role: Option<accesskit::Role>,
136    /// Optional accessibility label.
137    pub label: Option<String>,
138    /// Optional accessibility description.
139    pub description: Option<String>,
140    /// Optional external interaction state.
141    pub interaction_state: Option<State<InteractionState>>,
142    /// Optional press callback with normalized position and element size.
143    pub on_press: Option<PressCallback>,
144    /// Optional release callback with normalized position and element size.
145    pub on_release: Option<PressCallback>,
146    /// Optional externally managed focus requester for this selectable target.
147    pub focus_requester: Option<FocusRequester>,
148    /// Optional persistent tap recognizer state for component-owned gestures.
149    pub tap_recognizer: Option<State<TapRecognizer>>,
150}
151
152impl Default for SelectableArgs {
153    fn default() -> Self {
154        Self {
155            selected: false,
156            on_click: Callback::noop(),
157            enabled: true,
158            role: None,
159            label: None,
160            description: None,
161            interaction_state: None,
162            on_press: None,
163            on_release: None,
164            focus_requester: None,
165            tap_recognizer: None,
166        }
167    }
168}
169
170/// Pointer delta produced by the `draggable` modifier.
171#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
172pub struct DragDelta {
173    /// Horizontal delta in local physical pixels.
174    pub x: Px,
175    /// Vertical delta in local physical pixels.
176    pub y: Px,
177}
178
179/// Arguments for the `draggable` modifier.
180#[derive(Clone)]
181pub struct DraggableArgs {
182    /// Callback invoked with each drag delta update.
183    pub on_drag_delta: DragCallback,
184    /// Whether dragging is enabled.
185    pub enabled: bool,
186    /// Optional drag axis lock.
187    pub axis: Option<DragAxis>,
188    /// Minimum travel before the drag starts.
189    pub slop_px: f32,
190    /// Whether drag pointer changes should be consumed after dragging starts.
191    pub consume_when_dragging: bool,
192    /// Optional callback invoked when dragging starts.
193    pub on_drag_started: Option<Callback>,
194    /// Optional callback invoked when dragging stops after an active drag.
195    pub on_drag_stopped: Option<Callback>,
196    /// Optional external interaction state updated with the dragged flag.
197    pub interaction_state: Option<State<InteractionState>>,
198    /// Optional persistent drag recognizer state for component-owned gestures.
199    pub drag_recognizer: Option<State<DragRecognizer>>,
200}
201
202impl Default for DraggableArgs {
203    fn default() -> Self {
204        Self {
205            on_drag_delta: CallbackWith::default_value(),
206            enabled: true,
207            axis: None,
208            slop_px: DragSettings::default().slop_px,
209            consume_when_dragging: DragSettings::default().consume_when_dragging,
210            on_drag_started: None,
211            on_drag_stopped: None,
212            interaction_state: None,
213            drag_recognizer: None,
214        }
215    }
216}
217
218/// A visual interaction used when a control has no active user interaction.
219///
220/// Presentation changes appearance without requesting focus, dispatching input,
221/// or changing accessibility state. Disabled controls ignore presentation
222/// state.
223///
224/// ```
225/// use tessera_foundation::modifier::{InteractionState, InteractionVisualState};
226///
227/// let live = InteractionState::new();
228/// let visual = live.with_presentation(Some(InteractionVisualState::Focus), true);
229/// assert!(visual.is_focused());
230/// assert!(!live.is_focused());
231/// ```
232#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
233pub enum InteractionVisualState {
234    /// The resting appearance.
235    #[default]
236    Rest,
237    /// The hovered appearance.
238    Hover,
239    /// The focused appearance.
240    Focus,
241    /// The pressed appearance.
242    Pressed,
243    /// The dragged appearance.
244    Dragged,
245}
246
247/// Tracks basic interaction flags and derives state-layer alpha.
248#[derive(Clone, PartialEq, Copy, Debug, Default)]
249pub struct InteractionState {
250    is_hovered: bool,
251    is_focused: bool,
252    is_dragged: bool,
253    is_pressed: bool,
254}
255
256impl InteractionState {
257    /// Resolves appearance while preserving all active user interaction flags.
258    ///
259    /// Disabled controls return a resting state. Otherwise, any active live
260    /// interaction takes precedence over the optional presentation state.
261    /// The returned snapshot does not modify the live interaction state.
262    pub fn with_presentation(
263        self,
264        presentation_state: Option<InteractionVisualState>,
265        enabled: bool,
266    ) -> Self {
267        if !enabled {
268            return Self::default();
269        }
270        if self.is_hovered || self.is_focused || self.is_dragged || self.is_pressed {
271            return self;
272        }
273        Self {
274            is_hovered: presentation_state == Some(InteractionVisualState::Hover),
275            is_focused: presentation_state == Some(InteractionVisualState::Focus),
276            is_dragged: presentation_state == Some(InteractionVisualState::Dragged),
277            is_pressed: presentation_state == Some(InteractionVisualState::Pressed),
278        }
279    }
280
281    /// Creates a new interaction state with all flags cleared.
282    pub fn new() -> Self {
283        Self::default()
284    }
285
286    /// Marks the component as no longer pressed.
287    pub fn release(&mut self) {
288        self.set_pressed(false);
289    }
290
291    /// Sets whether the component is hovered.
292    pub fn set_hovered(&mut self, hovered: bool) {
293        self.is_hovered = hovered;
294    }
295
296    /// Returns whether the component is hovered.
297    pub fn is_hovered(&self) -> bool {
298        self.is_hovered
299    }
300
301    /// Sets whether the component is focused.
302    pub fn set_focused(&mut self, focused: bool) {
303        self.is_focused = focused;
304    }
305
306    /// Returns whether the component is focused.
307    pub fn is_focused(&self) -> bool {
308        self.is_focused
309    }
310
311    /// Sets whether the component is dragged.
312    pub fn set_dragged(&mut self, dragged: bool) {
313        self.is_dragged = dragged;
314    }
315
316    /// Returns whether the component is dragged.
317    pub fn is_dragged(&self) -> bool {
318        self.is_dragged
319    }
320
321    /// Sets whether the component is pressed.
322    pub fn set_pressed(&mut self, pressed: bool) {
323        self.is_pressed = pressed;
324    }
325
326    /// Returns whether the component is pressed.
327    pub fn is_pressed(&self) -> bool {
328        self.is_pressed
329    }
330
331    /// Returns the state-layer alpha derived from the current interactions.
332    pub fn state_layer_alpha(&self) -> f32 {
333        if self.is_dragged {
334            0.16
335        } else if self.is_pressed || self.is_focused {
336            0.10
337        } else if self.is_hovered {
338            0.08
339        } else {
340            0.0
341        }
342    }
343}
344
345#[derive(Clone)]
346enum DragRecognizerHandle {
347    State(State<DragRecognizer>),
348}
349
350impl DragRecognizerHandle {
351    fn with_mut<R>(&self, f: impl FnOnce(&mut DragRecognizer) -> R) -> R {
352        match self {
353            Self::State(state) => state.with_mut(f),
354        }
355    }
356}
357
358struct DraggablePointerModifierNode {
359    drag_recognizer: DragRecognizerHandle,
360    on_drag_delta: DragCallback,
361    enabled: bool,
362    on_drag_started: Option<Callback>,
363    on_drag_stopped: Option<Callback>,
364    interaction_state: Option<State<InteractionState>>,
365}
366
367impl PointerInputModifierNode for DraggablePointerModifierNode {
368    fn on_pointer_input(&self, input: PointerInput<'_>) {
369        if !self.enabled {
370            return;
371        }
372
373        let within_bounds = cursor_within_bounds(
374            input.cursor_position_rel,
375            PxSize::new(input.computed_data.width, input.computed_data.height),
376        );
377        let (was_dragging, drag_result) = self.drag_recognizer.with_mut(|recognizer| {
378            let was_dragging = recognizer.is_dragging();
379            let drag_result = recognizer.update(input.pass, input.pointer_changes, within_bounds);
380            (was_dragging, drag_result)
381        });
382
383        if drag_result.started {
384            if let Some(interaction_state) = self.interaction_state {
385                interaction_state.with_mut(|state| state.set_dragged(true));
386            }
387            if let Some(on_drag_started) = self.on_drag_started {
388                on_drag_started.call();
389            }
390        }
391
392        if drag_result.updated {
393            let (x, y) = self.drag_recognizer.with_mut(|recognizer| {
394                recognizer.local_integer_delta(
395                    drag_result.delta_x,
396                    drag_result.delta_y,
397                    input.local_to_window,
398                )
399            });
400            self.on_drag_delta.call(DragDelta { x, y });
401        }
402
403        if drag_result.ended && was_dragging {
404            if let Some(interaction_state) = self.interaction_state {
405                interaction_state.with_mut(|state| state.set_dragged(false));
406            }
407            if let Some(on_drag_stopped) = self.on_drag_stopped {
408                on_drag_stopped.call();
409            }
410        }
411    }
412}
413
414fn cursor_within_bounds(position: Option<PxPosition>, size: PxSize) -> bool {
415    let Some(position) = position else {
416        return false;
417    };
418
419    position.x >= 0.0
420        && position.y >= 0.0
421        && position.x < size.width.0 as f32
422        && position.y < size.height.0 as f32
423}
424
425pub(crate) fn apply_draggable_modifier(base: Modifier, args: DraggableArgs) -> Modifier {
426    let DraggableArgs {
427        on_drag_delta,
428        enabled,
429        axis,
430        slop_px,
431        consume_when_dragging,
432        on_drag_started,
433        on_drag_stopped,
434        interaction_state,
435        drag_recognizer,
436    } = args;
437
438    let settings = DragSettings {
439        slop_px,
440        consume_when_dragging,
441        axis,
442    };
443    base.push_build(DraggableBuild {
444        settings,
445        drag_recognizer,
446        on_drag_delta,
447        enabled,
448        on_drag_started,
449        on_drag_stopped,
450        interaction_state,
451    })
452}
453
454struct DraggableBuild {
455    settings: DragSettings,
456    drag_recognizer: Option<State<DragRecognizer>>,
457    on_drag_delta: DragCallback,
458    enabled: bool,
459    on_drag_started: Option<Callback>,
460    on_drag_stopped: Option<Callback>,
461    interaction_state: Option<State<InteractionState>>,
462}
463
464impl BuildModifierNode for DraggableBuild {
465    fn materialize(&self, context: &mut ModifierBuildContext) -> Option<Modifier> {
466        if !self.enabled {
467            if let Some(state) = self.interaction_state {
468                state.with_mut(|state| state.set_dragged(false));
469            }
470            return Some(Modifier::new());
471        }
472        let recognizer = self
473            .drag_recognizer
474            .unwrap_or_else(|| context.state(|| DragRecognizer::new(self.settings)));
475        recognizer.with_mut(|recognizer| recognizer.set_settings(self.settings));
476        Some(
477            Modifier::new().push_pointer_input(DraggablePointerModifierNode {
478                drag_recognizer: DragRecognizerHandle::State(recognizer),
479                on_drag_delta: self.on_drag_delta,
480                enabled: self.enabled,
481                on_drag_started: self.on_drag_started,
482                on_drag_stopped: self.on_drag_stopped,
483                interaction_state: self.interaction_state,
484            }),
485        )
486    }
487}
488
489#[cfg(test)]
490mod presentation_tests {
491    use super::{InteractionState, InteractionVisualState};
492
493    #[test]
494    fn presentation_preserves_live_combinations_and_disabled_priority() {
495        for bits in 0..16 {
496            let mut live = InteractionState::new();
497            live.set_hovered(bits & 1 != 0);
498            live.set_focused(bits & 2 != 0);
499            live.set_pressed(bits & 4 != 0);
500            live.set_dragged(bits & 8 != 0);
501            assert_eq!(live.with_presentation(None, true), live);
502            for preview in [
503                InteractionVisualState::Rest,
504                InteractionVisualState::Hover,
505                InteractionVisualState::Focus,
506                InteractionVisualState::Pressed,
507                InteractionVisualState::Dragged,
508            ] {
509                assert_eq!(
510                    live.with_presentation(Some(preview), false),
511                    InteractionState::new(),
512                );
513                let visual = live.with_presentation(Some(preview), true);
514                if bits != 0 {
515                    assert_eq!(visual, live);
516                } else {
517                    assert_eq!(
518                        visual.is_hovered(),
519                        preview == InteractionVisualState::Hover
520                    );
521                    assert_eq!(
522                        visual.is_focused(),
523                        preview == InteractionVisualState::Focus
524                    );
525                    assert_eq!(
526                        visual.is_pressed(),
527                        preview == InteractionVisualState::Pressed
528                    );
529                    assert_eq!(
530                        visual.is_dragged(),
531                        preview == InteractionVisualState::Dragged
532                    );
533                }
534            }
535        }
536    }
537}