Skip to main content

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}