Skip to main content

truce_gui/
interaction.rs

1//! `BaseviewTranslator` - the windowing-toolkit-specific half of
2//! `truce-gui`'s interaction surface. The platform-agnostic data
3//! types (`InputEvent`, `MouseButton`, `Modifiers`, `WidgetRegion`,
4//! `InteractionState`, `DragState`, `DropdownState`, `dispatch`, …)
5//! live in [`truce_gui_types::interaction`] and are re-exported here
6//! so existing `truce_gui::interaction::*` paths keep working.
7
8pub use truce_gui_types::interaction::*;
9
10// Baseview-event translator is macOS / Windows / Linux only. iOS
11// delivers events via UIKit touch handlers in `editor_ios`.
12#[cfg(not(target_os = "ios"))]
13const DOUBLE_CLICK_MS: u128 = 300;
14#[cfg(not(target_os = "ios"))]
15const DOUBLE_CLICK_SLOP: f32 = 4.0;
16#[cfg(not(target_os = "ios"))]
17const WHEEL_LINE_PX: f32 = 20.0;
18
19/// Stateful translator from baseview events to truce-gui's
20/// platform-agnostic [`InputEvent`] stream.
21#[cfg(not(target_os = "ios"))]
22///
23/// Exists because baseview emits logical-point mouse positions on every
24/// platform (macOS via Cocoa points; X11 and Windows via explicit
25/// `to_logical`) but does not carry a position on `ButtonPressed` /
26/// `ButtonReleased` nor synthesize double-clicks.
27///
28/// Emitted `InputEvent`s carry **logical** coordinates unchanged from
29/// baseview. The rendering backend (e.g. `WgpuBackend`) handles the
30/// logical→physical conversion at raster time; callers must not
31/// pre-multiply by `scale`.
32// All fields share a `last_` prefix because the struct's whole purpose
33// is to remember the previous cursor / click - the prefix is meaningful,
34// not redundant.
35#[cfg(not(target_os = "ios"))]
36#[allow(clippy::struct_field_names)]
37#[derive(Default)]
38pub struct BaseviewTranslator {
39    last_cursor: (f32, f32),
40    last_click_time: Option<std::time::Instant>,
41    last_click_pos: (f32, f32),
42}
43
44#[cfg(not(target_os = "ios"))]
45impl BaseviewTranslator {
46    /// The last cursor position we saw from a `CursorMoved`, in logical
47    /// points. Useful when a caller needs to query cursor state outside
48    /// the event stream (e.g. for its own overlays).
49    #[must_use]
50    pub fn last_cursor(&self) -> (f32, f32) {
51        self.last_cursor
52    }
53
54    /// Convert a baseview event into an [`InputEvent`]. Returns `None`
55    /// for events truce-gui doesn't consume (keyboard, non-L/R/M mouse
56    /// buttons, window lifecycle).
57    pub fn translate(&mut self, event: &baseview::Event) -> Option<InputEvent> {
58        let baseview::Event::Mouse(m) = event else {
59            return None;
60        };
61        match m {
62            baseview::MouseEvent::CursorMoved { position, .. } => {
63                // baseview reports cursor in f64 logical points; the
64                // hit-test math is f32. Window dimensions never reach
65                // 2^23, so the narrowing is invisible.
66                #[allow(clippy::cast_possible_truncation)]
67                let x = position.x as f32;
68                #[allow(clippy::cast_possible_truncation)]
69                let y = position.y as f32;
70                self.last_cursor = (x, y);
71                Some(InputEvent::MouseMove {
72                    pointer_id: truce_gui_types::interaction::SINGLE_POINTER,
73                    x,
74                    y,
75                })
76            }
77            baseview::MouseEvent::ButtonPressed { button, .. } => {
78                let mb = map_button(*button)?;
79                let (x, y) = self.last_cursor;
80                if mb == MouseButton::Left {
81                    let now = std::time::Instant::now();
82                    let is_double = self.last_click_time.is_some_and(|t| {
83                        now.duration_since(t).as_millis() < DOUBLE_CLICK_MS
84                            && (x - self.last_click_pos.0).abs() < DOUBLE_CLICK_SLOP
85                            && (y - self.last_click_pos.1).abs() < DOUBLE_CLICK_SLOP
86                    });
87                    self.last_click_time = Some(now);
88                    self.last_click_pos = (x, y);
89                    if is_double {
90                        self.last_click_time = None;
91                        return Some(InputEvent::MouseDoubleClick { x, y });
92                    }
93                }
94                Some(InputEvent::MouseDown {
95                    pointer_id: truce_gui_types::interaction::SINGLE_POINTER,
96                    x,
97                    y,
98                    button: mb,
99                })
100            }
101            baseview::MouseEvent::ButtonReleased { button, .. } => {
102                let mb = map_button(*button)?;
103                let (x, y) = self.last_cursor;
104                Some(InputEvent::MouseUp {
105                    pointer_id: truce_gui_types::interaction::SINGLE_POINTER,
106                    x,
107                    y,
108                    button: mb,
109                })
110            }
111            baseview::MouseEvent::WheelScrolled { delta, .. } => {
112                let dy = match delta {
113                    baseview::ScrollDelta::Lines { y, .. } => y * WHEEL_LINE_PX,
114                    baseview::ScrollDelta::Pixels { y, .. } => *y,
115                };
116                let (x, y) = self.last_cursor;
117                Some(InputEvent::Scroll { x, y, dy })
118            }
119            baseview::MouseEvent::CursorLeft => Some(InputEvent::MouseLeave),
120            _ => None,
121        }
122    }
123}
124
125#[cfg(not(target_os = "ios"))]
126fn map_button(b: baseview::MouseButton) -> Option<MouseButton> {
127    match b {
128        baseview::MouseButton::Left => Some(MouseButton::Left),
129        baseview::MouseButton::Right => Some(MouseButton::Right),
130        baseview::MouseButton::Middle => Some(MouseButton::Middle),
131        _ => None,
132    }
133}