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}