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::toast;
use crate::tooltip;
use crate::tree::{Color, El, FontWeight, Rect, TextWrap};
const SCROLL_PAGE_OVERLAP: f32 = 24.0;
#[derive(Clone, Copy, Debug, Default)]
pub struct PrepareResult {
pub needs_redraw: bool,
pub next_redraw_in: Option<std::time::Duration>,
pub next_layout_redraw_in: Option<std::time::Duration>,
pub next_paint_redraw_in: Option<std::time::Duration>,
pub timings: PrepareTimings,
}
#[derive(Debug, Default)]
pub struct PointerMove {
pub events: Vec<UiEvent>,
pub needs_redraw: bool,
}
pub struct LayoutPrepared {
pub ops: Vec<DrawOp>,
pub needs_redraw: bool,
pub next_layout_redraw_in: Option<std::time::Duration>,
pub next_paint_redraw_in: Option<std::time::Duration>,
}
#[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 last_ops: Vec<DrawOp>,
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(),
last_ops: 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) -> PointerMove {
self.ui_state.pointer_pos = Some((x, y));
if let Some(drag) = self.ui_state.scroll.thumb_drag.clone() {
let dy = y - drag.start_pointer_y;
let new_offset = if drag.track_remaining > 0.0 {
drag.start_offset + dy * (drag.max_offset / drag.track_remaining)
} else {
drag.start_offset
};
let clamped = new_offset.clamp(0.0, drag.max_offset);
let prev = self.ui_state.scroll.offsets.insert(drag.scroll_id, clamped);
let changed = prev.is_none_or(|old| (old - clamped).abs() > f32::EPSILON);
return PointerMove {
events: Vec::new(),
needs_redraw: changed,
};
}
let hit = self
.last_tree
.as_ref()
.and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
let prev_hover = self.ui_state.hovered.clone();
let hover_changed = self.ui_state.set_hovered(hit, Instant::now());
let prev_hovered_link = self.ui_state.hovered_link.clone();
let new_hovered_link = self
.last_tree
.as_ref()
.and_then(|t| hit_test::link_at(t, &self.ui_state, (x, y)));
let link_hover_changed = new_hovered_link != prev_hovered_link;
self.ui_state.hovered_link = new_hovered_link;
let modifiers = self.ui_state.modifiers;
let mut out = Vec::new();
if hover_changed {
if let Some(prev) = prev_hover {
out.push(UiEvent {
key: Some(prev.key.clone()),
target: Some(prev),
pointer: Some((x, y)),
key_press: None,
text: None,
selection: None,
modifiers,
click_count: 0,
path: None,
kind: UiEventKind::PointerLeave,
});
}
if let Some(new) = self.ui_state.hovered.clone() {
out.push(UiEvent {
key: Some(new.key.clone()),
target: Some(new),
pointer: Some((x, y)),
key_press: None,
text: None,
selection: None,
modifiers,
click_count: 0,
path: None,
kind: UiEventKind::PointerEnter,
});
}
}
if let Some(drag) = self.ui_state.selection.drag.clone()
&& let Some(tree) = self.last_tree.as_ref()
{
let head_point =
head_for_drag(tree, &self.ui_state, (x, y)).unwrap_or_else(|| drag.anchor.clone());
let new_sel = crate::selection::Selection {
range: Some(crate::selection::SelectionRange {
anchor: drag.anchor.clone(),
head: head_point,
}),
};
if new_sel != self.ui_state.current_selection {
self.ui_state.current_selection = new_sel.clone();
out.push(selection_event(new_sel, modifiers, Some((x, y))));
}
}
if let Some(p) = self.ui_state.pressed.clone() {
if self.focused_captures_keys() {
self.ui_state.bump_caret_activity(Instant::now());
}
out.push(UiEvent {
key: Some(p.key.clone()),
target: Some(p),
pointer: Some((x, y)),
key_press: None,
text: None,
selection: None,
modifiers,
click_count: 0,
path: None,
kind: UiEventKind::Drag,
});
}
let needs_redraw = hover_changed || link_hover_changed || !out.is_empty();
PointerMove {
events: out,
needs_redraw,
}
}
pub fn pointer_left(&mut self) -> Vec<UiEvent> {
let last_pos = self.ui_state.pointer_pos;
let prev_hover = self.ui_state.hovered.clone();
let modifiers = self.ui_state.modifiers;
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;
self.ui_state.hovered_link = None;
self.ui_state.pressed_link = None;
let mut out = Vec::new();
if let Some(prev) = prev_hover {
out.push(UiEvent {
key: Some(prev.key.clone()),
target: Some(prev),
pointer: last_pos,
key_press: None,
text: None,
selection: None,
modifiers,
click_count: 0,
path: None,
kind: UiEventKind::PointerLeave,
});
}
out
}
pub fn file_hovered(&mut self, path: std::path::PathBuf, x: f32, y: f32) -> Vec<UiEvent> {
self.ui_state.pointer_pos = Some((x, y));
let target = self
.last_tree
.as_ref()
.and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
let key = target.as_ref().map(|t| t.key.clone());
vec![UiEvent {
key,
target,
pointer: Some((x, y)),
key_press: None,
text: None,
selection: None,
modifiers: self.ui_state.modifiers,
click_count: 0,
path: Some(path),
kind: UiEventKind::FileHovered,
}]
}
pub fn file_hover_cancelled(&mut self) -> Vec<UiEvent> {
vec![UiEvent {
key: None,
target: None,
pointer: self.ui_state.pointer_pos,
key_press: None,
text: None,
selection: None,
modifiers: self.ui_state.modifiers,
click_count: 0,
path: None,
kind: UiEventKind::FileHoverCancelled,
}]
}
pub fn file_dropped(&mut self, path: std::path::PathBuf, x: f32, y: f32) -> Vec<UiEvent> {
self.ui_state.pointer_pos = Some((x, y));
let target = self
.last_tree
.as_ref()
.and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
let key = target.as_ref().map(|t| t.key.clone());
vec![UiEvent {
key,
target,
pointer: Some((x, y)),
key_press: None,
text: None,
selection: None,
modifiers: self.ui_state.modifiers,
click_count: 0,
path: Some(path),
kind: UiEventKind::FileDropped,
}]
}
pub fn pointer_down(&mut self, x: f32, y: f32, button: PointerButton) -> Vec<UiEvent> {
if matches!(button, PointerButton::Primary)
&& let Some((scroll_id, _track, thumb_rect)) = self.ui_state.thumb_at(x, y)
{
let metrics = self
.ui_state
.scroll
.metrics
.get(&scroll_id)
.copied()
.unwrap_or_default();
let start_offset = self
.ui_state
.scroll
.offsets
.get(&scroll_id)
.copied()
.unwrap_or(0.0);
let grabbed = y >= thumb_rect.y && y <= thumb_rect.y + thumb_rect.h;
if grabbed {
let track_remaining = (metrics.viewport_h - thumb_rect.h).max(0.0);
self.ui_state.scroll.thumb_drag = Some(crate::state::ThumbDrag {
scroll_id,
start_pointer_y: y,
start_offset,
track_remaining,
max_offset: metrics.max_offset,
});
} else {
let page = (metrics.viewport_h - SCROLL_PAGE_OVERLAP).max(0.0);
let delta = if y < thumb_rect.y { -page } else { page };
let new_offset = (start_offset + delta).clamp(0.0, metrics.max_offset);
self.ui_state.scroll.offsets.insert(scroll_id, new_offset);
}
return Vec::new();
}
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.pressed_secondary = hit.map(|h| (h, button));
return Vec::new();
}
self.ui_state.pressed_link = self
.last_tree
.as_ref()
.and_then(|t| hit_test::link_at(t, &self.ui_state, (x, y)));
self.ui_state.set_focus(hit.clone());
self.ui_state.set_focus_visible(false);
self.ui_state.pressed = hit.clone();
self.ui_state.tooltip.dismissed_for_hover = true;
let modifiers = self.ui_state.modifiers;
let now = Instant::now();
let click_count =
self.ui_state
.next_click_count(now, (x, y), hit.as_ref().map(|t| t.node_id.as_str()));
let mut out = Vec::new();
if let Some(p) = hit.clone() {
if self.focused_captures_keys() {
self.ui_state.bump_caret_activity(now);
}
out.push(UiEvent {
key: Some(p.key.clone()),
target: Some(p),
pointer: Some((x, y)),
key_press: None,
text: None,
selection: None,
modifiers,
click_count,
path: None,
kind: UiEventKind::PointerDown,
});
}
if let Some(point) = self
.last_tree
.as_ref()
.and_then(|t| hit_test::selection_point_at(t, &self.ui_state, (x, y)))
{
self.start_selection_drag(point, &mut out, modifiers, (x, y), click_count);
} else if !self.ui_state.current_selection.is_empty() {
let click_handles_selection = match (&hit, &self.ui_state.current_selection.range) {
(Some(h), Some(range)) => {
h.key == range.anchor.key
|| h.key == range.head.key
|| self
.last_tree
.as_ref()
.and_then(|t| find_capture_keys(t, &h.node_id))
.unwrap_or(false)
}
_ => false,
};
if !click_handles_selection {
out.push(selection_event(
crate::selection::Selection::default(),
modifiers,
Some((x, y)),
));
self.ui_state.current_selection = crate::selection::Selection::default();
self.ui_state.selection.drag = None;
}
}
out
}
fn start_selection_drag(
&mut self,
point: crate::selection::SelectionPoint,
out: &mut Vec<UiEvent>,
modifiers: KeyModifiers,
pointer: (f32, f32),
click_count: u8,
) {
let leaf_text = self
.last_tree
.as_ref()
.and_then(|t| crate::selection::find_keyed_text(t, &point.key))
.unwrap_or_default();
let (anchor_byte, head_byte) = match click_count {
2 => crate::selection::word_range_at(&leaf_text, point.byte),
n if n >= 3 => (0, leaf_text.len()),
_ => (point.byte, point.byte),
};
let anchor = crate::selection::SelectionPoint::new(point.key.clone(), anchor_byte);
let head = crate::selection::SelectionPoint::new(point.key.clone(), head_byte);
let new_sel = crate::selection::Selection {
range: Some(crate::selection::SelectionRange {
anchor: anchor.clone(),
head,
}),
};
self.ui_state.current_selection = new_sel.clone();
self.ui_state.selection.drag = Some(crate::state::SelectionDrag { anchor });
out.push(selection_event(new_sel, modifiers, Some(pointer)));
}
pub fn pointer_up(&mut self, x: f32, y: f32, button: PointerButton) -> Vec<UiEvent> {
if matches!(button, PointerButton::Primary) && self.ui_state.scroll.thumb_drag.is_some() {
self.ui_state.scroll.thumb_drag = None;
return Vec::new();
}
if matches!(button, PointerButton::Primary) {
self.ui_state.selection.drag = None;
}
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();
let click_count = self.ui_state.current_click_count();
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,
selection: None,
modifiers,
click_count,
path: None,
kind: UiEventKind::PointerUp,
});
}
if let (Some(p), Some(h)) = (pressed, hit)
&& p.node_id == h.node_id
{
if let Some(id) = toast::parse_dismiss_key(&p.key) {
self.ui_state.dismiss_toast(id);
} else {
out.push(UiEvent {
key: Some(p.key.clone()),
target: Some(p),
pointer: Some((x, y)),
key_press: None,
text: None,
selection: None,
modifiers,
click_count,
path: None,
kind: UiEventKind::Click,
});
}
}
if let Some(pressed_url) = self.ui_state.pressed_link.take() {
let up_link = self
.last_tree
.as_ref()
.and_then(|t| hit_test::link_at(t, &self.ui_state, (x, y)));
if up_link.as_ref() == Some(&pressed_url) {
out.push(UiEvent {
key: Some(pressed_url),
target: None,
pointer: Some((x, y)),
key_press: None,
text: None,
selection: None,
modifiers,
click_count: 1,
path: None,
kind: UiEventKind::LinkActivated,
});
}
}
}
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,
selection: None,
modifiers,
click_count: 1,
path: None,
kind,
});
}
}
}
out
}
pub fn key_down(&mut self, key: UiKey, modifiers: KeyModifiers, repeat: bool) -> Vec<UiEvent> {
if self.focused_captures_keys() {
if let Some(event) = self.ui_state.try_hotkey(&key, modifiers, repeat) {
return vec![event];
}
self.ui_state.bump_caret_activity(Instant::now());
self.ui_state.set_focus_visible(true);
return self
.ui_state
.key_down_raw(key, modifiers, repeat)
.into_iter()
.collect();
}
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 vec![event];
}
self.move_focus_in_group(&key, &siblings);
return Vec::new();
}
let mut out: Vec<UiEvent> = self
.ui_state
.key_down(key, modifiers, repeat)
.into_iter()
.collect();
if matches!(out.first().map(|e| e.kind), Some(UiEventKind::Escape))
&& !self.ui_state.current_selection.is_empty()
{
self.ui_state.current_selection = crate::selection::Selection::default();
self.ui_state.selection.drag = None;
out.push(selection_event(
crate::selection::Selection::default(),
modifiers,
None,
));
}
out
}
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()));
self.ui_state.set_focus_visible(true);
}
}
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;
self.ui_state.bump_caret_activity(Instant::now());
Some(UiEvent {
key: Some(target.key.clone()),
target: Some(target),
pointer: None,
key_press: None,
text: Some(text),
selection: None,
modifiers,
click_count: 0,
path: None,
kind: UiEventKind::TextInput,
})
}
pub fn set_hotkeys(&mut self, hotkeys: Vec<(KeyChord, String)>) {
self.ui_state.set_hotkeys(hotkeys);
}
pub fn set_selection(&mut self, selection: crate::selection::Selection) {
if self.ui_state.current_selection != selection {
self.ui_state.bump_caret_activity(Instant::now());
}
self.ui_state.current_selection = selection;
}
pub fn push_toasts(&mut self, specs: Vec<crate::toast::ToastSpec>) {
let now = Instant::now();
for spec in specs {
self.ui_state.push_toast(spec, now);
}
}
pub fn dismiss_toast(&mut self, id: u64) {
self.ui_state.dismiss_toast(id);
}
pub fn push_focus_requests(&mut self, keys: Vec<String>) {
self.ui_state.push_focus_requests(keys);
}
pub fn push_scroll_requests(&mut self, requests: Vec<crate::scroll::ScrollRequest>) {
self.ui_state.push_scroll_requests(requests);
}
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<F>(
&mut self,
root: &mut El,
viewport: Rect,
scale_factor: f32,
timings: &mut PrepareTimings,
samples_time: F,
) -> LayoutPrepared
where
F: Fn(&ShaderHandle) -> bool,
{
let t0 = Instant::now();
let mut needs_redraw = {
crate::profile_span!("prepare::layout");
{
crate::profile_span!("prepare::layout::assign_ids");
layout::assign_ids(root);
}
let tooltip_pending = {
crate::profile_span!("prepare::layout::tooltip");
tooltip::synthesize_tooltip(root, &self.ui_state, t0)
};
let toast_pending = {
crate::profile_span!("prepare::layout::toast");
toast::synthesize_toasts(root, &mut self.ui_state, t0)
};
{
crate::profile_span!("prepare::layout::apply_metrics");
self.theme.apply_metrics(root);
}
{
crate::profile_span!("prepare::layout::layout");
layout::layout_post_assign(root, &mut self.ui_state, viewport);
self.ui_state.clear_pending_scroll_requests();
}
{
crate::profile_span!("prepare::layout::sync_focus_order");
self.ui_state.sync_focus_order(root);
}
{
crate::profile_span!("prepare::layout::sync_selection_order");
self.ui_state.sync_selection_order(root);
}
{
crate::profile_span!("prepare::layout::sync_popover_focus");
focus::sync_popover_focus(root, &mut self.ui_state);
}
{
crate::profile_span!("prepare::layout::drain_focus_requests");
self.ui_state.drain_focus_requests();
}
{
crate::profile_span!("prepare::layout::apply_state");
self.ui_state.apply_to_state();
}
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 animations = {
crate::profile_span!("prepare::layout::tick_animations");
self.ui_state.tick_visual_animations(root, Instant::now())
};
animations || tooltip_pending || toast_pending
};
let t_after_layout = Instant::now();
let ops = {
crate::profile_span!("prepare::draw_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;
let shader_needs_redraw = ops.iter().any(|op| op_is_continuous(op, &samples_time));
let widget_redraw =
aggregate_redraw_within(root, viewport, &self.ui_state.layout.computed_rects);
let next_layout_redraw_in = match (needs_redraw, widget_redraw) {
(true, Some(d)) => Some(d.min(std::time::Duration::ZERO)),
(true, None) => Some(std::time::Duration::ZERO),
(false, d) => d,
};
let next_paint_redraw_in = if shader_needs_redraw {
Some(std::time::Duration::ZERO)
} else {
None
};
if next_layout_redraw_in.is_some() || next_paint_redraw_in.is_some() {
needs_redraw = true;
}
LayoutPrepared {
ops,
needs_redraw,
next_layout_redraw_in,
next_paint_redraw_in,
}
}
pub fn prepare_paint_cached<F1, F2>(
&mut self,
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 ops = std::mem::take(&mut self.last_ops);
self.prepare_paint(
&ops,
is_registered,
samples_backdrop,
text,
scale_factor,
timings,
);
self.last_ops = ops;
}
pub fn no_time_shaders(_shader: &ShaderHandle) -> bool {
false
}
pub fn scan_continuous_shaders<F>(&self, samples_time: F) -> Option<std::time::Duration>
where
F: Fn(&ShaderHandle) -> bool,
{
let any = self
.last_ops
.iter()
.any(|op| op_is_continuous(op, &samples_time));
if any {
Some(std::time::Duration::ZERO)
} else {
None
}
}
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,
{
crate::profile_span!("prepare::paint");
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,
line_height,
family,
mono_family,
weight,
mono,
wrap,
anchor,
underline,
strikethrough,
link,
..
} => {
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 mut style = crate::text::atlas::RunStyle::new(*weight, *color)
.family(*family)
.mono_family(*mono_family);
if *mono {
style = style.mono();
}
if *underline {
style = style.underline();
}
if *strikethrough {
style = style.strikethrough();
}
if let Some(url) = link {
style = style.with_link(url.clone());
}
let layers = text.record(
*rect,
phys,
&style,
glyph_text,
*size,
*line_height,
*wrap,
*anchor,
scale_factor,
);
for index in layers {
self.paint_items.push(PaintItem::Text(index));
}
}
DrawOp::AttributedText {
rect,
scissor,
runs,
size,
line_height,
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,
*line_height,
*wrap,
*anchor,
scale_factor,
);
for index in layers {
self.paint_items.push(PaintItem::Text(index));
}
}
DrawOp::Icon {
rect,
scissor,
source,
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,
source,
*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::Image {
rect,
scissor,
image,
tint,
radius,
fit,
..
} => {
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_image(*rect, phys, image, *tint, *radius, *fit, scale_factor);
for index in recorded {
self.paint_items.push(PaintItem::Image(index));
}
}
DrawOp::AppTexture {
rect,
scissor,
texture,
alpha,
transform,
..
} => {
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_app_texture(
*rect,
phys,
texture,
*alpha,
*transform,
scale_factor,
);
for index in recorded {
self.paint_items.push(PaintItem::AppTexture(index));
}
}
DrawOp::Vector {
rect,
scissor,
asset,
render_mode,
..
} => {
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_vector(*rect, phys, asset, *render_mode, scale_factor);
for index in recorded {
self.paint_items.push(PaintItem::Vector(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) {
crate::profile_span!("prepare::snapshot");
let t0 = Instant::now();
self.last_tree = Some(root.clone());
timings.snapshot = Instant::now() - t0;
}
}
fn op_is_continuous<F>(op: &DrawOp, samples_time: &F) -> bool
where
F: Fn(&ShaderHandle) -> bool,
{
match op.shader() {
Some(handle @ ShaderHandle::Stock(s)) => s.is_continuous() || samples_time(handle),
Some(handle @ ShaderHandle::Custom(_)) => samples_time(handle),
None => false,
}
}
fn aggregate_redraw_within(
node: &El,
viewport: Rect,
rects: &rustc_hash::FxHashMap<String, Rect>,
) -> Option<std::time::Duration> {
let mut acc: Option<std::time::Duration> = None;
visit_redraw_within(node, viewport, rects, VisibilityClip::Unclipped, &mut acc);
acc
}
#[derive(Clone, Copy)]
enum VisibilityClip {
Unclipped,
Clipped(Rect),
Empty,
}
impl VisibilityClip {
fn intersect(self, rect: Rect) -> Self {
if rect.w <= 0.0 || rect.h <= 0.0 {
return Self::Empty;
}
match self {
Self::Unclipped => Self::Clipped(rect),
Self::Clipped(prev) => prev
.intersect(rect)
.map(Self::Clipped)
.unwrap_or(Self::Empty),
Self::Empty => Self::Empty,
}
}
fn permits(self, rect: Rect) -> bool {
if rect.w <= 0.0 || rect.h <= 0.0 {
return false;
}
match self {
Self::Unclipped => true,
Self::Clipped(clip) => rect.intersect(clip).is_some(),
Self::Empty => false,
}
}
}
fn visit_redraw_within(
node: &El,
viewport: Rect,
rects: &rustc_hash::FxHashMap<String, Rect>,
inherited_clip: VisibilityClip,
acc: &mut Option<std::time::Duration>,
) {
let rect = rects.get(&node.computed_id).copied();
if let Some(d) = node.redraw_within {
if let Some(rect) = rect
&& rect.w > 0.0
&& rect.h > 0.0
&& rect.intersect(viewport).is_some()
&& inherited_clip.permits(rect)
{
*acc = Some(match *acc {
Some(prev) => prev.min(d),
None => d,
});
}
}
let child_clip = if node.clip {
rect.map(|r| inherited_clip.intersect(r))
.unwrap_or(VisibilityClip::Empty)
} else {
inherited_clip
};
for child in &node.children {
visit_redraw_within(child, viewport, rects, child_clip, acc);
}
}
pub(crate) 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))
}
fn selection_event(
new_sel: crate::selection::Selection,
modifiers: KeyModifiers,
pointer: Option<(f32, f32)>,
) -> UiEvent {
UiEvent {
kind: UiEventKind::SelectionChanged,
key: None,
target: None,
pointer,
key_press: None,
text: None,
selection: Some(new_sel),
modifiers,
click_count: 0,
path: None,
}
}
fn head_for_drag(
root: &El,
ui_state: &UiState,
point: (f32, f32),
) -> Option<crate::selection::SelectionPoint> {
if let Some(p) = hit_test::selection_point_at(root, ui_state, point) {
return Some(p);
}
let order = &ui_state.selection.order;
if order.is_empty() {
return None;
}
let target = order
.iter()
.find(|t| point.1 >= t.rect.y && point.1 < t.rect.y + t.rect.h)
.or_else(|| {
order.iter().min_by(|a, b| {
let da = y_distance(a.rect, point.1);
let db = y_distance(b.rect, point.1);
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
})?;
let target_rect = target.rect;
let cy = point
.1
.clamp(target_rect.y, target_rect.y + target_rect.h - 1.0);
if let Some(p) = hit_test::selection_point_at(root, ui_state, (point.0, cy)) {
return Some(p);
}
let leaf_len = find_text_len(root, &target.node_id).unwrap_or(0);
let byte = if point.0 < target_rect.x { 0 } else { leaf_len };
Some(crate::selection::SelectionPoint {
key: target.key.clone(),
byte,
})
}
fn y_distance(rect: Rect, y: f32) -> f32 {
if y < rect.y {
rect.y - y
} else if y > rect.y + rect.h {
y - (rect.y + rect.h)
} else {
0.0
}
}
fn find_text_len(node: &El, id: &str) -> Option<usize> {
if node.computed_id == id {
if let Some(source) = &node.selection_source {
return Some(source.visible_len());
}
return node.text.as_ref().map(|t| t.len());
}
node.children.iter().find_map(|c| find_text_len(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>,
style: &RunStyle,
text: &str,
size: f32,
line_height: f32,
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,
line_height: 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>,
source: &crate::svg_icon::IconSource,
color: Color,
size: f32,
_stroke_width: f32,
scale_factor: f32,
) -> RecordedPaint {
let glyph = match source {
crate::svg_icon::IconSource::Builtin(name) => name.fallback_glyph(),
crate::svg_icon::IconSource::Custom(_) => "?",
};
RecordedPaint::Text(self.record(
rect,
scissor,
&RunStyle::new(FontWeight::Regular, color),
glyph,
size,
crate::text::metrics::line_height(size),
TextWrap::NoWrap,
TextAnchor::Middle,
scale_factor,
))
}
#[allow(clippy::too_many_arguments)]
fn record_image(
&mut self,
_rect: Rect,
_scissor: Option<PhysicalScissor>,
_image: &crate::image::Image,
_tint: Option<Color>,
_radius: crate::tree::Corners,
_fit: crate::image::ImageFit,
_scale_factor: f32,
) -> Range<usize> {
0..0
}
fn record_app_texture(
&mut self,
_rect: Rect,
_scissor: Option<PhysicalScissor>,
_texture: &crate::surface::AppTexture,
_alpha: crate::surface::SurfaceAlpha,
_transform: crate::affine::Affine2,
_scale_factor: f32,
) -> Range<usize> {
0..0
}
fn record_vector(
&mut self,
_rect: Rect,
_scissor: Option<PhysicalScissor>,
_asset: &crate::vector::VectorAsset,
_render_mode: crate::vector::VectorRenderMode,
_scale_factor: f32,
) -> Range<usize> {
0..0
}
}
#[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>,
_style: &RunStyle,
_text: &str,
_size: f32,
_line_height: f32,
_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,
_line_height: 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]);
}
fn lay_out_link_tree() -> (RunnerCore, Rect, &'static str) {
use crate::tree::*;
const URL: &str = "https://github.com/computer-whisperer/aetna";
let mut tree = crate::column([crate::text_runs([
crate::text("Visit "),
crate::text("github.com/computer-whisperer/aetna").link(URL),
crate::text("."),
])])
.padding(10.0);
let mut core = RunnerCore::new();
crate::layout::layout(
&mut tree,
&mut core.ui_state,
Rect::new(0.0, 0.0, 600.0, 200.0),
);
core.ui_state.sync_focus_order(&tree);
let mut t = PrepareTimings::default();
core.snapshot(&tree, &mut t);
let para = core
.last_tree
.as_ref()
.and_then(|t| t.children.first())
.map(|p| core.ui_state.rect(&p.computed_id))
.expect("paragraph rect");
(core, para, URL)
}
#[test]
fn pointer_up_on_link_emits_link_activated_with_url() {
let (mut core, para, url) = lay_out_link_tree();
let cx = para.x + 100.0;
let cy = para.y + para.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 link = events
.iter()
.find(|e| e.kind == UiEventKind::LinkActivated)
.expect("LinkActivated event");
assert_eq!(link.key.as_deref(), Some(url));
}
#[test]
fn pointer_up_after_drag_off_link_does_not_activate() {
let (mut core, para, _url) = lay_out_link_tree();
let press_x = para.x + 100.0;
let cy = para.y + para.h * 0.5;
core.pointer_moved(press_x, cy);
core.pointer_down(press_x, cy, PointerButton::Primary);
let events = core.pointer_up(press_x, 180.0, PointerButton::Primary);
let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
assert!(
!kinds.contains(&UiEventKind::LinkActivated),
"drag-off-link should cancel the link activation; got {kinds:?}",
);
}
#[test]
fn pointer_moved_over_link_resolves_cursor_to_pointer_and_requests_redraw() {
use crate::cursor::Cursor;
let (mut core, para, _url) = lay_out_link_tree();
let cx = para.x + 100.0;
let cy = para.y + para.h * 0.5;
let initial = core.pointer_moved(para.x - 50.0, cy);
assert!(
!initial.needs_redraw,
"moving in empty space shouldn't request a redraw"
);
let tree = core.last_tree.as_ref().expect("tree").clone();
assert_eq!(
core.ui_state.cursor(&tree),
Cursor::Default,
"no link under pointer → default cursor"
);
let onto = core.pointer_moved(cx, cy);
assert!(
onto.needs_redraw,
"entering a link region should flag a redraw so the cursor refresh isn't stale"
);
assert_eq!(
core.ui_state.cursor(&tree),
Cursor::Pointer,
"pointer over a link → Pointer cursor"
);
let off = core.pointer_moved(para.x - 50.0, cy);
assert!(
off.needs_redraw,
"leaving a link region should flag a redraw"
);
assert_eq!(core.ui_state.cursor(&tree), Cursor::Default);
}
#[test]
fn pointer_up_on_unlinked_text_does_not_emit_link_activated() {
let (mut core, para, _url) = lay_out_link_tree();
let cx = para.x + 1.0;
let cy = para.y + para.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!(
!kinds.contains(&UiEventKind::LinkActivated),
"click on the unlinked prefix should not surface a link event; got {kinds:?}",
);
}
#[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)
.events
.into_iter()
.find(|e| e.kind == UiEventKind::Drag)
.expect("drag while pressed");
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 toast_dismiss_click_removes_toast_and_suppresses_click_event() {
use crate::toast::ToastSpec;
use crate::tree::Size;
let mut core = RunnerCore::new();
core.ui_state
.push_toast(ToastSpec::success("hi"), Instant::now());
let toast_id = core.ui_state.toasts()[0].id;
let mut tree: El = crate::stack(std::iter::empty::<El>())
.width(Size::Fill(1.0))
.height(Size::Fill(1.0));
crate::layout::assign_ids(&mut tree);
let _ = crate::toast::synthesize_toasts(&mut tree, &mut core.ui_state, Instant::now());
crate::layout::layout(
&mut tree,
&mut core.ui_state,
Rect::new(0.0, 0.0, 800.0, 600.0),
);
core.ui_state.sync_focus_order(&tree);
let mut t = PrepareTimings::default();
core.snapshot(&tree, &mut t);
let dismiss_key = format!("toast-dismiss-{toast_id}");
let dismiss_rect = core.rect_of_key(&dismiss_key).expect("dismiss button");
let cx = dismiss_rect.x + dismiss_rect.w * 0.5;
let cy = dismiss_rect.y + dismiss_rect.h * 0.5;
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!(
!kinds.contains(&UiEventKind::Click),
"Click on toast-dismiss should not be surfaced: {kinds:?}",
);
assert!(
core.ui_state.toasts().iter().all(|t| t.id != toast_id),
"toast {toast_id} should be dropped after dismiss-click",
);
}
#[test]
fn pointer_moved_without_press_emits_no_drag() {
let mut core = lay_out_input_tree(false);
let events = core.pointer_moved(50.0, 50.0).events;
assert!(!events.iter().any(|e| e.kind == UiEventKind::Drag));
}
#[test]
fn spinner_in_tree_keeps_needs_redraw_set() {
use crate::widgets::spinner::spinner;
let mut tree = crate::column([spinner()]);
let mut core = RunnerCore::new();
let mut t = PrepareTimings::default();
let LayoutPrepared { needs_redraw, .. } = core.prepare_layout(
&mut tree,
Rect::new(0.0, 0.0, 200.0, 200.0),
1.0,
&mut t,
RunnerCore::no_time_shaders,
);
assert!(
needs_redraw,
"tree with a spinner must request continuous redraw",
);
let mut bare = crate::column([crate::widgets::text::text("idle")]);
let mut core2 = RunnerCore::new();
let mut t2 = PrepareTimings::default();
let LayoutPrepared {
needs_redraw: needs_redraw2,
..
} = core2.prepare_layout(
&mut bare,
Rect::new(0.0, 0.0, 200.0, 200.0),
1.0,
&mut t2,
RunnerCore::no_time_shaders,
);
assert!(
!needs_redraw2,
"tree without time-driven shaders should idle: got needs_redraw={needs_redraw2}",
);
}
#[test]
fn custom_samples_time_shader_keeps_needs_redraw_set() {
let mut tree = crate::column([crate::tree::El::new(crate::tree::Kind::Custom("anim"))
.shader(crate::shader::ShaderBinding::custom("my_animated_glow"))
.width(crate::tree::Size::Fixed(32.0))
.height(crate::tree::Size::Fixed(32.0))]);
let mut core = RunnerCore::new();
let mut t = PrepareTimings::default();
let LayoutPrepared {
needs_redraw: idle, ..
} = core.prepare_layout(
&mut tree,
Rect::new(0.0, 0.0, 200.0, 200.0),
1.0,
&mut t,
RunnerCore::no_time_shaders,
);
assert!(
!idle,
"without a samples_time registration the host should idle",
);
let mut t2 = PrepareTimings::default();
let LayoutPrepared {
needs_redraw: animated,
..
} = core.prepare_layout(
&mut tree,
Rect::new(0.0, 0.0, 200.0, 200.0),
1.0,
&mut t2,
|handle| matches!(handle, ShaderHandle::Custom("my_animated_glow")),
);
assert!(
animated,
"custom shader registered as samples_time=true must request continuous redraw",
);
}
#[test]
fn redraw_within_aggregates_to_minimum_visible_deadline() {
use std::time::Duration;
let mut tree = crate::column([
crate::widgets::text::text("a")
.redraw_within(Duration::from_millis(16))
.width(crate::tree::Size::Fixed(20.0))
.height(crate::tree::Size::Fixed(20.0)),
crate::widgets::text::text("b")
.redraw_within(Duration::from_millis(50))
.width(crate::tree::Size::Fixed(20.0))
.height(crate::tree::Size::Fixed(20.0)),
]);
let mut core = RunnerCore::new();
let mut t = PrepareTimings::default();
let LayoutPrepared {
needs_redraw,
next_layout_redraw_in,
..
} = core.prepare_layout(
&mut tree,
Rect::new(0.0, 0.0, 200.0, 200.0),
1.0,
&mut t,
RunnerCore::no_time_shaders,
);
assert!(needs_redraw, "redraw_within must lift the legacy bool");
assert_eq!(
next_layout_redraw_in,
Some(Duration::from_millis(16)),
"tightest visible deadline wins, on the layout lane",
);
}
#[test]
fn redraw_within_off_screen_widget_is_ignored() {
use std::time::Duration;
let mut tree = crate::column([
crate::tree::spacer().height(crate::tree::Size::Fixed(150.0)),
crate::widgets::text::text("offscreen")
.redraw_within(Duration::from_millis(16))
.width(crate::tree::Size::Fixed(10.0))
.height(crate::tree::Size::Fixed(10.0)),
]);
let mut core = RunnerCore::new();
let mut t = PrepareTimings::default();
let LayoutPrepared {
next_layout_redraw_in,
..
} = core.prepare_layout(
&mut tree,
Rect::new(0.0, 0.0, 100.0, 100.0),
1.0,
&mut t,
RunnerCore::no_time_shaders,
);
assert_eq!(
next_layout_redraw_in, None,
"off-screen redraw_within must not contribute to the aggregate",
);
}
#[test]
fn redraw_within_clipped_out_widget_is_ignored() {
use std::time::Duration;
let clipped = crate::column([crate::widgets::text::text("clipped")
.redraw_within(Duration::from_millis(16))
.width(crate::tree::Size::Fixed(10.0))
.height(crate::tree::Size::Fixed(10.0))])
.clip()
.width(crate::tree::Size::Fixed(100.0))
.height(crate::tree::Size::Fixed(20.0))
.layout(|ctx| {
vec![Rect::new(
ctx.container.x,
ctx.container.y + 30.0,
10.0,
10.0,
)]
});
let mut tree = crate::column([clipped]);
let mut core = RunnerCore::new();
let mut t = PrepareTimings::default();
let LayoutPrepared {
next_layout_redraw_in,
..
} = core.prepare_layout(
&mut tree,
Rect::new(0.0, 0.0, 100.0, 100.0),
1.0,
&mut t,
RunnerCore::no_time_shaders,
);
assert_eq!(
next_layout_redraw_in, None,
"redraw_within inside an inherited clip but outside the clip rect must not contribute",
);
}
#[test]
fn pointer_moved_within_same_hovered_node_does_not_request_redraw() {
let mut core = lay_out_input_tree(false);
let btn = core.rect_of_key("btn").expect("btn rect");
let (cx, cy) = (btn.x + btn.w * 0.5, btn.y + btn.h * 0.5);
let first = core.pointer_moved(cx, cy);
assert_eq!(first.events.len(), 1);
assert_eq!(first.events[0].kind, UiEventKind::PointerEnter);
assert_eq!(first.events[0].key.as_deref(), Some("btn"));
assert!(
first.needs_redraw,
"entering a focusable should warrant a redraw",
);
let second = core.pointer_moved(cx + 1.0, cy);
assert!(second.events.is_empty());
assert!(
!second.needs_redraw,
"identical hover, no drag → host should idle",
);
let off = core.pointer_moved(0.0, 0.0);
assert_eq!(off.events.len(), 1);
assert_eq!(off.events[0].kind, UiEventKind::PointerLeave);
assert_eq!(off.events[0].key.as_deref(), Some("btn"));
assert!(
off.needs_redraw,
"leaving a hovered node still warrants a redraw",
);
}
#[test]
fn pointer_moved_between_keyed_targets_emits_leave_then_enter() {
let mut core = lay_out_input_tree(false);
let btn = core.rect_of_key("btn").expect("btn rect");
let ti = core.rect_of_key("ti").expect("ti rect");
let _ = core.pointer_moved(btn.x + 4.0, btn.y + 4.0);
let cross = core.pointer_moved(ti.x + 4.0, ti.y + 4.0);
let kinds: Vec<UiEventKind> = cross.events.iter().map(|e| e.kind).collect();
assert_eq!(
kinds,
vec![UiEventKind::PointerLeave, UiEventKind::PointerEnter],
"paired Leave-then-Enter on cross-target hover transition",
);
assert_eq!(cross.events[0].key.as_deref(), Some("btn"));
assert_eq!(cross.events[1].key.as_deref(), Some("ti"));
assert!(cross.needs_redraw);
}
#[test]
fn pointer_left_emits_leave_for_prior_hover() {
let mut core = lay_out_input_tree(false);
let btn = core.rect_of_key("btn").expect("btn rect");
let _ = core.pointer_moved(btn.x + 4.0, btn.y + 4.0);
let events = core.pointer_left();
assert_eq!(events.len(), 1);
assert_eq!(events[0].kind, UiEventKind::PointerLeave);
assert_eq!(events[0].key.as_deref(), Some("btn"));
}
#[test]
fn pointer_left_with_no_prior_hover_emits_nothing() {
let mut core = lay_out_input_tree(false);
let events = core.pointer_left();
assert!(events.is_empty());
}
#[test]
fn ui_state_hovered_key_returns_leaf_key() {
let mut core = lay_out_input_tree(false);
assert_eq!(core.ui_state().hovered_key(), None);
let btn = core.rect_of_key("btn").expect("btn rect");
core.pointer_moved(btn.x + 4.0, btn.y + 4.0);
assert_eq!(core.ui_state().hovered_key(), Some("btn"));
core.pointer_moved(0.0, 0.0);
assert_eq!(core.ui_state().hovered_key(), None);
}
#[test]
fn ui_state_is_hovering_within_walks_subtree() {
use crate::tree::*;
let mut tree = crate::column([crate::stack([
crate::widgets::button::button("Inner").key("inner_btn")
])
.key("card")
.focusable()
.width(Size::Fixed(120.0))
.height(Size::Fixed(60.0))])
.padding(20.0);
let mut core = RunnerCore::new();
crate::layout::layout(
&mut tree,
&mut core.ui_state,
Rect::new(0.0, 0.0, 400.0, 200.0),
);
core.ui_state.sync_focus_order(&tree);
let mut t = PrepareTimings::default();
core.snapshot(&tree, &mut t);
assert!(!core.ui_state().is_hovering_within("card"));
assert!(!core.ui_state().is_hovering_within("inner_btn"));
let inner = core.rect_of_key("inner_btn").expect("inner rect");
core.pointer_moved(inner.x + 4.0, inner.y + 4.0);
assert!(core.ui_state().is_hovering_within("card"));
assert!(core.ui_state().is_hovering_within("inner_btn"));
assert!(!core.ui_state().is_hovering_within("not_a_key"));
core.pointer_moved(0.0, 0.0);
assert!(!core.ui_state().is_hovering_within("card"));
assert!(!core.ui_state().is_hovering_within("inner_btn"));
}
#[test]
fn hover_driven_scale_via_is_hovering_within_plus_animate() {
use crate::Theme;
use crate::anim::Timing;
use crate::tree::*;
let build_card = |hovering: bool| -> El {
let scale = if hovering { 1.05 } else { 1.0 };
crate::column([crate::stack(
[crate::widgets::button::button("Inner").key("inner_btn")],
)
.key("card")
.focusable()
.scale(scale)
.animate(Timing::SPRING_QUICK)
.width(Size::Fixed(120.0))
.height(Size::Fixed(60.0))])
.padding(20.0)
};
let mut core = RunnerCore::new();
core.ui_state
.set_animation_mode(crate::state::AnimationMode::Settled);
let theme = Theme::default();
let cx_pre = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
assert!(!cx_pre.is_hovering_within("card"));
let mut tree = build_card(cx_pre.is_hovering_within("card"));
crate::layout::layout(
&mut tree,
&mut core.ui_state,
Rect::new(0.0, 0.0, 400.0, 200.0),
);
core.ui_state.sync_focus_order(&tree);
let mut t = PrepareTimings::default();
core.snapshot(&tree, &mut t);
core.ui_state
.tick_visual_animations(&mut tree, web_time::Instant::now());
let card_at_rest = tree.children[0].clone();
assert!((card_at_rest.scale - 1.0).abs() < 1e-3);
let card_rect = core.rect_of_key("card").expect("card rect");
core.pointer_moved(card_rect.x + 4.0, card_rect.y + 4.0);
let cx_hot = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
assert!(cx_hot.is_hovering_within("card"));
let mut tree = build_card(cx_hot.is_hovering_within("card"));
crate::layout::layout(
&mut tree,
&mut core.ui_state,
Rect::new(0.0, 0.0, 400.0, 200.0),
);
core.ui_state.sync_focus_order(&tree);
core.snapshot(&tree, &mut t);
core.ui_state
.tick_visual_animations(&mut tree, web_time::Instant::now());
let card_hot = tree.children[0].clone();
assert!(
(card_hot.scale - 1.05).abs() < 1e-3,
"hover should drive card scale to 1.05 via animate; got {}",
card_hot.scale,
);
core.pointer_moved(0.0, 0.0);
let cx_cold = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
assert!(!cx_cold.is_hovering_within("card"));
let mut tree = build_card(cx_cold.is_hovering_within("card"));
crate::layout::layout(
&mut tree,
&mut core.ui_state,
Rect::new(0.0, 0.0, 400.0, 200.0),
);
core.ui_state.sync_focus_order(&tree);
core.snapshot(&tree, &mut t);
core.ui_state
.tick_visual_animations(&mut tree, web_time::Instant::now());
let card_after = tree.children[0].clone();
assert!((card_after.scale - 1.0).abs() < 1e-3);
}
#[test]
fn file_dropped_routes_to_keyed_leaf_at_pointer() {
let mut core = lay_out_input_tree(false);
let btn = core.rect_of_key("btn").expect("btn rect");
let path = std::path::PathBuf::from("/tmp/screenshot.png");
let events = core.file_dropped(path.clone(), btn.x + 4.0, btn.y + 4.0);
assert_eq!(events.len(), 1);
let event = &events[0];
assert_eq!(event.kind, UiEventKind::FileDropped);
assert_eq!(event.key.as_deref(), Some("btn"));
assert_eq!(event.path.as_deref(), Some(path.as_path()));
assert_eq!(event.pointer, Some((btn.x + 4.0, btn.y + 4.0)));
}
#[test]
fn file_dropped_outside_keyed_surface_emits_window_level_event() {
let mut core = lay_out_input_tree(false);
let path = std::path::PathBuf::from("/tmp/screenshot.png");
let events = core.file_dropped(path.clone(), 1.0, 1.0);
assert_eq!(events.len(), 1);
let event = &events[0];
assert_eq!(event.kind, UiEventKind::FileDropped);
assert!(
event.target.is_none(),
"drop outside any keyed surface routes window-level",
);
assert!(event.key.is_none());
assert_eq!(event.path.as_deref(), Some(path.as_path()));
}
#[test]
fn file_hovered_then_cancelled_pair() {
let mut core = lay_out_input_tree(false);
let btn = core.rect_of_key("btn").expect("btn rect");
let path = std::path::PathBuf::from("/tmp/a.png");
let hover = core.file_hovered(path.clone(), btn.x + 4.0, btn.y + 4.0);
assert_eq!(hover.len(), 1);
assert_eq!(hover[0].kind, UiEventKind::FileHovered);
assert_eq!(hover[0].key.as_deref(), Some("btn"));
assert_eq!(hover[0].path.as_deref(), Some(path.as_path()));
let cancel = core.file_hover_cancelled();
assert_eq!(cancel.len(), 1);
assert_eq!(cancel[0].kind, UiEventKind::FileHoverCancelled);
assert!(cancel[0].target.is_none());
assert!(cancel[0].path.is_none());
}
#[test]
fn build_cx_hover_accessors_default_off_without_state() {
use crate::Theme;
let theme = Theme::default();
let cx = crate::BuildCx::new(&theme);
assert_eq!(cx.hovered_key(), None);
assert!(!cx.is_hovering_within("anything"));
}
#[test]
fn build_cx_hover_accessors_delegate_when_state_attached() {
use crate::Theme;
let mut core = lay_out_input_tree(false);
let btn = core.rect_of_key("btn").expect("btn rect");
core.pointer_moved(btn.x + 4.0, btn.y + 4.0);
let theme = Theme::default();
let cx = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
assert_eq!(cx.hovered_key(), Some("btn"));
assert!(cx.is_hovering_within("btn"));
assert!(!cx.is_hovering_within("ti"));
}
fn lay_out_paragraph_tree() -> RunnerCore {
use crate::tree::*;
let mut tree = crate::column([
crate::widgets::text::text("First paragraph of text.")
.key("p1")
.selectable(),
crate::widgets::text::text("Second paragraph of text.")
.key("p2")
.selectable(),
])
.padding(20.0);
let mut core = RunnerCore::new();
crate::layout::layout(
&mut tree,
&mut core.ui_state,
Rect::new(0.0, 0.0, 400.0, 300.0),
);
core.ui_state.sync_focus_order(&tree);
core.ui_state.sync_selection_order(&tree);
let mut t = PrepareTimings::default();
core.snapshot(&tree, &mut t);
core
}
#[test]
fn pointer_down_on_selectable_text_emits_selection_changed() {
let mut core = lay_out_paragraph_tree();
let p1 = core.rect_of_key("p1").expect("p1 rect");
let cx = p1.x + 4.0;
let cy = p1.y + p1.h * 0.5;
let events = core.pointer_down(cx, cy, PointerButton::Primary);
let sel_event = events
.iter()
.find(|e| e.kind == UiEventKind::SelectionChanged)
.expect("SelectionChanged emitted");
let new_sel = sel_event
.selection
.as_ref()
.expect("SelectionChanged carries a selection");
let range = new_sel.range.as_ref().expect("collapsed selection at hit");
assert_eq!(range.anchor.key, "p1");
assert_eq!(range.head.key, "p1");
assert_eq!(range.anchor.byte, range.head.byte);
assert!(core.ui_state.selection.drag.is_some());
}
#[test]
fn pointer_drag_on_selectable_text_extends_head() {
let mut core = lay_out_paragraph_tree();
let p1 = core.rect_of_key("p1").expect("p1 rect");
let cx = p1.x + 4.0;
let cy = p1.y + p1.h * 0.5;
core.pointer_moved(cx, cy);
core.pointer_down(cx, cy, PointerButton::Primary);
let events = core.pointer_moved(p1.x + p1.w - 10.0, cy).events;
let sel_event = events
.iter()
.find(|e| e.kind == UiEventKind::SelectionChanged)
.expect("Drag emits SelectionChanged");
let new_sel = sel_event.selection.as_ref().unwrap();
let range = new_sel.range.as_ref().unwrap();
assert_eq!(range.anchor.key, "p1");
assert_eq!(range.head.key, "p1");
assert!(
range.head.byte > range.anchor.byte,
"head should advance past anchor (anchor={}, head={})",
range.anchor.byte,
range.head.byte
);
}
#[test]
fn pointer_up_clears_drag_but_keeps_selection() {
let mut core = lay_out_paragraph_tree();
let p1 = core.rect_of_key("p1").expect("p1 rect");
let cx = p1.x + 4.0;
let cy = p1.y + p1.h * 0.5;
core.pointer_down(cx, cy, PointerButton::Primary);
core.pointer_moved(p1.x + p1.w - 10.0, cy);
let _ = core.pointer_up(p1.x + p1.w - 10.0, cy, PointerButton::Primary);
assert!(
core.ui_state.selection.drag.is_none(),
"drag flag should clear on pointer_up"
);
assert!(
!core.ui_state.current_selection.is_empty(),
"selection itself should persist after pointer_up"
);
}
#[test]
fn drag_past_a_leaf_bottom_keeps_head_in_that_leaf_not_anchor() {
let mut core = lay_out_paragraph_tree();
let p1 = core.rect_of_key("p1").expect("p1 rect");
let p2 = core.rect_of_key("p2").expect("p2 rect");
core.pointer_down(p1.x + 4.0, p1.y + p1.h * 0.5, PointerButton::Primary);
core.pointer_moved(p2.x + 8.0, p2.y + p2.h * 0.5);
let events = core.pointer_moved(p2.x + 8.0, p2.y + p2.h + 200.0).events;
let sel = events
.iter()
.find(|e| e.kind == UiEventKind::SelectionChanged)
.map(|e| e.selection.as_ref().unwrap().clone())
.unwrap_or_else(|| core.ui_state.current_selection.clone());
let r = sel.range.as_ref().expect("selection still active");
assert_eq!(r.anchor.key, "p1", "anchor unchanged");
assert_eq!(
r.head.key, "p2",
"head must stay in p2 even when pointer is below p2's rect"
);
}
#[test]
fn drag_into_a_sibling_selectable_extends_head_into_that_leaf() {
let mut core = lay_out_paragraph_tree();
let p1 = core.rect_of_key("p1").expect("p1 rect");
let p2 = core.rect_of_key("p2").expect("p2 rect");
core.pointer_down(p1.x + 4.0, p1.y + p1.h * 0.5, PointerButton::Primary);
let events = core.pointer_moved(p2.x + 8.0, p2.y + p2.h * 0.5).events;
let sel_event = events
.iter()
.find(|e| e.kind == UiEventKind::SelectionChanged)
.expect("Drag emits SelectionChanged");
let new_sel = sel_event.selection.as_ref().unwrap();
let range = new_sel.range.as_ref().unwrap();
assert_eq!(range.anchor.key, "p1", "anchor stays in p1");
assert_eq!(range.head.key, "p2", "head migrates into p2");
}
#[test]
fn pointer_down_on_focusable_owning_selection_does_not_clear_it() {
let mut core = lay_out_input_tree(true);
core.set_selection(crate::selection::Selection::caret("ti", 3));
let ti = core.rect_of_key("ti").expect("ti rect");
let cx = ti.x + ti.w * 0.5;
let cy = ti.y + ti.h * 0.5;
let events = core.pointer_down(cx, cy, PointerButton::Primary);
let cleared = events.iter().find(|e| {
e.kind == UiEventKind::SelectionChanged
&& e.selection.as_ref().map(|s| s.is_empty()).unwrap_or(false)
});
assert!(
cleared.is_none(),
"click on the selection-owning input must not emit a clearing SelectionChanged"
);
assert_eq!(
core.ui_state.current_selection,
crate::selection::Selection::caret("ti", 3),
"runtime mirror is preserved when the click owns the selection"
);
}
#[test]
fn pointer_down_into_a_different_capture_keys_widget_does_not_clear_first() {
let mut core = lay_out_input_tree(true);
core.set_selection(crate::selection::Selection::caret("other", 4));
let ti = core.rect_of_key("ti").expect("ti rect");
let cx = ti.x + ti.w * 0.5;
let cy = ti.y + ti.h * 0.5;
let events = core.pointer_down(cx, cy, PointerButton::Primary);
let cleared = events.iter().any(|e| {
e.kind == UiEventKind::SelectionChanged
&& e.selection.as_ref().map(|s| s.is_empty()).unwrap_or(false)
});
assert!(
!cleared,
"click on a different capture_keys widget must not race-clear the selection"
);
}
#[test]
fn pointer_down_on_non_selectable_clears_existing_selection() {
let mut core = lay_out_paragraph_tree();
let p1 = core.rect_of_key("p1").expect("p1 rect");
let cy = p1.y + p1.h * 0.5;
core.pointer_down(p1.x + 4.0, cy, PointerButton::Primary);
core.pointer_up(p1.x + 4.0, cy, PointerButton::Primary);
assert!(!core.ui_state.current_selection.is_empty());
let events = core.pointer_down(2.0, 2.0, PointerButton::Primary);
let cleared = events
.iter()
.find(|e| e.kind == UiEventKind::SelectionChanged)
.expect("clearing emits SelectionChanged");
let new_sel = cleared.selection.as_ref().unwrap();
assert!(new_sel.is_empty(), "new selection should be empty");
assert!(core.ui_state.current_selection.is_empty());
}
#[test]
fn key_down_bumps_caret_activity_when_focused_widget_captures_keys() {
let mut core = lay_out_input_tree(true);
let target = core
.ui_state
.focus
.order
.iter()
.find(|t| t.key == "ti")
.cloned();
core.ui_state.set_focus(target); let after_focus = core.ui_state.caret.activity_at.expect("focus bump");
std::thread::sleep(std::time::Duration::from_millis(2));
let _ = core.key_down(UiKey::ArrowRight, KeyModifiers::default(), false);
let after_arrow = core
.ui_state
.caret
.activity_at
.expect("arrow key bumps even without app-side selection");
assert!(
after_arrow > after_focus,
"ArrowRight to a capture_keys focused widget bumps caret activity"
);
}
#[test]
fn text_input_bumps_caret_activity_when_focused() {
let mut core = lay_out_input_tree(true);
let target = core
.ui_state
.focus
.order
.iter()
.find(|t| t.key == "ti")
.cloned();
core.ui_state.set_focus(target);
let after_focus = core.ui_state.caret.activity_at.unwrap();
std::thread::sleep(std::time::Duration::from_millis(2));
let _ = core.text_input("a".into());
let after_text = core.ui_state.caret.activity_at.unwrap();
assert!(
after_text > after_focus,
"TextInput to focused widget bumps caret activity"
);
}
#[test]
fn pointer_down_inside_focused_input_bumps_caret_activity() {
let mut core = lay_out_input_tree(true);
let ti = core.rect_of_key("ti").expect("ti rect");
let cx = ti.x + ti.w * 0.5;
let cy = ti.y + ti.h * 0.5;
core.pointer_down(cx, cy, PointerButton::Primary);
let _ = core.pointer_up(cx, cy, PointerButton::Primary);
let after_first = core.ui_state.caret.activity_at.unwrap();
std::thread::sleep(std::time::Duration::from_millis(2));
core.pointer_down(cx + 1.0, cy, PointerButton::Primary);
let after_second = core
.ui_state
.caret
.activity_at
.expect("second click bumps too");
assert!(
after_second > after_first,
"click within already-focused capture_keys widget still bumps"
);
}
#[test]
fn arrow_key_through_apply_event_mutates_selection_and_bumps_on_set() {
use crate::widgets::text_input;
let mut sel = crate::selection::Selection::caret("ti", 2);
let mut value = String::from("hello");
let mut core = RunnerCore::new();
core.set_selection(sel.clone());
let baseline = core.ui_state.caret.activity_at;
let arrow_right = UiEvent {
key: Some("ti".into()),
target: None,
pointer: None,
key_press: Some(crate::event::KeyPress {
key: UiKey::ArrowRight,
modifiers: KeyModifiers::default(),
repeat: false,
}),
text: None,
selection: None,
modifiers: KeyModifiers::default(),
click_count: 0,
path: None,
kind: UiEventKind::KeyDown,
};
let mutated = text_input::apply_event(&mut value, &mut sel, "ti", &arrow_right);
assert!(mutated, "ArrowRight should mutate selection");
assert_eq!(
sel.within("ti").unwrap().head,
3,
"head moved one char right (h-e-l-l-o, byte 2 → 3)"
);
std::thread::sleep(std::time::Duration::from_millis(2));
core.set_selection(sel);
let after = core.ui_state.caret.activity_at.unwrap();
if let Some(b) = baseline {
assert!(after > b, "arrow-key flow should bump activity");
}
}
#[test]
fn set_selection_bumps_caret_activity_only_when_value_changes() {
let mut core = lay_out_paragraph_tree();
core.set_selection(crate::selection::Selection::default());
assert!(
core.ui_state.caret.activity_at.is_none(),
"no-op set_selection should not bump activity"
);
let sel_a = crate::selection::Selection::caret("p1", 3);
core.set_selection(sel_a.clone());
let bumped_at = core
.ui_state
.caret
.activity_at
.expect("first real selection bumps");
core.set_selection(sel_a.clone());
assert_eq!(
core.ui_state.caret.activity_at,
Some(bumped_at),
"set_selection with same value is a no-op"
);
std::thread::sleep(std::time::Duration::from_millis(2));
let sel_b = crate::selection::Selection::caret("p1", 7);
core.set_selection(sel_b);
let new_bump = core.ui_state.caret.activity_at.expect("second bump");
assert!(
new_bump > bumped_at,
"moving the caret bumps activity again",
);
}
#[test]
fn escape_clears_active_selection_and_emits_selection_changed() {
let mut core = lay_out_paragraph_tree();
let p1 = core.rect_of_key("p1").expect("p1 rect");
let cy = p1.y + p1.h * 0.5;
core.pointer_down(p1.x + 4.0, cy, PointerButton::Primary);
core.pointer_moved(p1.x + p1.w - 10.0, cy);
core.pointer_up(p1.x + p1.w - 10.0, cy, PointerButton::Primary);
assert!(!core.ui_state.current_selection.is_empty());
let events = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
assert_eq!(
kinds,
vec![UiEventKind::Escape, UiEventKind::SelectionChanged],
"Esc emits Escape (for popover dismiss) AND SelectionChanged"
);
let cleared = events
.iter()
.find(|e| e.kind == UiEventKind::SelectionChanged)
.unwrap();
assert!(cleared.selection.as_ref().unwrap().is_empty());
assert!(core.ui_state.current_selection.is_empty());
}
#[test]
fn consecutive_clicks_on_same_target_extend_count() {
let mut core = lay_out_input_tree(false);
let btn = core.rect_of_key("btn").expect("btn rect");
let cx = btn.x + btn.w * 0.5;
let cy = btn.y + btn.h * 0.5;
let down1 = core.pointer_down(cx, cy, PointerButton::Primary);
let pd1 = down1
.iter()
.find(|e| e.kind == UiEventKind::PointerDown)
.expect("PointerDown emitted");
assert_eq!(pd1.click_count, 1, "first press starts the sequence");
let up1 = core.pointer_up(cx, cy, PointerButton::Primary);
let click1 = up1
.iter()
.find(|e| e.kind == UiEventKind::Click)
.expect("Click emitted");
assert_eq!(
click1.click_count, 1,
"Click carries the same count as its PointerDown"
);
let down2 = core.pointer_down(cx, cy, PointerButton::Primary);
let pd2 = down2
.iter()
.find(|e| e.kind == UiEventKind::PointerDown)
.unwrap();
assert_eq!(pd2.click_count, 2, "second press extends the sequence");
let up2 = core.pointer_up(cx, cy, PointerButton::Primary);
assert_eq!(
up2.iter()
.find(|e| e.kind == UiEventKind::Click)
.unwrap()
.click_count,
2
);
let down3 = core.pointer_down(cx, cy, PointerButton::Primary);
let pd3 = down3
.iter()
.find(|e| e.kind == UiEventKind::PointerDown)
.unwrap();
assert_eq!(pd3.click_count, 3, "third press → triple-click");
core.pointer_up(cx, cy, PointerButton::Primary);
}
#[test]
fn click_count_resets_when_target_changes() {
let mut core = lay_out_input_tree(false);
let btn = core.rect_of_key("btn").expect("btn rect");
let ti = core.rect_of_key("ti").expect("ti rect");
let down1 = core.pointer_down(
btn.x + btn.w * 0.5,
btn.y + btn.h * 0.5,
PointerButton::Primary,
);
assert_eq!(
down1
.iter()
.find(|e| e.kind == UiEventKind::PointerDown)
.unwrap()
.click_count,
1
);
let _ = core.pointer_up(
btn.x + btn.w * 0.5,
btn.y + btn.h * 0.5,
PointerButton::Primary,
);
let down2 = core.pointer_down(ti.x + ti.w * 0.5, ti.y + ti.h * 0.5, PointerButton::Primary);
let pd2 = down2
.iter()
.find(|e| e.kind == UiEventKind::PointerDown)
.unwrap();
assert_eq!(
pd2.click_count, 1,
"press on a new target resets the multi-click sequence"
);
}
#[test]
fn double_click_on_selectable_text_selects_word_at_hit() {
let mut core = lay_out_paragraph_tree();
let p1 = core.rect_of_key("p1").expect("p1 rect");
let cy = p1.y + p1.h * 0.5;
let cx = p1.x + 4.0;
core.pointer_down(cx, cy, PointerButton::Primary);
core.pointer_up(cx, cy, PointerButton::Primary);
core.pointer_down(cx, cy, PointerButton::Primary);
let sel = &core.ui_state.current_selection;
let r = sel.range.as_ref().expect("selection set");
assert_eq!(r.anchor.key, "p1");
assert_eq!(r.head.key, "p1");
assert_eq!(r.anchor.byte.min(r.head.byte), 0);
assert_eq!(r.anchor.byte.max(r.head.byte), 5);
}
#[test]
fn triple_click_on_selectable_text_selects_whole_leaf() {
let mut core = lay_out_paragraph_tree();
let p1 = core.rect_of_key("p1").expect("p1 rect");
let cy = p1.y + p1.h * 0.5;
let cx = p1.x + 4.0;
core.pointer_down(cx, cy, PointerButton::Primary);
core.pointer_up(cx, cy, PointerButton::Primary);
core.pointer_down(cx, cy, PointerButton::Primary);
core.pointer_up(cx, cy, PointerButton::Primary);
core.pointer_down(cx, cy, PointerButton::Primary);
let sel = &core.ui_state.current_selection;
let r = sel.range.as_ref().expect("selection set");
assert_eq!(r.anchor.byte, 0);
assert_eq!(r.head.byte, 24);
}
#[test]
fn click_count_resets_when_press_drifts_outside_distance_window() {
let mut core = lay_out_input_tree(false);
let btn = core.rect_of_key("btn").expect("btn rect");
let cx = btn.x + btn.w * 0.5;
let cy = btn.y + btn.h * 0.5;
let _ = core.pointer_down(cx, cy, PointerButton::Primary);
let _ = core.pointer_up(cx, cy, PointerButton::Primary);
let down2 = core.pointer_down(cx + 10.0, cy, PointerButton::Primary);
let pd2 = down2
.iter()
.find(|e| e.kind == UiEventKind::PointerDown)
.unwrap();
assert_eq!(pd2.click_count, 1);
}
#[test]
fn escape_with_no_selection_emits_only_escape() {
let mut core = lay_out_paragraph_tree();
assert!(core.ui_state.current_selection.is_empty());
let events = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
assert_eq!(
kinds,
vec![UiEventKind::Escape],
"no selection → no SelectionChanged side-effect"
);
}
fn lay_out_scroll_tree() -> (RunnerCore, String) {
use crate::tree::*;
let mut tree = crate::scroll(
(0..6)
.map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
)
.gap(12.0)
.height(Size::Fixed(200.0));
let mut core = RunnerCore::new();
crate::layout::layout(
&mut tree,
&mut core.ui_state,
Rect::new(0.0, 0.0, 300.0, 200.0),
);
let scroll_id = tree.computed_id.clone();
let mut t = PrepareTimings::default();
core.snapshot(&tree, &mut t);
(core, scroll_id)
}
#[test]
fn thumb_pointer_down_captures_drag_and_suppresses_events() {
let (mut core, scroll_id) = lay_out_scroll_tree();
let thumb = core
.ui_state
.scroll
.thumb_rects
.get(&scroll_id)
.copied()
.expect("scrollable should have a thumb");
let event = core.pointer_down(
thumb.x + thumb.w * 0.5,
thumb.y + thumb.h * 0.5,
PointerButton::Primary,
);
assert!(
event.is_empty(),
"thumb press should not emit PointerDown to the app"
);
let drag = core
.ui_state
.scroll
.thumb_drag
.as_ref()
.expect("scroll.thumb_drag should be set after pointer_down on thumb");
assert_eq!(drag.scroll_id, scroll_id);
}
#[test]
fn track_click_above_thumb_pages_up_below_pages_down() {
let (mut core, scroll_id) = lay_out_scroll_tree();
let track = core
.ui_state
.scroll
.thumb_tracks
.get(&scroll_id)
.copied()
.expect("scrollable should have a track");
let thumb = core
.ui_state
.scroll
.thumb_rects
.get(&scroll_id)
.copied()
.unwrap();
let metrics = core
.ui_state
.scroll
.metrics
.get(&scroll_id)
.copied()
.unwrap();
let evt = core.pointer_down(
track.x + track.w * 0.5,
thumb.y + thumb.h + 10.0,
PointerButton::Primary,
);
assert!(evt.is_empty(), "track press should not surface PointerDown");
assert!(
core.ui_state.scroll.thumb_drag.is_none(),
"track click outside the thumb should not start a drag",
);
let after_down = core.ui_state.scroll_offset(&scroll_id);
let expected_page = (metrics.viewport_h - SCROLL_PAGE_OVERLAP).max(0.0);
assert!(
(after_down - expected_page.min(metrics.max_offset)).abs() < 0.5,
"page-down offset = {after_down} (expected ~{expected_page})",
);
let _ = core.pointer_up(0.0, 0.0, PointerButton::Primary);
let mut tree = lay_out_scroll_tree_only();
crate::layout::layout(
&mut tree,
&mut core.ui_state,
Rect::new(0.0, 0.0, 300.0, 200.0),
);
let mut t = PrepareTimings::default();
core.snapshot(&tree, &mut t);
let track = core
.ui_state
.scroll
.thumb_tracks
.get(&tree.computed_id)
.copied()
.unwrap();
let thumb = core
.ui_state
.scroll
.thumb_rects
.get(&tree.computed_id)
.copied()
.unwrap();
core.pointer_down(
track.x + track.w * 0.5,
thumb.y - 4.0,
PointerButton::Primary,
);
let after_up = core.ui_state.scroll_offset(&tree.computed_id);
assert!(
after_up < after_down,
"page-up should reduce offset: before={after_down} after={after_up}",
);
}
fn lay_out_scroll_tree_only() -> El {
use crate::tree::*;
crate::scroll(
(0..6)
.map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
)
.gap(12.0)
.height(Size::Fixed(200.0))
}
#[test]
fn thumb_drag_translates_pointer_delta_into_scroll_offset() {
let (mut core, scroll_id) = lay_out_scroll_tree();
let thumb = core
.ui_state
.scroll
.thumb_rects
.get(&scroll_id)
.copied()
.unwrap();
let metrics = core
.ui_state
.scroll
.metrics
.get(&scroll_id)
.copied()
.unwrap();
let track_remaining = (metrics.viewport_h - thumb.h).max(0.0);
let press_y = thumb.y + thumb.h * 0.5;
core.pointer_down(thumb.x + thumb.w * 0.5, press_y, PointerButton::Primary);
let evt = core.pointer_moved(thumb.x + thumb.w * 0.5, press_y + 20.0);
assert!(
evt.events.is_empty(),
"thumb-drag move should suppress Drag event",
);
let offset = core.ui_state.scroll_offset(&scroll_id);
let expected = 20.0 * (metrics.max_offset / track_remaining);
assert!(
(offset - expected).abs() < 0.5,
"offset {offset} (expected {expected})",
);
core.pointer_moved(thumb.x + thumb.w * 0.5, press_y + 9999.0);
let offset = core.ui_state.scroll_offset(&scroll_id);
assert!(
(offset - metrics.max_offset).abs() < 0.5,
"overshoot offset {offset} (expected {})",
metrics.max_offset
);
let events = core.pointer_up(thumb.x, press_y, PointerButton::Primary);
assert!(events.is_empty(), "thumb release shouldn't emit events");
assert!(core.ui_state.scroll.thumb_drag.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 events = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
assert_eq!(events.len(), 1, "Tab → exactly one KeyDown");
let event = &events[0];
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 pointer_down_focus_does_not_raise_focus_visible() {
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);
assert_eq!(
core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
Some("btn"),
"primary click focuses the button",
);
assert!(
!core.ui_state.focus_visible,
"click focus must not raise focus_visible — ring stays off",
);
}
#[test]
fn tab_key_raises_focus_visible_so_ring_appears() {
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);
assert!(!core.ui_state.focus_visible);
let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
assert!(
core.ui_state.focus_visible,
"Tab must raise focus_visible so the ring paints on the new target",
);
}
#[test]
fn click_after_tab_clears_focus_visible_again() {
let mut core = lay_out_input_tree(false);
let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
assert!(core.ui_state.focus_visible, "Tab raises ring");
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);
assert!(
!core.ui_state.focus_visible,
"pointer-down clears focus_visible — ring fades back out",
);
}
#[test]
fn keypress_on_focused_widget_raises_focus_visible_after_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_down(cx, cy, PointerButton::Primary);
assert!(!core.ui_state.focus_visible);
let _ = core.key_down(UiKey::ArrowRight, KeyModifiers::default(), false);
assert!(
core.ui_state.focus_visible,
"non-Tab key on focused widget raises focus_visible",
);
}
#[test]
fn arrow_nav_in_sibling_group_raises_focus_visible() {
let mut core = lay_out_arrow_nav_tree();
core.ui_state.set_focus_visible(false);
let _ = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
assert!(
core.ui_state.focus_visible,
"arrow-nav within an arrow_nav_siblings group is keyboard navigation",
);
}
#[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_empty(), "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,
RunnerCore::no_time_shaders,
);
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.popover_focus.focus_stack.len(),
1,
"trigger should be saved on the focus stack",
);
assert_eq!(
core.ui_state.popover_focus.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.popover_focus.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.popover_focus.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.popover_focus.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.popover_focus.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.popover_focus.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.popover_focus.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.popover_focus.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 events = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
assert_eq!(
events.len(),
1,
"ArrowDown without navigable parent → event"
);
assert_eq!(events[0].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::Image(_) => "M",
PaintItem::AppTexture(_) => "A",
PaintItem::Vector(_) => "V",
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");
}
}