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