1use std::ops::Range;
51use std::time::Duration;
52
53use web_time::Instant;
54
55use crate::draw_ops;
56use crate::event::{KeyChord, KeyModifiers, PointerButton, UiEvent, UiEventKind, UiKey, UiTarget};
57use crate::focus;
58use crate::hit_test;
59use crate::ir::{DrawOp, TextAnchor};
60use crate::layout;
61use crate::paint::{
62 InstanceRun, PaintItem, PhysicalScissor, QuadInstance, close_run, pack_instance,
63 physical_scissor,
64};
65use crate::shader::ShaderHandle;
66use crate::state::{AnimationMode, UiState};
67use crate::text::atlas::RunStyle;
68use crate::theme::Theme;
69use crate::tooltip;
70use crate::tree::{Color, El, FontWeight, Rect, TextWrap};
71
72#[derive(Clone, Copy, Debug, Default)]
78pub struct PrepareResult {
79 pub needs_redraw: bool,
80 pub timings: PrepareTimings,
81}
82
83#[derive(Clone, Copy, Debug, Default)]
94pub struct PrepareTimings {
95 pub layout: Duration,
96 pub draw_ops: Duration,
97 pub paint: Duration,
98 pub gpu_upload: Duration,
99 pub snapshot: Duration,
100}
101
102pub struct RunnerCore {
110 pub ui_state: UiState,
111 pub last_tree: Option<El>,
115
116 pub quad_scratch: Vec<QuadInstance>,
119 pub runs: Vec<InstanceRun>,
120 pub paint_items: Vec<PaintItem>,
121
122 pub viewport_px: (u32, u32),
126 pub surface_size_override: Option<(u32, u32)>,
132
133 pub theme: Theme,
135}
136
137impl Default for RunnerCore {
138 fn default() -> Self {
139 Self::new()
140 }
141}
142
143impl RunnerCore {
144 pub fn new() -> Self {
145 Self {
146 ui_state: UiState::default(),
147 last_tree: None,
148 quad_scratch: Vec::new(),
149 runs: Vec::new(),
150 paint_items: Vec::new(),
151 viewport_px: (1, 1),
152 surface_size_override: None,
153 theme: Theme::default(),
154 }
155 }
156
157 pub fn set_theme(&mut self, theme: Theme) {
158 self.theme = theme;
159 }
160
161 pub fn theme(&self) -> &Theme {
162 &self.theme
163 }
164
165 pub fn set_surface_size(&mut self, width: u32, height: u32) {
171 self.surface_size_override = Some((width.max(1), height.max(1)));
172 }
173
174 pub fn ui_state(&self) -> &UiState {
175 &self.ui_state
176 }
177
178 pub fn debug_summary(&self) -> String {
179 self.ui_state.debug_summary()
180 }
181
182 pub fn rect_of_key(&self, key: &str) -> Option<Rect> {
183 self.last_tree
184 .as_ref()
185 .and_then(|t| self.ui_state.rect_of_key(t, key))
186 }
187
188 pub fn pointer_moved(&mut self, x: f32, y: f32) -> Option<UiEvent> {
197 self.ui_state.pointer_pos = Some((x, y));
198 let hit = self
199 .last_tree
200 .as_ref()
201 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
202 self.ui_state.set_hovered(hit, Instant::now());
203 let modifiers = self.ui_state.modifiers;
208 self.ui_state.pressed.clone().map(|p| UiEvent {
209 key: Some(p.key.clone()),
210 target: Some(p),
211 pointer: Some((x, y)),
212 key_press: None,
213 text: None,
214 modifiers,
215 kind: UiEventKind::Drag,
216 })
217 }
218
219 pub fn pointer_left(&mut self) {
220 self.ui_state.pointer_pos = None;
221 self.ui_state.set_hovered(None, Instant::now());
222 self.ui_state.pressed = None;
223 self.ui_state.pressed_secondary = None;
224 }
225
226 pub fn pointer_down(&mut self, x: f32, y: f32, button: PointerButton) -> Option<UiEvent> {
233 let hit = self
234 .last_tree
235 .as_ref()
236 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
237 if matches!(button, PointerButton::Primary) {
242 self.ui_state.set_focus(hit.clone());
243 self.ui_state.pressed = hit.clone();
244 self.ui_state.tooltip_dismissed_for_hover = true;
247 let modifiers = self.ui_state.modifiers;
248 hit.map(|p| UiEvent {
249 key: Some(p.key.clone()),
250 target: Some(p),
251 pointer: Some((x, y)),
252 key_press: None,
253 text: None,
254 modifiers,
255 kind: UiEventKind::PointerDown,
256 })
257 } else {
258 self.ui_state.pressed_secondary = hit.map(|h| (h, button));
261 None
262 }
263 }
264
265 pub fn pointer_up(&mut self, x: f32, y: f32, button: PointerButton) -> Vec<UiEvent> {
273 let hit = self
274 .last_tree
275 .as_ref()
276 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
277 let modifiers = self.ui_state.modifiers;
278 let mut out = Vec::new();
279 match button {
280 PointerButton::Primary => {
281 let pressed = self.ui_state.pressed.take();
282 if let Some(p) = pressed.clone() {
283 out.push(UiEvent {
284 key: Some(p.key.clone()),
285 target: Some(p),
286 pointer: Some((x, y)),
287 key_press: None,
288 text: None,
289 modifiers,
290 kind: UiEventKind::PointerUp,
291 });
292 }
293 if let (Some(p), Some(h)) = (pressed, hit)
294 && p.node_id == h.node_id
295 {
296 out.push(UiEvent {
297 key: Some(p.key.clone()),
298 target: Some(p),
299 pointer: Some((x, y)),
300 key_press: None,
301 text: None,
302 modifiers,
303 kind: UiEventKind::Click,
304 });
305 }
306 }
307 PointerButton::Secondary | PointerButton::Middle => {
308 let pressed = self.ui_state.pressed_secondary.take();
309 if let (Some((p, b)), Some(h)) = (pressed, hit)
310 && b == button
311 && p.node_id == h.node_id
312 {
313 let kind = match button {
314 PointerButton::Secondary => UiEventKind::SecondaryClick,
315 PointerButton::Middle => UiEventKind::MiddleClick,
316 PointerButton::Primary => unreachable!(),
317 };
318 out.push(UiEvent {
319 key: Some(p.key.clone()),
320 target: Some(p),
321 pointer: Some((x, y)),
322 key_press: None,
323 text: None,
324 modifiers,
325 kind,
326 });
327 }
328 }
329 }
330 out
331 }
332
333 pub fn key_down(
334 &mut self,
335 key: UiKey,
336 modifiers: KeyModifiers,
337 repeat: bool,
338 ) -> Option<UiEvent> {
339 if self.focused_captures_keys() {
345 if let Some(event) = self.ui_state.try_hotkey(&key, modifiers, repeat) {
346 return Some(event);
347 }
348 return self.ui_state.key_down_raw(key, modifiers, repeat);
349 }
350
351 if matches!(
357 key,
358 UiKey::ArrowUp | UiKey::ArrowDown | UiKey::Home | UiKey::End
359 ) && let Some(siblings) = self.focused_arrow_nav_group()
360 {
361 if let Some(event) = self.ui_state.try_hotkey(&key, modifiers, repeat) {
362 return Some(event);
363 }
364 self.move_focus_in_group(&key, &siblings);
365 return None;
366 }
367
368 self.ui_state.key_down(key, modifiers, repeat)
369 }
370
371 fn focused_arrow_nav_group(&self) -> Option<Vec<UiTarget>> {
378 let focused = self.ui_state.focused.as_ref()?;
379 let tree = self.last_tree.as_ref()?;
380 focus::arrow_nav_group(tree, &self.ui_state, &focused.node_id)
381 }
382
383 fn move_focus_in_group(&mut self, key: &UiKey, siblings: &[UiTarget]) {
388 if siblings.is_empty() {
389 return;
390 }
391 let focused_id = match self.ui_state.focused.as_ref() {
392 Some(t) => t.node_id.clone(),
393 None => return,
394 };
395 let idx = siblings.iter().position(|t| t.node_id == focused_id);
396 let next_idx = match (key, idx) {
397 (UiKey::ArrowUp, Some(i)) => i.saturating_sub(1),
398 (UiKey::ArrowDown, Some(i)) => (i + 1).min(siblings.len() - 1),
399 (UiKey::Home, _) => 0,
400 (UiKey::End, _) => siblings.len() - 1,
401 _ => return,
402 };
403 if Some(next_idx) != idx {
404 self.ui_state.set_focus(Some(siblings[next_idx].clone()));
405 }
406 }
407
408 fn focused_captures_keys(&self) -> bool {
412 let Some(focused) = self.ui_state.focused.as_ref() else {
413 return false;
414 };
415 let Some(tree) = self.last_tree.as_ref() else {
416 return false;
417 };
418 find_capture_keys(tree, &focused.node_id).unwrap_or(false)
419 }
420
421 pub fn text_input(&mut self, text: String) -> Option<UiEvent> {
427 if text.is_empty() {
428 return None;
429 }
430 let target = self.ui_state.focused.clone()?;
431 let modifiers = self.ui_state.modifiers;
432 Some(UiEvent {
433 key: Some(target.key.clone()),
434 target: Some(target),
435 pointer: None,
436 key_press: None,
437 text: Some(text),
438 modifiers,
439 kind: UiEventKind::TextInput,
440 })
441 }
442
443 pub fn set_hotkeys(&mut self, hotkeys: Vec<(KeyChord, String)>) {
444 self.ui_state.set_hotkeys(hotkeys);
445 }
446
447 pub fn set_animation_mode(&mut self, mode: AnimationMode) {
448 self.ui_state.set_animation_mode(mode);
449 }
450
451 pub fn pointer_wheel(&mut self, x: f32, y: f32, dy: f32) -> bool {
452 let Some(tree) = self.last_tree.as_ref() else {
453 return false;
454 };
455 self.ui_state.pointer_wheel(tree, (x, y), dy)
456 }
457
458 pub fn prepare_layout(
465 &mut self,
466 root: &mut El,
467 viewport: Rect,
468 scale_factor: f32,
469 timings: &mut PrepareTimings,
470 ) -> (Vec<DrawOp>, bool) {
471 let t0 = Instant::now();
472 layout::assign_ids(root);
479 let tooltip_pending = tooltip::synthesize_tooltip(root, &self.ui_state, t0);
480 layout::layout(root, &mut self.ui_state, viewport);
481 self.ui_state.sync_focus_order(root);
482 focus::sync_popover_focus(root, &mut self.ui_state);
483 self.ui_state.apply_to_state();
484 let needs_redraw =
485 self.ui_state.tick_visual_animations(root, Instant::now()) || tooltip_pending;
486 self.viewport_px = self.surface_size_override.unwrap_or_else(|| {
487 (
488 (viewport.w * scale_factor).ceil().max(1.0) as u32,
489 (viewport.h * scale_factor).ceil().max(1.0) as u32,
490 )
491 });
492 let t_after_layout = Instant::now();
493 let ops = draw_ops::draw_ops_with_theme(root, &self.ui_state, &self.theme);
494 let t_after_draw_ops = Instant::now();
495 timings.layout = t_after_layout - t0;
496 timings.draw_ops = t_after_draw_ops - t_after_layout;
497 (ops, needs_redraw)
498 }
499
500 pub fn prepare_paint<F1, F2>(
511 &mut self,
512 ops: &[DrawOp],
513 is_registered: F1,
514 samples_backdrop: F2,
515 text: &mut dyn TextRecorder,
516 scale_factor: f32,
517 timings: &mut PrepareTimings,
518 ) where
519 F1: Fn(&ShaderHandle) -> bool,
520 F2: Fn(&ShaderHandle) -> bool,
521 {
522 let t0 = Instant::now();
523 self.quad_scratch.clear();
524 self.runs.clear();
525 self.paint_items.clear();
526
527 let mut current: Option<(ShaderHandle, Option<PhysicalScissor>)> = None;
528 let mut run_first: u32 = 0;
529 let mut snapshot_emitted = false;
532
533 for op in ops {
534 match op {
535 DrawOp::Quad {
536 rect,
537 scissor,
538 shader,
539 uniforms,
540 ..
541 } => {
542 if !is_registered(shader) {
543 continue;
544 }
545 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
546 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
547 continue;
548 }
549 if !snapshot_emitted && samples_backdrop(shader) {
550 close_run(
551 &mut self.runs,
552 &mut self.paint_items,
553 current,
554 run_first,
555 self.quad_scratch.len() as u32,
556 );
557 current = None;
558 run_first = self.quad_scratch.len() as u32;
559 self.paint_items.push(PaintItem::BackdropSnapshot);
560 snapshot_emitted = true;
561 }
562 let inst = pack_instance(*rect, *shader, uniforms);
563
564 let key = (*shader, phys);
565 if current != Some(key) {
566 close_run(
567 &mut self.runs,
568 &mut self.paint_items,
569 current,
570 run_first,
571 self.quad_scratch.len() as u32,
572 );
573 current = Some(key);
574 run_first = self.quad_scratch.len() as u32;
575 }
576 self.quad_scratch.push(inst);
577 }
578 DrawOp::GlyphRun {
579 rect,
580 scissor,
581 color,
582 text: glyph_text,
583 size,
584 weight,
585 wrap,
586 anchor,
587 ..
588 } => {
589 close_run(
590 &mut self.runs,
591 &mut self.paint_items,
592 current,
593 run_first,
594 self.quad_scratch.len() as u32,
595 );
596 current = None;
597 run_first = self.quad_scratch.len() as u32;
598
599 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
600 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
601 continue;
602 }
603 let layers = text.record(
604 *rect,
605 phys,
606 *color,
607 glyph_text,
608 *size,
609 *weight,
610 *wrap,
611 *anchor,
612 scale_factor,
613 );
614 for index in layers {
615 self.paint_items.push(PaintItem::Text(index));
616 }
617 }
618 DrawOp::AttributedText {
619 rect,
620 scissor,
621 runs,
622 size,
623 wrap,
624 anchor,
625 ..
626 } => {
627 close_run(
628 &mut self.runs,
629 &mut self.paint_items,
630 current,
631 run_first,
632 self.quad_scratch.len() as u32,
633 );
634 current = None;
635 run_first = self.quad_scratch.len() as u32;
636
637 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
638 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
639 continue;
640 }
641 let layers =
642 text.record_runs(*rect, phys, runs, *size, *wrap, *anchor, scale_factor);
643 for index in layers {
644 self.paint_items.push(PaintItem::Text(index));
645 }
646 }
647 DrawOp::Icon {
648 rect,
649 scissor,
650 name,
651 color,
652 size,
653 stroke_width,
654 ..
655 } => {
656 close_run(
657 &mut self.runs,
658 &mut self.paint_items,
659 current,
660 run_first,
661 self.quad_scratch.len() as u32,
662 );
663 current = None;
664 run_first = self.quad_scratch.len() as u32;
665
666 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
667 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
668 continue;
669 }
670 let recorded = text.record_icon(
671 *rect,
672 phys,
673 *name,
674 *color,
675 *size,
676 *stroke_width,
677 scale_factor,
678 );
679 match recorded {
680 RecordedPaint::Text(layers) => {
681 for index in layers {
682 self.paint_items.push(PaintItem::Text(index));
683 }
684 }
685 RecordedPaint::Icon(runs) => {
686 for index in runs {
687 self.paint_items.push(PaintItem::IconRun(index));
688 }
689 }
690 }
691 }
692 DrawOp::BackdropSnapshot => {
693 close_run(
694 &mut self.runs,
695 &mut self.paint_items,
696 current,
697 run_first,
698 self.quad_scratch.len() as u32,
699 );
700 current = None;
701 run_first = self.quad_scratch.len() as u32;
702 if !snapshot_emitted {
705 self.paint_items.push(PaintItem::BackdropSnapshot);
706 snapshot_emitted = true;
707 }
708 }
709 }
710 }
711 close_run(
712 &mut self.runs,
713 &mut self.paint_items,
714 current,
715 run_first,
716 self.quad_scratch.len() as u32,
717 );
718 timings.paint = Instant::now() - t0;
719 }
720
721 pub fn snapshot(&mut self, root: &El, timings: &mut PrepareTimings) {
726 let t0 = Instant::now();
727 self.last_tree = Some(root.clone());
728 timings.snapshot = Instant::now() - t0;
729 }
730}
731
732fn find_capture_keys(node: &El, id: &str) -> Option<bool> {
737 if node.computed_id == id {
738 return Some(node.capture_keys);
739 }
740 node.children.iter().find_map(|c| find_capture_keys(c, id))
741}
742
743pub enum RecordedPaint {
746 Text(Range<usize>),
747 Icon(Range<usize>),
748}
749
750pub trait TextRecorder {
754 #[allow(clippy::too_many_arguments)]
758 fn record(
759 &mut self,
760 rect: Rect,
761 scissor: Option<PhysicalScissor>,
762 color: Color,
763 text: &str,
764 size: f32,
765 weight: FontWeight,
766 wrap: TextWrap,
767 anchor: TextAnchor,
768 scale_factor: f32,
769 ) -> Range<usize>;
770
771 #[allow(clippy::too_many_arguments)]
776 fn record_runs(
777 &mut self,
778 rect: Rect,
779 scissor: Option<PhysicalScissor>,
780 runs: &[(String, RunStyle)],
781 size: f32,
782 wrap: TextWrap,
783 anchor: TextAnchor,
784 scale_factor: f32,
785 ) -> Range<usize>;
786
787 #[allow(clippy::too_many_arguments)]
791 fn record_icon(
792 &mut self,
793 rect: Rect,
794 scissor: Option<PhysicalScissor>,
795 name: crate::tree::IconName,
796 color: Color,
797 size: f32,
798 _stroke_width: f32,
799 scale_factor: f32,
800 ) -> RecordedPaint {
801 RecordedPaint::Text(self.record(
802 rect,
803 scissor,
804 color,
805 name.fallback_glyph(),
806 size,
807 FontWeight::Regular,
808 TextWrap::NoWrap,
809 TextAnchor::Middle,
810 scale_factor,
811 ))
812 }
813}
814
815#[cfg(test)]
816mod tests {
817 use super::*;
818 use crate::shader::{ShaderHandle, StockShader, UniformBlock};
819
820 struct NoText;
822 impl TextRecorder for NoText {
823 fn record(
824 &mut self,
825 _rect: Rect,
826 _scissor: Option<PhysicalScissor>,
827 _color: Color,
828 _text: &str,
829 _size: f32,
830 _weight: FontWeight,
831 _wrap: TextWrap,
832 _anchor: TextAnchor,
833 _scale_factor: f32,
834 ) -> Range<usize> {
835 0..0
836 }
837 fn record_runs(
838 &mut self,
839 _rect: Rect,
840 _scissor: Option<PhysicalScissor>,
841 _runs: &[(String, RunStyle)],
842 _size: f32,
843 _wrap: TextWrap,
844 _anchor: TextAnchor,
845 _scale_factor: f32,
846 ) -> Range<usize> {
847 0..0
848 }
849 }
850
851 fn lay_out_input_tree(capture: bool) -> RunnerCore {
858 use crate::tree::*;
859 let ti = if capture {
860 crate::widgets::text::text("input").key("ti").capture_keys()
861 } else {
862 crate::widgets::text::text("noop").key("ti").focusable()
863 };
864 let mut tree =
865 crate::column([crate::widgets::button::button("Btn").key("btn"), ti]).padding(10.0);
866 let mut core = RunnerCore::new();
867 crate::layout::layout(
868 &mut tree,
869 &mut core.ui_state,
870 Rect::new(0.0, 0.0, 200.0, 200.0),
871 );
872 core.ui_state.sync_focus_order(&tree);
873 let mut t = PrepareTimings::default();
874 core.snapshot(&tree, &mut t);
875 core
876 }
877
878 #[test]
879 fn pointer_up_emits_pointer_up_then_click() {
880 let mut core = lay_out_input_tree(false);
881 let btn_rect = core.rect_of_key("btn").expect("btn rect");
882 let cx = btn_rect.x + btn_rect.w * 0.5;
883 let cy = btn_rect.y + btn_rect.h * 0.5;
884 core.pointer_moved(cx, cy);
885 core.pointer_down(cx, cy, PointerButton::Primary);
886 let events = core.pointer_up(cx, cy, PointerButton::Primary);
887 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
888 assert_eq!(kinds, vec![UiEventKind::PointerUp, UiEventKind::Click]);
889 }
890
891 #[test]
892 fn pointer_up_off_target_emits_only_pointer_up() {
893 let mut core = lay_out_input_tree(false);
894 let btn_rect = core.rect_of_key("btn").expect("btn rect");
895 let cx = btn_rect.x + btn_rect.w * 0.5;
896 let cy = btn_rect.y + btn_rect.h * 0.5;
897 core.pointer_down(cx, cy, PointerButton::Primary);
898 let events = core.pointer_up(180.0, 180.0, PointerButton::Primary);
900 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
901 assert_eq!(
902 kinds,
903 vec![UiEventKind::PointerUp],
904 "drag-off-target should still surface PointerUp so widgets see drag-end"
905 );
906 }
907
908 #[test]
909 fn pointer_moved_while_pressed_emits_drag() {
910 let mut core = lay_out_input_tree(false);
911 let btn_rect = core.rect_of_key("btn").expect("btn rect");
912 let cx = btn_rect.x + btn_rect.w * 0.5;
913 let cy = btn_rect.y + btn_rect.h * 0.5;
914 core.pointer_down(cx, cy, PointerButton::Primary);
915 let drag = core
916 .pointer_moved(cx + 30.0, cy)
917 .expect("drag while pressed");
918 assert_eq!(drag.kind, UiEventKind::Drag);
919 assert_eq!(drag.target.as_ref().map(|t| t.key.as_str()), Some("btn"));
920 assert_eq!(drag.pointer, Some((cx + 30.0, cy)));
921 }
922
923 #[test]
924 fn pointer_moved_without_press_emits_no_drag() {
925 let mut core = lay_out_input_tree(false);
926 assert!(core.pointer_moved(50.0, 50.0).is_none());
927 }
928
929 #[test]
930 fn secondary_click_does_not_steal_focus_or_press() {
931 let mut core = lay_out_input_tree(false);
932 let btn_rect = core.rect_of_key("btn").expect("btn rect");
933 let cx = btn_rect.x + btn_rect.w * 0.5;
934 let cy = btn_rect.y + btn_rect.h * 0.5;
935 let ti_rect = core.rect_of_key("ti").expect("ti rect");
937 let tx = ti_rect.x + ti_rect.w * 0.5;
938 let ty = ti_rect.y + ti_rect.h * 0.5;
939 core.pointer_down(tx, ty, PointerButton::Primary);
940 let _ = core.pointer_up(tx, ty, PointerButton::Primary);
941 let focused_before = core.ui_state.focused.as_ref().map(|t| t.key.clone());
942 core.pointer_down(cx, cy, PointerButton::Secondary);
944 let events = core.pointer_up(cx, cy, PointerButton::Secondary);
945 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
946 assert_eq!(kinds, vec![UiEventKind::SecondaryClick]);
947 let focused_after = core.ui_state.focused.as_ref().map(|t| t.key.clone());
948 assert_eq!(
949 focused_before, focused_after,
950 "right-click must not steal focus"
951 );
952 assert!(
953 core.ui_state.pressed.is_none(),
954 "right-click must not set primary press"
955 );
956 }
957
958 #[test]
959 fn text_input_routes_to_focused_only() {
960 let mut core = lay_out_input_tree(false);
961 assert!(core.text_input("a".into()).is_none());
963 let btn_rect = core.rect_of_key("btn").expect("btn rect");
965 let cx = btn_rect.x + btn_rect.w * 0.5;
966 let cy = btn_rect.y + btn_rect.h * 0.5;
967 core.pointer_down(cx, cy, PointerButton::Primary);
968 let _ = core.pointer_up(cx, cy, PointerButton::Primary);
969 let event = core.text_input("hi".into()).expect("focused → event");
970 assert_eq!(event.kind, UiEventKind::TextInput);
971 assert_eq!(event.text.as_deref(), Some("hi"));
972 assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("btn"));
973 assert!(core.text_input(String::new()).is_none());
975 }
976
977 #[test]
978 fn capture_keys_bypasses_tab_traversal_for_focused_node() {
979 let mut core = lay_out_input_tree(true);
982 let ti_rect = core.rect_of_key("ti").expect("ti rect");
983 let tx = ti_rect.x + ti_rect.w * 0.5;
984 let ty = ti_rect.y + ti_rect.h * 0.5;
985 core.pointer_down(tx, ty, PointerButton::Primary);
986 let _ = core.pointer_up(tx, ty, PointerButton::Primary);
987 assert_eq!(
988 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
989 Some("ti"),
990 "primary click on capture_keys node still focuses it"
991 );
992
993 let event = core
994 .key_down(UiKey::Tab, KeyModifiers::default(), false)
995 .expect("Tab → KeyDown to focused");
996 assert_eq!(event.kind, UiEventKind::KeyDown);
997 assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("ti"));
998 assert_eq!(
999 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1000 Some("ti"),
1001 "Tab inside capture_keys must NOT move focus"
1002 );
1003 }
1004
1005 #[test]
1006 fn capture_keys_falls_back_to_default_when_focus_off_capturing_node() {
1007 let mut core = lay_out_input_tree(true);
1011 let btn_rect = core.rect_of_key("btn").expect("btn rect");
1012 let cx = btn_rect.x + btn_rect.w * 0.5;
1013 let cy = btn_rect.y + btn_rect.h * 0.5;
1014 core.pointer_down(cx, cy, PointerButton::Primary);
1015 let _ = core.pointer_up(cx, cy, PointerButton::Primary);
1016 assert_eq!(
1017 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1018 Some("btn"),
1019 "primary click focuses button"
1020 );
1021 let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
1023 assert_eq!(
1024 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1025 Some("ti"),
1026 "Tab from non-capturing focused does library-default traversal"
1027 );
1028 }
1029
1030 fn lay_out_arrow_nav_tree() -> RunnerCore {
1035 use crate::tree::*;
1036 let mut tree = crate::column([
1037 crate::widgets::button::button("Red").key("opt-red"),
1038 crate::widgets::button::button("Green").key("opt-green"),
1039 crate::widgets::button::button("Blue").key("opt-blue"),
1040 ])
1041 .arrow_nav_siblings()
1042 .padding(10.0);
1043 let mut core = RunnerCore::new();
1044 crate::layout::layout(
1045 &mut tree,
1046 &mut core.ui_state,
1047 Rect::new(0.0, 0.0, 200.0, 300.0),
1048 );
1049 core.ui_state.sync_focus_order(&tree);
1050 let mut t = PrepareTimings::default();
1051 core.snapshot(&tree, &mut t);
1052 let target = core
1055 .ui_state
1056 .focus_order
1057 .iter()
1058 .find(|t| t.key == "opt-green")
1059 .cloned();
1060 core.ui_state.set_focus(target);
1061 core
1062 }
1063
1064 #[test]
1065 fn arrow_nav_moves_focus_among_siblings() {
1066 let mut core = lay_out_arrow_nav_tree();
1067
1068 let down = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
1071 assert!(down.is_none(), "arrow-nav consumes the key event");
1072 assert_eq!(
1073 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1074 Some("opt-blue"),
1075 );
1076
1077 core.key_down(UiKey::ArrowUp, KeyModifiers::default(), false);
1079 assert_eq!(
1080 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1081 Some("opt-green"),
1082 );
1083
1084 core.key_down(UiKey::Home, KeyModifiers::default(), false);
1086 assert_eq!(
1087 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1088 Some("opt-red"),
1089 );
1090
1091 core.key_down(UiKey::End, KeyModifiers::default(), false);
1093 assert_eq!(
1094 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1095 Some("opt-blue"),
1096 );
1097 }
1098
1099 #[test]
1100 fn arrow_nav_saturates_at_ends() {
1101 let mut core = lay_out_arrow_nav_tree();
1102 core.key_down(UiKey::Home, KeyModifiers::default(), false);
1104 core.key_down(UiKey::ArrowUp, KeyModifiers::default(), false);
1105 assert_eq!(
1106 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1107 Some("opt-red"),
1108 "ArrowUp at top stays at top — no wrap",
1109 );
1110 core.key_down(UiKey::End, KeyModifiers::default(), false);
1112 core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
1113 assert_eq!(
1114 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1115 Some("opt-blue"),
1116 "ArrowDown at bottom stays at bottom — no wrap",
1117 );
1118 }
1119
1120 fn build_popover_tree(open: bool) -> El {
1124 use crate::widgets::button::button;
1125 use crate::widgets::overlay::overlay;
1126 use crate::widgets::popover::{dropdown, menu_item};
1127 let mut layers: Vec<El> = vec![button("Trigger").key("trigger")];
1128 if open {
1129 layers.push(dropdown(
1130 "menu",
1131 "trigger",
1132 [
1133 menu_item("A").key("item-a"),
1134 menu_item("B").key("item-b"),
1135 menu_item("C").key("item-c"),
1136 ],
1137 ));
1138 }
1139 overlay(layers).padding(20.0)
1140 }
1141
1142 fn run_frame(core: &mut RunnerCore, tree: &mut El) {
1146 let mut t = PrepareTimings::default();
1147 core.prepare_layout(tree, Rect::new(0.0, 0.0, 400.0, 300.0), 1.0, &mut t);
1148 core.snapshot(tree, &mut t);
1149 }
1150
1151 #[test]
1152 fn popover_open_pushes_focus_and_auto_focuses_first_item() {
1153 let mut core = RunnerCore::new();
1154 let mut closed = build_popover_tree(false);
1155 run_frame(&mut core, &mut closed);
1156 let trigger = core
1159 .ui_state
1160 .focus_order
1161 .iter()
1162 .find(|t| t.key == "trigger")
1163 .cloned();
1164 core.ui_state.set_focus(trigger);
1165 assert_eq!(
1166 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1167 Some("trigger"),
1168 );
1169
1170 let mut open = build_popover_tree(true);
1173 run_frame(&mut core, &mut open);
1174 assert_eq!(
1175 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1176 Some("item-a"),
1177 "popover open should auto-focus the first menu item",
1178 );
1179 assert_eq!(
1180 core.ui_state.focus_stack.len(),
1181 1,
1182 "trigger should be saved on the focus stack",
1183 );
1184 assert_eq!(
1185 core.ui_state.focus_stack[0].key.as_str(),
1186 "trigger",
1187 "saved focus should be the pre-open target",
1188 );
1189 }
1190
1191 #[test]
1192 fn popover_close_restores_focus_to_trigger() {
1193 let mut core = RunnerCore::new();
1194 let mut closed = build_popover_tree(false);
1195 run_frame(&mut core, &mut closed);
1196 let trigger = core
1197 .ui_state
1198 .focus_order
1199 .iter()
1200 .find(|t| t.key == "trigger")
1201 .cloned();
1202 core.ui_state.set_focus(trigger);
1203
1204 let mut open = build_popover_tree(true);
1206 run_frame(&mut core, &mut open);
1207 assert_eq!(
1208 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1209 Some("item-a"),
1210 );
1211
1212 let mut closed_again = build_popover_tree(false);
1214 run_frame(&mut core, &mut closed_again);
1215 assert_eq!(
1216 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1217 Some("trigger"),
1218 "closing the popover should pop the saved focus",
1219 );
1220 assert!(
1221 core.ui_state.focus_stack.is_empty(),
1222 "focus stack should be drained after restore",
1223 );
1224 }
1225
1226 #[test]
1227 fn popover_close_does_not_override_intentional_focus_move() {
1228 let mut core = RunnerCore::new();
1229 let build = |open: bool| -> El {
1232 use crate::widgets::button::button;
1233 use crate::widgets::overlay::overlay;
1234 use crate::widgets::popover::{dropdown, menu_item};
1235 let main = crate::row([
1236 button("Trigger").key("trigger"),
1237 button("Other").key("other"),
1238 ]);
1239 let mut layers: Vec<El> = vec![main];
1240 if open {
1241 layers.push(dropdown("menu", "trigger", [menu_item("A").key("item-a")]));
1242 }
1243 overlay(layers).padding(20.0)
1244 };
1245
1246 let mut closed = build(false);
1247 run_frame(&mut core, &mut closed);
1248 let trigger = core
1249 .ui_state
1250 .focus_order
1251 .iter()
1252 .find(|t| t.key == "trigger")
1253 .cloned();
1254 core.ui_state.set_focus(trigger);
1255
1256 let mut open = build(true);
1257 run_frame(&mut core, &mut open);
1258 assert_eq!(core.ui_state.focus_stack.len(), 1);
1259
1260 let other = core
1265 .ui_state
1266 .focus_order
1267 .iter()
1268 .find(|t| t.key == "other")
1269 .cloned();
1270 core.ui_state.set_focus(other);
1271
1272 let mut closed_again = build(false);
1273 run_frame(&mut core, &mut closed_again);
1274 assert_eq!(
1275 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1276 Some("other"),
1277 "focus moved before close should not be overridden by restore",
1278 );
1279 assert!(core.ui_state.focus_stack.is_empty());
1280 }
1281
1282 #[test]
1283 fn nested_popovers_stack_and_unwind_focus_correctly() {
1284 let mut core = RunnerCore::new();
1285 let build = |outer: bool, inner: bool| -> El {
1290 use crate::widgets::button::button;
1291 use crate::widgets::overlay::overlay;
1292 use crate::widgets::popover::{Anchor, popover, popover_panel};
1293 let main = button("Trigger").key("trigger");
1294 let mut layers: Vec<El> = vec![main];
1295 if outer {
1296 layers.push(popover(
1297 "outer",
1298 Anchor::below_key("trigger"),
1299 popover_panel([button("Open inner").key("inner-trigger")]),
1300 ));
1301 }
1302 if inner {
1303 layers.push(popover(
1304 "inner",
1305 Anchor::below_key("inner-trigger"),
1306 popover_panel([button("X").key("inner-a"), button("Y").key("inner-b")]),
1307 ));
1308 }
1309 overlay(layers).padding(20.0)
1310 };
1311
1312 let mut closed = build(false, false);
1314 run_frame(&mut core, &mut closed);
1315 let trigger = core
1316 .ui_state
1317 .focus_order
1318 .iter()
1319 .find(|t| t.key == "trigger")
1320 .cloned();
1321 core.ui_state.set_focus(trigger);
1322
1323 let mut outer = build(true, false);
1325 run_frame(&mut core, &mut outer);
1326 assert_eq!(
1327 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1328 Some("inner-trigger"),
1329 );
1330 assert_eq!(core.ui_state.focus_stack.len(), 1);
1331
1332 let mut both = build(true, true);
1334 run_frame(&mut core, &mut both);
1335 assert_eq!(
1336 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1337 Some("inner-a"),
1338 );
1339 assert_eq!(core.ui_state.focus_stack.len(), 2);
1340
1341 let mut outer_only = build(true, false);
1343 run_frame(&mut core, &mut outer_only);
1344 assert_eq!(
1345 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1346 Some("inner-trigger"),
1347 );
1348 assert_eq!(core.ui_state.focus_stack.len(), 1);
1349
1350 let mut none = build(false, false);
1352 run_frame(&mut core, &mut none);
1353 assert_eq!(
1354 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
1355 Some("trigger"),
1356 );
1357 assert!(core.ui_state.focus_stack.is_empty());
1358 }
1359
1360 #[test]
1361 fn arrow_nav_does_not_intercept_outside_navigable_groups() {
1362 let mut core = lay_out_input_tree(false);
1366 let target = core
1367 .ui_state
1368 .focus_order
1369 .iter()
1370 .find(|t| t.key == "btn")
1371 .cloned();
1372 core.ui_state.set_focus(target);
1373 let event = core
1374 .key_down(UiKey::ArrowDown, KeyModifiers::default(), false)
1375 .expect("ArrowDown without navigable parent → event");
1376 assert_eq!(event.kind, UiEventKind::KeyDown);
1377 }
1378
1379 fn quad(shader: ShaderHandle) -> DrawOp {
1380 DrawOp::Quad {
1381 id: "q".into(),
1382 rect: Rect::new(0.0, 0.0, 10.0, 10.0),
1383 scissor: None,
1384 shader,
1385 uniforms: UniformBlock::new(),
1386 }
1387 }
1388
1389 #[test]
1390 fn samples_backdrop_inserts_snapshot_before_first_glass_quad() {
1391 let mut core = RunnerCore::new();
1392 core.set_surface_size(100, 100);
1393 let ops = vec![
1394 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
1395 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
1396 quad(ShaderHandle::Custom("liquid_glass")),
1397 quad(ShaderHandle::Custom("liquid_glass")),
1398 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
1399 ];
1400 let mut timings = PrepareTimings::default();
1401 core.prepare_paint(
1402 &ops,
1403 |_| true,
1404 |s| matches!(s, ShaderHandle::Custom(name) if *name == "liquid_glass"),
1405 &mut NoText,
1406 1.0,
1407 &mut timings,
1408 );
1409
1410 let kinds: Vec<&'static str> = core
1411 .paint_items
1412 .iter()
1413 .map(|p| match p {
1414 PaintItem::QuadRun(_) => "Q",
1415 PaintItem::IconRun(_) => "I",
1416 PaintItem::Text(_) => "T",
1417 PaintItem::BackdropSnapshot => "S",
1418 })
1419 .collect();
1420 assert_eq!(
1421 kinds,
1422 vec!["Q", "S", "Q", "Q"],
1423 "expected one stock run, snapshot, then a glass run, then a foreground stock run"
1424 );
1425 }
1426
1427 #[test]
1428 fn no_snapshot_when_no_glass_drawn() {
1429 let mut core = RunnerCore::new();
1430 core.set_surface_size(100, 100);
1431 let ops = vec![
1432 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
1433 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
1434 ];
1435 let mut timings = PrepareTimings::default();
1436 core.prepare_paint(&ops, |_| true, |_| false, &mut NoText, 1.0, &mut timings);
1437 assert!(
1438 !core
1439 .paint_items
1440 .iter()
1441 .any(|p| matches!(p, PaintItem::BackdropSnapshot)),
1442 "no glass shader registered → no snapshot"
1443 );
1444 }
1445
1446 #[test]
1447 fn at_most_one_snapshot_per_frame() {
1448 let mut core = RunnerCore::new();
1449 core.set_surface_size(100, 100);
1450 let ops = vec![
1451 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
1452 quad(ShaderHandle::Custom("g")),
1453 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
1454 quad(ShaderHandle::Custom("g")),
1455 ];
1456 let mut timings = PrepareTimings::default();
1457 core.prepare_paint(
1458 &ops,
1459 |_| true,
1460 |s| matches!(s, ShaderHandle::Custom("g")),
1461 &mut NoText,
1462 1.0,
1463 &mut timings,
1464 );
1465 let snapshots = core
1466 .paint_items
1467 .iter()
1468 .filter(|p| matches!(p, PaintItem::BackdropSnapshot))
1469 .count();
1470 assert_eq!(snapshots, 1, "backdrop depth is capped at 1");
1471 }
1472}