Skip to main content

fret_ui/
input_modality.rs

1use crate::UiHost;
2use fret_core::{AppWindowId, Event};
3use std::collections::HashMap;
4
5/// Last input modality observed for a window.
6///
7/// This is a lightweight policy signal used by component-layer behaviors (e.g. Radix-aligned menus)
8/// to distinguish "opened via keyboard" from "opened via pointer", without requiring every
9/// component to explicitly thread an "open reason" flag through its own model.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum InputModality {
12    Keyboard,
13    Pointer,
14}
15
16#[derive(Default)]
17struct InputModalityState {
18    per_window: HashMap<AppWindowId, InputModality>,
19}
20
21pub fn modality<H: UiHost>(app: &mut H, window: Option<AppWindowId>) -> InputModality {
22    let Some(window) = window else {
23        return InputModality::Pointer;
24    };
25    let Some(state) = app.global::<InputModalityState>() else {
26        return InputModality::Pointer;
27    };
28    state
29        .per_window
30        .get(&window)
31        .copied()
32        .unwrap_or(InputModality::Pointer)
33}
34
35pub fn is_keyboard<H: UiHost>(app: &mut H, window: Option<AppWindowId>) -> bool {
36    modality(app, window) == InputModality::Keyboard
37}
38
39fn set_modality_if_changed<H: UiHost>(
40    app: &mut H,
41    window: AppWindowId,
42    modality: InputModality,
43) -> bool {
44    app.with_global_mut_untracked(InputModalityState::default, |state, _app| {
45        let prev = state
46            .per_window
47            .get(&window)
48            .copied()
49            .unwrap_or(InputModality::Pointer);
50        if prev != modality {
51            state.per_window.insert(window, modality);
52            true
53        } else {
54            false
55        }
56    })
57}
58
59/// Update the input modality state for a window.
60///
61/// Returns `true` if the modality changed.
62pub fn update_for_event<H: UiHost>(app: &mut H, window: AppWindowId, event: &Event) -> bool {
63    match event {
64        // Radix-style: any keydown counts as "keyboard interaction" until we see pointer activity.
65        Event::KeyDown { .. } => set_modality_if_changed(app, window, InputModality::Keyboard),
66        // Any pointer activity switches back to pointer modality.
67        Event::Pointer(_) | Event::ExternalDrag(_) | Event::InternalDrag(_) => {
68            set_modality_if_changed(app, window, InputModality::Pointer)
69        }
70        _ => false,
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77    use crate::test_host::TestHost;
78    use fret_core::{Modifiers, PointerEvent};
79
80    #[test]
81    fn defaults_to_pointer() {
82        let window = AppWindowId::default();
83        let mut app = TestHost::default();
84        assert_eq!(modality(&mut app, Some(window)), InputModality::Pointer);
85    }
86
87    #[test]
88    fn keydown_sets_keyboard_until_pointer_activity() {
89        let window = AppWindowId::default();
90        let mut app = TestHost::default();
91
92        assert!(update_for_event(
93            &mut app,
94            window,
95            &Event::KeyDown {
96                key: fret_core::KeyCode::KeyA,
97                modifiers: Modifiers::default(),
98                repeat: false,
99            }
100        ));
101        assert_eq!(modality(&mut app, Some(window)), InputModality::Keyboard);
102
103        assert!(update_for_event(
104            &mut app,
105            window,
106            &Event::Pointer(PointerEvent::Move {
107                position: fret_core::Point::new(fret_core::Px(1.0), fret_core::Px(2.0)),
108                buttons: fret_core::MouseButtons::default(),
109                modifiers: Modifiers::default(),
110                pointer_id: fret_core::PointerId(0),
111                pointer_type: fret_core::PointerType::Mouse,
112            })
113        ));
114        assert_eq!(modality(&mut app, Some(window)), InputModality::Pointer);
115    }
116}