use std::ops::Range;
use std::time::Duration;
use web_time::Instant;
use crate::draw_ops;
use crate::event::{KeyChord, KeyModifiers, PointerButton, UiEvent, UiEventKind, UiKey, UiTarget};
use crate::focus;
use crate::hit_test;
use crate::ir::{DrawOp, TextAnchor};
use crate::layout;
use crate::paint::{
InstanceRun, PaintItem, PhysicalScissor, QuadInstance, close_run, pack_instance,
physical_scissor,
};
use crate::shader::ShaderHandle;
use crate::state::{AnimationMode, UiState};
use crate::text::atlas::RunStyle;
use crate::theme::Theme;
use crate::tooltip;
use crate::tree::{Color, El, FontWeight, Rect, TextWrap};
#[derive(Clone, Copy, Debug, Default)]
pub struct PrepareResult {
pub needs_redraw: bool,
pub timings: PrepareTimings,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct PrepareTimings {
pub layout: Duration,
pub draw_ops: Duration,
pub paint: Duration,
pub gpu_upload: Duration,
pub snapshot: Duration,
}
pub struct RunnerCore {
pub ui_state: UiState,
pub last_tree: Option<El>,
pub quad_scratch: Vec<QuadInstance>,
pub runs: Vec<InstanceRun>,
pub paint_items: Vec<PaintItem>,
pub viewport_px: (u32, u32),
pub surface_size_override: Option<(u32, u32)>,
pub theme: Theme,
}
impl Default for RunnerCore {
fn default() -> Self {
Self::new()
}
}
impl RunnerCore {
pub fn new() -> Self {
Self {
ui_state: UiState::default(),
last_tree: None,
quad_scratch: Vec::new(),
runs: Vec::new(),
paint_items: Vec::new(),
viewport_px: (1, 1),
surface_size_override: None,
theme: Theme::default(),
}
}
pub fn set_theme(&mut self, theme: Theme) {
self.theme = theme;
}
pub fn theme(&self) -> &Theme {
&self.theme
}
pub fn set_surface_size(&mut self, width: u32, height: u32) {
self.surface_size_override = Some((width.max(1), height.max(1)));
}
pub fn ui_state(&self) -> &UiState {
&self.ui_state
}
pub fn debug_summary(&self) -> String {
self.ui_state.debug_summary()
}
pub fn rect_of_key(&self, key: &str) -> Option<Rect> {
self.last_tree
.as_ref()
.and_then(|t| self.ui_state.rect_of_key(t, key))
}
pub fn pointer_moved(&mut self, x: f32, y: f32) -> Option<UiEvent> {
self.ui_state.pointer_pos = Some((x, y));
let hit = self
.last_tree
.as_ref()
.and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
self.ui_state.set_hovered(hit, Instant::now());
let modifiers = self.ui_state.modifiers;
self.ui_state.pressed.clone().map(|p| UiEvent {
key: Some(p.key.clone()),
target: Some(p),
pointer: Some((x, y)),
key_press: None,
text: None,
modifiers,
kind: UiEventKind::Drag,
})
}
pub fn pointer_left(&mut self) {
self.ui_state.pointer_pos = None;
self.ui_state.set_hovered(None, Instant::now());
self.ui_state.pressed = None;
self.ui_state.pressed_secondary = None;
}
pub fn pointer_down(&mut self, x: f32, y: f32, button: PointerButton) -> Option<UiEvent> {
let hit = self
.last_tree
.as_ref()
.and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
if matches!(button, PointerButton::Primary) {
self.ui_state.set_focus(hit.clone());
self.ui_state.pressed = hit.clone();
self.ui_state.tooltip_dismissed_for_hover = true;
let modifiers = self.ui_state.modifiers;
hit.map(|p| UiEvent {
key: Some(p.key.clone()),
target: Some(p),
pointer: Some((x, y)),
key_press: None,
text: None,
modifiers,
kind: UiEventKind::PointerDown,
})
} else {
self.ui_state.pressed_secondary = hit.map(|h| (h, button));
None
}
}
pub fn pointer_up(&mut self, x: f32, y: f32, button: PointerButton) -> Vec<UiEvent> {
let hit = self
.last_tree
.as_ref()
.and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
let modifiers = self.ui_state.modifiers;
let mut out = Vec::new();
match button {
PointerButton::Primary => {
let pressed = self.ui_state.pressed.take();
if let Some(p) = pressed.clone() {
out.push(UiEvent {
key: Some(p.key.clone()),
target: Some(p),
pointer: Some((x, y)),
key_press: None,
text: None,
modifiers,
kind: UiEventKind::PointerUp,
});
}
if let (Some(p), Some(h)) = (pressed, hit)
&& p.node_id == h.node_id
{
out.push(UiEvent {
key: Some(p.key.clone()),
target: Some(p),
pointer: Some((x, y)),
key_press: None,
text: None,
modifiers,
kind: UiEventKind::Click,
});
}
}
PointerButton::Secondary | PointerButton::Middle => {
let pressed = self.ui_state.pressed_secondary.take();
if let (Some((p, b)), Some(h)) = (pressed, hit)
&& b == button
&& p.node_id == h.node_id
{
let kind = match button {
PointerButton::Secondary => UiEventKind::SecondaryClick,
PointerButton::Middle => UiEventKind::MiddleClick,
PointerButton::Primary => unreachable!(),
};
out.push(UiEvent {
key: Some(p.key.clone()),
target: Some(p),
pointer: Some((x, y)),
key_press: None,
text: None,
modifiers,
kind,
});
}
}
}
out
}
pub fn key_down(
&mut self,
key: UiKey,
modifiers: KeyModifiers,
repeat: bool,
) -> Option<UiEvent> {
if self.focused_captures_keys() {
if let Some(event) = self.ui_state.try_hotkey(&key, modifiers, repeat) {
return Some(event);
}
return self.ui_state.key_down_raw(key, modifiers, repeat);
}
if matches!(
key,
UiKey::ArrowUp | UiKey::ArrowDown | UiKey::Home | UiKey::End
) && let Some(siblings) = self.focused_arrow_nav_group()
{
if let Some(event) = self.ui_state.try_hotkey(&key, modifiers, repeat) {
return Some(event);
}
self.move_focus_in_group(&key, &siblings);
return None;
}
self.ui_state.key_down(key, modifiers, repeat)
}
fn focused_arrow_nav_group(&self) -> Option<Vec<UiTarget>> {
let focused = self.ui_state.focused.as_ref()?;
let tree = self.last_tree.as_ref()?;
focus::arrow_nav_group(tree, &self.ui_state, &focused.node_id)
}
fn move_focus_in_group(&mut self, key: &UiKey, siblings: &[UiTarget]) {
if siblings.is_empty() {
return;
}
let focused_id = match self.ui_state.focused.as_ref() {
Some(t) => t.node_id.clone(),
None => return,
};
let idx = siblings.iter().position(|t| t.node_id == focused_id);
let next_idx = match (key, idx) {
(UiKey::ArrowUp, Some(i)) => i.saturating_sub(1),
(UiKey::ArrowDown, Some(i)) => (i + 1).min(siblings.len() - 1),
(UiKey::Home, _) => 0,
(UiKey::End, _) => siblings.len() - 1,
_ => return,
};
if Some(next_idx) != idx {
self.ui_state.set_focus(Some(siblings[next_idx].clone()));
}
}
fn focused_captures_keys(&self) -> bool {
let Some(focused) = self.ui_state.focused.as_ref() else {
return false;
};
let Some(tree) = self.last_tree.as_ref() else {
return false;
};
find_capture_keys(tree, &focused.node_id).unwrap_or(false)
}
pub fn text_input(&mut self, text: String) -> Option<UiEvent> {
if text.is_empty() {
return None;
}
let target = self.ui_state.focused.clone()?;
let modifiers = self.ui_state.modifiers;
Some(UiEvent {
key: Some(target.key.clone()),
target: Some(target),
pointer: None,
key_press: None,
text: Some(text),
modifiers,
kind: UiEventKind::TextInput,
})
}
pub fn set_hotkeys(&mut self, hotkeys: Vec<(KeyChord, String)>) {
self.ui_state.set_hotkeys(hotkeys);
}
pub fn set_animation_mode(&mut self, mode: AnimationMode) {
self.ui_state.set_animation_mode(mode);
}
pub fn pointer_wheel(&mut self, x: f32, y: f32, dy: f32) -> bool {
let Some(tree) = self.last_tree.as_ref() else {
return false;
};
self.ui_state.pointer_wheel(tree, (x, y), dy)
}
pub fn prepare_layout(
&mut self,
root: &mut El,
viewport: Rect,
scale_factor: f32,
timings: &mut PrepareTimings,
) -> (Vec<DrawOp>, bool) {
let t0 = Instant::now();
layout::assign_ids(root);
let tooltip_pending = tooltip::synthesize_tooltip(root, &self.ui_state, t0);
layout::layout(root, &mut self.ui_state, viewport);
self.ui_state.sync_focus_order(root);
focus::sync_popover_focus(root, &mut self.ui_state);
self.ui_state.apply_to_state();
let needs_redraw =
self.ui_state.tick_visual_animations(root, Instant::now()) || tooltip_pending;
self.viewport_px = self.surface_size_override.unwrap_or_else(|| {
(
(viewport.w * scale_factor).ceil().max(1.0) as u32,
(viewport.h * scale_factor).ceil().max(1.0) as u32,
)
});
let t_after_layout = Instant::now();
let ops = draw_ops::draw_ops_with_theme(root, &self.ui_state, &self.theme);
let t_after_draw_ops = Instant::now();
timings.layout = t_after_layout - t0;
timings.draw_ops = t_after_draw_ops - t_after_layout;
(ops, needs_redraw)
}
pub fn prepare_paint<F1, F2>(
&mut self,
ops: &[DrawOp],
is_registered: F1,
samples_backdrop: F2,
text: &mut dyn TextRecorder,
scale_factor: f32,
timings: &mut PrepareTimings,
) where
F1: Fn(&ShaderHandle) -> bool,
F2: Fn(&ShaderHandle) -> bool,
{
let t0 = Instant::now();
self.quad_scratch.clear();
self.runs.clear();
self.paint_items.clear();
let mut current: Option<(ShaderHandle, Option<PhysicalScissor>)> = None;
let mut run_first: u32 = 0;
let mut snapshot_emitted = false;
for op in ops {
match op {
DrawOp::Quad {
rect,
scissor,
shader,
uniforms,
..
} => {
if !is_registered(shader) {
continue;
}
let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
continue;
}
if !snapshot_emitted && samples_backdrop(shader) {
close_run(
&mut self.runs,
&mut self.paint_items,
current,
run_first,
self.quad_scratch.len() as u32,
);
current = None;
run_first = self.quad_scratch.len() as u32;
self.paint_items.push(PaintItem::BackdropSnapshot);
snapshot_emitted = true;
}
let inst = pack_instance(*rect, *shader, uniforms);
let key = (*shader, phys);
if current != Some(key) {
close_run(
&mut self.runs,
&mut self.paint_items,
current,
run_first,
self.quad_scratch.len() as u32,
);
current = Some(key);
run_first = self.quad_scratch.len() as u32;
}
self.quad_scratch.push(inst);
}
DrawOp::GlyphRun {
rect,
scissor,
color,
text: glyph_text,
size,
weight,
wrap,
anchor,
..
} => {
close_run(
&mut self.runs,
&mut self.paint_items,
current,
run_first,
self.quad_scratch.len() as u32,
);
current = None;
run_first = self.quad_scratch.len() as u32;
let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
continue;
}
let layers = text.record(
*rect,
phys,
*color,
glyph_text,
*size,
*weight,
*wrap,
*anchor,
scale_factor,
);
for index in layers {
self.paint_items.push(PaintItem::Text(index));
}
}
DrawOp::AttributedText {
rect,
scissor,
runs,
size,
wrap,
anchor,
..
} => {
close_run(
&mut self.runs,
&mut self.paint_items,
current,
run_first,
self.quad_scratch.len() as u32,
);
current = None;
run_first = self.quad_scratch.len() as u32;
let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
continue;
}
let layers =
text.record_runs(*rect, phys, runs, *size, *wrap, *anchor, scale_factor);
for index in layers {
self.paint_items.push(PaintItem::Text(index));
}
}
DrawOp::Icon {
rect,
scissor,
name,
color,
size,
stroke_width,
..
} => {
close_run(
&mut self.runs,
&mut self.paint_items,
current,
run_first,
self.quad_scratch.len() as u32,
);
current = None;
run_first = self.quad_scratch.len() as u32;
let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
continue;
}
let recorded = text.record_icon(
*rect,
phys,
*name,
*color,
*size,
*stroke_width,
scale_factor,
);
match recorded {
RecordedPaint::Text(layers) => {
for index in layers {
self.paint_items.push(PaintItem::Text(index));
}
}
RecordedPaint::Icon(runs) => {
for index in runs {
self.paint_items.push(PaintItem::IconRun(index));
}
}
}
}
DrawOp::BackdropSnapshot => {
close_run(
&mut self.runs,
&mut self.paint_items,
current,
run_first,
self.quad_scratch.len() as u32,
);
current = None;
run_first = self.quad_scratch.len() as u32;
if !snapshot_emitted {
self.paint_items.push(PaintItem::BackdropSnapshot);
snapshot_emitted = true;
}
}
}
}
close_run(
&mut self.runs,
&mut self.paint_items,
current,
run_first,
self.quad_scratch.len() as u32,
);
timings.paint = Instant::now() - t0;
}
pub fn snapshot(&mut self, root: &El, timings: &mut PrepareTimings) {
let t0 = Instant::now();
self.last_tree = Some(root.clone());
timings.snapshot = Instant::now() - t0;
}
}
fn find_capture_keys(node: &El, id: &str) -> Option<bool> {
if node.computed_id == id {
return Some(node.capture_keys);
}
node.children.iter().find_map(|c| find_capture_keys(c, id))
}
pub enum RecordedPaint {
Text(Range<usize>),
Icon(Range<usize>),
}
pub trait TextRecorder {
#[allow(clippy::too_many_arguments)]
fn record(
&mut self,
rect: Rect,
scissor: Option<PhysicalScissor>,
color: Color,
text: &str,
size: f32,
weight: FontWeight,
wrap: TextWrap,
anchor: TextAnchor,
scale_factor: f32,
) -> Range<usize>;
#[allow(clippy::too_many_arguments)]
fn record_runs(
&mut self,
rect: Rect,
scissor: Option<PhysicalScissor>,
runs: &[(String, RunStyle)],
size: f32,
wrap: TextWrap,
anchor: TextAnchor,
scale_factor: f32,
) -> Range<usize>;
#[allow(clippy::too_many_arguments)]
fn record_icon(
&mut self,
rect: Rect,
scissor: Option<PhysicalScissor>,
name: crate::tree::IconName,
color: Color,
size: f32,
_stroke_width: f32,
scale_factor: f32,
) -> RecordedPaint {
RecordedPaint::Text(self.record(
rect,
scissor,
color,
name.fallback_glyph(),
size,
FontWeight::Regular,
TextWrap::NoWrap,
TextAnchor::Middle,
scale_factor,
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shader::{ShaderHandle, StockShader, UniformBlock};
struct NoText;
impl TextRecorder for NoText {
fn record(
&mut self,
_rect: Rect,
_scissor: Option<PhysicalScissor>,
_color: Color,
_text: &str,
_size: f32,
_weight: FontWeight,
_wrap: TextWrap,
_anchor: TextAnchor,
_scale_factor: f32,
) -> Range<usize> {
0..0
}
fn record_runs(
&mut self,
_rect: Rect,
_scissor: Option<PhysicalScissor>,
_runs: &[(String, RunStyle)],
_size: f32,
_wrap: TextWrap,
_anchor: TextAnchor,
_scale_factor: f32,
) -> Range<usize> {
0..0
}
}
fn lay_out_input_tree(capture: bool) -> RunnerCore {
use crate::tree::*;
let ti = if capture {
crate::widgets::text::text("input").key("ti").capture_keys()
} else {
crate::widgets::text::text("noop").key("ti").focusable()
};
let mut tree =
crate::column([crate::widgets::button::button("Btn").key("btn"), ti]).padding(10.0);
let mut core = RunnerCore::new();
crate::layout::layout(
&mut tree,
&mut core.ui_state,
Rect::new(0.0, 0.0, 200.0, 200.0),
);
core.ui_state.sync_focus_order(&tree);
let mut t = PrepareTimings::default();
core.snapshot(&tree, &mut t);
core
}
#[test]
fn pointer_up_emits_pointer_up_then_click() {
let mut core = lay_out_input_tree(false);
let btn_rect = core.rect_of_key("btn").expect("btn rect");
let cx = btn_rect.x + btn_rect.w * 0.5;
let cy = btn_rect.y + btn_rect.h * 0.5;
core.pointer_moved(cx, cy);
core.pointer_down(cx, cy, PointerButton::Primary);
let events = core.pointer_up(cx, cy, PointerButton::Primary);
let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
assert_eq!(kinds, vec![UiEventKind::PointerUp, UiEventKind::Click]);
}
#[test]
fn pointer_up_off_target_emits_only_pointer_up() {
let mut core = lay_out_input_tree(false);
let btn_rect = core.rect_of_key("btn").expect("btn rect");
let cx = btn_rect.x + btn_rect.w * 0.5;
let cy = btn_rect.y + btn_rect.h * 0.5;
core.pointer_down(cx, cy, PointerButton::Primary);
let events = core.pointer_up(180.0, 180.0, PointerButton::Primary);
let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
assert_eq!(
kinds,
vec![UiEventKind::PointerUp],
"drag-off-target should still surface PointerUp so widgets see drag-end"
);
}
#[test]
fn pointer_moved_while_pressed_emits_drag() {
let mut core = lay_out_input_tree(false);
let btn_rect = core.rect_of_key("btn").expect("btn rect");
let cx = btn_rect.x + btn_rect.w * 0.5;
let cy = btn_rect.y + btn_rect.h * 0.5;
core.pointer_down(cx, cy, PointerButton::Primary);
let drag = core
.pointer_moved(cx + 30.0, cy)
.expect("drag while pressed");
assert_eq!(drag.kind, UiEventKind::Drag);
assert_eq!(drag.target.as_ref().map(|t| t.key.as_str()), Some("btn"));
assert_eq!(drag.pointer, Some((cx + 30.0, cy)));
}
#[test]
fn pointer_moved_without_press_emits_no_drag() {
let mut core = lay_out_input_tree(false);
assert!(core.pointer_moved(50.0, 50.0).is_none());
}
#[test]
fn secondary_click_does_not_steal_focus_or_press() {
let mut core = lay_out_input_tree(false);
let btn_rect = core.rect_of_key("btn").expect("btn rect");
let cx = btn_rect.x + btn_rect.w * 0.5;
let cy = btn_rect.y + btn_rect.h * 0.5;
let ti_rect = core.rect_of_key("ti").expect("ti rect");
let tx = ti_rect.x + ti_rect.w * 0.5;
let ty = ti_rect.y + ti_rect.h * 0.5;
core.pointer_down(tx, ty, PointerButton::Primary);
let _ = core.pointer_up(tx, ty, PointerButton::Primary);
let focused_before = core.ui_state.focused.as_ref().map(|t| t.key.clone());
core.pointer_down(cx, cy, PointerButton::Secondary);
let events = core.pointer_up(cx, cy, PointerButton::Secondary);
let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
assert_eq!(kinds, vec![UiEventKind::SecondaryClick]);
let focused_after = core.ui_state.focused.as_ref().map(|t| t.key.clone());
assert_eq!(
focused_before, focused_after,
"right-click must not steal focus"
);
assert!(
core.ui_state.pressed.is_none(),
"right-click must not set primary press"
);
}
#[test]
fn text_input_routes_to_focused_only() {
let mut core = lay_out_input_tree(false);
assert!(core.text_input("a".into()).is_none());
let btn_rect = core.rect_of_key("btn").expect("btn rect");
let cx = btn_rect.x + btn_rect.w * 0.5;
let cy = btn_rect.y + btn_rect.h * 0.5;
core.pointer_down(cx, cy, PointerButton::Primary);
let _ = core.pointer_up(cx, cy, PointerButton::Primary);
let event = core.text_input("hi".into()).expect("focused → event");
assert_eq!(event.kind, UiEventKind::TextInput);
assert_eq!(event.text.as_deref(), Some("hi"));
assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("btn"));
assert!(core.text_input(String::new()).is_none());
}
#[test]
fn capture_keys_bypasses_tab_traversal_for_focused_node() {
let mut core = lay_out_input_tree(true);
let ti_rect = core.rect_of_key("ti").expect("ti rect");
let tx = ti_rect.x + ti_rect.w * 0.5;
let ty = ti_rect.y + ti_rect.h * 0.5;
core.pointer_down(tx, ty, PointerButton::Primary);
let _ = core.pointer_up(tx, ty, PointerButton::Primary);
assert_eq!(
core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
Some("ti"),
"primary click on capture_keys node still focuses it"
);
let event = core
.key_down(UiKey::Tab, KeyModifiers::default(), false)
.expect("Tab → KeyDown to focused");
assert_eq!(event.kind, UiEventKind::KeyDown);
assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("ti"));
assert_eq!(
core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
Some("ti"),
"Tab inside capture_keys must NOT move focus"
);
}
#[test]
fn capture_keys_falls_back_to_default_when_focus_off_capturing_node() {
let mut core = lay_out_input_tree(true);
let btn_rect = core.rect_of_key("btn").expect("btn rect");
let cx = btn_rect.x + btn_rect.w * 0.5;
let cy = btn_rect.y + btn_rect.h * 0.5;
core.pointer_down(cx, cy, PointerButton::Primary);
let _ = core.pointer_up(cx, cy, PointerButton::Primary);
assert_eq!(
core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
Some("btn"),
"primary click focuses button"
);
let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
assert_eq!(
core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
Some("ti"),
"Tab from non-capturing focused does library-default traversal"
);
}
fn lay_out_arrow_nav_tree() -> RunnerCore {
use crate::tree::*;
let mut tree = crate::column([
crate::widgets::button::button("Red").key("opt-red"),
crate::widgets::button::button("Green").key("opt-green"),
crate::widgets::button::button("Blue").key("opt-blue"),
])
.arrow_nav_siblings()
.padding(10.0);
let mut core = RunnerCore::new();
crate::layout::layout(
&mut tree,
&mut core.ui_state,
Rect::new(0.0, 0.0, 200.0, 300.0),
);
core.ui_state.sync_focus_order(&tree);
let mut t = PrepareTimings::default();
core.snapshot(&tree, &mut t);
let target = core
.ui_state
.focus_order
.iter()
.find(|t| t.key == "opt-green")
.cloned();
core.ui_state.set_focus(target);
core
}
#[test]
fn arrow_nav_moves_focus_among_siblings() {
let mut core = lay_out_arrow_nav_tree();
let down = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
assert!(down.is_none(), "arrow-nav consumes the key event");
assert_eq!(
core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
Some("opt-blue"),
);
core.key_down(UiKey::ArrowUp, KeyModifiers::default(), false);
assert_eq!(
core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
Some("opt-green"),
);
core.key_down(UiKey::Home, KeyModifiers::default(), false);
assert_eq!(
core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
Some("opt-red"),
);
core.key_down(UiKey::End, KeyModifiers::default(), false);
assert_eq!(
core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
Some("opt-blue"),
);
}
#[test]
fn arrow_nav_saturates_at_ends() {
let mut core = lay_out_arrow_nav_tree();
core.key_down(UiKey::Home, KeyModifiers::default(), false);
core.key_down(UiKey::ArrowUp, KeyModifiers::default(), false);
assert_eq!(
core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
Some("opt-red"),
"ArrowUp at top stays at top — no wrap",
);
core.key_down(UiKey::End, KeyModifiers::default(), false);
core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
assert_eq!(
core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
Some("opt-blue"),
"ArrowDown at bottom stays at bottom — no wrap",
);
}
fn build_popover_tree(open: bool) -> El {
use crate::widgets::button::button;
use crate::widgets::overlay::overlay;
use crate::widgets::popover::{dropdown, menu_item};
let mut layers: Vec<El> = vec![button("Trigger").key("trigger")];
if open {
layers.push(dropdown(
"menu",
"trigger",
[
menu_item("A").key("item-a"),
menu_item("B").key("item-b"),
menu_item("C").key("item-c"),
],
));
}
overlay(layers).padding(20.0)
}
fn run_frame(core: &mut RunnerCore, tree: &mut El) {
let mut t = PrepareTimings::default();
core.prepare_layout(tree, Rect::new(0.0, 0.0, 400.0, 300.0), 1.0, &mut t);
core.snapshot(tree, &mut t);
}
#[test]
fn popover_open_pushes_focus_and_auto_focuses_first_item() {
let mut core = RunnerCore::new();
let mut closed = build_popover_tree(false);
run_frame(&mut core, &mut closed);
let trigger = core
.ui_state
.focus_order
.iter()
.find(|t| t.key == "trigger")
.cloned();
core.ui_state.set_focus(trigger);
assert_eq!(
core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
Some("trigger"),
);
let mut open = build_popover_tree(true);
run_frame(&mut core, &mut open);
assert_eq!(
core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
Some("item-a"),
"popover open should auto-focus the first menu item",
);
assert_eq!(
core.ui_state.focus_stack.len(),
1,
"trigger should be saved on the focus stack",
);
assert_eq!(
core.ui_state.focus_stack[0].key.as_str(),
"trigger",
"saved focus should be the pre-open target",
);
}
#[test]
fn popover_close_restores_focus_to_trigger() {
let mut core = RunnerCore::new();
let mut closed = build_popover_tree(false);
run_frame(&mut core, &mut closed);
let trigger = core
.ui_state
.focus_order
.iter()
.find(|t| t.key == "trigger")
.cloned();
core.ui_state.set_focus(trigger);
let mut open = build_popover_tree(true);
run_frame(&mut core, &mut open);
assert_eq!(
core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
Some("item-a"),
);
let mut closed_again = build_popover_tree(false);
run_frame(&mut core, &mut closed_again);
assert_eq!(
core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
Some("trigger"),
"closing the popover should pop the saved focus",
);
assert!(
core.ui_state.focus_stack.is_empty(),
"focus stack should be drained after restore",
);
}
#[test]
fn popover_close_does_not_override_intentional_focus_move() {
let mut core = RunnerCore::new();
let build = |open: bool| -> El {
use crate::widgets::button::button;
use crate::widgets::overlay::overlay;
use crate::widgets::popover::{dropdown, menu_item};
let main = crate::row([
button("Trigger").key("trigger"),
button("Other").key("other"),
]);
let mut layers: Vec<El> = vec![main];
if open {
layers.push(dropdown("menu", "trigger", [menu_item("A").key("item-a")]));
}
overlay(layers).padding(20.0)
};
let mut closed = build(false);
run_frame(&mut core, &mut closed);
let trigger = core
.ui_state
.focus_order
.iter()
.find(|t| t.key == "trigger")
.cloned();
core.ui_state.set_focus(trigger);
let mut open = build(true);
run_frame(&mut core, &mut open);
assert_eq!(core.ui_state.focus_stack.len(), 1);
let other = core
.ui_state
.focus_order
.iter()
.find(|t| t.key == "other")
.cloned();
core.ui_state.set_focus(other);
let mut closed_again = build(false);
run_frame(&mut core, &mut closed_again);
assert_eq!(
core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
Some("other"),
"focus moved before close should not be overridden by restore",
);
assert!(core.ui_state.focus_stack.is_empty());
}
#[test]
fn nested_popovers_stack_and_unwind_focus_correctly() {
let mut core = RunnerCore::new();
let build = |outer: bool, inner: bool| -> El {
use crate::widgets::button::button;
use crate::widgets::overlay::overlay;
use crate::widgets::popover::{Anchor, popover, popover_panel};
let main = button("Trigger").key("trigger");
let mut layers: Vec<El> = vec![main];
if outer {
layers.push(popover(
"outer",
Anchor::below_key("trigger"),
popover_panel([button("Open inner").key("inner-trigger")]),
));
}
if inner {
layers.push(popover(
"inner",
Anchor::below_key("inner-trigger"),
popover_panel([button("X").key("inner-a"), button("Y").key("inner-b")]),
));
}
overlay(layers).padding(20.0)
};
let mut closed = build(false, false);
run_frame(&mut core, &mut closed);
let trigger = core
.ui_state
.focus_order
.iter()
.find(|t| t.key == "trigger")
.cloned();
core.ui_state.set_focus(trigger);
let mut outer = build(true, false);
run_frame(&mut core, &mut outer);
assert_eq!(
core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
Some("inner-trigger"),
);
assert_eq!(core.ui_state.focus_stack.len(), 1);
let mut both = build(true, true);
run_frame(&mut core, &mut both);
assert_eq!(
core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
Some("inner-a"),
);
assert_eq!(core.ui_state.focus_stack.len(), 2);
let mut outer_only = build(true, false);
run_frame(&mut core, &mut outer_only);
assert_eq!(
core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
Some("inner-trigger"),
);
assert_eq!(core.ui_state.focus_stack.len(), 1);
let mut none = build(false, false);
run_frame(&mut core, &mut none);
assert_eq!(
core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
Some("trigger"),
);
assert!(core.ui_state.focus_stack.is_empty());
}
#[test]
fn arrow_nav_does_not_intercept_outside_navigable_groups() {
let mut core = lay_out_input_tree(false);
let target = core
.ui_state
.focus_order
.iter()
.find(|t| t.key == "btn")
.cloned();
core.ui_state.set_focus(target);
let event = core
.key_down(UiKey::ArrowDown, KeyModifiers::default(), false)
.expect("ArrowDown without navigable parent → event");
assert_eq!(event.kind, UiEventKind::KeyDown);
}
fn quad(shader: ShaderHandle) -> DrawOp {
DrawOp::Quad {
id: "q".into(),
rect: Rect::new(0.0, 0.0, 10.0, 10.0),
scissor: None,
shader,
uniforms: UniformBlock::new(),
}
}
#[test]
fn samples_backdrop_inserts_snapshot_before_first_glass_quad() {
let mut core = RunnerCore::new();
core.set_surface_size(100, 100);
let ops = vec![
quad(ShaderHandle::Stock(StockShader::RoundedRect)),
quad(ShaderHandle::Stock(StockShader::RoundedRect)),
quad(ShaderHandle::Custom("liquid_glass")),
quad(ShaderHandle::Custom("liquid_glass")),
quad(ShaderHandle::Stock(StockShader::RoundedRect)),
];
let mut timings = PrepareTimings::default();
core.prepare_paint(
&ops,
|_| true,
|s| matches!(s, ShaderHandle::Custom(name) if *name == "liquid_glass"),
&mut NoText,
1.0,
&mut timings,
);
let kinds: Vec<&'static str> = core
.paint_items
.iter()
.map(|p| match p {
PaintItem::QuadRun(_) => "Q",
PaintItem::IconRun(_) => "I",
PaintItem::Text(_) => "T",
PaintItem::BackdropSnapshot => "S",
})
.collect();
assert_eq!(
kinds,
vec!["Q", "S", "Q", "Q"],
"expected one stock run, snapshot, then a glass run, then a foreground stock run"
);
}
#[test]
fn no_snapshot_when_no_glass_drawn() {
let mut core = RunnerCore::new();
core.set_surface_size(100, 100);
let ops = vec![
quad(ShaderHandle::Stock(StockShader::RoundedRect)),
quad(ShaderHandle::Stock(StockShader::RoundedRect)),
];
let mut timings = PrepareTimings::default();
core.prepare_paint(&ops, |_| true, |_| false, &mut NoText, 1.0, &mut timings);
assert!(
!core
.paint_items
.iter()
.any(|p| matches!(p, PaintItem::BackdropSnapshot)),
"no glass shader registered → no snapshot"
);
}
#[test]
fn at_most_one_snapshot_per_frame() {
let mut core = RunnerCore::new();
core.set_surface_size(100, 100);
let ops = vec![
quad(ShaderHandle::Stock(StockShader::RoundedRect)),
quad(ShaderHandle::Custom("g")),
quad(ShaderHandle::Stock(StockShader::RoundedRect)),
quad(ShaderHandle::Custom("g")),
];
let mut timings = PrepareTimings::default();
core.prepare_paint(
&ops,
|_| true,
|s| matches!(s, ShaderHandle::Custom("g")),
&mut NoText,
1.0,
&mut timings,
);
let snapshots = core
.paint_items
.iter()
.filter(|p| matches!(p, PaintItem::BackdropSnapshot))
.count();
assert_eq!(snapshots, 1, "backdrop depth is capped at 1");
}
}