teksilo_scene/item_handlers.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Per-item event handlers, cursor and tooltip overrides.
5//!
6//! [`SceneItemHandlerSet`] is the lightweight-tier counterpart to
7//! widget-level [`HandlerSet`](teksilo_core::widget_builder::HandlerSet).
8//! It carries optional closures the [`SceneView`](crate::SceneView)
9//! invokes when pointer / hover / context-menu events land on the
10//! item, plus per-item cursor and tooltip overrides.
11//!
12//! Apps attach handlers via `Scene::set_item_handlers` /
13//! `Scene::handlers_mut` after `add_item`:
14//!
15//! ```ignore
16//! let id = scene.add_item(rect, Point::ZERO);
17//! scene.handlers_mut(id).unwrap()
18//! .on_tap(|_pt, ctx| ctx.send_intent(AppIntent::OpenCard))
19//! .cursor(CursorIcon::Pointer)
20//! .tooltip("Open card");
21//! ```
22
23use std::rc::Rc;
24
25use teksilo_canvas::Point;
26use teksilo_core::event::{ButtonMask, Modifiers, PointerButton};
27use teksilo_core::widget::{CursorIcon, EventContext};
28use teksilo_i18n::LocalizedString;
29
30/// Box of an item-level event closure with a single non-event
31/// argument (used for `on_hover`'s `bool` payload).
32type ItemHandler<A> = Rc<dyn Fn(A, &mut EventContext)>;
33
34/// Click-style gesture envelope for scene items. Mirrors the
35/// widget-tier [`teksilo_core::gesture::TapEvent`] but with the
36/// position in **scene** coordinates instead of widget-local. Used
37/// by the tap / double-tap / triple-tap / long-press / context-menu
38/// handlers on [`SceneItemHandlerSet`].
39///
40/// `#[non_exhaustive]` so future additions (e.g. tap count,
41/// stylus pressure) can land without breaking match patterns.
42#[derive(Debug, Clone, Copy)]
43#[non_exhaustive]
44pub struct SceneTapEvent {
45 /// Click position in **scene** coordinates. The SceneView's
46 /// dispatch converts the raw screen-pixel pointer position
47 /// through the view transform before populating this field,
48 /// so handlers see the same frame their item's geometry is in.
49 pub position_scene: Point,
50 /// Which button finalised the gesture.
51 pub button: PointerButton,
52 /// Modifier keys held at dispatch time.
53 pub modifiers: Modifiers,
54}
55
56impl SceneTapEvent {
57 /// Construct one by hand. Useful for tests; dispatch builds
58 /// these from the live pointer event in `SceneView`.
59 pub fn new(position_scene: Point, button: PointerButton, modifiers: Modifiers) -> Self {
60 Self {
61 position_scene,
62 button,
63 modifiers,
64 }
65 }
66}
67
68/// Rich tap-family handler storage type — what every Set's
69/// `on_tap` / `on_double_tap` / `on_context_menu` field actually
70/// holds. The Point-only convenience setter
71/// [`SceneItemHandlerSet::on_tap`] wraps caller closures with a
72/// shim that extracts `event.position_scene`, so legacy call
73/// sites compile unchanged.
74type SceneTapHandler = Rc<dyn Fn(&SceneTapEvent, &mut EventContext)>;
75
76/// What a [`SceneView`](crate::SceneView)'s on-canvas pointer drag
77/// does in empty space.
78///
79/// * [`DragMode::NoDrag`] — nothing happens. Useful for embedded
80/// read-only diagrams.
81/// * [`DragMode::ScrollHandDrag`] — left-click-drag pans the view.
82/// Item-level on-drag handlers are bypassed; the canvas grabs
83/// the gesture unconditionally.
84/// * [`DragMode::RubberBand`] (default) — drag-on-empty-space
85/// creates a marquee that selects items inside on release.
86/// Drag-on-an-item dispatches to that item's drag handler if
87/// wired (the drag pipeline honours `IS_DRAGGABLE` for drag-to-move).
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
89pub enum DragMode {
90 /// Empty-space drag is a no-op; useful for read-only embedded diagrams.
91 NoDrag,
92 /// Left-click-drag pans the viewport; item-level drag handlers are bypassed.
93 ScrollHandDrag,
94 /// Empty-space drag draws a selection marquee; item drag dispatches to the
95 /// item's drag handler (respecting `IS_DRAGGABLE`). This is the default.
96 #[default]
97 RubberBand,
98}
99
100/// Per-item event closures + cursor + tooltip + drop acceptance.
101///
102/// Closures are stored as `Rc<dyn Fn>` so cloning the handler set
103/// is cheap; the SceneView clones into its dispatch path.
104#[derive(Clone)]
105pub struct SceneItemHandlerSet {
106 /// Tap (single click). Stored as the rich `SceneTapEvent`
107 /// form internally; the simpler [`Self::on_tap`] setter wraps
108 /// `Fn(Point, ...)` callers in a shim that extracts
109 /// `event.position_scene`.
110 pub on_tap: Option<SceneTapHandler>,
111 /// Double-tap. Storage only — the SceneView's dispatch site
112 /// doesn't synthesise double-tap recognition yet; this field
113 /// stays unset-on-fire until a future unit wires the
114 /// recognizer in. Treat as a forward-declared slot.
115 pub on_double_tap: Option<SceneTapHandler>,
116 /// Hover transitions: `bool` is `true` on enter, `false` on
117 /// leave.
118 pub on_hover: Option<ItemHandler<bool>>,
119 /// Right-click / OS-native context-menu trigger.
120 pub on_context_menu: Option<SceneTapHandler>,
121 /// Which pointer buttons count as a "tap" / context-menu
122 /// invocation. Default [`ButtonMask::PRIMARY`] for tap; the
123 /// SECONDARY button always routes through `on_context_menu`
124 /// regardless of this mask. Items wanting middle-click-as-tap
125 /// extend the mask: `accept_tap_buttons(PRIMARY | MIDDLE)`.
126 /// Mirrors widget-tier `accept_tap_buttons(...)`.
127 pub accept_tap_buttons: ButtonMask,
128 /// Cursor icon shown while the pointer is over this item.
129 /// Overrides the SceneView default.
130 pub cursor: Option<CursorIcon>,
131 /// Tooltip body for this item. Kept as a `LocalizedString` (not an
132 /// eagerly-resolved `String`) so a `tr!(...)` source follows the
133 /// active locale — the SceneView resolves it against the current
134 /// locale at show time. The SceneView's hover machinery surfaces it
135 /// as a point-anchored overlay through the standard overlay manager.
136 pub tooltip: Option<LocalizedString>,
137 /// Whether the item accepts dropped payloads.
138 pub accepts_drops: bool,
139}
140
141impl Default for SceneItemHandlerSet {
142 fn default() -> Self {
143 Self {
144 on_tap: None,
145 on_double_tap: None,
146 on_hover: None,
147 on_context_menu: None,
148 accept_tap_buttons: ButtonMask::PRIMARY,
149 cursor: None,
150 tooltip: None,
151 accepts_drops: false,
152 }
153 }
154}
155
156impl SceneItemHandlerSet {
157 /// An empty handler set — every closure unset, no cursor or
158 /// tooltip.
159 pub fn new() -> Self {
160 Self::default()
161 }
162
163 /// Register a tap callback. Simpler `Fn(Point, &mut ctx)`
164 /// signature for callers that only need the click position;
165 /// internally wraps in a shim that extracts
166 /// `event.position_scene`. For modifier-aware handlers (Shift-
167 /// click selection, Ctrl-click toggle, etc.) use
168 /// [`Self::on_tap_event`] which exposes the full
169 /// [`SceneTapEvent`].
170 pub fn on_tap<F>(&mut self, f: F) -> &mut Self
171 where
172 F: Fn(Point, &mut EventContext) + 'static,
173 {
174 let f = Rc::new(f);
175 self.on_tap = Some(Rc::new(move |ev: &SceneTapEvent, ctx| {
176 f(ev.position_scene, ctx);
177 }));
178 self
179 }
180
181 /// Register a tap callback that receives the full
182 /// [`SceneTapEvent`] — scene-coord position, button, modifiers.
183 /// Use for modifier-aware patterns (`Shift+click extends
184 /// selection`, `Ctrl+click toggles`, middle-click handlers
185 /// once paired with `accept_tap_buttons`).
186 pub fn on_tap_event<F>(&mut self, f: F) -> &mut Self
187 where
188 F: Fn(&SceneTapEvent, &mut EventContext) + 'static,
189 {
190 self.on_tap = Some(Rc::new(f));
191 self
192 }
193
194 /// Register a double-tap callback (Point-only shim — see
195 /// [`Self::on_tap`]). **Not wired yet:** the SceneView's
196 /// dispatch doesn't recognise double-tap; the field is stored
197 /// but never fired. A future unit wires the recognizer.
198 pub fn on_double_tap<F>(&mut self, f: F) -> &mut Self
199 where
200 F: Fn(Point, &mut EventContext) + 'static,
201 {
202 let f = Rc::new(f);
203 self.on_double_tap = Some(Rc::new(move |ev: &SceneTapEvent, ctx| {
204 f(ev.position_scene, ctx);
205 }));
206 self
207 }
208
209 /// Rich-event variant of [`Self::on_double_tap`].
210 pub fn on_double_tap_event<F>(&mut self, f: F) -> &mut Self
211 where
212 F: Fn(&SceneTapEvent, &mut EventContext) + 'static,
213 {
214 self.on_double_tap = Some(Rc::new(f));
215 self
216 }
217
218 /// Register a hover callback. Receives `true` on enter,
219 /// `false` on leave.
220 pub fn on_hover<F>(&mut self, f: F) -> &mut Self
221 where
222 F: Fn(bool, &mut EventContext) + 'static,
223 {
224 self.on_hover = Some(Rc::new(f));
225 self
226 }
227
228 /// Register a context-menu callback (right-click). Point-only
229 /// shim; see [`Self::on_context_menu_event`] for the rich
230 /// variant.
231 pub fn on_context_menu<F>(&mut self, f: F) -> &mut Self
232 where
233 F: Fn(Point, &mut EventContext) + 'static,
234 {
235 let f = Rc::new(f);
236 self.on_context_menu = Some(Rc::new(move |ev: &SceneTapEvent, ctx| {
237 f(ev.position_scene, ctx);
238 }));
239 self
240 }
241
242 /// Rich-event variant of [`Self::on_context_menu`].
243 pub fn on_context_menu_event<F>(&mut self, f: F) -> &mut Self
244 where
245 F: Fn(&SceneTapEvent, &mut EventContext) + 'static,
246 {
247 self.on_context_menu = Some(Rc::new(f));
248 self
249 }
250
251 /// Mask of pointer buttons that should be treated as a tap
252 /// for this item. Default [`ButtonMask::PRIMARY`]. Right-click
253 /// (`SECONDARY`) always routes through `on_context_menu`
254 /// regardless of this mask.
255 pub fn accept_tap_buttons(&mut self, mask: ButtonMask) -> &mut Self {
256 self.accept_tap_buttons = mask;
257 self
258 }
259
260 /// Override the cursor icon shown over this item.
261 pub fn cursor(&mut self, c: CursorIcon) -> &mut Self {
262 self.cursor = Some(c);
263 self
264 }
265
266 /// Set a tooltip. Accepts anything convertible into
267 /// [`LocalizedString`] — most commonly
268 /// `tr!(...)` for translated copy or `lit!(...)` for fixed text.
269 /// Stored unresolved; the SceneView resolves it against the active
270 /// locale when the tooltip is shown, so a `tr!(...)` source tracks
271 /// locale changes.
272 pub fn tooltip(&mut self, t: impl Into<LocalizedString>) -> &mut Self {
273 self.tooltip = Some(t.into());
274 self
275 }
276
277 /// Mark whether the item accepts dropped payloads.
278 pub fn accepts_drops(&mut self, accepts: bool) -> &mut Self {
279 self.accepts_drops = accepts;
280 self
281 }
282}
283
284impl std::fmt::Debug for SceneItemHandlerSet {
285 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286 f.debug_struct("SceneItemHandlerSet")
287 .field("on_tap", &self.on_tap.as_ref().map(|_| "..."))
288 .field("on_double_tap", &self.on_double_tap.as_ref().map(|_| "..."))
289 .field("on_hover", &self.on_hover.as_ref().map(|_| "..."))
290 .field(
291 "on_context_menu",
292 &self.on_context_menu.as_ref().map(|_| "..."),
293 )
294 .field("accept_tap_buttons", &self.accept_tap_buttons)
295 .field("cursor", &self.cursor)
296 .field("tooltip", &self.tooltip)
297 .field("accepts_drops", &self.accepts_drops)
298 .finish()
299 }
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305 use teksilo_i18n::lit;
306
307 #[test]
308 fn empty_handler_set_has_no_callbacks() {
309 let h = SceneItemHandlerSet::new();
310 assert!(h.on_tap.is_none());
311 assert!(h.on_hover.is_none());
312 assert!(h.on_context_menu.is_none());
313 assert!(h.cursor.is_none());
314 assert!(h.tooltip.is_none());
315 assert!(!h.accepts_drops);
316 }
317
318 #[test]
319 fn cursor_and_tooltip_round_trip() {
320 let mut h = SceneItemHandlerSet::new();
321 h.cursor(CursorIcon::Pointer).tooltip(lit!("hello"));
322 assert_eq!(h.cursor, Some(CursorIcon::Pointer));
323 assert_eq!(
324 h.tooltip.as_ref().map(|t| t.resolve_now()),
325 Some("hello".to_string())
326 );
327 }
328
329 #[test]
330 fn drag_mode_default_is_rubber_band() {
331 assert_eq!(DragMode::default(), DragMode::RubberBand);
332 }
333}