lumen_core/input.rs
1//! Pointer, keyboard, drag, IME, and file-drop event types plus shared input resources.
2//!
3//! - Window backends translate raw OS events into the typed messages defined here.
4//! - `lumen-input` reads them to drive hit-testing, focus routing, and Click/Hover dispatch.
5
6use bevy_ecs::message::Message;
7use bevy_ecs::prelude::*;
8use glam::Vec2;
9
10/// Mouse button identifier carried by pointer-press and pointer-release messages.
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
12pub enum PointerButton {
13 /// Primary (typically left mouse).
14 Primary,
15 /// Secondary (typically right mouse).
16 Secondary,
17 /// Middle button / wheel click.
18 Middle,
19 /// Any other button, identified by raw OS code.
20 Other(u16),
21}
22
23/// Pointer moved to a new position in window coordinates.
24#[derive(Message, Clone, Copy, Debug)]
25pub struct PointerMoved {
26 /// New position in logical pixels, top-left origin.
27 pub position: Vec2,
28}
29
30/// Pointer button pressed.
31#[derive(Message, Clone, Copy, Debug)]
32pub struct PointerPressed {
33 /// Position at time of press.
34 pub position: Vec2,
35 /// Which button.
36 pub button: PointerButton,
37}
38
39/// Pointer button released.
40#[derive(Message, Clone, Copy, Debug)]
41pub struct PointerReleased {
42 /// Position at time of release.
43 pub position: Vec2,
44 /// Which button.
45 pub button: PointerButton,
46}
47
48/// Pointer left the window.
49#[derive(Message, Clone, Copy, Debug)]
50pub struct PointerLeft;
51
52/// Mouse-wheel scroll event. `delta` is in logical pixels (positive y scrolls content down).
53/// Backends normalise line-based wheel input to pixels with a fixed 32 px/line.
54#[derive(Message, Clone, Copy, Debug)]
55pub struct MouseWheel {
56 /// Scroll delta in logical pixels.
57 pub delta: Vec2,
58 /// Cursor position at the moment of the scroll, used for hit-testing.
59 pub position: Vec2,
60}
61
62/// Axis (or axes) a scroll container responds to. Used by `lumen-input` for scroll-aware hit-testing and by `lumen-primitives` for the accumulator and extract.
63#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
64pub enum ScrollAxis {
65 /// Vertical scrolling only.
66 #[default]
67 Y,
68 /// Horizontal scrolling only.
69 X,
70 /// Both axes.
71 Both,
72}
73
74impl ScrollAxis {
75 /// True when this axis setting scrolls horizontally (`X` or `Both`).
76 pub fn allows_x(self) -> bool {
77 matches!(self, ScrollAxis::X | ScrollAxis::Both)
78 }
79
80 /// True when this axis setting scrolls vertically (`Y` or `Both`).
81 pub fn allows_y(self) -> bool {
82 matches!(self, ScrollAxis::Y | ScrollAxis::Both)
83 }
84}
85
86/// Scrollable container configuration. Each instance carries its own sensitivity, inertia, and momentum.
87#[derive(Component, Clone, Copy, Debug)]
88pub struct Scroll {
89 /// Allowed scroll axes.
90 pub axis: ScrollAxis,
91 /// Multiplier on raw wheel-delta pixels (`1.0` = normal, lower slows scrolling).
92 pub sensitivity: f32,
93 /// Fraction of each wheel delta added to velocity instead of being applied to offset directly. Range `[0.0, 1.0]`; `0.0` produces instant jumps, `~0.4` a gentle glide, higher values longer fling.
94 pub inertia: f32,
95 /// Per-container momentum in logical pixels per tick. Wheel events add to it; `integrate_scroll` decays it by `INERTIA_DECAY` each frame and writes the delta into [`ScrollOffset`].
96 pub velocity: glam::Vec2,
97}
98
99impl Default for Scroll {
100 fn default() -> Self {
101 Self::vertical()
102 }
103}
104
105impl Scroll {
106 /// Returns a vertical scroller (`axis = Y`, `sensitivity = 1.0`, `inertia = 0.4`).
107 pub const fn vertical() -> Self {
108 Self {
109 axis: ScrollAxis::Y,
110 sensitivity: 1.0,
111 inertia: 0.4,
112 velocity: glam::Vec2::ZERO,
113 }
114 }
115
116 /// Returns a horizontal scroller (`axis = X`, `sensitivity = 1.0`, `inertia = 0.4`).
117 pub const fn horizontal() -> Self {
118 Self {
119 axis: ScrollAxis::X,
120 sensitivity: 1.0,
121 inertia: 0.4,
122 velocity: glam::Vec2::ZERO,
123 }
124 }
125
126 /// Returns a two-axis scroller (`axis = Both`, `sensitivity = 1.0`, `inertia = 0.4`).
127 pub const fn both() -> Self {
128 Self {
129 axis: ScrollAxis::Both,
130 sensitivity: 1.0,
131 inertia: 0.4,
132 velocity: glam::Vec2::ZERO,
133 }
134 }
135
136 /// Returns `self` with `sensitivity` overridden.
137 pub const fn with_sensitivity(mut self, sensitivity: f32) -> Self {
138 self.sensitivity = sensitivity;
139 self
140 }
141
142 /// Returns `self` with `inertia` overridden.
143 pub const fn with_inertia(mut self, inertia: f32) -> Self {
144 self.inertia = inertia;
145 self
146 }
147}
148
149/// Current scroll offset (positive = content shifted up/left).
150#[derive(Component, Clone, Copy, Debug, Default)]
151pub struct ScrollOffset(pub Vec2);
152
153/// Overlay-scrollbar paint + fade state, auto-attached to every
154/// [`Scroll`] entity by `lumen_primitives::scrollbar::update_scrollbars`
155/// (spec section 16.2 / section 16.6). The interaction FSM (hover / drag) is global
156/// per-pointer and lives in [`ScrollbarInteraction`]; this component only
157/// carries the per-container fade clock the extract pass reads.
158#[derive(Component, Clone, Copy, Debug, PartialEq)]
159pub struct ScrollbarState {
160 /// Current fade alpha in `[0, 1]`; multiplied into the bar colors at
161 /// extract time. `0` = fully faded out (bars skip paint AND hit).
162 pub alpha: f32,
163 /// Ticks-of-inactivity accumulator in seconds. Reset to zero on any
164 /// activity (offset change, bar hover, drag); once it exceeds the
165 /// fade delay the alpha ramps down.
166 pub idle_secs: f32,
167 /// [`ScrollOffset`] observed last tick - used to detect scroll
168 /// activity from any source (wheel, keyboard, inertia, script).
169 pub last_offset: Vec2,
170}
171
172impl Default for ScrollbarState {
173 fn default() -> Self {
174 Self {
175 // Bars start visible so a freshly-mounted overflowing
176 // container advertises its scrollability, then fade.
177 alpha: 1.0,
178 idle_secs: 0.0,
179 last_offset: Vec2::ZERO,
180 }
181 }
182}
183
184/// CSS `scrollbar-width` keyword (CSS Scrollbars Styling Level 1).
185#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
186pub enum ScrollbarWidthMode {
187 /// Platform-default overlay thickness.
188 #[default]
189 Auto,
190 /// Narrow rail.
191 Thin,
192 /// Bars hidden entirely (content still scrolls).
193 None,
194}
195
196/// Overlay-scrollbar styling for one scroll container - the runtime
197/// mirror of the standard CSS properties `scrollbar-color: <thumb>
198/// [<track>]` and `scrollbar-width: auto | thin | none` (CSS Scrollbars
199/// Styling Level 1; both transpile 1:1 to the web backend). Set from
200/// the stylesheet by `lumenc`; the skin defaults live in
201/// `skins/default.css` via the `--lumen-scrollbar-*` tokens.
202///
203/// This component's [`Default`] is the only place fallback visuals are
204/// defined (the blank-no-css contract): when no stylesheet rule matches,
205/// both the paint extract and the interaction FSM read these values.
206/// Fields without a real-CSS spelling today (minimum thumb length, fade
207/// timings) still live here - one struct a per-OS skin layer or future
208/// custom property can override without touching the FSM or the paint
209/// code.
210#[derive(Component, Clone, Copy, Debug, PartialEq)]
211pub struct ScrollbarStyle {
212 /// Thumb fill (`scrollbar-color` first value).
213 pub thumb: crate::components::Color,
214 /// Track fill (`scrollbar-color` second value). `Some` = painted
215 /// whenever the bar is visible (CSS semantics); `None` = the
216 /// fallback translucent track shown only while the bar is hovered
217 /// (overlay convention).
218 pub track: Option<crate::components::Color>,
219 /// `scrollbar-width` keyword.
220 pub width: ScrollbarWidthMode,
221 /// Bar thickness in logical pixels at `scrollbar-width: auto`
222 /// (`scrollbar-thickness` CSS property).
223 pub thickness: f32,
224 /// Bar thickness in logical pixels at `scrollbar-width: thin`
225 /// (`scrollbar-thickness-thin` CSS property).
226 pub thickness_thin: f32,
227 /// Thumb alpha multiplier while the bar is hovered / dragged
228 /// (`scrollbar-hover-boost` CSS property).
229 pub hover_boost: f32,
230 /// Fallback hover-only track fill used when [`Self::track`] is
231 /// `None` (`scrollbar-track-hover` CSS property).
232 pub hover_track: crate::components::Color,
233 /// Minimum thumb length in logical pixels (theme minimum;
234 /// `scrollbar-min-thumb` CSS property).
235 pub min_thumb: f32,
236 /// Inset from the viewport edges in logical pixels
237 /// (`scrollbar-margin` CSS property).
238 pub margin: f32,
239 /// Seconds of inactivity before the bars start fading out
240 /// (`scrollbar-fade-delay` CSS property).
241 pub fade_delay_secs: f32,
242 /// Fade-out ramp length in seconds (`scrollbar-fade-duration` CSS
243 /// property).
244 pub fade_secs: f32,
245}
246
247/// Fallback thumb alpha multiplier while hovered / dragged.
248pub const SCROLLBAR_HOVER_BOOST: f32 = 1.6;
249/// Fallback hover-only track fill used when no `scrollbar-color` track
250/// value is authored.
251pub const SCROLLBAR_HOVER_TRACK: crate::components::Color =
252 crate::components::Color::rgba(0.5, 0.5, 0.5, 0.16);
253/// Fallback idle time, in seconds, before an overlay scrollbar starts
254/// fading out.
255pub const SCROLLBAR_FADE_DELAY_SECS: f32 = 1.0;
256/// Fallback fade-out ramp length, in seconds.
257pub const SCROLLBAR_FADE_SECS: f32 = 0.25;
258
259impl Default for ScrollbarStyle {
260 fn default() -> Self {
261 Self {
262 // Neutral translucent thumb, readable on light and dark
263 // surfaces; skins override via `--lumen-scrollbar-thumb`.
264 thumb: crate::components::Color::rgba(0.62, 0.67, 0.74, 0.55),
265 track: None,
266 width: ScrollbarWidthMode::Auto,
267 thickness: SCROLLBAR_THICKNESS,
268 thickness_thin: SCROLLBAR_THICKNESS_THIN,
269 hover_boost: SCROLLBAR_HOVER_BOOST,
270 hover_track: SCROLLBAR_HOVER_TRACK,
271 min_thumb: SCROLLBAR_MIN_THUMB,
272 margin: SCROLLBAR_MARGIN,
273 fade_delay_secs: SCROLLBAR_FADE_DELAY_SECS,
274 fade_secs: SCROLLBAR_FADE_SECS,
275 }
276 }
277}
278
279impl ScrollbarStyle {
280 /// Resolved bar thickness in logical pixels; `None` = bars hidden
281 /// (`scrollbar-width: none`).
282 pub fn thickness(&self) -> Option<f32> {
283 match self.width {
284 ScrollbarWidthMode::Auto => Some(self.thickness),
285 ScrollbarWidthMode::Thin => Some(self.thickness_thin),
286 ScrollbarWidthMode::None => None,
287 }
288 }
289
290 /// Geometry inputs for [`vertical_scrollbar`] / [`horizontal_scrollbar`];
291 /// `None` when bars are disabled.
292 pub fn metrics(&self) -> Option<ScrollbarMetrics> {
293 Some(ScrollbarMetrics {
294 thickness: self.thickness()?,
295 margin: self.margin,
296 min_thumb: self.min_thumb,
297 })
298 }
299}
300
301#[cfg(test)]
302mod scrollbar_style_tests {
303 use super::*;
304
305 /// `ScrollbarStyle::default()` reproduces today's fixed thickness /
306 /// hover-boost / hover-track / fade timing constants exactly - the
307 /// no-CSS fallback must equal current behaviour.
308 #[test]
309 fn default_matches_the_fallback_constants() {
310 let sb = ScrollbarStyle::default();
311 assert_eq!(sb.thickness, SCROLLBAR_THICKNESS);
312 assert_eq!(sb.thickness_thin, SCROLLBAR_THICKNESS_THIN);
313 assert_eq!(sb.hover_boost, SCROLLBAR_HOVER_BOOST);
314 assert_eq!(sb.hover_track, SCROLLBAR_HOVER_TRACK);
315 assert_eq!(sb.min_thumb, SCROLLBAR_MIN_THUMB);
316 assert_eq!(sb.margin, SCROLLBAR_MARGIN);
317 assert_eq!(sb.fade_delay_secs, SCROLLBAR_FADE_DELAY_SECS);
318 assert_eq!(sb.fade_secs, SCROLLBAR_FADE_SECS);
319 assert_eq!(sb.thickness(), Some(SCROLLBAR_THICKNESS));
320 }
321
322 /// A `scrollbar-thickness` / `scrollbar-thickness-thin` override (as
323 /// the spawn / restyle path would set from CSS) changes the resolved
324 /// bar thickness for the matching `scrollbar-width` mode, without
325 /// touching the other mode's value.
326 #[test]
327 fn thickness_override_is_selected_by_width_mode() {
328 let sb = ScrollbarStyle {
329 thickness: 12.0,
330 thickness_thin: 3.0,
331 ..Default::default()
332 };
333 assert_eq!(sb.thickness(), Some(12.0));
334 let thin = ScrollbarStyle {
335 width: ScrollbarWidthMode::Thin,
336 ..sb
337 };
338 assert_eq!(thin.thickness(), Some(3.0));
339 let none = ScrollbarStyle {
340 width: ScrollbarWidthMode::None,
341 ..sb
342 };
343 assert_eq!(
344 none.thickness(),
345 None,
346 "scrollbar-width: none hides bars regardless of thickness"
347 );
348 }
349}
350
351/// Which functional part of an overlay scrollbar the pointer is on.
352#[derive(Clone, Copy, Debug, PartialEq, Eq)]
353pub enum ScrollbarPart {
354 /// The draggable thumb.
355 Thumb,
356 /// The track outside the thumb (click = jump-to-position).
357 Track,
358}
359
360/// Axis of the bar under the pointer.
361#[derive(Clone, Copy, Debug, PartialEq, Eq)]
362pub enum ScrollbarAxisPick {
363 /// The vertical bar on the right edge.
364 Vertical,
365 /// The horizontal bar on the bottom edge.
366 Horizontal,
367}
368
369/// An in-flight thumb drag (pointer captured by the scrollbar).
370#[derive(Clone, Copy, Debug)]
371pub struct ScrollbarDrag {
372 /// Scroll container whose bar is being dragged.
373 pub entity: Entity,
374 /// Bar axis.
375 pub axis: ScrollbarAxisPick,
376 /// Pointer offset from the thumb's leading edge at press time, in
377 /// logical pixels along the bar axis. Keeps the grab point glued to
378 /// the same spot on the thumb for the whole drag (absolute 1:1
379 /// mapping).
380 pub grab: f32,
381 /// The container's [`ScrollOffset`] when the drag began, so Escape
382 /// can cancel the drag and restore the pre-drag scroll position
383 /// (Qt drag-cancel contract).
384 pub start_offset: glam::Vec2,
385}
386
387/// Pointer <-> overlay-scrollbar arbitration, shared between
388/// `lumen_primitives::scrollbar` (writer) and `lumen-input`'s `hit_test`
389/// (reader). While the pointer is over a visible bar - or a thumb drag
390/// is active - the hit-test resolves to the scroll container itself, so
391/// bars sit above content for clicks/hover, wheel events still route
392/// through the container's normal scroll chain (bars never steal
393/// wheel), and dragging keeps working when the pointer leaves the bar
394/// (pointer capture).
395#[derive(Resource, Clone, Copy, Debug, Default)]
396pub struct ScrollbarInteraction {
397 /// Bar region currently under the pointer (visible bars only).
398 pub hover: Option<(Entity, ScrollbarAxisPick, ScrollbarPart)>,
399 /// Active thumb drag, if any. Takes precedence over `hover`.
400 pub drag: Option<ScrollbarDrag>,
401}
402
403// --- Overlay-scrollbar geometry (spec section 16.2) ---------------------------------
404//
405// Pure math shared by the paint extract (`render_world::extract_scrollbars`)
406// and the interaction FSM (`lumen_primitives::scrollbar`), so hit regions and
407// painted pixels can never disagree. Visual metrics are inputs
408// ([`ScrollbarMetrics`], resolved from [`ScrollbarStyle`] = CSS) - only the
409// mapping math lives here.
410
411/// Fallback overlay bar thickness (`scrollbar-width: auto`).
412pub const SCROLLBAR_THICKNESS: f32 = 8.0;
413/// Narrow rail thickness (`scrollbar-width: thin`).
414pub const SCROLLBAR_THICKNESS_THIN: f32 = 4.0;
415/// Fallback inset from the viewport edges, in logical pixels.
416pub const SCROLLBAR_MARGIN: f32 = 2.0;
417/// Fallback minimum thumb length in logical pixels - a 100 000-px
418/// document must still leave a grabbable thumb.
419pub const SCROLLBAR_MIN_THUMB: f32 = 24.0;
420
421/// Resolved geometry inputs for one bar, derived from
422/// [`ScrollbarStyle`] (see [`ScrollbarStyle::metrics`]).
423#[derive(Clone, Copy, Debug, PartialEq)]
424pub struct ScrollbarMetrics {
425 /// Bar thickness (thumb + track width) in logical pixels.
426 pub thickness: f32,
427 /// Inset from the viewport edges in logical pixels.
428 pub margin: f32,
429 /// Minimum thumb length in logical pixels.
430 pub min_thumb: f32,
431}
432
433impl Default for ScrollbarMetrics {
434 fn default() -> Self {
435 Self {
436 thickness: SCROLLBAR_THICKNESS,
437 margin: SCROLLBAR_MARGIN,
438 min_thumb: SCROLLBAR_MIN_THUMB,
439 }
440 }
441}
442
443/// Resolved geometry for one overlay bar, in window coordinates.
444#[derive(Clone, Copy, Debug, PartialEq)]
445pub struct ScrollbarGeometry {
446 /// Track rect origin (top-left).
447 pub track_origin: Vec2,
448 /// Track rect size.
449 pub track_size: Vec2,
450 /// Thumb rect origin (top-left).
451 pub thumb_origin: Vec2,
452 /// Thumb rect size.
453 pub thumb_size: Vec2,
454 /// Maximum scroll offset on this axis (`content - viewport`, > 0).
455 pub max_offset: f32,
456}
457
458impl ScrollbarGeometry {
459 /// Map a pointer coordinate along the bar axis to the scroll offset
460 /// that puts the thumb's leading edge at `pointer - grab` (the
461 /// absolute 1:1 inverse of the thumb-position formula).
462 pub fn offset_for_thumb_pos(&self, pointer_along: f32, grab: f32, vertical: bool) -> f32 {
463 let (track_start, track_len, thumb_len) = if vertical {
464 (self.track_origin.y, self.track_size.y, self.thumb_size.y)
465 } else {
466 (self.track_origin.x, self.track_size.x, self.thumb_size.x)
467 };
468 let range = (track_len - thumb_len).max(f32::EPSILON);
469 let frac = ((pointer_along - grab - track_start) / range).clamp(0.0, 1.0);
470 frac * self.max_offset
471 }
472
473 /// `true` when `p` lies inside the thumb rect.
474 pub fn point_in_thumb(&self, p: Vec2) -> bool {
475 p.x >= self.thumb_origin.x
476 && p.y >= self.thumb_origin.y
477 && p.x < self.thumb_origin.x + self.thumb_size.x
478 && p.y < self.thumb_origin.y + self.thumb_size.y
479 }
480
481 /// `true` when `p` lies inside the track rect (thumb included).
482 pub fn point_in_track(&self, p: Vec2) -> bool {
483 p.x >= self.track_origin.x
484 && p.y >= self.track_origin.y
485 && p.x < self.track_origin.x + self.track_size.x
486 && p.y < self.track_origin.y + self.track_size.y
487 }
488}
489
490/// Geometry for the vertical overlay bar of a viewport at
491/// `(viewport_origin, viewport_size)` whose content is `content_h` tall,
492/// scrolled to `offset_y`. Returns `None` when the content does not
493/// overflow (as-needed visibility, spec section 16.2). `corner_reserved` should
494/// be `true` when the horizontal bar is also visible so the two bars
495/// don't overlap in the corner. `m` supplies the style-resolved visual
496/// metrics ([`ScrollbarStyle::metrics`]).
497pub fn vertical_scrollbar(
498 viewport_origin: Vec2,
499 viewport_size: Vec2,
500 content_h: f32,
501 offset_y: f32,
502 corner_reserved: bool,
503 m: ScrollbarMetrics,
504) -> Option<ScrollbarGeometry> {
505 let max_offset = content_h - viewport_size.y;
506 if max_offset <= 0.5 {
507 return None;
508 }
509 let corner = if corner_reserved {
510 m.thickness + m.margin
511 } else {
512 0.0
513 };
514 let track_len = (viewport_size.y - 2.0 * m.margin - corner).max(0.0);
515 if track_len < m.min_thumb {
516 return None;
517 }
518 let track_origin = Vec2::new(
519 viewport_origin.x + viewport_size.x - m.thickness - m.margin,
520 viewport_origin.y + m.margin,
521 );
522 // Thumb length proportional to the visible fraction, floored at the
523 // theme minimum and capped at the track.
524 let thumb_len = (viewport_size.y / content_h * track_len)
525 .max(m.min_thumb)
526 .min(track_len);
527 let frac = (offset_y / max_offset).clamp(0.0, 1.0);
528 let thumb_y = track_origin.y + frac * (track_len - thumb_len);
529 Some(ScrollbarGeometry {
530 track_origin,
531 track_size: Vec2::new(m.thickness, track_len),
532 thumb_origin: Vec2::new(track_origin.x, thumb_y),
533 thumb_size: Vec2::new(m.thickness, thumb_len),
534 max_offset,
535 })
536}
537
538/// Horizontal counterpart of [`vertical_scrollbar`] (bar along the
539/// bottom edge).
540pub fn horizontal_scrollbar(
541 viewport_origin: Vec2,
542 viewport_size: Vec2,
543 content_w: f32,
544 offset_x: f32,
545 corner_reserved: bool,
546 m: ScrollbarMetrics,
547) -> Option<ScrollbarGeometry> {
548 let max_offset = content_w - viewport_size.x;
549 if max_offset <= 0.5 {
550 return None;
551 }
552 let corner = if corner_reserved {
553 m.thickness + m.margin
554 } else {
555 0.0
556 };
557 let track_len = (viewport_size.x - 2.0 * m.margin - corner).max(0.0);
558 if track_len < m.min_thumb {
559 return None;
560 }
561 let track_origin = Vec2::new(
562 viewport_origin.x + m.margin,
563 viewport_origin.y + viewport_size.y - m.thickness - m.margin,
564 );
565 let thumb_len = (viewport_size.x / content_w * track_len)
566 .max(m.min_thumb)
567 .min(track_len);
568 let frac = (offset_x / max_offset).clamp(0.0, 1.0);
569 let thumb_x = track_origin.x + frac * (track_len - thumb_len);
570 Some(ScrollbarGeometry {
571 track_origin,
572 track_size: Vec2::new(track_len, m.thickness),
573 thumb_origin: Vec2::new(thumb_x, track_origin.y),
574 thumb_size: Vec2::new(thumb_len, m.thickness),
575 max_offset,
576 })
577}
578
579/// Emitted by `lumen-input` when a press and release land on the same entity without leaving it.
580#[derive(Message, Clone, Copy, Debug)]
581pub struct ClickEvent {
582 /// The entity that was clicked.
583 pub entity: Entity,
584 /// Position at time of click (release point).
585 pub position: Vec2,
586 /// Which button.
587 pub button: PointerButton,
588}
589
590/// Emitted by `lumen-primitives::press` when an entity has carried [`Pressed`] continuously past the configured long-press threshold (default 500 ms).
591#[derive(Message, Clone, Copy, Debug)]
592pub struct LongPressEvent {
593 /// The entity that was held.
594 pub entity: Entity,
595}
596
597/// Emitted by `lumen-primitives::press` when two [`ClickEvent`]s land on the same entity within the double-click window (default 300 ms).
598#[derive(Message, Clone, Copy, Debug)]
599pub struct DoubleClickEvent {
600 /// The entity that was double-clicked.
601 pub entity: Entity,
602 /// Position at time of the second click.
603 pub position: Vec2,
604}
605
606/// Emitted once when the pointer moves past the drag-start threshold while pressed on the entity.
607#[derive(Message, Clone, Copy, Debug)]
608pub struct DragStartEvent {
609 /// The entity being dragged.
610 pub entity: Entity,
611 /// Pointer position where the press began.
612 pub start: Vec2,
613 /// Current pointer position when the drag crossed the threshold.
614 pub position: Vec2,
615}
616
617/// Emitted on each pointer move while a drag is active on the entity.
618#[derive(Message, Clone, Copy, Debug)]
619pub struct DragMoveEvent {
620 /// The entity being dragged.
621 pub entity: Entity,
622 /// Current pointer position.
623 pub position: Vec2,
624 /// Position delta since the last move event.
625 pub delta: Vec2,
626}
627
628/// Emitted once when the pointer releases at the end of an active drag.
629#[derive(Message, Clone, Copy, Debug)]
630pub struct DragEndEvent {
631 /// The entity that was dragged.
632 pub entity: Entity,
633 /// Final pointer position.
634 pub position: Vec2,
635}
636
637/// Aggregated pointer state refreshed each tick by the window backend.
638#[derive(Resource, Clone, Copy, Debug, Default)]
639pub struct PointerState {
640 /// Current cursor position in window coords; `None` when the pointer is outside the window.
641 pub position: Option<Vec2>,
642 /// `true` when the primary button is currently held.
643 pub primary_down: bool,
644}
645
646/// Marker inserted/removed by the hit-test system to mark the entity currently under the pointer; matched by style systems for `:hover` behaviour.
647#[derive(Component, Clone, Copy, Debug, Default)]
648pub struct Hovered;
649
650/// Marker indicating an active primary-button press on the entity.
651/// Inserted on `PointerPressed`; removed on `PointerReleased` and `PointerLeft`.
652#[derive(Component, Clone, Copy, Debug, Default)]
653pub struct Pressed;
654
655/// Tick-scoped flag written by `lumen_input::cancel_press_on_escape`:
656/// `true` exactly on ticks where an Escape key-press cancelled an
657/// in-flight press (an entity carried [`Pressed`]). Consumers that also
658/// react to Escape (dialog / popup close handlers) should treat such an
659/// Escape as consumed and leave their state alone - cancelling a press
660/// and closing a dialog must never happen on the same keystroke.
661#[derive(Resource, Clone, Copy, Debug, Default)]
662pub struct EscapePressCancel(pub bool);
663
664/// Cursor shape the UI wants for the current pointer position.
665/// Deliberately tiny - only the shapes Lumen widgets actually request;
666/// window backends map it onto the OS cursor set (winit `CursorIcon`).
667#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
668pub enum CursorShape {
669 /// The platform default arrow.
670 #[default]
671 Default,
672 /// I-beam over editable text.
673 Text,
674 /// Pointing hand over clickable widgets.
675 Pointer,
676 /// Open hand over a grabbable handle (scrollbar / slider thumb).
677 Grab,
678 /// Closed hand while a handle drag is in flight.
679 Grabbing,
680}
681
682/// Requested mouse cursor, written main-side each tick (see
683/// `lumen_primitives::update_cursor_request`) and applied to the OS
684/// window by the window backend, which tracks the last applied value so
685/// the OS call only happens on change. Headless runners simply never
686/// read it.
687#[derive(Resource, Clone, Copy, Debug, Default, PartialEq, Eq)]
688pub struct CursorRequest(pub CursorShape);
689
690/// Named non-printable keys recognised by Lumen.
691#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
692pub enum NamedKey {
693 /// Tab key (focus advance).
694 Tab,
695 /// Enter / Return.
696 Enter,
697 /// Escape.
698 Escape,
699 /// Backspace.
700 Backspace,
701 /// Spacebar.
702 Space,
703 /// Up arrow.
704 ArrowUp,
705 /// Down arrow.
706 ArrowDown,
707 /// Left arrow.
708 ArrowLeft,
709 /// Right arrow.
710 ArrowRight,
711 /// Home.
712 Home,
713 /// End.
714 End,
715 /// Delete (forward delete).
716 Delete,
717}
718
719/// Logical key, either a named navigation key or a Unicode character cluster (already resolved through modifiers and IME).
720#[derive(Clone, Debug, PartialEq, Eq, Hash)]
721pub enum Key {
722 /// Named non-printable key.
723 Named(NamedKey),
724 /// Character input, stored as a String to hold multi-scalar clusters.
725 Character(String),
726}
727
728impl From<&str> for Key {
729 /// Parses a human-readable key name into a [`Key`].
730 /// Case-sensitive `NamedKey` lookups with `"Return"` aliasing `Enter`, `"Esc"` aliasing `Escape`, etc.; any other string becomes a `Key::Character`.
731 fn from(name: &str) -> Self {
732 match name {
733 "Tab" => Key::Named(NamedKey::Tab),
734 "Enter" | "Return" => Key::Named(NamedKey::Enter),
735 "Escape" | "Esc" => Key::Named(NamedKey::Escape),
736 "Backspace" => Key::Named(NamedKey::Backspace),
737 "Space" => Key::Named(NamedKey::Space),
738 "ArrowUp" | "Up" => Key::Named(NamedKey::ArrowUp),
739 "ArrowDown" | "Down" => Key::Named(NamedKey::ArrowDown),
740 "ArrowLeft" | "Left" => Key::Named(NamedKey::ArrowLeft),
741 "ArrowRight" | "Right" => Key::Named(NamedKey::ArrowRight),
742 "Home" => Key::Named(NamedKey::Home),
743 "End" => Key::Named(NamedKey::End),
744 "Delete" | "Del" => Key::Named(NamedKey::Delete),
745 other => Key::Character(other.to_string()),
746 }
747 }
748}
749
750impl From<&str> for PointerButton {
751 /// Parses a button name case-insensitively. Recognised values: `"primary"` / `"left"`, `"secondary"` / `"right"`, `"middle"`. Unknown names yield `Primary`.
752 fn from(name: &str) -> Self {
753 match name.to_ascii_lowercase().as_str() {
754 "secondary" | "right" => PointerButton::Secondary,
755 "middle" => PointerButton::Middle,
756 _ => PointerButton::Primary,
757 }
758 }
759}
760
761#[cfg(test)]
762mod from_str_tests {
763 use super::*;
764
765 #[test]
766 fn key_from_str_named() {
767 assert_eq!(Key::from("Enter"), Key::Named(NamedKey::Enter));
768 assert_eq!(Key::from("Esc"), Key::Named(NamedKey::Escape));
769 assert_eq!(Key::from("Up"), Key::Named(NamedKey::ArrowUp));
770 }
771
772 #[test]
773 fn key_from_str_character() {
774 assert_eq!(Key::from("a"), Key::Character("a".into()));
775 }
776
777 #[test]
778 fn pointer_button_from_str() {
779 assert_eq!(PointerButton::from("right"), PointerButton::Secondary);
780 assert_eq!(PointerButton::from("MIDDLE"), PointerButton::Middle);
781 assert_eq!(PointerButton::from("anything"), PointerButton::Primary);
782 }
783}
784
785/// Active keyboard modifier flags.
786#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
787pub struct Modifiers {
788 /// Shift key held.
789 pub shift: bool,
790 /// Control held (or Cmd on macOS by app convention).
791 pub ctrl: bool,
792 /// Alt / Option held.
793 pub alt: bool,
794 /// Super / Cmd / Windows key held.
795 pub super_: bool,
796}
797
798/// Live modifier state, refreshed by the window backend on `ModifiersChanged`.
799#[derive(Resource, Clone, Copy, Debug, Default)]
800pub struct ModifiersState(pub Modifiers);
801
802/// Raw global key-press event. Subscribe directly for global shortcuts, or read [`FocusedKey`] for per-entity routing through `lumen-input`.
803#[derive(Message, Clone, Debug)]
804pub struct KeyPressed {
805 /// Logical key.
806 pub key: Key,
807 /// Modifier state at time of press.
808 pub modifiers: Modifiers,
809 /// Whether this is an OS-generated key repeat (held key).
810 pub repeat: bool,
811}
812
813/// Raw global key-release event.
814#[derive(Message, Clone, Debug)]
815pub struct KeyReleased {
816 /// Logical key.
817 pub key: Key,
818 /// Modifier state at time of release.
819 pub modifiers: Modifiers,
820}
821
822/// Marker placed by `lumen-input`'s focus router on the keyboard-focused entity. The router maintains at most one [`Focused`] at a time.
823#[derive(Component, Clone, Copy, Debug, Default)]
824pub struct Focused;
825
826/// Marker placed alongside [`Focused`] when focus arrived via the
827/// keyboard (Tab / Shift-Tab cycling), mirroring the CSS
828/// `:focus-visible` heuristic. Pointer-driven focus (click-to-focus)
829/// carries [`Focused`] alone. Styling keyed on `:focus-visible`
830/// (keyboard-only focus rings) reads this marker; `:focus` styling
831/// stays always-on.
832#[derive(Component, Clone, Copy, Debug, Default)]
833pub struct FocusVisible;
834
835/// Resource mirror of [`Focused`], letting systems look up the focused entity without a query.
836#[derive(Resource, Clone, Copy, Debug, Default)]
837pub struct FocusTracker(pub Option<Entity>);
838
839/// Pending file-drop entries from the OS this frame.
840///
841/// - Window backends push `(path, pos)` in arrival order.
842/// - `lumen-input::dispatch_file_drops` drains the queue and emits [`FileDropped`] at the topmost [`DropTarget`] under the cursor.
843#[derive(Resource, Default)]
844pub struct PendingFileDrops {
845 /// Pending raw drops in arrival order.
846 pub drops: Vec<(std::path::PathBuf, Vec2)>,
847}
848
849/// Emitted while a file is dragged over the window prior to drop; forwarded by backends from the OS drag-and-drop session.
850#[derive(Message, Clone, Debug)]
851pub struct FileHovered {
852 /// Path of the source file (absolute when the OS provides it).
853 pub path: std::path::PathBuf,
854 /// Cursor position at the time of the hover event.
855 pub position: Vec2,
856}
857
858/// Emitted when the user cancels a drag-and-drop session (release outside the window or Escape).
859#[derive(Message, Clone, Copy, Debug)]
860pub struct FileHoverCancelled;
861
862/// Emitted when a file drops on a [`DropTarget`].
863/// `entity` is the topmost `DropTarget` under the cursor at drop time; drops without a matching target are not emitted.
864#[derive(Message, Clone, Debug)]
865pub struct FileDropped {
866 /// Recipient entity (carries `DropTarget`).
867 pub entity: Entity,
868 /// File path the OS handed us.
869 pub path: std::path::PathBuf,
870 /// Cursor position at the time of drop.
871 pub position: Vec2,
872}
873
874/// Emitted by the OS for a previously-registered global hotkey. Routed by the scripting layer as `on_hotkey(name)` and through `on("hotkey", name, fn)`.
875#[derive(Message, Clone, Debug)]
876pub struct HotkeyFired {
877 /// Identifier matching the `register_hotkey(name, ...)` call that installed the binding.
878 pub name: String,
879}
880
881/// Emitted when a previously-pressed global hotkey is released. Routed by the scripting layer as `on_hotkey_release(name)` and through `on("hotkey_release", name, fn)`. Pairs with [`HotkeyFired`] so one chord can drive push-to-talk.
882#[derive(Message, Clone, Debug)]
883pub struct HotkeyReleased {
884 /// Identifier matching the `register_hotkey(name, ...)` call that installed the binding.
885 pub name: String,
886}
887
888/// Emitted when the user activates an action button on a desktop notification. Routed as `on_notification_action(id, action_id)` and through `on("notification_action", id, fn)`.
889#[derive(Message, Clone, Debug)]
890pub struct NotificationActionInvoked {
891 /// Notification id, matching the `notify_ex(id, ...)` call that raised it.
892 pub id: String,
893 /// Action id, matching one entry of that call's action spec.
894 pub action_id: String,
895}
896
897/// Emitted once a `clipboard_read(tag)` request has pulled the system clipboard text. Routed as `on_clipboard(tag, text)` and through `on("clipboard", tag, fn)`.
898#[derive(Message, Clone, Debug)]
899pub struct ClipboardRead {
900 /// Identifier the script passed to `clipboard_read(tag)`.
901 pub tag: String,
902 /// Clipboard text; empty when the clipboard holds no text payload.
903 pub text: String,
904}
905
906/// Emitted when the user clicks a native menu item. Routed as `on_menu(id)` and through `on("menu", id, fn)`.
907#[derive(Message, Clone, Debug)]
908pub struct MenuClicked {
909 /// `id="..."` attribute on the markup `<menuitem>`.
910 pub id: String,
911}
912
913/// Emitted exactly once per `<dialog>` close (open -> closed edge).
914///
915/// `accepted = true` when the close went through the dialog's DEFAULT
916/// button (Enter-anywhere or a direct click on it) - Qt
917/// `QDialog::accepted`; every other close path (Escape, cancel/close
918/// buttons, script signal write) is `accepted = false` -
919/// `QDialog::rejected`. Never both, never twice per open/close cycle.
920/// The scripting layer routes it as `on_dialog_accepted(id)` /
921/// `on_dialog_rejected(id)` plus the per-id
922/// `on("dialog_accepted", id, fn)` / `on("dialog_rejected", id, fn)`
923/// registries.
924#[derive(Message, Clone, Debug)]
925pub struct DialogClosed {
926 /// The `<dialog>` entity.
927 pub entity: Entity,
928 /// The dialog's markup `id="..."` when present, else its bound open
929 /// signal name - the key handed to script handlers.
930 pub id: String,
931 /// `true` = accepted (default-button path), `false` = rejected.
932 pub accepted: bool,
933}
934
935/// Emitted when the user clicks a system tray icon. Routed as `on_tray(id)` and through `on("tray", id, fn)`.
936#[derive(Message, Clone, Debug)]
937pub struct TrayClicked {
938 /// Identifier matching the `tray_icon(id, ...)` registration call.
939 pub id: String,
940}
941
942/// Emitted when the user resolves a native file dialog (open / save / folder / multi-open).
943///
944/// - One message per closed dialog; cancelled dialogs still emit with empty [`Self::paths`] so scripts can clean up.
945/// - The scripting layer routes by [`Self::kind`] to `on_file_picked(tag, path)`, `on_files_picked(tag, paths)` (paths joined by `|`), or `on_folder_picked(tag, path)`.
946#[derive(Message, Clone, Debug)]
947pub struct FilePicked {
948 /// `"open"` | `"open_multi"` | `"save"` | `"folder"`. Selects which scripting dispatcher receives the message.
949 pub kind: &'static str,
950 /// Identifier carried through from `pick_file(tag)` / `pick_files(tag)` / `pick_folder(tag)` / `save_file(tag, name)`, routed through the per-id `on()` registry.
951 pub tag: String,
952 /// Resolved path(s): single entry for open / save / folder; one or more for `open_multi`; empty for cancellation.
953 pub paths: Vec<std::path::PathBuf>,
954}
955
956/// IME (input-method editor) state-machine event forwarded by window backends. The variant set mirrors winit's `Ime` enum.
957///
958/// A typical CJK composition produces: `Enabled` -> `Preedit("ni")` -> `Preedit("nih", caret)` -> `Preedit(composed)` -> `Commit(composed)` -> `Preedit("")` -> `Disabled`.
959#[derive(Message, Clone, Debug)]
960pub enum ImeEvent {
961 /// IME activated for the focused widget.
962 Enabled,
963 /// Preedit (composition) buffer changed.
964 Preedit {
965 /// Current composition text; an empty string clears the preedit.
966 text: String,
967 /// Optional caret/selection byte range `(start, end)` within `text`; `None` hides the caret.
968 cursor: Option<(usize, usize)>,
969 },
970 /// Composition finalised; `text` is inserted at the caret.
971 Commit(String),
972 /// IME deactivated.
973 Disabled,
974}
975
976/// Per-window IME control written by `lumen-input`'s focus router and read by the window backend, which forwards changes to winit's `set_ime_allowed` and `set_ime_cursor_area`.
977#[derive(Resource, Clone, Copy, Debug, Default)]
978pub struct ImeRequest {
979 /// `true` enables IME input acceptance.
980 pub allowed: bool,
981 /// Caret area in physical-pixel screen coordinates, `(origin, size)`; used by the IME UI to position the candidate window.
982 pub cursor_area: Option<(Vec2, Vec2)>,
983}
984
985/// Emitted by `lumen-input`'s IME router after splicing a commit segment into [`TextContent`].
986#[derive(Message, Clone, Debug)]
987pub struct TextInputCommitted {
988 /// Target entity (typically the focused entity at commit time).
989 pub entity: Entity,
990 /// Final committed text segment.
991 pub text: String,
992}
993
994/// Emitted by `lumen-input` on each [`KeyPressed`] when [`FocusTracker`] points at an entity; subscribe instead of [`KeyPressed`] for per-entity routing.
995#[derive(Message, Clone, Debug)]
996pub struct FocusedKey {
997 /// Recipient.
998 pub entity: Entity,
999 /// Logical key.
1000 pub key: Key,
1001 /// Modifier state.
1002 pub modifiers: Modifiers,
1003 /// OS-generated repeat.
1004 pub repeat: bool,
1005}
1006
1007/// Emitted by the a11y inbound-action plumbing (and any other route)
1008/// when assistive technology asks an entity to present a context menu.
1009///
1010/// - Producer: `handle_a11y_action` in `lumen-window-winit` for the
1011/// `Action::ShowContextMenu` AccessKit action; pointer-secondary
1012/// handlers in apps may also emit this directly.
1013/// - Consumer: app code (typically scripts wired through `on_menu` /
1014/// `on_context_menu` handlers) subscribes via `MessageReader`.
1015/// - Mirrors `gtk_widget_show` of a `GtkPopoverMenu` and
1016/// `QAbstractItemView::customContextMenuRequested` semantics.
1017#[derive(Message, Clone, Copy, Debug)]
1018pub struct ShowContextMenu {
1019 /// Entity the menu should attach to.
1020 pub entity: Entity,
1021}
1022
1023/// Emitted by window backends when the OS reports the window gained or
1024/// lost keyboard focus. Backends also pause the redraw scheduler while
1025/// `focused = false` so unfocused windows stop polling at vsync.
1026#[derive(Message, Clone, Copy, Debug)]
1027pub struct WindowFocused {
1028 /// `true` on focus-in, `false` on focus-out.
1029 pub focused: bool,
1030}
1031
1032/// Emitted by window backends when the OS reports the window was
1033/// occluded (covered by another window or moved off-screen) or revealed.
1034/// Backends pause the redraw scheduler while `occluded = true` to avoid
1035/// spending GPU on invisible frames.
1036#[derive(Message, Clone, Copy, Debug)]
1037pub struct WindowOccluded {
1038 /// `true` when the window is fully occluded, `false` when visible.
1039 pub occluded: bool,
1040}
1041
1042/// Emitted by window backends on `WindowEvent::CloseRequested`. Apps that
1043/// want to veto the close (e.g. to show a "save before quit?" dialog) set
1044/// `vetoed = true` in a system reading this message; if no system vetoes,
1045/// the backend exits the event loop on the next `about_to_wait`.
1046///
1047/// Vetoing here mirrors `QCloseEvent::ignore()` and GTK4's
1048/// `close-request -> TRUE`.
1049#[derive(Message, Clone, Copy, Debug, Default)]
1050pub struct CloseRequest {
1051 /// Set by an app system to keep the window open.
1052 pub vetoed: bool,
1053}