Skip to main content

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}