Skip to main content

lumen_primitives/
hover.rs

1//! Interaction-state paint primitives: hover, press, focus ring - all
2//! folded into a single [`Interaction`] fat component.
3//!
4//! `Interaction.hover_tint` blends the [`Visuals`] solid fill toward a
5//! tint while the entity is [`Hovered`] or [`Focused`]. `press_tint`
6//! overrides that blend when the entity is [`Pressed`] - pressed >
7//! hovered/focused > idle. `focus_outline` paints a stroked ring around
8//! the entity while it has [`Focused`]. Gradient fills are skipped by
9//! the tween (no animation path); the gradient renders unchanged.
10//!
11//! Tween state (`HoverTween`, `PressTween`) stays in separate transient
12//! components - they're auto-attached / cleared by the systems below, never
13//! authored. The tinted fill and the swapped border are held in
14//! [`StyleOverrides`] while they show, so a restyle meanwhile keeps them and
15//! they let go onto whatever the element's styles say by then.
16
17use crate::held::{fill_below, hold_fill};
18use bevy_ecs::prelude::*;
19use lumen_core::components::Visuals;
20use lumen_core::prelude::*;
21use lumen_core::render_world::{ExtractedOutline, build_parent_map, paint_order_of};
22use lumen_core::style_overrides::{Live, StyleOverrides, StyleOwner, StyleValues};
23use lumen_core::time::Instant;
24
25/// Default-construct a `last_step: Instant` for tween components. The
26/// systems immediately overwrite this with `Tick.now` on the first step,
27/// so the wall-clock initialiser only exists to keep the public
28/// `Default` impl total - actual elapsed-time math always runs through
29/// the shared [`Tick`] resource.
30fn tween_instant_seed() -> Instant {
31    Instant::now()
32}
33
34/// Author-set interaction-state paint: hover tint + press tint + focus
35/// outline, all on one component. Each field is `Option`-shaped so
36/// markup can opt into any subset. Absent component = no
37/// hover / press / focus visual feedback.
38#[derive(Component, Clone, Copy, Debug, Default, PartialEq)]
39#[require(StyleOverrides)]
40pub struct Interaction {
41    /// Color blended into a solid [`Visuals::fill`] while hovered or
42    /// focused. Markup: `hover-bg="#xxx"`.
43    pub hover_tint: Option<Color>,
44    /// Color blended into a solid fill while pressed. Wins over
45    /// `hover_tint`. Markup: `press-bg="#xxx"`.
46    pub press_tint: Option<Color>,
47    /// Outline ring stroked around the entity while focused. Markup:
48    /// `focus-outline="<width>px <#color>"`.
49    pub focus_outline: Option<FocusOutlineSpec>,
50    /// Outline ring shown only when focus arrived via the keyboard
51    /// (CSS `:focus-visible`). Wins over [`Self::focus_outline`] while
52    /// the `FocusVisible` marker is present; pointer-driven focus falls
53    /// back to `focus_outline` (or nothing).
54    pub focus_visible_outline: Option<FocusOutlineSpec>,
55    /// Border swapped into [`Visuals::border`] while hovered. CSS:
56    /// `:hover { border: ... }` (or the Lumen-native `hover-border`
57    /// property). Snaps rather than tweening - same as CSS without a
58    /// `transition`.
59    pub hover_border: Option<lumen_core::components::Border>,
60    /// Border swapped into [`Visuals::border`] while focused. Wins over
61    /// [`Self::hover_border`] when both states are active (mirrors the
62    /// skin convention that focus chrome is authored after hover).
63    pub focus_border: Option<lumen_core::components::Border>,
64}
65
66/// Stored on [`Interaction::focus_outline`]. Was a free-standing
67/// `FocusOutline` component before the alpha2 collapse.
68#[derive(Clone, Copy, Debug, PartialEq)]
69pub struct FocusOutlineSpec {
70    /// Stroke width in logical pixels.
71    pub width: f32,
72    /// Stroke color.
73    pub color: Color,
74    /// CSS `outline-offset`: gap between the border box edge and the
75    /// ring's inner edge. Default 0.
76    pub offset: f32,
77}
78
79/// How long it takes for the hover-tint to fully blend in or out, in
80/// seconds. Picked to feel responsive but not jittery - comparable to a
81/// macOS button highlight, faster than a Material ripple.
82pub const HOVER_TWEEN_DURATION: f32 = 0.12;
83
84/// Per-entity timer driving the hover tween. `progress` runs 0 -> 1 toward
85/// the hover tint while hovered/focused, and 1 -> 0 toward what is under the
86/// tint when both interaction markers are gone. Idempotent across frames so
87/// the system can simply step `progress` by delta-time.
88#[derive(Component, Clone, Copy, Debug)]
89pub struct HoverTween {
90    /// Currently in [0, 1].
91    pub progress: f32,
92    /// Where to push `progress` toward (0 = idle, 1 = active).
93    pub target: f32,
94    /// Last instant the tween was advanced. Wall-clock - the plugin doesn't
95    /// run on a fixed timestep, so we measure real elapsed time.
96    pub last_step: Instant,
97}
98
99impl StyleOwner for HoverTween {
100    const RANK: u8 = 40;
101}
102
103impl Default for HoverTween {
104    fn default() -> Self {
105        Self {
106            progress: 0.0,
107            target: 0.0,
108            last_step: tween_instant_seed(),
109        }
110    }
111}
112
113/// Plugin: registers [`apply_hover_tint`] then [`apply_press_tint`] in
114/// `TickStage::Systems`. Press runs second so it can stamp over whatever
115/// hover wrote on the same tick.
116///
117/// Every component these systems attach goes through `try_insert`. The
118/// entity under the pointer is the one most likely to leave the tree on this
119/// very tick: a click on a nav link runs the handler that swaps the page,
120/// and the `<if>` reconciler despawns the outgoing tree in this same stage
121/// with nothing ordering it against these systems. A plain `insert` against
122/// an element the swap already took away fails the whole command buffer
123/// rather than just itself.
124pub struct HoverTintPlugin;
125
126impl Plugin for HoverTintPlugin {
127    fn build(self, app: &mut App) {
128        // `.after(hit_test)` / `.after(dispatch_clicks)`: the Hovered /
129        // Pressed markers land via Commands; the explicit edges pull in
130        // the sync point so this tick's paint reflects this tick's
131        // pointer state. Without them the press/hover visual trailed the
132        // marker by one tick - invisible under a streaming mouse, but a
133        // real frame of latency for taps and for the capture re-entry
134        // re-press (spec section 0 rule 3). No-ops when `lumen-input` isn't
135        // installed (the target set is empty).
136        app.add_systems(
137            TickStage::Systems,
138            apply_hover_tint.after(lumen_input::hit_test),
139        );
140        app.add_systems(
141            TickStage::Systems,
142            apply_press_tint
143                .after(apply_hover_tint)
144                .after(lumen_input::dispatch_clicks),
145        );
146        app.add_systems(TickStage::Systems, apply_state_borders);
147        // Render-world side: focused entities also produce an outline.
148        app.add_extract_fn(extract_focus_outlines);
149    }
150}
151
152/// Marks an element whose [`Visuals::border`] [`apply_state_borders`]
153/// holds, and names its layer in [`StyleOverrides`].
154#[derive(Component, Clone, Copy, Debug, Default)]
155pub struct StateBorderHeld;
156
157impl StyleOwner for StateBorderHeld {
158    const RANK: u8 = 60;
159}
160
161/// Swap [`Visuals::border`] per interaction state: focused ->
162/// [`Interaction::focus_border`], hovered -> [`Interaction::hover_border`],
163/// idle -> whatever the element's styles give it then. Focus wins over
164/// hover when both are active. Borders snap (no tween), matching CSS
165/// `:hover` / `:focus` border rules without a `transition`.
166#[allow(clippy::type_complexity)]
167pub fn apply_state_borders(
168    mut commands: Commands,
169    mut active: Query<
170        (
171            Entity,
172            &Interaction,
173            &mut Visuals,
174            &mut StyleOverrides,
175            Has<StateBorderHeld>,
176            Option<&Hovered>,
177            Option<&Focused>,
178        ),
179        Or<(With<Hovered>, With<Focused>)>,
180    >,
181    mut idle: Query<
182        (Entity, &mut Visuals, &mut StyleOverrides),
183        (
184            With<StateBorderHeld>,
185            With<Interaction>,
186            Without<Hovered>,
187            Without<Focused>,
188        ),
189    >,
190    mut orphaned: Query<
191        (Entity, &mut Visuals, &mut StyleOverrides),
192        (With<StateBorderHeld>, Without<Interaction>),
193    >,
194) {
195    for (entity, ix, mut vis, mut overrides, held, hovered, focused) in &mut active {
196        let want = match (
197            focused.and(ix.focus_border.as_ref()),
198            hovered.and(ix.hover_border.as_ref()),
199        ) {
200            (Some(b), _) => Some(*b),
201            (None, Some(b)) => Some(*b),
202            (None, None) => None,
203        };
204        let Some(want) = want else {
205            if held {
206                overrides
207                    .release::<StateBorderHeld>()
208                    .write_visuals(&mut vis);
209                commands.entity(entity).remove::<StateBorderHeld>();
210            }
211            continue;
212        };
213        if overrides
214            .held_by::<StateBorderHeld>()
215            .is_some_and(|v| v.border == Some(Some(want)))
216        {
217            continue;
218        }
219        if !held {
220            commands.entity(entity).try_insert(StateBorderHeld);
221        }
222        let live = Live {
223            visuals: Some(&vis),
224            ..Live::default()
225        };
226        let values = StyleValues {
227            border: Some(Some(want)),
228            ..StyleValues::default()
229        };
230        overrides
231            .hold::<StateBorderHeld>(values, &live)
232            .write_visuals(&mut vis);
233    }
234    // Idle, or a restyle took the state borders away.
235    for (entity, mut vis, mut overrides) in idle.iter_mut().chain(orphaned.iter_mut()) {
236        overrides
237            .release::<StateBorderHeld>()
238            .write_visuals(&mut vis);
239        commands.entity(entity).remove::<StateBorderHeld>();
240    }
241}
242
243/// Extract: for every focused entity whose [`Interaction`] declares a
244/// focus outline, spawn an [`ExtractedOutline`] in the render world
245/// that draws on top of the matching rect.
246pub fn extract_focus_outlines(main: &mut World, render: &mut World) {
247    use lumen_core::components::Opacity;
248    use lumen_core::render_world::{RenderEntityMap, hidden_entities, parent_scroll_offsets};
249    let (parents, mut depth_cache) = build_parent_map(main);
250    let scroll = parent_scroll_offsets(main, &parents);
251    // A `Visible(false)` on the focused entity or any ancestor suppresses its
252    // focus ring along with the rest of the hidden subtree.
253    let hidden = hidden_entities(main, &parents);
254    type Row<'a> = (
255        Entity,
256        &'a Transform,
257        &'a Interaction,
258        Option<&'a Opacity>,
259        Option<&'a Visuals>,
260        Option<&'a lumen_core::input::FocusVisible>,
261    );
262    let mut q = main.query_filtered::<Row, With<Focused>>();
263    let pairs: Vec<(Entity, ExtractedOutline)> = q
264        .iter(main)
265        .filter(|(e, ..)| !hidden.contains(e))
266        .filter_map(|(e, t, ix, opacity, vis, focus_visible)| {
267            // `:focus-visible` ring wins while focus is keyboard-driven;
268            // otherwise the always-on `:focus` ring (which may be absent
269            // - a skin can style keyboard focus only).
270            let ring = match (focus_visible, ix.focus_visible_outline) {
271                (Some(_), Some(fv)) => fv,
272                _ => ix.focus_outline?,
273            };
274            let alpha = opacity.copied().unwrap_or_default();
275            let off = scroll.get(&e).copied().unwrap_or(glam::Vec2::ZERO);
276            // CSS `outline` semantics: the ring paints just OUTSIDE the
277            // border box, pushed out further by `outline-offset`, and
278            // never affects layout. The emitter strokes centered on the
279            // given rect, so grow the rect by offset + half the stroke
280            // width per side; the ring then covers exactly
281            // [box edge + offset, box edge + offset + width]. Corner
282            // rounding follows the box radius concentrically.
283            let half = ring.width * 0.5;
284            let grow = ring.offset + half;
285            let radius = vis.map(|v| v.radius).unwrap_or(0.0);
286            Some((
287                e,
288                ExtractedOutline {
289                    origin: t.absolute - off - glam::Vec2::splat(grow),
290                    size: t.size + glam::Vec2::splat(grow * 2.0),
291                    stroke: alpha.apply(ring.color),
292                    width: ring.width,
293                    radius: if radius > 0.0 {
294                        radius + grow.max(0.0)
295                    } else {
296                        0.0
297                    },
298                    order: paint_order_of(e, &parents, &mut depth_cache),
299                },
300            ))
301        })
302        .collect();
303    // Keyed-upsert against `RenderEntityMap.outline`; reused render entities are validated to drop recycled ids.
304    let prior = std::mem::take(&mut render.resource_mut::<RenderEntityMap>().outline);
305    let mut next: std::collections::HashMap<Entity, Entity> =
306        std::collections::HashMap::with_capacity(pairs.len());
307    for (main_e, outline) in pairs {
308        let reuse = prior
309            .get(&main_e)
310            .copied()
311            .filter(|&re| render.get_entity(re).is_ok());
312        let render_e = match reuse {
313            Some(re) => {
314                render.entity_mut(re).insert(outline);
315                re
316            }
317            None => render.spawn(outline).id(),
318        };
319        next.insert(main_e, render_e);
320    }
321    for (main_e, render_e) in &prior {
322        if !next.contains_key(main_e)
323            && let Ok(em) = render.get_entity_mut(*render_e)
324        {
325            em.despawn();
326        }
327    }
328    render.resource_mut::<RenderEntityMap>().outline = next;
329}
330
331/// Resolve the duration + easing the hover / press tint tween should
332/// use. A CSS `transition: background-color <dur> <easing>` declaration
333/// on the entity (its [`TransitionSpecs`]) wins; otherwise the built-in
334/// `default_duration` + cubic ease-out.
335///
336/// [`TransitionSpecs`]: crate::transition::TransitionSpecs
337fn bg_tween_params(
338    specs: Option<&crate::transition::TransitionSpecs>,
339    default_duration: f32,
340) -> (f32, crate::transition::Easing) {
341    match specs.and_then(|s| s.for_property(crate::transition::TransitionProperty::BackgroundColor))
342    {
343        Some(spec) => (spec.duration.as_secs_f32().max(f32::EPSILON), spec.easing),
344        None => (default_duration, crate::transition::Easing::EaseOut),
345    }
346}
347
348fn lerp(a: f32, b: f32, t: f32) -> f32 {
349    a + (b - a) * t
350}
351
352fn lerp_color(a: Color, b: Color, t: f32) -> Color {
353    Color {
354        r: lerp(a.r, b.r, t),
355        g: lerp(a.g, b.g, t),
356        b: lerp(a.b, b.b, t),
357        a: lerp(a.a, b.a, t),
358    }
359}
360
361/// Drive [`HoverTween`]'s progress toward `target`, then blend the solid
362/// [`Visuals::fill`] from what is under the tint toward
363/// [`Interaction::hover_tint`].
364///
365/// The blended fill is held in [`StyleOverrides`] at the hover layer, so a
366/// restyle meanwhile keeps it, and the colour it blends from is whatever is
367/// under that layer now: the resting fill, or a toggle's checked track, as
368/// they stand this frame. A class that changes the resting `bg` while the
369/// pointer is over the element is what the tint fades back to.
370///
371/// Idle entities (no `Hovered`/`Focused`) target 0; active ones target 1.
372/// Once an idle entity finishes draining (progress = 0), the tween is
373/// removed and the fill let go, to keep archetype churn bounded.
374#[allow(clippy::type_complexity)]
375pub fn apply_hover_tint(
376    mut commands: Commands,
377    tick: Res<Tick>,
378    anim: Res<lumen_core::render_world::AnimationsActive>,
379    mut active: Query<
380        (
381            Entity,
382            &Interaction,
383            &mut Visuals,
384            &mut StyleOverrides,
385            Option<&mut HoverTween>,
386            Option<&crate::transition::TransitionSpecs>,
387        ),
388        Or<(With<Hovered>, With<Focused>)>,
389    >,
390    mut idle: Query<
391        (
392            Entity,
393            &mut Visuals,
394            &mut StyleOverrides,
395            &Interaction,
396            &mut HoverTween,
397            Option<&crate::transition::TransitionSpecs>,
398        ),
399        (Without<Hovered>, Without<Focused>),
400    >,
401    mut orphaned: Query<
402        (Entity, &mut Visuals, &mut StyleOverrides),
403        (With<HoverTween>, Without<Interaction>),
404    >,
405) {
406    let now = tick.now;
407    // A restyle took the tint away (the class that set `hover-bg` went):
408    // nothing is left to fade toward, so the fill lets go at once.
409    for (entity, mut vis, mut overrides) in &mut orphaned {
410        overrides.release::<HoverTween>().write_visuals(&mut vis);
411        commands.entity(entity).remove::<HoverTween>();
412    }
413    for (entity, ix, mut vis, mut overrides, tween, specs) in &mut active {
414        let Some(tint) = ix.hover_tint else {
415            continue;
416        };
417        let Some(base) = fill_below::<HoverTween>(&overrides, &vis) else {
418            continue;
419        };
420        // CSS `transition: background-color ...` on the entity overrides
421        // the built-in duration + curve for the state tween.
422        let (duration, easing) = bg_tween_params(specs, HOVER_TWEEN_DURATION);
423        let progress = match tween {
424            Some(mut t) => {
425                let dt = now.duration_since(t.last_step).as_secs_f32();
426                t.last_step = now;
427                t.target = 1.0;
428                let step = (dt / duration).min(1.0);
429                t.progress = (t.progress + step).min(1.0);
430                t.progress
431            }
432            None => {
433                commands.entity(entity).try_insert(HoverTween {
434                    progress: 0.0,
435                    target: 1.0,
436                    last_step: now,
437                });
438                0.0
439            }
440        };
441        // Ramp toward the hover tint still in flight - keep the loop awake.
442        if progress < 1.0 {
443            anim.request();
444        }
445        hold_fill::<HoverTween>(
446            &mut overrides,
447            &mut vis,
448            lerp_color(base, tint, easing.apply(progress)),
449        );
450    }
451    for (entity, mut vis, mut overrides, ix, mut tween, specs) in &mut idle {
452        let (Some(tint), Some(base)) = (ix.hover_tint, fill_below::<HoverTween>(&overrides, &vis))
453        else {
454            overrides.release::<HoverTween>().write_visuals(&mut vis);
455            commands.entity(entity).remove::<HoverTween>();
456            continue;
457        };
458        let (duration, easing) = bg_tween_params(specs, HOVER_TWEEN_DURATION);
459        let dt = now.duration_since(tween.last_step).as_secs_f32();
460        tween.last_step = now;
461        tween.target = 0.0;
462        let step = (dt / duration).min(1.0);
463        tween.progress = (tween.progress - step).max(0.0);
464        if tween.progress <= 0.0 {
465            // Back to exactly what is under the tint, and the tween state
466            // goes so archetype iteration stays cheap for hovers that never
467            // fire.
468            overrides.release::<HoverTween>().write_visuals(&mut vis);
469            commands.entity(entity).remove::<HoverTween>();
470            continue;
471        }
472        // Hover-out fade still in flight - keep the loop awake.
473        anim.request();
474        hold_fill::<HoverTween>(
475            &mut overrides,
476            &mut vis,
477            lerp_color(base, tint, easing.apply(tween.progress)),
478        );
479    }
480}
481
482/// Press tween duration - faster than hover, matches the snappy "click
483/// pulse" of native buttons.
484pub const PRESS_TWEEN_DURATION: f32 = 0.06;
485
486/// Per-entity press tween (mirror of [`HoverTween`] for the press state).
487#[derive(Component, Clone, Copy, Debug)]
488pub struct PressTween {
489    /// Currently in [0, 1].
490    pub progress: f32,
491    /// Last instant the tween was advanced.
492    pub last_step: Instant,
493}
494
495impl StyleOwner for PressTween {
496    const RANK: u8 = 50;
497}
498
499/// Override the solid [`Visuals::fill`] with [`Interaction::press_tint`]
500/// while the entity is *visually* pressed. Tweens over
501/// [`PRESS_TWEEN_DURATION`] using ease-out.
502///
503/// Visually pressed = `Pressed` AND (currently over OR keyboard press):
504/// during a pointer press, `Pressed` doubles as the capture marker and
505/// survives the pointer dragging off the widget (spec section 0 rule 3), but
506/// the pressed *visual* must un-press while off and re-press on
507/// re-entry. "Currently over" is the [`Hovered`] marker (`hit_test`
508/// confines it to the captured entity mid-press); keyboard presses
509/// (Space FSM) have the primary button up and present pressed
510/// regardless of hover.
511///
512/// The press layer sits over the hover layer in [`StyleOverrides`], so it
513/// blends from whatever the element shows under it, hover tint included,
514/// and a press released while still hovered fades back to the hover colour.
515/// Gradient-filled tiles are skipped (no animation path).
516#[allow(clippy::type_complexity)]
517pub fn apply_press_tint(
518    mut commands: Commands,
519    tick: Res<Tick>,
520    anim: Res<lumen_core::render_world::AnimationsActive>,
521    pointer: Option<Res<PointerState>>,
522    mut tinted: Query<
523        (
524            Entity,
525            &Interaction,
526            &mut Visuals,
527            &mut StyleOverrides,
528            Option<&mut PressTween>,
529            Option<&crate::transition::TransitionSpecs>,
530            Has<Pressed>,
531            Has<Hovered>,
532        ),
533        Or<(With<Pressed>, With<PressTween>)>,
534    >,
535    mut orphaned: Query<
536        (Entity, &mut Visuals, &mut StyleOverrides),
537        (With<PressTween>, Without<Interaction>),
538    >,
539) {
540    let now = tick.now;
541    let primary_down = pointer.map(|p| p.primary_down).unwrap_or(false);
542    for (entity, mut vis, mut overrides) in &mut orphaned {
543        overrides.release::<PressTween>().write_visuals(&mut vis);
544        commands.entity(entity).remove::<PressTween>();
545    }
546    for (entity, ix, mut vis, mut overrides, tween, specs, is_pressed, is_hovered) in &mut tinted {
547        let engaged = is_pressed && (is_hovered || !primary_down);
548        let (duration, easing) = bg_tween_params(specs, PRESS_TWEEN_DURATION);
549        let (Some(tint), Some(base)) = (ix.press_tint, fill_below::<PressTween>(&overrides, &vis))
550        else {
551            if tween.is_some() {
552                overrides.release::<PressTween>().write_visuals(&mut vis);
553                commands.entity(entity).remove::<PressTween>();
554            }
555            continue;
556        };
557        if engaged {
558            let progress = match tween {
559                Some(mut t) => {
560                    let dt = now.duration_since(t.last_step).as_secs_f32();
561                    t.last_step = now;
562                    let step = (dt / duration).min(1.0);
563                    t.progress = (t.progress + step).min(1.0);
564                    t.progress
565                }
566                None => {
567                    // Instant press tint: seed the tween fully-pressed so
568                    // the FIRST frame after activation renders the full
569                    // press tint rather than the base color. Press feedback
570                    // must read as immediate - a 60 ms ramp-up from base
571                    // means a quick tap often shows no tint at all. The
572                    // disengaged branch below animates the fade back down
573                    // from 1.0 -> 0.0. Re-entering the captured widget after
574                    // a drag-off takes this same instant-on path.
575                    commands.entity(entity).try_insert(PressTween {
576                        progress: 1.0,
577                        last_step: now,
578                    });
579                    1.0
580                }
581            };
582            // Still ramping toward the pressed tint (only relevant if some
583            // other code seeds progress < 1) - keep the loop awake so the
584            // ramp completes without an unrelated event.
585            if progress < 1.0 {
586                anim.request();
587            }
588            hold_fill::<PressTween>(
589                &mut overrides,
590                &mut vis,
591                lerp_color(base, tint, easing.apply(progress)),
592            );
593        } else {
594            // Not visually pressed: released, or captured-but-off. Fade
595            // the tint back out. Entities that never tinted (pressed with
596            // no tween yet - e.g. gained `Pressed` while dragged off)
597            // have nothing to fade.
598            let Some(mut tween) = tween else {
599                continue;
600            };
601            let dt = now.duration_since(tween.last_step).as_secs_f32();
602            tween.last_step = now;
603            let step = (dt / duration).min(1.0);
604            tween.progress = (tween.progress - step).max(0.0);
605            if tween.progress <= 0.0 {
606                // Back to exactly what is under the press tint.
607                overrides.release::<PressTween>().write_visuals(&mut vis);
608                commands.entity(entity).remove::<PressTween>();
609                continue;
610            }
611            // Release fade still in flight - keep the loop awake so the
612            // fade reaches 0 without waiting for an unrelated OS event.
613            anim.request();
614            hold_fill::<PressTween>(
615                &mut overrides,
616                &mut vis,
617                lerp_color(base, tint, easing.apply(tween.progress)),
618            );
619        }
620    }
621}
622
623#[cfg(test)]
624mod press_tint_tests {
625    //! Pressed-but-off visual (spec section 0 rule 3): the press tint shows only
626    //! while `Pressed` AND (`Hovered` OR pointer-up). Dragging off the
627    //! captured widget fades the tint out; re-entering snaps it back on;
628    //! keyboard presses (pointer up, no hover) tint too.
629    use super::*;
630    use bevy_ecs::schedule::Schedule;
631    use lumen_core::input::PointerState;
632    use lumen_core::render_world::AnimationsActive;
633
634    const IDLE: Color = Color {
635        r: 0.2,
636        g: 0.2,
637        b: 0.2,
638        a: 1.0,
639    };
640    const TINT: Color = Color {
641        r: 0.9,
642        g: 0.1,
643        b: 0.1,
644        a: 1.0,
645    };
646
647    fn world_with_button(primary_down: bool) -> (World, Entity) {
648        let mut world = World::new();
649        world.insert_resource(Tick::default());
650        world.init_resource::<AnimationsActive>();
651        world.insert_resource(PointerState {
652            position: None,
653            primary_down,
654        });
655        let e = world
656            .spawn((
657                Interaction {
658                    hover_tint: None,
659                    press_tint: Some(TINT),
660                    ..Default::default()
661                },
662                Visuals {
663                    fill: Some(Fill::Solid(IDLE)),
664                    ..Default::default()
665                },
666            ))
667            .id();
668        (world, e)
669    }
670
671    fn run(world: &mut World) {
672        let mut s = Schedule::default();
673        s.add_systems(apply_press_tint);
674        s.run(world);
675        // Second pass so command-inserted tween/base state is applied
676        // and the blend lands.
677        let mut s2 = Schedule::default();
678        s2.add_systems(apply_press_tint);
679        s2.run(world);
680    }
681
682    fn fill(world: &World, e: Entity) -> Color {
683        world
684            .get::<Visuals>(e)
685            .unwrap()
686            .fill
687            .as_ref()
688            .and_then(Fill::as_solid)
689            .unwrap()
690    }
691
692    #[test]
693    fn pressed_and_hovered_tints_dragged_off_untints_reenter_retints() {
694        let (mut world, e) = world_with_button(true);
695        world.entity_mut(e).insert((Pressed, Hovered));
696        run(&mut world);
697        assert_eq!(fill(&world, e), TINT, "pressed+over shows the press tint");
698
699        // Drag off: capture retained (Pressed stays), hover gone -> the
700        // visual must fade back toward idle. Advance time far enough to
701        // finish the 60 ms fade in one step.
702        world.entity_mut(e).remove::<Hovered>();
703        world.resource_mut::<Tick>().now = Instant::now() + std::time::Duration::from_secs(1);
704        run(&mut world);
705        assert_eq!(
706            fill(&world, e),
707            IDLE,
708            "captured-but-off shows the un-pressed visual"
709        );
710        assert!(world.get::<Pressed>(e).is_some(), "capture retained");
711
712        // Re-enter: instant-on press tint again.
713        world.entity_mut(e).insert(Hovered);
714        run(&mut world);
715        assert_eq!(fill(&world, e), TINT, "re-entering re-presses");
716    }
717
718    #[test]
719    fn keyboard_press_tints_without_hover() {
720        let (mut world, e) = world_with_button(false);
721        world.entity_mut(e).insert(Pressed);
722        run(&mut world);
723        assert_eq!(
724            fill(&world, e),
725            TINT,
726            "Space-FSM press (pointer up, no hover) still shows the tint"
727        );
728    }
729}
730
731#[cfg(test)]
732mod state_border_tests {
733    use super::*;
734    use lumen_core::components::{Border, Edges};
735
736    fn border(w: f32) -> Border {
737        Border {
738            widths: Edges::all(w),
739            color: Color::rgb(1.0, 0.0, 0.0),
740            side_colors: None,
741        }
742    }
743
744    fn run(world: &mut bevy_ecs::world::World) {
745        let mut schedule = bevy_ecs::schedule::Schedule::default();
746        schedule.add_systems(apply_state_borders);
747        schedule.run(world);
748    }
749
750    /// Hover swaps the border in; un-hover lets go onto the element's own
751    /// border (including `None`); focus wins over hover when both are
752    /// active.
753    #[test]
754    fn hover_focus_border_swap_and_restore() {
755        let mut world = bevy_ecs::world::World::new();
756        let e = world
757            .spawn((
758                Interaction {
759                    hover_border: Some(border(1.0)),
760                    focus_border: Some(border(2.0)),
761                    ..Default::default()
762                },
763                Visuals::default(),
764            ))
765            .id();
766
767        // Idle -> no swap.
768        run(&mut world);
769        assert_eq!(world.get::<Visuals>(e).unwrap().border, None);
770
771        // Hover -> hover border, base captured.
772        world.entity_mut(e).insert(Hovered);
773        run(&mut world);
774        assert_eq!(
775            world.get::<Visuals>(e).unwrap().border,
776            Some(border(1.0)),
777            "hover border swapped in"
778        );
779        assert!(world.get::<StateBorderHeld>(e).is_some());
780
781        // Hover + focus -> focus border wins.
782        world.entity_mut(e).insert(lumen_core::input::Focused);
783        run(&mut world);
784        assert_eq!(world.get::<Visuals>(e).unwrap().border, Some(border(2.0)));
785
786        // Both end -> base (None) restored, capture removed.
787        world.entity_mut(e).remove::<Hovered>();
788        world.entity_mut(e).remove::<lumen_core::input::Focused>();
789        run(&mut world);
790        assert_eq!(world.get::<Visuals>(e).unwrap().border, None);
791        assert!(world.get::<StateBorderHeld>(e).is_none());
792    }
793}
794
795#[cfg(test)]
796mod focus_outline_tests {
797    //! Focus-ring extraction: `outline-offset` grows the ring rect, and
798    //! the `:focus-visible` ring wins over the always-on `:focus` ring
799    //! only while the `FocusVisible` marker is present.
800    use super::*;
801    use lumen_core::input::FocusVisible;
802    use lumen_core::render_world::RenderEntityMap;
803
804    fn spec(width: f32, offset: f32, r: f32) -> FocusOutlineSpec {
805        FocusOutlineSpec {
806            width,
807            color: Color::rgb(r, 0.0, 0.0),
808            offset,
809        }
810    }
811
812    fn outlines(render: &mut bevy_ecs::world::World) -> Vec<ExtractedOutline> {
813        let mut q = render.query::<&ExtractedOutline>();
814        q.iter(render).copied().collect()
815    }
816
817    #[test]
818    fn outline_offset_grows_ring_and_focus_visible_ring_wins_on_keyboard_focus() {
819        let mut main = bevy_ecs::world::World::new();
820        let mut render = bevy_ecs::world::World::new();
821        render.insert_resource(RenderEntityMap::default());
822
823        let e = main
824            .spawn((
825                Transform::new(glam::Vec2::new(10.0, 10.0), glam::Vec2::new(100.0, 40.0)),
826                Interaction {
827                    focus_outline: Some(spec(2.0, 0.0, 0.25)),
828                    focus_visible_outline: Some(spec(4.0, 3.0, 0.75)),
829                    ..Default::default()
830                },
831                Focused,
832            ))
833            .id();
834
835        // Pointer focus (no FocusVisible): the always-on :focus ring.
836        extract_focus_outlines(&mut main, &mut render);
837        let rings = outlines(&mut render);
838        assert_eq!(rings.len(), 1);
839        assert_eq!(rings[0].width, 2.0, "pointer focus paints the :focus ring");
840        // offset 0 -> grown by half the width per side.
841        assert_eq!(rings[0].origin, glam::Vec2::new(9.0, 9.0));
842        assert_eq!(rings[0].size, glam::Vec2::new(102.0, 42.0));
843
844        // Keyboard focus: the :focus-visible ring wins, offset 3 pushes
845        // the rect out by offset + half width = 5 per side.
846        main.entity_mut(e).insert(FocusVisible);
847        extract_focus_outlines(&mut main, &mut render);
848        let rings = outlines(&mut render);
849        assert_eq!(rings.len(), 1);
850        assert_eq!(rings[0].width, 4.0, "keyboard focus paints :focus-visible");
851        assert_eq!(rings[0].origin, glam::Vec2::new(5.0, 5.0));
852        assert_eq!(rings[0].size, glam::Vec2::new(110.0, 50.0));
853    }
854}