frust_core/event/input.rs
1//! The [`InputEvent`] enum the tree receives, plus its overlay and file-drop payloads.
2
3use std::path::PathBuf;
4
5use kurbo::{Affine, Point, Vec2};
6
7use crate::overlay::OverlayKey;
8use crate::shortcuts::ShortcutId;
9
10use super::ime::ImeEvent;
11use super::key::{EditCommand, KeyEvent};
12use super::pointer::{PointerEvent, PointerId, ScaleEvent, ScalePhase, ScrollDelta};
13
14/// What an [`InputEvent::Overlay`] carries: the owner's key and the input, in window space.
15#[derive(Clone, Debug, PartialEq)]
16pub struct OverlayEvent {
17 pub key: OverlayKey,
18 pub kind: OverlayEventKind,
19}
20
21/// An [`OverlayEvent`]'s input, in window space (the owner subtracts its rect origin itself).
22/// [`InputEvent::translated`] leaves an `Overlay` payload unchanged, so it stays in window space.
23#[derive(Clone, Debug, PartialEq)]
24pub enum OverlayEventKind {
25 Pointer(PointerEvent),
26 Scroll {
27 position: Point,
28 delta: ScrollDelta,
29 },
30 Scale {
31 focal: Point,
32 phase: ScalePhase,
33 scale_delta: f64,
34 velocity: f64,
35 },
36 /// A primary press outside every surface (light dismiss), sent only to `Notify` entries.
37 OutsideDown,
38 /// A shortcut the pod declared; the owner forwards it as [`InputEvent::Shortcut`].
39 Shortcut(ShortcutId),
40 /// A key the focus path declined (see [`crate::overlay`]); answer via `consume_overlay_key`.
41 Key(KeyEvent),
42}
43
44/// The phase of a desktop OS file drag over the window. A shell publishes `Hover`, `Drop` and
45/// `Cancel`; [`Ended`](FileDropPhase::Ended) is the root's own broadcast follow-up.
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum FileDropPhase {
48 Hover,
49 Drop,
50 Cancel,
51 /// Broadcast-only, after every `Drop`/`Cancel`: the drag is over, so end any state it opened.
52 Ended,
53}
54
55/// A desktop OS file drag/drop event, hit-tested and bubbling like [`InputEvent::Scroll`].
56///
57/// # Source
58/// Only `frust-shell-desktop` publishes it, mapping winit's `HoveredFile`/`DroppedFile`/
59/// `HoveredFileCancelled`; Android, iOS and web publish nothing (see `docs/LIMITATIONS.md`).
60///
61/// # Broadcast follow-up
62/// After the hit-tested dispatch of every `Drop`/`Cancel`, handled or not, the root broadcasts
63/// [`FileDropPhase::Ended`] to every widget in the same call; answering it must be idempotent.
64#[derive(Clone, Debug, PartialEq)]
65pub struct FileDropEvent {
66 pub phase: FileDropPhase,
67 /// Where the drag is, in the receiving widget's local logical space.
68 pub position: Point,
69 /// The dropped files, populated only for `Drop`. Untrusted OS input: resolve symlinks, check
70 /// type and size before opening, and sanitise a name before showing it.
71 pub paths: Vec<PathBuf>,
72}
73
74/// An input event delivered to the widget tree. Pointer, scroll, scale and file-drop events are
75/// hit-tested; key, IME and edit-command events are focus-routed; the rest are broadcasts.
76#[derive(Clone, Debug, PartialEq)]
77pub enum InputEvent {
78 /// A pointer gesture event — the only pointer form a widget receives; from a shell, the mouse.
79 Pointer(PointerEvent),
80 /// One identified pointer contact from a shell. Widgets never receive it: the root dispatches
81 /// `event` as [`InputEvent::Pointer`] and reports `pointer_id` via `EventCtx::pointer_id`.
82 ///
83 /// # Multi-contact contract
84 /// * **(a)** With no live capture, slot `0` is hit-tested exactly like `Pointer`; a capture
85 /// taken on its `Down` latches this id as the gesture's **claimant**.
86 /// * **(b)** With no live capture, slot `1` and above is dropped at the root.
87 /// * **(c)** While a capture is live, the claimant's events take the captured path; any other
88 /// id reaches only the captor (enclosing containers forward it unhandled), and only if it
89 /// called `EventCtx::capture_contacts` on its capturing `Down`; otherwise it is dropped.
90 /// Only the claimant's `Up`/`Cancel` releases the capture, and a container takeover
91 /// (`EventCtx::release_captured_child`) ends the opt-in.
92 PointerContact {
93 pointer_id: PointerId,
94 event: PointerEvent,
95 },
96 /// A scroll event at `position` carrying `delta`.
97 Scroll {
98 /// Where the scroll occurred, in the receiving widget's local logical space.
99 position: Point,
100 delta: ScrollDelta,
101 },
102 /// A pinch/zoom event, hit-tested by [`ScaleEvent::focal`] and bubbling until handled.
103 Scale(ScaleEvent),
104 /// A keyboard key event, routed down the focus path.
105 Key(KeyEvent),
106 /// An IME event, routed down the focus path.
107 Ime(ImeEvent),
108 /// A decoded clipboard/selection command, routed down the focus path; with nothing focused it
109 /// is dropped, so a shell may answer a stale paste request unconditionally.
110 EditCommand(EditCommand),
111 /// Not user input: a broadcast, never consumed, that lets a widget run a callback it queued
112 /// needing `&mut State` during a state-free build pass, on the frame that queued it.
113 Housekeeping,
114 /// A floated overlay surface's own input: a broadcast only the owner holding the matching
115 /// [`OverlayKey`] acts on; every other widget ignores it.
116 Overlay(OverlayEvent),
117 /// A desktop OS file drag; see [`FileDropEvent`]. Not routed through the overlay pre-pass.
118 FileDrop(FileDropEvent),
119 /// A broadcast naming the shortcut a key pressed (`crate::shortcuts`); the declarer answers.
120 Shortcut(ShortcutId),
121}
122
123impl InputEvent {
124 /// The event's location in the receiving widget's local space; [`Point::ZERO`] for
125 /// focus-routed events and broadcasts, which callers must never hit-test on.
126 pub fn position(&self) -> Point {
127 match self {
128 InputEvent::Pointer(p) | InputEvent::PointerContact { event: p, .. } => p.position,
129 InputEvent::Scroll { position, .. } => *position,
130 InputEvent::Scale(scale) => scale.focal,
131 InputEvent::FileDrop(drop) if drop.phase == FileDropPhase::Ended => Point::ZERO,
132 InputEvent::FileDrop(drop) => drop.position,
133 InputEvent::Key(_)
134 | InputEvent::Ime(_)
135 | InputEvent::EditCommand(_)
136 | InputEvent::Housekeeping
137 | InputEvent::Overlay(_)
138 | InputEvent::Shortcut(_) => Point::ZERO,
139 }
140 }
141
142 /// A copy with its position shifted by `offset` (a container passes `-child_origin`).
143 /// Positionless events and the window-space `Overlay` payload are returned unchanged.
144 pub fn translated(&self, offset: Vec2) -> InputEvent {
145 match self {
146 InputEvent::Pointer(p) => InputEvent::Pointer(PointerEvent {
147 position: p.position + offset,
148 ..*p
149 }),
150 InputEvent::PointerContact { pointer_id, event } => InputEvent::PointerContact {
151 pointer_id: *pointer_id,
152 event: PointerEvent {
153 position: event.position + offset,
154 ..*event
155 },
156 },
157 InputEvent::Scroll { position, delta } => InputEvent::Scroll {
158 position: *position + offset,
159 delta: *delta,
160 },
161 InputEvent::Scale(scale) => InputEvent::Scale(ScaleEvent {
162 focal: scale.focal + offset,
163 ..*scale
164 }),
165 InputEvent::FileDrop(drop) if drop.phase == FileDropPhase::Ended => self.clone(),
166 InputEvent::FileDrop(drop) => InputEvent::FileDrop(FileDropEvent {
167 position: drop.position + offset,
168 phase: drop.phase,
169 paths: drop.paths.clone(),
170 }),
171 InputEvent::Key(_)
172 | InputEvent::Ime(_)
173 | InputEvent::EditCommand(_)
174 | InputEvent::Housekeeping
175 | InputEvent::Overlay(_)
176 | InputEvent::Shortcut(_) => self.clone(),
177 }
178 }
179
180 /// A copy with exactly the positions `translated` shifts mapped through `affine` (the inverse
181 /// of the child's transform); deltas and velocities are carried over as-is.
182 pub fn transformed(&self, affine: &Affine) -> InputEvent {
183 match self {
184 InputEvent::Pointer(p) => InputEvent::Pointer(PointerEvent {
185 position: *affine * p.position,
186 ..*p
187 }),
188 InputEvent::PointerContact { pointer_id, event } => InputEvent::PointerContact {
189 pointer_id: *pointer_id,
190 event: PointerEvent {
191 position: *affine * event.position,
192 ..*event
193 },
194 },
195 InputEvent::Scroll { position, delta } => InputEvent::Scroll {
196 position: *affine * *position,
197 delta: *delta,
198 },
199 InputEvent::Scale(scale) => InputEvent::Scale(ScaleEvent {
200 focal: *affine * scale.focal,
201 ..*scale
202 }),
203 InputEvent::FileDrop(drop) if drop.phase == FileDropPhase::Ended => self.clone(),
204 InputEvent::FileDrop(drop) => InputEvent::FileDrop(FileDropEvent {
205 position: *affine * drop.position,
206 phase: drop.phase,
207 paths: drop.paths.clone(),
208 }),
209 InputEvent::Key(_)
210 | InputEvent::Ime(_)
211 | InputEvent::EditCommand(_)
212 | InputEvent::Housekeeping
213 | InputEvent::Overlay(_)
214 | InputEvent::Shortcut(_) => self.clone(),
215 }
216 }
217
218 /// Whether this event is focus-routed (key, IME, edit command) rather than hit-tested.
219 pub fn is_focus_routed(&self) -> bool {
220 matches!(
221 self,
222 InputEvent::Key(_) | InputEvent::Ime(_) | InputEvent::EditCommand(_)
223 )
224 }
225
226 /// Whether this event is a broadcast — `Housekeeping`, `Overlay`, `Shortcut`, or a file drop's
227 /// `Ended` — forwarded to every child before any capture/focus/hit-test branch.
228 pub fn is_broadcast(&self) -> bool {
229 matches!(
230 self,
231 InputEvent::Housekeeping
232 | InputEvent::Overlay(_)
233 | InputEvent::Shortcut(_)
234 | InputEvent::FileDrop(FileDropEvent {
235 phase: FileDropPhase::Ended,
236 ..
237 })
238 )
239 }
240}