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}