Skip to main content

aetna_core/
runtime.rs

1//! `RunnerCore` — the backend-agnostic half of every Aetna runner.
2//!
3//! Holds interaction state ([`UiState`], `last_tree`) and paint scratch
4//! buffers (`quad_scratch`, `runs`, `paint_items`) plus the geometry
5//! context (`viewport_px`, `surface_size_override`) needed to project
6//! layout's logical-pixel rects into physical-pixel scissors. Exposes
7//! the identical interaction methods both backends ship: `pointer_*`,
8//! `key_down`, `set_hotkeys`, `set_animation_mode`, `ui_state`,
9//! `rect_of_key`, `debug_summary`, `set_surface_size`, plus the layout
10//! / paint-stream stages that are pure CPU work.
11//!
12//! Each backend's `Runner` *contains* a `RunnerCore` and forwards the
13//! interaction methods to it; only the GPU resources (pipelines,
14//! buffers, atlases) and the actual GPU upload + draw work stay
15//! per-backend. The split shares what's identical without a trait —
16//! same shape as `crate::paint`, larger surface.
17//!
18//! ## What this module does NOT own
19//!
20//! - **Pipeline registration.** Each backend builds its own
21//!   `pipelines: HashMap<ShaderHandle, BackendPipeline>` because the
22//!   pipeline value type is GPU-specific.
23//! - **Text upload.** Glyph atlas pages live on the GPU as backend
24//!   images; the `TextPaint` that owns them is per-backend. Core
25//!   reaches into it through the [`TextRecorder`] trait during the
26//!   paint stream loop, then the backend flushes its atlas separately.
27//! - **GPU upload of `quad_scratch` / frame uniforms.** Backend
28//!   responsibility — `prepare()` orchestrates the full sequence.
29//! - **`draw()`.** Both backends walk `core.paint_items` + `core.runs`
30//!   themselves because the encoder type (and lifetime) diverges.
31//!
32//! ## Why no `Painter` trait
33//!
34//! Extracting a `trait Painter { fn prepare(...); fn draw(...); fn
35//! set_scissor(...); }` was considered so backends would share *one*
36//! abstraction surface. We declined: the only call sites left after
37//! this module + [`crate::paint`] are the two
38//! `prepare()` GPU-upload tails and the two `draw()` walks, and both
39//! need backend-typed handles (`wgpu::RenderPass<'_>` /
40//! `AutoCommandBufferBuilder<...>`) that no trait can hide without
41//! generics that re-fragment the surface. A `Painter` trait would
42//! reduce to a 1-method `set_scissor` indirection plus host-side
43//! ceremony — dead weight. The duplication that *is* worth abstracting
44//! is the host harness (winit init, swapchain management,
45//! `aetna-{wgpu,vulkano}-demo::run`) — and that lives a layer above
46//! the paint surface, not inside it. Revisit if a third backend lands
47//! or if the GPU-upload sequences diverge enough to make a typed-state
48//! interface earn its keep.
49
50use std::ops::Range;
51use std::time::Duration;
52
53use web_time::Instant;
54
55use crate::draw_ops;
56use crate::event::{KeyChord, KeyModifiers, PointerButton, UiEvent, UiEventKind, UiKey, UiTarget};
57use crate::focus;
58use crate::hit_test;
59use crate::ir::{DrawOp, TextAnchor};
60use crate::layout;
61use crate::paint::{
62    InstanceRun, PaintItem, PhysicalScissor, QuadInstance, close_run, pack_instance,
63    physical_scissor,
64};
65use crate::shader::ShaderHandle;
66use crate::state::{AnimationMode, UiState};
67use crate::text::atlas::RunStyle;
68use crate::theme::Theme;
69use crate::tooltip;
70use crate::tree::{Color, El, FontWeight, Rect, TextWrap};
71
72/// Reported back from each backend's `prepare(...)` per frame. The
73/// host uses `needs_redraw` to keep the redraw loop ticking only
74/// while there is in-flight motion (a hover spring still settling, a
75/// focus ring still fading out), then idles. `timings` is a per-frame
76/// CPU breakdown for diagnostic logging.
77#[derive(Clone, Copy, Debug, Default)]
78pub struct PrepareResult {
79    pub needs_redraw: bool,
80    pub timings: PrepareTimings,
81}
82
83/// Per-stage CPU timing inside each backend's `prepare`. Cheap to
84/// compute (a handful of `Instant::now()` calls per frame) and useful
85/// for finding the dominant cost when frame budget is tight.
86///
87/// Stages:
88/// - `layout`: layout pass + focus order sync + state apply + animation tick.
89/// - `draw_ops`: tree → DrawOp[] resolution.
90/// - `paint`: paint-stream loop (quad packing + text shaping via cosmic-text).
91/// - `gpu_upload`: backend-side instance buffer write + atlas flush + frame uniforms.
92/// - `snapshot`: cloning the laid-out tree for next-frame hit-testing.
93#[derive(Clone, Copy, Debug, Default)]
94pub struct PrepareTimings {
95    pub layout: Duration,
96    pub draw_ops: Duration,
97    pub paint: Duration,
98    pub gpu_upload: Duration,
99    pub snapshot: Duration,
100}
101
102/// Backend-agnostic runner state.
103///
104/// Each backend's `Runner` owns one of these as its `core` field and
105/// forwards the public interaction surface to it. The fields are `pub`
106/// so backends can read them in `draw()` (which has to traverse
107/// `paint_items` + `runs` against backend-specific pipeline and
108/// instance-buffer objects).
109pub struct RunnerCore {
110    pub ui_state: UiState,
111    /// Snapshot of the last laid-out tree, kept so pointer events
112    /// arriving between frames hit-test against the geometry the user
113    /// is actually looking at.
114    pub last_tree: Option<El>,
115
116    /// Per-frame quad instance scratch — backends `bytemuck::cast_slice`
117    /// this into their VBO upload.
118    pub quad_scratch: Vec<QuadInstance>,
119    pub runs: Vec<InstanceRun>,
120    pub paint_items: Vec<PaintItem>,
121
122    /// Physical viewport size in pixels. Backends use this for `draw()`
123    /// scissor binding (logical scissors get projected into this space
124    /// inside `prepare_paint`).
125    pub viewport_px: (u32, u32),
126    /// When set, overrides the physical viewport derived from
127    /// `viewport.w * scale_factor` so paint-side scissor math matches
128    /// the actual swapchain extent. Backends call
129    /// [`Self::set_surface_size`] from their host's surface-config /
130    /// resize hook to keep this in lockstep.
131    pub surface_size_override: Option<(u32, u32)>,
132
133    /// Theme used when resolving implicit widget surfaces to shaders.
134    pub theme: Theme,
135}
136
137impl Default for RunnerCore {
138    fn default() -> Self {
139        Self::new()
140    }
141}
142
143impl RunnerCore {
144    pub fn new() -> Self {
145        Self {
146            ui_state: UiState::default(),
147            last_tree: None,
148            quad_scratch: Vec::new(),
149            runs: Vec::new(),
150            paint_items: Vec::new(),
151            viewport_px: (1, 1),
152            surface_size_override: None,
153            theme: Theme::default(),
154        }
155    }
156
157    pub fn set_theme(&mut self, theme: Theme) {
158        self.theme = theme;
159    }
160
161    pub fn theme(&self) -> &Theme {
162        &self.theme
163    }
164
165    /// Override the physical viewport size. Call after the host's
166    /// surface configure or resize so scissor math sees the swapchain's
167    /// real extent (fractional `scale_factor` round-trips can otherwise
168    /// land `viewport_px` one pixel off and trip
169    /// `set_scissor_rect` validation).
170    pub fn set_surface_size(&mut self, width: u32, height: u32) {
171        self.surface_size_override = Some((width.max(1), height.max(1)));
172    }
173
174    pub fn ui_state(&self) -> &UiState {
175        &self.ui_state
176    }
177
178    pub fn debug_summary(&self) -> String {
179        self.ui_state.debug_summary()
180    }
181
182    pub fn rect_of_key(&self, key: &str) -> Option<Rect> {
183        self.last_tree
184            .as_ref()
185            .and_then(|t| self.ui_state.rect_of_key(t, key))
186    }
187
188    // ---- Input plumbing ----
189
190    /// Pointer moved to `(x, y)` (logical px). Updates the hovered
191    /// node (readable via `ui_state().hovered`) and, if the primary
192    /// button is currently held, returns a `Drag` event routed to the
193    /// originally pressed target. The event's `modifiers` field
194    /// reflects the mask currently tracked on `UiState` (set by the
195    /// host via `set_modifiers`).
196    pub fn pointer_moved(&mut self, x: f32, y: f32) -> Option<UiEvent> {
197        self.ui_state.pointer_pos = Some((x, y));
198        let hit = self
199            .last_tree
200            .as_ref()
201            .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
202        self.ui_state.set_hovered(hit, Instant::now());
203        // Drag: pointer moved while primary button is down → emit Drag
204        // to the originally pressed target. Cursor escape from the
205        // pressed node is the *normal* drag-extend case (e.g. text
206        // selection); we keep emitting until pointer_up clears `pressed`.
207        let modifiers = self.ui_state.modifiers;
208        self.ui_state.pressed.clone().map(|p| UiEvent {
209            key: Some(p.key.clone()),
210            target: Some(p),
211            pointer: Some((x, y)),
212            key_press: None,
213            text: None,
214            modifiers,
215            kind: UiEventKind::Drag,
216        })
217    }
218
219    pub fn pointer_left(&mut self) {
220        self.ui_state.pointer_pos = None;
221        self.ui_state.set_hovered(None, Instant::now());
222        self.ui_state.pressed = None;
223        self.ui_state.pressed_secondary = None;
224    }
225
226    /// Primary/secondary/middle pointer button pressed at `(x, y)`.
227    /// For the primary button, focuses the hit target and stashes it
228    /// as the pressed target; returns a `PointerDown` event so widgets
229    /// like text_input can react at down-time (e.g., set the selection
230    /// anchor before any drag extends it). Secondary/middle store on a
231    /// separate channel and never emit a `PointerDown`.
232    pub fn pointer_down(&mut self, x: f32, y: f32, button: PointerButton) -> Option<UiEvent> {
233        let hit = self
234            .last_tree
235            .as_ref()
236            .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
237        // Only the primary button drives focus + the visual press
238        // envelope. Secondary/middle clicks shouldn't yank focus from
239        // the currently-focused element (matches browser/native behavior
240        // where right-clicking a button doesn't take focus).
241        if matches!(button, PointerButton::Primary) {
242            self.ui_state.set_focus(hit.clone());
243            self.ui_state.pressed = hit.clone();
244            // A press on the hovered node dismisses any tooltip for
245            // the rest of this hover session — matches native UIs.
246            self.ui_state.tooltip_dismissed_for_hover = true;
247            let modifiers = self.ui_state.modifiers;
248            hit.map(|p| UiEvent {
249                key: Some(p.key.clone()),
250                target: Some(p),
251                pointer: Some((x, y)),
252                key_press: None,
253                text: None,
254                modifiers,
255                kind: UiEventKind::PointerDown,
256            })
257        } else {
258            // Stash the down-target on the secondary/middle channel so
259            // pointer_up can confirm the click landed on the same node.
260            self.ui_state.pressed_secondary = hit.map(|h| (h, button));
261            None
262        }
263    }
264
265    /// Pointer released. For the primary button, fires `PointerUp`
266    /// (always, with the originally pressed target so drag-aware
267    /// widgets see drag-end) and additionally `Click` if the release
268    /// landed on the same node as the down. For secondary / middle,
269    /// fires the corresponding click variant when the up landed on the
270    /// same node; no analogue of `PointerUp` since drag is a primary-
271    /// button concept here.
272    pub fn pointer_up(&mut self, x: f32, y: f32, button: PointerButton) -> Vec<UiEvent> {
273        let hit = self
274            .last_tree
275            .as_ref()
276            .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
277        let modifiers = self.ui_state.modifiers;
278        let mut out = Vec::new();
279        match button {
280            PointerButton::Primary => {
281                let pressed = self.ui_state.pressed.take();
282                if let Some(p) = pressed.clone() {
283                    out.push(UiEvent {
284                        key: Some(p.key.clone()),
285                        target: Some(p),
286                        pointer: Some((x, y)),
287                        key_press: None,
288                        text: None,
289                        modifiers,
290                        kind: UiEventKind::PointerUp,
291                    });
292                }
293                if let (Some(p), Some(h)) = (pressed, hit)
294                    && p.node_id == h.node_id
295                {
296                    out.push(UiEvent {
297                        key: Some(p.key.clone()),
298                        target: Some(p),
299                        pointer: Some((x, y)),
300                        key_press: None,
301                        text: None,
302                        modifiers,
303                        kind: UiEventKind::Click,
304                    });
305                }
306            }
307            PointerButton::Secondary | PointerButton::Middle => {
308                let pressed = self.ui_state.pressed_secondary.take();
309                if let (Some((p, b)), Some(h)) = (pressed, hit)
310                    && b == button
311                    && p.node_id == h.node_id
312                {
313                    let kind = match button {
314                        PointerButton::Secondary => UiEventKind::SecondaryClick,
315                        PointerButton::Middle => UiEventKind::MiddleClick,
316                        PointerButton::Primary => unreachable!(),
317                    };
318                    out.push(UiEvent {
319                        key: Some(p.key.clone()),
320                        target: Some(p),
321                        pointer: Some((x, y)),
322                        key_press: None,
323                        text: None,
324                        modifiers,
325                        kind,
326                    });
327                }
328            }
329        }
330        out
331    }
332
333    pub fn key_down(
334        &mut self,
335        key: UiKey,
336        modifiers: KeyModifiers,
337        repeat: bool,
338    ) -> Option<UiEvent> {
339        // Capture path: when the focused node opted into raw key
340        // capture, the library's Tab/Enter/Escape interpretation is
341        // bypassed and the event is delivered as a raw `KeyDown` to
342        // the focused target. Hotkeys still match first — an app's
343        // global Ctrl+S beats a text input's local consumption of S.
344        if self.focused_captures_keys() {
345            if let Some(event) = self.ui_state.try_hotkey(&key, modifiers, repeat) {
346                return Some(event);
347            }
348            return self.ui_state.key_down_raw(key, modifiers, repeat);
349        }
350
351        // Arrow-nav: if the focused node sits inside an arrow-navigable
352        // group (typically a popover_panel of menu items), Up / Down /
353        // Home / End move focus among its focusable siblings rather
354        // than emitting a `KeyDown` event. Hotkeys are still matched
355        // first so a global Ctrl+ArrowUp chord beats menu navigation.
356        if matches!(
357            key,
358            UiKey::ArrowUp | UiKey::ArrowDown | UiKey::Home | UiKey::End
359        ) && let Some(siblings) = self.focused_arrow_nav_group()
360        {
361            if let Some(event) = self.ui_state.try_hotkey(&key, modifiers, repeat) {
362                return Some(event);
363            }
364            self.move_focus_in_group(&key, &siblings);
365            return None;
366        }
367
368        self.ui_state.key_down(key, modifiers, repeat)
369    }
370
371    /// Look up the focused node's nearest [`El::arrow_nav_siblings`]
372    /// parent in the last laid-out tree and return the focusable
373    /// siblings (the navigation targets for Up / Down / Home / End).
374    /// Returns `None` when no node is focused, the tree hasn't been
375    /// built yet, or the focused element isn't inside an
376    /// arrow-navigable parent.
377    fn focused_arrow_nav_group(&self) -> Option<Vec<UiTarget>> {
378        let focused = self.ui_state.focused.as_ref()?;
379        let tree = self.last_tree.as_ref()?;
380        focus::arrow_nav_group(tree, &self.ui_state, &focused.node_id)
381    }
382
383    /// Move the focused element to the appropriate sibling for `key`.
384    /// `Up` / `Down` step by one (saturating at the ends — no wrap, so
385    /// holding the key doesn't loop visually); `Home` / `End` jump to
386    /// the first / last sibling.
387    fn move_focus_in_group(&mut self, key: &UiKey, siblings: &[UiTarget]) {
388        if siblings.is_empty() {
389            return;
390        }
391        let focused_id = match self.ui_state.focused.as_ref() {
392            Some(t) => t.node_id.clone(),
393            None => return,
394        };
395        let idx = siblings.iter().position(|t| t.node_id == focused_id);
396        let next_idx = match (key, idx) {
397            (UiKey::ArrowUp, Some(i)) => i.saturating_sub(1),
398            (UiKey::ArrowDown, Some(i)) => (i + 1).min(siblings.len() - 1),
399            (UiKey::Home, _) => 0,
400            (UiKey::End, _) => siblings.len() - 1,
401            _ => return,
402        };
403        if Some(next_idx) != idx {
404            self.ui_state.set_focus(Some(siblings[next_idx].clone()));
405        }
406    }
407
408    /// Look up the focused node in the last laid-out tree and return
409    /// its `capture_keys` flag. False when no node is focused or the
410    /// tree hasn't been built yet.
411    fn focused_captures_keys(&self) -> bool {
412        let Some(focused) = self.ui_state.focused.as_ref() else {
413            return false;
414        };
415        let Some(tree) = self.last_tree.as_ref() else {
416            return false;
417        };
418        find_capture_keys(tree, &focused.node_id).unwrap_or(false)
419    }
420
421    /// OS-composed text input (printable characters after dead-key /
422    /// shift / IME composition). Routed to the focused element as a
423    /// `TextInput` event. Returns `None` if no node has focus, or if
424    /// `text` is empty (some platforms emit empty composition strings
425    /// during IME selection).
426    pub fn text_input(&mut self, text: String) -> Option<UiEvent> {
427        if text.is_empty() {
428            return None;
429        }
430        let target = self.ui_state.focused.clone()?;
431        let modifiers = self.ui_state.modifiers;
432        Some(UiEvent {
433            key: Some(target.key.clone()),
434            target: Some(target),
435            pointer: None,
436            key_press: None,
437            text: Some(text),
438            modifiers,
439            kind: UiEventKind::TextInput,
440        })
441    }
442
443    pub fn set_hotkeys(&mut self, hotkeys: Vec<(KeyChord, String)>) {
444        self.ui_state.set_hotkeys(hotkeys);
445    }
446
447    pub fn set_animation_mode(&mut self, mode: AnimationMode) {
448        self.ui_state.set_animation_mode(mode);
449    }
450
451    pub fn pointer_wheel(&mut self, x: f32, y: f32, dy: f32) -> bool {
452        let Some(tree) = self.last_tree.as_ref() else {
453            return false;
454        };
455        self.ui_state.pointer_wheel(tree, (x, y), dy)
456    }
457
458    // ---- Per-frame staging ----
459
460    /// Layout + state apply + animation tick + viewport projection +
461    /// `DrawOp` resolution. Returns the resolved op list and whether
462    /// visual animations need another frame; writes per-stage timings
463    /// into `timings` (`layout` + `draw_ops`).
464    pub fn prepare_layout(
465        &mut self,
466        root: &mut El,
467        viewport: Rect,
468        scale_factor: f32,
469        timings: &mut PrepareTimings,
470    ) -> (Vec<DrawOp>, bool) {
471        let t0 = Instant::now();
472        // Tooltip synthesis runs before the real layout: assign ids
473        // first so we can find the hovered node by computed_id, then
474        // append a tooltip layer if one is due. The subsequent
475        // `layout::layout` call re-assigns (idempotently — same path
476        // shapes produce the same ids) and lays out the appended
477        // layer alongside everything else.
478        layout::assign_ids(root);
479        let tooltip_pending = tooltip::synthesize_tooltip(root, &self.ui_state, t0);
480        layout::layout(root, &mut self.ui_state, viewport);
481        self.ui_state.sync_focus_order(root);
482        focus::sync_popover_focus(root, &mut self.ui_state);
483        self.ui_state.apply_to_state();
484        let needs_redraw =
485            self.ui_state.tick_visual_animations(root, Instant::now()) || tooltip_pending;
486        self.viewport_px = self.surface_size_override.unwrap_or_else(|| {
487            (
488                (viewport.w * scale_factor).ceil().max(1.0) as u32,
489                (viewport.h * scale_factor).ceil().max(1.0) as u32,
490            )
491        });
492        let t_after_layout = Instant::now();
493        let ops = draw_ops::draw_ops_with_theme(root, &self.ui_state, &self.theme);
494        let t_after_draw_ops = Instant::now();
495        timings.layout = t_after_layout - t0;
496        timings.draw_ops = t_after_draw_ops - t_after_layout;
497        (ops, needs_redraw)
498    }
499
500    /// Walk the resolved `DrawOp` list, packing quads into
501    /// `quad_scratch` + grouping them into `runs`, interleaving text
502    /// records via the backend-supplied [`TextRecorder`]. Returns the
503    /// number of quad instances written (so the backend can size its
504    /// instance buffer).
505    ///
506    /// Callers must call `text.frame_begin()` themselves *before*
507    /// invoking this — `prepare_paint` does not call it for them
508    /// because backends often want to clear other per-frame text
509    /// scratch in the same step.
510    pub fn prepare_paint<F1, F2>(
511        &mut self,
512        ops: &[DrawOp],
513        is_registered: F1,
514        samples_backdrop: F2,
515        text: &mut dyn TextRecorder,
516        scale_factor: f32,
517        timings: &mut PrepareTimings,
518    ) where
519        F1: Fn(&ShaderHandle) -> bool,
520        F2: Fn(&ShaderHandle) -> bool,
521    {
522        let t0 = Instant::now();
523        self.quad_scratch.clear();
524        self.runs.clear();
525        self.paint_items.clear();
526
527        let mut current: Option<(ShaderHandle, Option<PhysicalScissor>)> = None;
528        let mut run_first: u32 = 0;
529        // At most one snapshot per frame. Auto-inserted before
530        // the first paint that samples the backdrop.
531        let mut snapshot_emitted = false;
532
533        for op in ops {
534            match op {
535                DrawOp::Quad {
536                    rect,
537                    scissor,
538                    shader,
539                    uniforms,
540                    ..
541                } => {
542                    if !is_registered(shader) {
543                        continue;
544                    }
545                    let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
546                    if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
547                        continue;
548                    }
549                    if !snapshot_emitted && samples_backdrop(shader) {
550                        close_run(
551                            &mut self.runs,
552                            &mut self.paint_items,
553                            current,
554                            run_first,
555                            self.quad_scratch.len() as u32,
556                        );
557                        current = None;
558                        run_first = self.quad_scratch.len() as u32;
559                        self.paint_items.push(PaintItem::BackdropSnapshot);
560                        snapshot_emitted = true;
561                    }
562                    let inst = pack_instance(*rect, *shader, uniforms);
563
564                    let key = (*shader, phys);
565                    if current != Some(key) {
566                        close_run(
567                            &mut self.runs,
568                            &mut self.paint_items,
569                            current,
570                            run_first,
571                            self.quad_scratch.len() as u32,
572                        );
573                        current = Some(key);
574                        run_first = self.quad_scratch.len() as u32;
575                    }
576                    self.quad_scratch.push(inst);
577                }
578                DrawOp::GlyphRun {
579                    rect,
580                    scissor,
581                    color,
582                    text: glyph_text,
583                    size,
584                    weight,
585                    wrap,
586                    anchor,
587                    ..
588                } => {
589                    close_run(
590                        &mut self.runs,
591                        &mut self.paint_items,
592                        current,
593                        run_first,
594                        self.quad_scratch.len() as u32,
595                    );
596                    current = None;
597                    run_first = self.quad_scratch.len() as u32;
598
599                    let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
600                    if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
601                        continue;
602                    }
603                    let layers = text.record(
604                        *rect,
605                        phys,
606                        *color,
607                        glyph_text,
608                        *size,
609                        *weight,
610                        *wrap,
611                        *anchor,
612                        scale_factor,
613                    );
614                    for index in layers {
615                        self.paint_items.push(PaintItem::Text(index));
616                    }
617                }
618                DrawOp::AttributedText {
619                    rect,
620                    scissor,
621                    runs,
622                    size,
623                    wrap,
624                    anchor,
625                    ..
626                } => {
627                    close_run(
628                        &mut self.runs,
629                        &mut self.paint_items,
630                        current,
631                        run_first,
632                        self.quad_scratch.len() as u32,
633                    );
634                    current = None;
635                    run_first = self.quad_scratch.len() as u32;
636
637                    let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
638                    if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
639                        continue;
640                    }
641                    let layers =
642                        text.record_runs(*rect, phys, runs, *size, *wrap, *anchor, scale_factor);
643                    for index in layers {
644                        self.paint_items.push(PaintItem::Text(index));
645                    }
646                }
647                DrawOp::Icon {
648                    rect,
649                    scissor,
650                    name,
651                    color,
652                    size,
653                    stroke_width,
654                    ..
655                } => {
656                    close_run(
657                        &mut self.runs,
658                        &mut self.paint_items,
659                        current,
660                        run_first,
661                        self.quad_scratch.len() as u32,
662                    );
663                    current = None;
664                    run_first = self.quad_scratch.len() as u32;
665
666                    let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
667                    if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
668                        continue;
669                    }
670                    let recorded = text.record_icon(
671                        *rect,
672                        phys,
673                        *name,
674                        *color,
675                        *size,
676                        *stroke_width,
677                        scale_factor,
678                    );
679                    match recorded {
680                        RecordedPaint::Text(layers) => {
681                            for index in layers {
682                                self.paint_items.push(PaintItem::Text(index));
683                            }
684                        }
685                        RecordedPaint::Icon(runs) => {
686                            for index in runs {
687                                self.paint_items.push(PaintItem::IconRun(index));
688                            }
689                        }
690                    }
691                }
692                DrawOp::BackdropSnapshot => {
693                    close_run(
694                        &mut self.runs,
695                        &mut self.paint_items,
696                        current,
697                        run_first,
698                        self.quad_scratch.len() as u32,
699                    );
700                    current = None;
701                    run_first = self.quad_scratch.len() as u32;
702                    // Cap at one snapshot per frame; an explicit op only
703                    // lands if the auto-emitter hasn't fired yet.
704                    if !snapshot_emitted {
705                        self.paint_items.push(PaintItem::BackdropSnapshot);
706                        snapshot_emitted = true;
707                    }
708                }
709            }
710        }
711        close_run(
712            &mut self.runs,
713            &mut self.paint_items,
714            current,
715            run_first,
716            self.quad_scratch.len() as u32,
717        );
718        timings.paint = Instant::now() - t0;
719    }
720
721    /// Take a clone of the laid-out tree for next-frame hit-testing.
722    /// Call after the per-frame work completes (GPU upload, atlas
723    /// flush, etc.) so the snapshot reflects final geometry. Writes
724    /// `timings.snapshot`.
725    pub fn snapshot(&mut self, root: &El, timings: &mut PrepareTimings) {
726        let t0 = Instant::now();
727        self.last_tree = Some(root.clone());
728        timings.snapshot = Instant::now() - t0;
729    }
730}
731
732/// Find the `capture_keys` flag of the node whose `computed_id`
733/// equals `id`, walking the laid-out tree. Returns `None` when the id
734/// isn't found (the focused target outlived its node — a one-frame
735/// race after a rebuild).
736fn find_capture_keys(node: &El, id: &str) -> Option<bool> {
737    if node.computed_id == id {
738        return Some(node.capture_keys);
739    }
740    node.children.iter().find_map(|c| find_capture_keys(c, id))
741}
742
743/// Recorded output from an icon draw op. Backends without a vector-icon
744/// path use `Text` fallback layers; wgpu can return dedicated icon runs.
745pub enum RecordedPaint {
746    Text(Range<usize>),
747    Icon(Range<usize>),
748}
749
750/// Glyph-recording surface implemented by each backend's `TextPaint`.
751/// `prepare_paint` calls into it exactly the same way wgpu and vulkano
752/// would call their per-backend equivalents.
753pub trait TextRecorder {
754    /// Append per-glyph instances for `text` and return the range of
755    /// indices written into the backend's `TextLayer` storage. Each
756    /// returned index lands in `paint_items` as a `PaintItem::Text`.
757    #[allow(clippy::too_many_arguments)]
758    fn record(
759        &mut self,
760        rect: Rect,
761        scissor: Option<PhysicalScissor>,
762        color: Color,
763        text: &str,
764        size: f32,
765        weight: FontWeight,
766        wrap: TextWrap,
767        anchor: TextAnchor,
768        scale_factor: f32,
769    ) -> Range<usize>;
770
771    /// Append per-glyph instances for an attributed paragraph (one
772    /// shaped run with per-character RunStyle metadata). Wrapping
773    /// decisions cross run boundaries — the result is one ShapedRun
774    /// just like a single-style call.
775    #[allow(clippy::too_many_arguments)]
776    fn record_runs(
777        &mut self,
778        rect: Rect,
779        scissor: Option<PhysicalScissor>,
780        runs: &[(String, RunStyle)],
781        size: f32,
782        wrap: TextWrap,
783        anchor: TextAnchor,
784        scale_factor: f32,
785    ) -> Range<usize>;
786
787    /// Append a vector icon. Backends with a native vector painter
788    /// override this; the default keeps experimental/simple backends on
789    /// the previous text-symbol fallback.
790    #[allow(clippy::too_many_arguments)]
791    fn record_icon(
792        &mut self,
793        rect: Rect,
794        scissor: Option<PhysicalScissor>,
795        name: crate::tree::IconName,
796        color: Color,
797        size: f32,
798        _stroke_width: f32,
799        scale_factor: f32,
800    ) -> RecordedPaint {
801        RecordedPaint::Text(self.record(
802            rect,
803            scissor,
804            color,
805            name.fallback_glyph(),
806            size,
807            FontWeight::Regular,
808            TextWrap::NoWrap,
809            TextAnchor::Middle,
810            scale_factor,
811        ))
812    }
813}
814
815#[cfg(test)]
816mod tests {
817    use super::*;
818    use crate::shader::{ShaderHandle, StockShader, UniformBlock};
819
820    /// Minimal recorder for tests that don't exercise the text path.
821    struct NoText;
822    impl TextRecorder for NoText {
823        fn record(
824            &mut self,
825            _rect: Rect,
826            _scissor: Option<PhysicalScissor>,
827            _color: Color,
828            _text: &str,
829            _size: f32,
830            _weight: FontWeight,
831            _wrap: TextWrap,
832            _anchor: TextAnchor,
833            _scale_factor: f32,
834        ) -> Range<usize> {
835            0..0
836        }
837        fn record_runs(
838            &mut self,
839            _rect: Rect,
840            _scissor: Option<PhysicalScissor>,
841            _runs: &[(String, RunStyle)],
842            _size: f32,
843            _wrap: TextWrap,
844            _anchor: TextAnchor,
845            _scale_factor: f32,
846        ) -> Range<usize> {
847            0..0
848        }
849    }
850
851    // ---- input plumbing ----
852
853    /// A tree with one focusable button at (10,10,80,40) keyed "btn",
854    /// plus an optional capture_keys text input at (10,60,80,40) keyed
855    /// "ti". layout() runs against a 200x200 viewport so the rects
856    /// land where we expect.
857    fn lay_out_input_tree(capture: bool) -> RunnerCore {
858        use crate::tree::*;
859        let ti = if capture {
860            crate::widgets::text::text("input").key("ti").capture_keys()
861        } else {
862            crate::widgets::text::text("noop").key("ti").focusable()
863        };
864        let mut tree =
865            crate::column([crate::widgets::button::button("Btn").key("btn"), ti]).padding(10.0);
866        let mut core = RunnerCore::new();
867        crate::layout::layout(
868            &mut tree,
869            &mut core.ui_state,
870            Rect::new(0.0, 0.0, 200.0, 200.0),
871        );
872        core.ui_state.sync_focus_order(&tree);
873        let mut t = PrepareTimings::default();
874        core.snapshot(&tree, &mut t);
875        core
876    }
877
878    #[test]
879    fn pointer_up_emits_pointer_up_then_click() {
880        let mut core = lay_out_input_tree(false);
881        let btn_rect = core.rect_of_key("btn").expect("btn rect");
882        let cx = btn_rect.x + btn_rect.w * 0.5;
883        let cy = btn_rect.y + btn_rect.h * 0.5;
884        core.pointer_moved(cx, cy);
885        core.pointer_down(cx, cy, PointerButton::Primary);
886        let events = core.pointer_up(cx, cy, PointerButton::Primary);
887        let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
888        assert_eq!(kinds, vec![UiEventKind::PointerUp, UiEventKind::Click]);
889    }
890
891    #[test]
892    fn pointer_up_off_target_emits_only_pointer_up() {
893        let mut core = lay_out_input_tree(false);
894        let btn_rect = core.rect_of_key("btn").expect("btn rect");
895        let cx = btn_rect.x + btn_rect.w * 0.5;
896        let cy = btn_rect.y + btn_rect.h * 0.5;
897        core.pointer_down(cx, cy, PointerButton::Primary);
898        // Release off-target (well outside any keyed node).
899        let events = core.pointer_up(180.0, 180.0, PointerButton::Primary);
900        let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
901        assert_eq!(
902            kinds,
903            vec![UiEventKind::PointerUp],
904            "drag-off-target should still surface PointerUp so widgets see drag-end"
905        );
906    }
907
908    #[test]
909    fn pointer_moved_while_pressed_emits_drag() {
910        let mut core = lay_out_input_tree(false);
911        let btn_rect = core.rect_of_key("btn").expect("btn rect");
912        let cx = btn_rect.x + btn_rect.w * 0.5;
913        let cy = btn_rect.y + btn_rect.h * 0.5;
914        core.pointer_down(cx, cy, PointerButton::Primary);
915        let drag = core
916            .pointer_moved(cx + 30.0, cy)
917            .expect("drag while pressed");
918        assert_eq!(drag.kind, UiEventKind::Drag);
919        assert_eq!(drag.target.as_ref().map(|t| t.key.as_str()), Some("btn"));
920        assert_eq!(drag.pointer, Some((cx + 30.0, cy)));
921    }
922
923    #[test]
924    fn pointer_moved_without_press_emits_no_drag() {
925        let mut core = lay_out_input_tree(false);
926        assert!(core.pointer_moved(50.0, 50.0).is_none());
927    }
928
929    #[test]
930    fn secondary_click_does_not_steal_focus_or_press() {
931        let mut core = lay_out_input_tree(false);
932        let btn_rect = core.rect_of_key("btn").expect("btn rect");
933        let cx = btn_rect.x + btn_rect.w * 0.5;
934        let cy = btn_rect.y + btn_rect.h * 0.5;
935        // Focus elsewhere first via primary click on the input.
936        let ti_rect = core.rect_of_key("ti").expect("ti rect");
937        let tx = ti_rect.x + ti_rect.w * 0.5;
938        let ty = ti_rect.y + ti_rect.h * 0.5;
939        core.pointer_down(tx, ty, PointerButton::Primary);
940        let _ = core.pointer_up(tx, ty, PointerButton::Primary);
941        let focused_before = core.ui_state.focused.as_ref().map(|t| t.key.clone());
942        // Right-click on the button.
943        core.pointer_down(cx, cy, PointerButton::Secondary);
944        let events = core.pointer_up(cx, cy, PointerButton::Secondary);
945        let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
946        assert_eq!(kinds, vec![UiEventKind::SecondaryClick]);
947        let focused_after = core.ui_state.focused.as_ref().map(|t| t.key.clone());
948        assert_eq!(
949            focused_before, focused_after,
950            "right-click must not steal focus"
951        );
952        assert!(
953            core.ui_state.pressed.is_none(),
954            "right-click must not set primary press"
955        );
956    }
957
958    #[test]
959    fn text_input_routes_to_focused_only() {
960        let mut core = lay_out_input_tree(false);
961        // No focus yet → no event.
962        assert!(core.text_input("a".into()).is_none());
963        // Focus the button via primary click.
964        let btn_rect = core.rect_of_key("btn").expect("btn rect");
965        let cx = btn_rect.x + btn_rect.w * 0.5;
966        let cy = btn_rect.y + btn_rect.h * 0.5;
967        core.pointer_down(cx, cy, PointerButton::Primary);
968        let _ = core.pointer_up(cx, cy, PointerButton::Primary);
969        let event = core.text_input("hi".into()).expect("focused → event");
970        assert_eq!(event.kind, UiEventKind::TextInput);
971        assert_eq!(event.text.as_deref(), Some("hi"));
972        assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("btn"));
973        // Empty text → no event (some IME paths emit empty composition).
974        assert!(core.text_input(String::new()).is_none());
975    }
976
977    #[test]
978    fn capture_keys_bypasses_tab_traversal_for_focused_node() {
979        // Focus the capture_keys input. Tab should NOT move focus —
980        // it should be delivered as a raw KeyDown to the input.
981        let mut core = lay_out_input_tree(true);
982        let ti_rect = core.rect_of_key("ti").expect("ti rect");
983        let tx = ti_rect.x + ti_rect.w * 0.5;
984        let ty = ti_rect.y + ti_rect.h * 0.5;
985        core.pointer_down(tx, ty, PointerButton::Primary);
986        let _ = core.pointer_up(tx, ty, PointerButton::Primary);
987        assert_eq!(
988            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
989            Some("ti"),
990            "primary click on capture_keys node still focuses it"
991        );
992
993        let event = core
994            .key_down(UiKey::Tab, KeyModifiers::default(), false)
995            .expect("Tab → KeyDown to focused");
996        assert_eq!(event.kind, UiEventKind::KeyDown);
997        assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("ti"));
998        assert_eq!(
999            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1000            Some("ti"),
1001            "Tab inside capture_keys must NOT move focus"
1002        );
1003    }
1004
1005    #[test]
1006    fn capture_keys_falls_back_to_default_when_focus_off_capturing_node() {
1007        // Tree has both a normal-focusable button and a capture_keys
1008        // input. Focus the button (normal focusable). Tab should then
1009        // do library-default focus traversal.
1010        let mut core = lay_out_input_tree(true);
1011        let btn_rect = core.rect_of_key("btn").expect("btn rect");
1012        let cx = btn_rect.x + btn_rect.w * 0.5;
1013        let cy = btn_rect.y + btn_rect.h * 0.5;
1014        core.pointer_down(cx, cy, PointerButton::Primary);
1015        let _ = core.pointer_up(cx, cy, PointerButton::Primary);
1016        assert_eq!(
1017            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1018            Some("btn"),
1019            "primary click focuses button"
1020        );
1021        // Tab should move focus to the next focusable (the input).
1022        let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
1023        assert_eq!(
1024            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1025            Some("ti"),
1026            "Tab from non-capturing focused does library-default traversal"
1027        );
1028    }
1029
1030    /// A column whose three buttons sit inside an `arrow_nav_siblings`
1031    /// parent (the shape `popover_panel` produces). Layout runs against
1032    /// a 200x300 viewport with 10px padding; each button is 80px wide
1033    /// and 36px tall stacked vertically, plenty inside the clip.
1034    fn lay_out_arrow_nav_tree() -> RunnerCore {
1035        use crate::tree::*;
1036        let mut tree = crate::column([
1037            crate::widgets::button::button("Red").key("opt-red"),
1038            crate::widgets::button::button("Green").key("opt-green"),
1039            crate::widgets::button::button("Blue").key("opt-blue"),
1040        ])
1041        .arrow_nav_siblings()
1042        .padding(10.0);
1043        let mut core = RunnerCore::new();
1044        crate::layout::layout(
1045            &mut tree,
1046            &mut core.ui_state,
1047            Rect::new(0.0, 0.0, 200.0, 300.0),
1048        );
1049        core.ui_state.sync_focus_order(&tree);
1050        let mut t = PrepareTimings::default();
1051        core.snapshot(&tree, &mut t);
1052        // Pre-focus the middle option (the typical state right after a
1053        // popover opens — we'll exercise transitions from there).
1054        let target = core
1055            .ui_state
1056            .focus_order
1057            .iter()
1058            .find(|t| t.key == "opt-green")
1059            .cloned();
1060        core.ui_state.set_focus(target);
1061        core
1062    }
1063
1064    #[test]
1065    fn arrow_nav_moves_focus_among_siblings() {
1066        let mut core = lay_out_arrow_nav_tree();
1067
1068        // ArrowDown moves to next sibling, no event emitted (it was
1069        // consumed by the navigation path).
1070        let down = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
1071        assert!(down.is_none(), "arrow-nav consumes the key event");
1072        assert_eq!(
1073            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1074            Some("opt-blue"),
1075        );
1076
1077        // ArrowUp moves back.
1078        core.key_down(UiKey::ArrowUp, KeyModifiers::default(), false);
1079        assert_eq!(
1080            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1081            Some("opt-green"),
1082        );
1083
1084        // Home jumps to first.
1085        core.key_down(UiKey::Home, KeyModifiers::default(), false);
1086        assert_eq!(
1087            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1088            Some("opt-red"),
1089        );
1090
1091        // End jumps to last.
1092        core.key_down(UiKey::End, KeyModifiers::default(), false);
1093        assert_eq!(
1094            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1095            Some("opt-blue"),
1096        );
1097    }
1098
1099    #[test]
1100    fn arrow_nav_saturates_at_ends() {
1101        let mut core = lay_out_arrow_nav_tree();
1102        // Walk to the first option and try to go before it.
1103        core.key_down(UiKey::Home, KeyModifiers::default(), false);
1104        core.key_down(UiKey::ArrowUp, KeyModifiers::default(), false);
1105        assert_eq!(
1106            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1107            Some("opt-red"),
1108            "ArrowUp at top stays at top — no wrap",
1109        );
1110        // Same at the bottom.
1111        core.key_down(UiKey::End, KeyModifiers::default(), false);
1112        core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
1113        assert_eq!(
1114            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1115            Some("opt-blue"),
1116            "ArrowDown at bottom stays at bottom — no wrap",
1117        );
1118    }
1119
1120    /// Build a tree shaped like a real app's `build()` output: a
1121    /// background row with a "Trigger" button, optionally with a
1122    /// dropdown popover layered on top.
1123    fn build_popover_tree(open: bool) -> El {
1124        use crate::widgets::button::button;
1125        use crate::widgets::overlay::overlay;
1126        use crate::widgets::popover::{dropdown, menu_item};
1127        let mut layers: Vec<El> = vec![button("Trigger").key("trigger")];
1128        if open {
1129            layers.push(dropdown(
1130                "menu",
1131                "trigger",
1132                [
1133                    menu_item("A").key("item-a"),
1134                    menu_item("B").key("item-b"),
1135                    menu_item("C").key("item-c"),
1136                ],
1137            ));
1138        }
1139        overlay(layers).padding(20.0)
1140    }
1141
1142    /// Run a full per-frame layout pass against `tree` so all the
1143    /// post-layout hooks (focus order sync, popover focus stack, etc.)
1144    /// fire just like a real frame.
1145    fn run_frame(core: &mut RunnerCore, tree: &mut El) {
1146        let mut t = PrepareTimings::default();
1147        core.prepare_layout(tree, Rect::new(0.0, 0.0, 400.0, 300.0), 1.0, &mut t);
1148        core.snapshot(tree, &mut t);
1149    }
1150
1151    #[test]
1152    fn popover_open_pushes_focus_and_auto_focuses_first_item() {
1153        let mut core = RunnerCore::new();
1154        let mut closed = build_popover_tree(false);
1155        run_frame(&mut core, &mut closed);
1156        // Pre-focus the trigger as if the user tabbed to it before
1157        // opening the menu.
1158        let trigger = core
1159            .ui_state
1160            .focus_order
1161            .iter()
1162            .find(|t| t.key == "trigger")
1163            .cloned();
1164        core.ui_state.set_focus(trigger);
1165        assert_eq!(
1166            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1167            Some("trigger"),
1168        );
1169
1170        // Open the popover. The runtime should snapshot the trigger
1171        // onto the focus stack and auto-focus the first menu item.
1172        let mut open = build_popover_tree(true);
1173        run_frame(&mut core, &mut open);
1174        assert_eq!(
1175            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1176            Some("item-a"),
1177            "popover open should auto-focus the first menu item",
1178        );
1179        assert_eq!(
1180            core.ui_state.focus_stack.len(),
1181            1,
1182            "trigger should be saved on the focus stack",
1183        );
1184        assert_eq!(
1185            core.ui_state.focus_stack[0].key.as_str(),
1186            "trigger",
1187            "saved focus should be the pre-open target",
1188        );
1189    }
1190
1191    #[test]
1192    fn popover_close_restores_focus_to_trigger() {
1193        let mut core = RunnerCore::new();
1194        let mut closed = build_popover_tree(false);
1195        run_frame(&mut core, &mut closed);
1196        let trigger = core
1197            .ui_state
1198            .focus_order
1199            .iter()
1200            .find(|t| t.key == "trigger")
1201            .cloned();
1202        core.ui_state.set_focus(trigger);
1203
1204        // Open → focus walks to the menu.
1205        let mut open = build_popover_tree(true);
1206        run_frame(&mut core, &mut open);
1207        assert_eq!(
1208            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1209            Some("item-a"),
1210        );
1211
1212        // Close → focus restored to trigger, stack drained.
1213        let mut closed_again = build_popover_tree(false);
1214        run_frame(&mut core, &mut closed_again);
1215        assert_eq!(
1216            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1217            Some("trigger"),
1218            "closing the popover should pop the saved focus",
1219        );
1220        assert!(
1221            core.ui_state.focus_stack.is_empty(),
1222            "focus stack should be drained after restore",
1223        );
1224    }
1225
1226    #[test]
1227    fn popover_close_does_not_override_intentional_focus_move() {
1228        let mut core = RunnerCore::new();
1229        // Tree with a second focusable button outside the popover so
1230        // the user can "click somewhere else" while the menu is open.
1231        let build = |open: bool| -> El {
1232            use crate::widgets::button::button;
1233            use crate::widgets::overlay::overlay;
1234            use crate::widgets::popover::{dropdown, menu_item};
1235            let main = crate::row([
1236                button("Trigger").key("trigger"),
1237                button("Other").key("other"),
1238            ]);
1239            let mut layers: Vec<El> = vec![main];
1240            if open {
1241                layers.push(dropdown("menu", "trigger", [menu_item("A").key("item-a")]));
1242            }
1243            overlay(layers).padding(20.0)
1244        };
1245
1246        let mut closed = build(false);
1247        run_frame(&mut core, &mut closed);
1248        let trigger = core
1249            .ui_state
1250            .focus_order
1251            .iter()
1252            .find(|t| t.key == "trigger")
1253            .cloned();
1254        core.ui_state.set_focus(trigger);
1255
1256        let mut open = build(true);
1257        run_frame(&mut core, &mut open);
1258        assert_eq!(core.ui_state.focus_stack.len(), 1);
1259
1260        // Simulate an intentional focus move to a sibling that is
1261        // outside the popover (e.g. the user re-tabbed somewhere). Do
1262        // this by setting focus directly while the popover is still in
1263        // the tree — the existing focus-order contains "other".
1264        let other = core
1265            .ui_state
1266            .focus_order
1267            .iter()
1268            .find(|t| t.key == "other")
1269            .cloned();
1270        core.ui_state.set_focus(other);
1271
1272        let mut closed_again = build(false);
1273        run_frame(&mut core, &mut closed_again);
1274        assert_eq!(
1275            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1276            Some("other"),
1277            "focus moved before close should not be overridden by restore",
1278        );
1279        assert!(core.ui_state.focus_stack.is_empty());
1280    }
1281
1282    #[test]
1283    fn nested_popovers_stack_and_unwind_focus_correctly() {
1284        let mut core = RunnerCore::new();
1285        // Two siblings layered at El root: an outer popover anchored to
1286        // the trigger, and an inner popover anchored to a button inside
1287        // the outer panel. Both are real popovers — separate
1288        // popover_layer ids — so the runtime sees them stack.
1289        let build = |outer: bool, inner: bool| -> El {
1290            use crate::widgets::button::button;
1291            use crate::widgets::overlay::overlay;
1292            use crate::widgets::popover::{Anchor, popover, popover_panel};
1293            let main = button("Trigger").key("trigger");
1294            let mut layers: Vec<El> = vec![main];
1295            if outer {
1296                layers.push(popover(
1297                    "outer",
1298                    Anchor::below_key("trigger"),
1299                    popover_panel([button("Open inner").key("inner-trigger")]),
1300                ));
1301            }
1302            if inner {
1303                layers.push(popover(
1304                    "inner",
1305                    Anchor::below_key("inner-trigger"),
1306                    popover_panel([button("X").key("inner-a"), button("Y").key("inner-b")]),
1307                ));
1308            }
1309            overlay(layers).padding(20.0)
1310        };
1311
1312        // Frame 1: nothing open, focus on the trigger.
1313        let mut closed = build(false, false);
1314        run_frame(&mut core, &mut closed);
1315        let trigger = core
1316            .ui_state
1317            .focus_order
1318            .iter()
1319            .find(|t| t.key == "trigger")
1320            .cloned();
1321        core.ui_state.set_focus(trigger);
1322
1323        // Frame 2: outer opens. Save trigger, focus inner-trigger.
1324        let mut outer = build(true, false);
1325        run_frame(&mut core, &mut outer);
1326        assert_eq!(
1327            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1328            Some("inner-trigger"),
1329        );
1330        assert_eq!(core.ui_state.focus_stack.len(), 1);
1331
1332        // Frame 3: inner also opens. Save inner-trigger, focus inner-a.
1333        let mut both = build(true, true);
1334        run_frame(&mut core, &mut both);
1335        assert_eq!(
1336            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1337            Some("inner-a"),
1338        );
1339        assert_eq!(core.ui_state.focus_stack.len(), 2);
1340
1341        // Frame 4: inner closes. Pop → restore inner-trigger.
1342        let mut outer_only = build(true, false);
1343        run_frame(&mut core, &mut outer_only);
1344        assert_eq!(
1345            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1346            Some("inner-trigger"),
1347        );
1348        assert_eq!(core.ui_state.focus_stack.len(), 1);
1349
1350        // Frame 5: outer closes. Pop → restore trigger.
1351        let mut none = build(false, false);
1352        run_frame(&mut core, &mut none);
1353        assert_eq!(
1354            core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1355            Some("trigger"),
1356        );
1357        assert!(core.ui_state.focus_stack.is_empty());
1358    }
1359
1360    #[test]
1361    fn arrow_nav_does_not_intercept_outside_navigable_groups() {
1362        // Reuse the input tree (no arrow_nav_siblings parent). Arrow
1363        // keys must produce a regular `KeyDown` event so a
1364        // capture_keys widget can interpret them as caret motion.
1365        let mut core = lay_out_input_tree(false);
1366        let target = core
1367            .ui_state
1368            .focus_order
1369            .iter()
1370            .find(|t| t.key == "btn")
1371            .cloned();
1372        core.ui_state.set_focus(target);
1373        let event = core
1374            .key_down(UiKey::ArrowDown, KeyModifiers::default(), false)
1375            .expect("ArrowDown without navigable parent → event");
1376        assert_eq!(event.kind, UiEventKind::KeyDown);
1377    }
1378
1379    fn quad(shader: ShaderHandle) -> DrawOp {
1380        DrawOp::Quad {
1381            id: "q".into(),
1382            rect: Rect::new(0.0, 0.0, 10.0, 10.0),
1383            scissor: None,
1384            shader,
1385            uniforms: UniformBlock::new(),
1386        }
1387    }
1388
1389    #[test]
1390    fn samples_backdrop_inserts_snapshot_before_first_glass_quad() {
1391        let mut core = RunnerCore::new();
1392        core.set_surface_size(100, 100);
1393        let ops = vec![
1394            quad(ShaderHandle::Stock(StockShader::RoundedRect)),
1395            quad(ShaderHandle::Stock(StockShader::RoundedRect)),
1396            quad(ShaderHandle::Custom("liquid_glass")),
1397            quad(ShaderHandle::Custom("liquid_glass")),
1398            quad(ShaderHandle::Stock(StockShader::RoundedRect)),
1399        ];
1400        let mut timings = PrepareTimings::default();
1401        core.prepare_paint(
1402            &ops,
1403            |_| true,
1404            |s| matches!(s, ShaderHandle::Custom(name) if *name == "liquid_glass"),
1405            &mut NoText,
1406            1.0,
1407            &mut timings,
1408        );
1409
1410        let kinds: Vec<&'static str> = core
1411            .paint_items
1412            .iter()
1413            .map(|p| match p {
1414                PaintItem::QuadRun(_) => "Q",
1415                PaintItem::IconRun(_) => "I",
1416                PaintItem::Text(_) => "T",
1417                PaintItem::BackdropSnapshot => "S",
1418            })
1419            .collect();
1420        assert_eq!(
1421            kinds,
1422            vec!["Q", "S", "Q", "Q"],
1423            "expected one stock run, snapshot, then a glass run, then a foreground stock run"
1424        );
1425    }
1426
1427    #[test]
1428    fn no_snapshot_when_no_glass_drawn() {
1429        let mut core = RunnerCore::new();
1430        core.set_surface_size(100, 100);
1431        let ops = vec![
1432            quad(ShaderHandle::Stock(StockShader::RoundedRect)),
1433            quad(ShaderHandle::Stock(StockShader::RoundedRect)),
1434        ];
1435        let mut timings = PrepareTimings::default();
1436        core.prepare_paint(&ops, |_| true, |_| false, &mut NoText, 1.0, &mut timings);
1437        assert!(
1438            !core
1439                .paint_items
1440                .iter()
1441                .any(|p| matches!(p, PaintItem::BackdropSnapshot)),
1442            "no glass shader registered → no snapshot"
1443        );
1444    }
1445
1446    #[test]
1447    fn at_most_one_snapshot_per_frame() {
1448        let mut core = RunnerCore::new();
1449        core.set_surface_size(100, 100);
1450        let ops = vec![
1451            quad(ShaderHandle::Stock(StockShader::RoundedRect)),
1452            quad(ShaderHandle::Custom("g")),
1453            quad(ShaderHandle::Stock(StockShader::RoundedRect)),
1454            quad(ShaderHandle::Custom("g")),
1455        ];
1456        let mut timings = PrepareTimings::default();
1457        core.prepare_paint(
1458            &ops,
1459            |_| true,
1460            |s| matches!(s, ShaderHandle::Custom("g")),
1461            &mut NoText,
1462            1.0,
1463            &mut timings,
1464        );
1465        let snapshots = core
1466            .paint_items
1467            .iter()
1468            .filter(|p| matches!(p, PaintItem::BackdropSnapshot))
1469            .count();
1470        assert_eq!(snapshots, 1, "backdrop depth is capped at 1");
1471    }
1472}