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::toast;
70use crate::tooltip;
71use crate::tree::{Color, El, FontWeight, Rect, TextWrap};
72
73const SCROLL_PAGE_OVERLAP: f32 = 24.0;
79
80#[derive(Clone, Copy, Debug, Default)]
103pub struct PrepareResult {
104 pub needs_redraw: bool,
108 pub next_redraw_in: Option<std::time::Duration>,
112 pub next_layout_redraw_in: Option<std::time::Duration>,
116 pub next_paint_redraw_in: Option<std::time::Duration>,
121 pub timings: PrepareTimings,
122}
123
124#[derive(Debug, Default)]
136pub struct PointerMove {
137 pub events: Vec<UiEvent>,
140 pub needs_redraw: bool,
144}
145
146pub struct LayoutPrepared {
153 pub ops: Vec<DrawOp>,
154 pub needs_redraw: bool,
155 pub next_layout_redraw_in: Option<std::time::Duration>,
156 pub next_paint_redraw_in: Option<std::time::Duration>,
157}
158
159#[derive(Clone, Copy, Debug, Default)]
170pub struct PrepareTimings {
171 pub layout: Duration,
172 pub draw_ops: Duration,
173 pub paint: Duration,
174 pub gpu_upload: Duration,
175 pub snapshot: Duration,
176}
177
178pub struct RunnerCore {
186 pub ui_state: UiState,
187 pub last_tree: Option<El>,
191
192 pub quad_scratch: Vec<QuadInstance>,
195 pub runs: Vec<InstanceRun>,
196 pub paint_items: Vec<PaintItem>,
197
198 pub last_ops: Vec<DrawOp>,
205
206 pub viewport_px: (u32, u32),
210 pub surface_size_override: Option<(u32, u32)>,
216
217 pub theme: Theme,
219}
220
221impl Default for RunnerCore {
222 fn default() -> Self {
223 Self::new()
224 }
225}
226
227impl RunnerCore {
228 pub fn new() -> Self {
229 Self {
230 ui_state: UiState::default(),
231 last_tree: None,
232 quad_scratch: Vec::new(),
233 runs: Vec::new(),
234 paint_items: Vec::new(),
235 last_ops: Vec::new(),
236 viewport_px: (1, 1),
237 surface_size_override: None,
238 theme: Theme::default(),
239 }
240 }
241
242 pub fn set_theme(&mut self, theme: Theme) {
243 self.theme = theme;
244 }
245
246 pub fn theme(&self) -> &Theme {
247 &self.theme
248 }
249
250 pub fn set_surface_size(&mut self, width: u32, height: u32) {
256 self.surface_size_override = Some((width.max(1), height.max(1)));
257 }
258
259 pub fn ui_state(&self) -> &UiState {
260 &self.ui_state
261 }
262
263 pub fn debug_summary(&self) -> String {
264 self.ui_state.debug_summary()
265 }
266
267 pub fn rect_of_key(&self, key: &str) -> Option<Rect> {
268 self.last_tree
269 .as_ref()
270 .and_then(|t| self.ui_state.rect_of_key(t, key))
271 }
272
273 pub fn pointer_moved(&mut self, x: f32, y: f32) -> PointerMove {
282 self.ui_state.pointer_pos = Some((x, y));
283
284 if let Some(drag) = self.ui_state.scroll.thumb_drag.clone() {
290 let dy = y - drag.start_pointer_y;
291 let new_offset = if drag.track_remaining > 0.0 {
292 drag.start_offset + dy * (drag.max_offset / drag.track_remaining)
293 } else {
294 drag.start_offset
295 };
296 let clamped = new_offset.clamp(0.0, drag.max_offset);
297 let prev = self.ui_state.scroll.offsets.insert(drag.scroll_id, clamped);
298 let changed = prev.is_none_or(|old| (old - clamped).abs() > f32::EPSILON);
299 return PointerMove {
300 events: Vec::new(),
301 needs_redraw: changed,
302 };
303 }
304
305 let hit = self
306 .last_tree
307 .as_ref()
308 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
309 let prev_hover = self.ui_state.hovered.clone();
313 let hover_changed = self.ui_state.set_hovered(hit, Instant::now());
314 let prev_hovered_link = self.ui_state.hovered_link.clone();
320 let new_hovered_link = self
321 .last_tree
322 .as_ref()
323 .and_then(|t| hit_test::link_at(t, &self.ui_state, (x, y)));
324 let link_hover_changed = new_hovered_link != prev_hovered_link;
325 self.ui_state.hovered_link = new_hovered_link;
326 let modifiers = self.ui_state.modifiers;
327
328 let mut out = Vec::new();
329
330 if hover_changed {
337 if let Some(prev) = prev_hover {
338 out.push(UiEvent {
339 key: Some(prev.key.clone()),
340 target: Some(prev),
341 pointer: Some((x, y)),
342 key_press: None,
343 text: None,
344 selection: None,
345 modifiers,
346 click_count: 0,
347 path: None,
348 kind: UiEventKind::PointerLeave,
349 });
350 }
351 if let Some(new) = self.ui_state.hovered.clone() {
352 out.push(UiEvent {
353 key: Some(new.key.clone()),
354 target: Some(new),
355 pointer: Some((x, y)),
356 key_press: None,
357 text: None,
358 selection: None,
359 modifiers,
360 click_count: 0,
361 path: None,
362 kind: UiEventKind::PointerEnter,
363 });
364 }
365 }
366
367 if let Some(drag) = self.ui_state.selection.drag.clone()
374 && let Some(tree) = self.last_tree.as_ref()
375 {
376 let head_point =
377 head_for_drag(tree, &self.ui_state, (x, y)).unwrap_or_else(|| drag.anchor.clone());
378 let new_sel = crate::selection::Selection {
379 range: Some(crate::selection::SelectionRange {
380 anchor: drag.anchor.clone(),
381 head: head_point,
382 }),
383 };
384 if new_sel != self.ui_state.current_selection {
385 self.ui_state.current_selection = new_sel.clone();
386 out.push(selection_event(new_sel, modifiers, Some((x, y))));
387 }
388 }
389
390 if let Some(p) = self.ui_state.pressed.clone() {
396 if self.focused_captures_keys() {
400 self.ui_state.bump_caret_activity(Instant::now());
401 }
402 out.push(UiEvent {
403 key: Some(p.key.clone()),
404 target: Some(p),
405 pointer: Some((x, y)),
406 key_press: None,
407 text: None,
408 selection: None,
409 modifiers,
410 click_count: 0,
411 path: None,
412 kind: UiEventKind::Drag,
413 });
414 }
415
416 let needs_redraw = hover_changed || link_hover_changed || !out.is_empty();
417 PointerMove {
418 events: out,
419 needs_redraw,
420 }
421 }
422
423 pub fn pointer_left(&mut self) -> Vec<UiEvent> {
431 let last_pos = self.ui_state.pointer_pos;
432 let prev_hover = self.ui_state.hovered.clone();
433 let modifiers = self.ui_state.modifiers;
434 self.ui_state.pointer_pos = None;
435 self.ui_state.set_hovered(None, Instant::now());
436 self.ui_state.pressed = None;
437 self.ui_state.pressed_secondary = None;
438 self.ui_state.hovered_link = None;
444 self.ui_state.pressed_link = None;
445
446 let mut out = Vec::new();
447 if let Some(prev) = prev_hover {
448 out.push(UiEvent {
449 key: Some(prev.key.clone()),
450 target: Some(prev),
451 pointer: last_pos,
452 key_press: None,
453 text: None,
454 selection: None,
455 modifiers,
456 click_count: 0,
457 path: None,
458 kind: UiEventKind::PointerLeave,
459 });
460 }
461 out
462 }
463
464 pub fn file_hovered(&mut self, path: std::path::PathBuf, x: f32, y: f32) -> Vec<UiEvent> {
478 self.ui_state.pointer_pos = Some((x, y));
479 let target = self
480 .last_tree
481 .as_ref()
482 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
483 let key = target.as_ref().map(|t| t.key.clone());
484 vec![UiEvent {
485 key,
486 target,
487 pointer: Some((x, y)),
488 key_press: None,
489 text: None,
490 selection: None,
491 modifiers: self.ui_state.modifiers,
492 click_count: 0,
493 path: Some(path),
494 kind: UiEventKind::FileHovered,
495 }]
496 }
497
498 pub fn file_hover_cancelled(&mut self) -> Vec<UiEvent> {
503 vec![UiEvent {
504 key: None,
505 target: None,
506 pointer: self.ui_state.pointer_pos,
507 key_press: None,
508 text: None,
509 selection: None,
510 modifiers: self.ui_state.modifiers,
511 click_count: 0,
512 path: None,
513 kind: UiEventKind::FileHoverCancelled,
514 }]
515 }
516
517 pub fn file_dropped(&mut self, path: std::path::PathBuf, x: f32, y: f32) -> Vec<UiEvent> {
522 self.ui_state.pointer_pos = Some((x, y));
523 let target = self
524 .last_tree
525 .as_ref()
526 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
527 let key = target.as_ref().map(|t| t.key.clone());
528 vec![UiEvent {
529 key,
530 target,
531 pointer: Some((x, y)),
532 key_press: None,
533 text: None,
534 selection: None,
535 modifiers: self.ui_state.modifiers,
536 click_count: 0,
537 path: Some(path),
538 kind: UiEventKind::FileDropped,
539 }]
540 }
541
542 pub fn pointer_down(&mut self, x: f32, y: f32, button: PointerButton) -> Vec<UiEvent> {
555 if matches!(button, PointerButton::Primary)
564 && let Some((scroll_id, _track, thumb_rect)) = self.ui_state.thumb_at(x, y)
565 {
566 let metrics = self
567 .ui_state
568 .scroll
569 .metrics
570 .get(&scroll_id)
571 .copied()
572 .unwrap_or_default();
573 let start_offset = self
574 .ui_state
575 .scroll
576 .offsets
577 .get(&scroll_id)
578 .copied()
579 .unwrap_or(0.0);
580
581 let grabbed = y >= thumb_rect.y && y <= thumb_rect.y + thumb_rect.h;
585 if grabbed {
586 let track_remaining = (metrics.viewport_h - thumb_rect.h).max(0.0);
587 self.ui_state.scroll.thumb_drag = Some(crate::state::ThumbDrag {
588 scroll_id,
589 start_pointer_y: y,
590 start_offset,
591 track_remaining,
592 max_offset: metrics.max_offset,
593 });
594 } else {
595 let page = (metrics.viewport_h - SCROLL_PAGE_OVERLAP).max(0.0);
601 let delta = if y < thumb_rect.y { -page } else { page };
602 let new_offset = (start_offset + delta).clamp(0.0, metrics.max_offset);
603 self.ui_state.scroll.offsets.insert(scroll_id, new_offset);
604 }
605 return Vec::new();
606 }
607
608 let hit = self
609 .last_tree
610 .as_ref()
611 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
612 if !matches!(button, PointerButton::Primary) {
617 self.ui_state.pressed_secondary = hit.map(|h| (h, button));
620 return Vec::new();
621 }
622
623 self.ui_state.pressed_link = self
631 .last_tree
632 .as_ref()
633 .and_then(|t| hit_test::link_at(t, &self.ui_state, (x, y)));
634 self.ui_state.set_focus(hit.clone());
635 self.ui_state.set_focus_visible(false);
639 self.ui_state.pressed = hit.clone();
640 self.ui_state.tooltip.dismissed_for_hover = true;
643 let modifiers = self.ui_state.modifiers;
644
645 let now = Instant::now();
648 let click_count =
649 self.ui_state
650 .next_click_count(now, (x, y), hit.as_ref().map(|t| t.node_id.as_str()));
651
652 let mut out = Vec::new();
653 if let Some(p) = hit.clone() {
654 if self.focused_captures_keys() {
661 self.ui_state.bump_caret_activity(now);
662 }
663 out.push(UiEvent {
664 key: Some(p.key.clone()),
665 target: Some(p),
666 pointer: Some((x, y)),
667 key_press: None,
668 text: None,
669 selection: None,
670 modifiers,
671 click_count,
672 path: None,
673 kind: UiEventKind::PointerDown,
674 });
675 }
676
677 if let Some(point) = self
685 .last_tree
686 .as_ref()
687 .and_then(|t| hit_test::selection_point_at(t, &self.ui_state, (x, y)))
688 {
689 self.start_selection_drag(point, &mut out, modifiers, (x, y), click_count);
690 } else if !self.ui_state.current_selection.is_empty() {
691 let click_handles_selection = match (&hit, &self.ui_state.current_selection.range) {
713 (Some(h), Some(range)) => {
714 h.key == range.anchor.key
715 || h.key == range.head.key
716 || self
717 .last_tree
718 .as_ref()
719 .and_then(|t| find_capture_keys(t, &h.node_id))
720 .unwrap_or(false)
721 }
722 _ => false,
723 };
724 if !click_handles_selection {
725 out.push(selection_event(
726 crate::selection::Selection::default(),
727 modifiers,
728 Some((x, y)),
729 ));
730 self.ui_state.current_selection = crate::selection::Selection::default();
731 self.ui_state.selection.drag = None;
732 }
733 }
734
735 out
736 }
737
738 fn start_selection_drag(
746 &mut self,
747 point: crate::selection::SelectionPoint,
748 out: &mut Vec<UiEvent>,
749 modifiers: KeyModifiers,
750 pointer: (f32, f32),
751 click_count: u8,
752 ) {
753 let leaf_text = self
754 .last_tree
755 .as_ref()
756 .and_then(|t| crate::selection::find_keyed_text(t, &point.key))
757 .unwrap_or_default();
758 let (anchor_byte, head_byte) = match click_count {
759 2 => crate::selection::word_range_at(&leaf_text, point.byte),
760 n if n >= 3 => (0, leaf_text.len()),
761 _ => (point.byte, point.byte),
762 };
763 let anchor = crate::selection::SelectionPoint::new(point.key.clone(), anchor_byte);
764 let head = crate::selection::SelectionPoint::new(point.key.clone(), head_byte);
765 let new_sel = crate::selection::Selection {
766 range: Some(crate::selection::SelectionRange {
767 anchor: anchor.clone(),
768 head,
769 }),
770 };
771 self.ui_state.current_selection = new_sel.clone();
772 self.ui_state.selection.drag = Some(crate::state::SelectionDrag { anchor });
776 out.push(selection_event(new_sel, modifiers, Some(pointer)));
777 }
778
779 pub fn pointer_up(&mut self, x: f32, y: f32, button: PointerButton) -> Vec<UiEvent> {
787 if matches!(button, PointerButton::Primary) && self.ui_state.scroll.thumb_drag.is_some() {
792 self.ui_state.scroll.thumb_drag = None;
793 return Vec::new();
794 }
795
796 if matches!(button, PointerButton::Primary) {
799 self.ui_state.selection.drag = None;
800 }
801
802 let hit = self
803 .last_tree
804 .as_ref()
805 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
806 let modifiers = self.ui_state.modifiers;
807 let mut out = Vec::new();
808 match button {
809 PointerButton::Primary => {
810 let pressed = self.ui_state.pressed.take();
811 let click_count = self.ui_state.current_click_count();
812 if let Some(p) = pressed.clone() {
813 out.push(UiEvent {
814 key: Some(p.key.clone()),
815 target: Some(p),
816 pointer: Some((x, y)),
817 key_press: None,
818 text: None,
819 selection: None,
820 modifiers,
821 click_count,
822 path: None,
823 kind: UiEventKind::PointerUp,
824 });
825 }
826 if let (Some(p), Some(h)) = (pressed, hit)
827 && p.node_id == h.node_id
828 {
829 if let Some(id) = toast::parse_dismiss_key(&p.key) {
835 self.ui_state.dismiss_toast(id);
836 } else {
837 out.push(UiEvent {
838 key: Some(p.key.clone()),
839 target: Some(p),
840 pointer: Some((x, y)),
841 key_press: None,
842 text: None,
843 selection: None,
844 modifiers,
845 click_count,
846 path: None,
847 kind: UiEventKind::Click,
848 });
849 }
850 }
851 if let Some(pressed_url) = self.ui_state.pressed_link.take() {
857 let up_link = self
858 .last_tree
859 .as_ref()
860 .and_then(|t| hit_test::link_at(t, &self.ui_state, (x, y)));
861 if up_link.as_ref() == Some(&pressed_url) {
862 out.push(UiEvent {
863 key: Some(pressed_url),
864 target: None,
865 pointer: Some((x, y)),
866 key_press: None,
867 text: None,
868 selection: None,
869 modifiers,
870 click_count: 1,
871 path: None,
872 kind: UiEventKind::LinkActivated,
873 });
874 }
875 }
876 }
877 PointerButton::Secondary | PointerButton::Middle => {
878 let pressed = self.ui_state.pressed_secondary.take();
879 if let (Some((p, b)), Some(h)) = (pressed, hit)
880 && b == button
881 && p.node_id == h.node_id
882 {
883 let kind = match button {
884 PointerButton::Secondary => UiEventKind::SecondaryClick,
885 PointerButton::Middle => UiEventKind::MiddleClick,
886 PointerButton::Primary => unreachable!(),
887 };
888 out.push(UiEvent {
889 key: Some(p.key.clone()),
890 target: Some(p),
891 pointer: Some((x, y)),
892 key_press: None,
893 text: None,
894 selection: None,
895 modifiers,
896 click_count: 1,
897 path: None,
898 kind,
899 });
900 }
901 }
902 }
903 out
904 }
905
906 pub fn key_down(&mut self, key: UiKey, modifiers: KeyModifiers, repeat: bool) -> Vec<UiEvent> {
907 if self.focused_captures_keys() {
913 if let Some(event) = self.ui_state.try_hotkey(&key, modifiers, repeat) {
914 return vec![event];
915 }
916 self.ui_state.bump_caret_activity(Instant::now());
923 self.ui_state.set_focus_visible(true);
924 return self
925 .ui_state
926 .key_down_raw(key, modifiers, repeat)
927 .into_iter()
928 .collect();
929 }
930
931 if matches!(
937 key,
938 UiKey::ArrowUp | UiKey::ArrowDown | UiKey::Home | UiKey::End
939 ) && let Some(siblings) = self.focused_arrow_nav_group()
940 {
941 if let Some(event) = self.ui_state.try_hotkey(&key, modifiers, repeat) {
942 return vec![event];
943 }
944 self.move_focus_in_group(&key, &siblings);
945 return Vec::new();
946 }
947
948 let mut out: Vec<UiEvent> = self
949 .ui_state
950 .key_down(key, modifiers, repeat)
951 .into_iter()
952 .collect();
953
954 if matches!(out.first().map(|e| e.kind), Some(UiEventKind::Escape))
962 && !self.ui_state.current_selection.is_empty()
963 {
964 self.ui_state.current_selection = crate::selection::Selection::default();
965 self.ui_state.selection.drag = None;
966 out.push(selection_event(
967 crate::selection::Selection::default(),
968 modifiers,
969 None,
970 ));
971 }
972
973 out
974 }
975
976 fn focused_arrow_nav_group(&self) -> Option<Vec<UiTarget>> {
983 let focused = self.ui_state.focused.as_ref()?;
984 let tree = self.last_tree.as_ref()?;
985 focus::arrow_nav_group(tree, &self.ui_state, &focused.node_id)
986 }
987
988 fn move_focus_in_group(&mut self, key: &UiKey, siblings: &[UiTarget]) {
993 if siblings.is_empty() {
994 return;
995 }
996 let focused_id = match self.ui_state.focused.as_ref() {
997 Some(t) => t.node_id.clone(),
998 None => return,
999 };
1000 let idx = siblings.iter().position(|t| t.node_id == focused_id);
1001 let next_idx = match (key, idx) {
1002 (UiKey::ArrowUp, Some(i)) => i.saturating_sub(1),
1003 (UiKey::ArrowDown, Some(i)) => (i + 1).min(siblings.len() - 1),
1004 (UiKey::Home, _) => 0,
1005 (UiKey::End, _) => siblings.len() - 1,
1006 _ => return,
1007 };
1008 if Some(next_idx) != idx {
1009 self.ui_state.set_focus(Some(siblings[next_idx].clone()));
1010 self.ui_state.set_focus_visible(true);
1011 }
1012 }
1013
1014 fn focused_captures_keys(&self) -> bool {
1018 let Some(focused) = self.ui_state.focused.as_ref() else {
1019 return false;
1020 };
1021 let Some(tree) = self.last_tree.as_ref() else {
1022 return false;
1023 };
1024 find_capture_keys(tree, &focused.node_id).unwrap_or(false)
1025 }
1026
1027 pub fn text_input(&mut self, text: String) -> Option<UiEvent> {
1033 if text.is_empty() {
1034 return None;
1035 }
1036 let target = self.ui_state.focused.clone()?;
1037 let modifiers = self.ui_state.modifiers;
1038 self.ui_state.bump_caret_activity(Instant::now());
1041 Some(UiEvent {
1042 key: Some(target.key.clone()),
1043 target: Some(target),
1044 pointer: None,
1045 key_press: None,
1046 text: Some(text),
1047 selection: None,
1048 modifiers,
1049 click_count: 0,
1050 path: None,
1051 kind: UiEventKind::TextInput,
1052 })
1053 }
1054
1055 pub fn set_hotkeys(&mut self, hotkeys: Vec<(KeyChord, String)>) {
1056 self.ui_state.set_hotkeys(hotkeys);
1057 }
1058
1059 pub fn set_selection(&mut self, selection: crate::selection::Selection) {
1064 if self.ui_state.current_selection != selection {
1065 self.ui_state.bump_caret_activity(Instant::now());
1066 }
1067 self.ui_state.current_selection = selection;
1068 }
1069
1070 pub fn push_toasts(&mut self, specs: Vec<crate::toast::ToastSpec>) {
1076 let now = Instant::now();
1077 for spec in specs {
1078 self.ui_state.push_toast(spec, now);
1079 }
1080 }
1081
1082 pub fn dismiss_toast(&mut self, id: u64) {
1086 self.ui_state.dismiss_toast(id);
1087 }
1088
1089 pub fn push_focus_requests(&mut self, keys: Vec<String>) {
1095 self.ui_state.push_focus_requests(keys);
1096 }
1097
1098 pub fn push_scroll_requests(&mut self, requests: Vec<crate::scroll::ScrollRequest>) {
1104 self.ui_state.push_scroll_requests(requests);
1105 }
1106
1107 pub fn set_animation_mode(&mut self, mode: AnimationMode) {
1108 self.ui_state.set_animation_mode(mode);
1109 }
1110
1111 pub fn pointer_wheel(&mut self, x: f32, y: f32, dy: f32) -> bool {
1112 let Some(tree) = self.last_tree.as_ref() else {
1113 return false;
1114 };
1115 self.ui_state.pointer_wheel(tree, (x, y), dy)
1116 }
1117
1118 pub fn prepare_layout<F>(
1135 &mut self,
1136 root: &mut El,
1137 viewport: Rect,
1138 scale_factor: f32,
1139 timings: &mut PrepareTimings,
1140 samples_time: F,
1141 ) -> LayoutPrepared
1142 where
1143 F: Fn(&ShaderHandle) -> bool,
1144 {
1145 let t0 = Instant::now();
1146 let mut needs_redraw = {
1153 crate::profile_span!("prepare::layout");
1154 {
1155 crate::profile_span!("prepare::layout::assign_ids");
1156 layout::assign_ids(root);
1157 }
1158 let tooltip_pending = {
1159 crate::profile_span!("prepare::layout::tooltip");
1160 tooltip::synthesize_tooltip(root, &self.ui_state, t0)
1161 };
1162 let toast_pending = {
1163 crate::profile_span!("prepare::layout::toast");
1164 toast::synthesize_toasts(root, &mut self.ui_state, t0)
1165 };
1166 {
1167 crate::profile_span!("prepare::layout::apply_metrics");
1168 self.theme.apply_metrics(root);
1169 }
1170 {
1171 crate::profile_span!("prepare::layout::layout");
1172 layout::layout_post_assign(root, &mut self.ui_state, viewport);
1179 self.ui_state.clear_pending_scroll_requests();
1184 }
1185 {
1186 crate::profile_span!("prepare::layout::sync_focus_order");
1187 self.ui_state.sync_focus_order(root);
1188 }
1189 {
1190 crate::profile_span!("prepare::layout::sync_selection_order");
1191 self.ui_state.sync_selection_order(root);
1192 }
1193 {
1194 crate::profile_span!("prepare::layout::sync_popover_focus");
1195 focus::sync_popover_focus(root, &mut self.ui_state);
1196 }
1197 {
1198 crate::profile_span!("prepare::layout::drain_focus_requests");
1203 self.ui_state.drain_focus_requests();
1204 }
1205 {
1206 crate::profile_span!("prepare::layout::apply_state");
1207 self.ui_state.apply_to_state();
1208 }
1209 self.viewport_px = self.surface_size_override.unwrap_or_else(|| {
1210 (
1211 (viewport.w * scale_factor).ceil().max(1.0) as u32,
1212 (viewport.h * scale_factor).ceil().max(1.0) as u32,
1213 )
1214 });
1215 let animations = {
1216 crate::profile_span!("prepare::layout::tick_animations");
1217 self.ui_state.tick_visual_animations(root, Instant::now())
1218 };
1219 animations || tooltip_pending || toast_pending
1220 };
1221 let t_after_layout = Instant::now();
1222 let ops = {
1223 crate::profile_span!("prepare::draw_ops");
1224 draw_ops::draw_ops_with_theme(root, &self.ui_state, &self.theme)
1225 };
1226 let t_after_draw_ops = Instant::now();
1227 timings.layout = t_after_layout - t0;
1228 timings.draw_ops = t_after_draw_ops - t_after_layout;
1229
1230 let shader_needs_redraw = ops.iter().any(|op| op_is_continuous(op, &samples_time));
1247 let widget_redraw =
1248 aggregate_redraw_within(root, viewport, &self.ui_state.layout.computed_rects);
1249
1250 let next_layout_redraw_in = match (needs_redraw, widget_redraw) {
1251 (true, Some(d)) => Some(d.min(std::time::Duration::ZERO)),
1252 (true, None) => Some(std::time::Duration::ZERO),
1253 (false, d) => d,
1254 };
1255 let next_paint_redraw_in = if shader_needs_redraw {
1256 Some(std::time::Duration::ZERO)
1257 } else {
1258 None
1259 };
1260 if next_layout_redraw_in.is_some() || next_paint_redraw_in.is_some() {
1261 needs_redraw = true;
1262 }
1263
1264 LayoutPrepared {
1269 ops,
1270 needs_redraw,
1271 next_layout_redraw_in,
1272 next_paint_redraw_in,
1273 }
1274 }
1275
1276 pub fn prepare_paint_cached<F1, F2>(
1289 &mut self,
1290 is_registered: F1,
1291 samples_backdrop: F2,
1292 text: &mut dyn TextRecorder,
1293 scale_factor: f32,
1294 timings: &mut PrepareTimings,
1295 ) where
1296 F1: Fn(&ShaderHandle) -> bool,
1297 F2: Fn(&ShaderHandle) -> bool,
1298 {
1299 let ops = std::mem::take(&mut self.last_ops);
1303 self.prepare_paint(
1304 &ops,
1305 is_registered,
1306 samples_backdrop,
1307 text,
1308 scale_factor,
1309 timings,
1310 );
1311 self.last_ops = ops;
1312 }
1313
1314 pub fn no_time_shaders(_shader: &ShaderHandle) -> bool {
1319 false
1320 }
1321
1322 pub fn scan_continuous_shaders<F>(&self, samples_time: F) -> Option<std::time::Duration>
1329 where
1330 F: Fn(&ShaderHandle) -> bool,
1331 {
1332 let any = self
1333 .last_ops
1334 .iter()
1335 .any(|op| op_is_continuous(op, &samples_time));
1336 if any {
1337 Some(std::time::Duration::ZERO)
1338 } else {
1339 None
1340 }
1341 }
1342
1343 pub fn prepare_paint<F1, F2>(
1354 &mut self,
1355 ops: &[DrawOp],
1356 is_registered: F1,
1357 samples_backdrop: F2,
1358 text: &mut dyn TextRecorder,
1359 scale_factor: f32,
1360 timings: &mut PrepareTimings,
1361 ) where
1362 F1: Fn(&ShaderHandle) -> bool,
1363 F2: Fn(&ShaderHandle) -> bool,
1364 {
1365 crate::profile_span!("prepare::paint");
1366 let t0 = Instant::now();
1367 self.quad_scratch.clear();
1368 self.runs.clear();
1369 self.paint_items.clear();
1370
1371 let mut current: Option<(ShaderHandle, Option<PhysicalScissor>)> = None;
1372 let mut run_first: u32 = 0;
1373 let mut snapshot_emitted = false;
1376
1377 for op in ops {
1378 match op {
1379 DrawOp::Quad {
1380 rect,
1381 scissor,
1382 shader,
1383 uniforms,
1384 ..
1385 } => {
1386 if !is_registered(shader) {
1387 continue;
1388 }
1389 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
1390 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
1391 continue;
1392 }
1393 if !snapshot_emitted && samples_backdrop(shader) {
1394 close_run(
1395 &mut self.runs,
1396 &mut self.paint_items,
1397 current,
1398 run_first,
1399 self.quad_scratch.len() as u32,
1400 );
1401 current = None;
1402 run_first = self.quad_scratch.len() as u32;
1403 self.paint_items.push(PaintItem::BackdropSnapshot);
1404 snapshot_emitted = true;
1405 }
1406 let inst = pack_instance(*rect, *shader, uniforms);
1407
1408 let key = (*shader, phys);
1409 if current != Some(key) {
1410 close_run(
1411 &mut self.runs,
1412 &mut self.paint_items,
1413 current,
1414 run_first,
1415 self.quad_scratch.len() as u32,
1416 );
1417 current = Some(key);
1418 run_first = self.quad_scratch.len() as u32;
1419 }
1420 self.quad_scratch.push(inst);
1421 }
1422 DrawOp::GlyphRun {
1423 rect,
1424 scissor,
1425 color,
1426 text: glyph_text,
1427 size,
1428 line_height,
1429 family,
1430 mono_family,
1431 weight,
1432 mono,
1433 wrap,
1434 anchor,
1435 underline,
1436 strikethrough,
1437 link,
1438 ..
1439 } => {
1440 close_run(
1441 &mut self.runs,
1442 &mut self.paint_items,
1443 current,
1444 run_first,
1445 self.quad_scratch.len() as u32,
1446 );
1447 current = None;
1448 run_first = self.quad_scratch.len() as u32;
1449
1450 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
1451 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
1452 continue;
1453 }
1454 let mut style = crate::text::atlas::RunStyle::new(*weight, *color)
1455 .family(*family)
1456 .mono_family(*mono_family);
1457 if *mono {
1458 style = style.mono();
1459 }
1460 if *underline {
1461 style = style.underline();
1462 }
1463 if *strikethrough {
1464 style = style.strikethrough();
1465 }
1466 if let Some(url) = link {
1467 style = style.with_link(url.clone());
1468 }
1469 let layers = text.record(
1470 *rect,
1471 phys,
1472 &style,
1473 glyph_text,
1474 *size,
1475 *line_height,
1476 *wrap,
1477 *anchor,
1478 scale_factor,
1479 );
1480 for index in layers {
1481 self.paint_items.push(PaintItem::Text(index));
1482 }
1483 }
1484 DrawOp::AttributedText {
1485 rect,
1486 scissor,
1487 runs,
1488 size,
1489 line_height,
1490 wrap,
1491 anchor,
1492 ..
1493 } => {
1494 close_run(
1495 &mut self.runs,
1496 &mut self.paint_items,
1497 current,
1498 run_first,
1499 self.quad_scratch.len() as u32,
1500 );
1501 current = None;
1502 run_first = self.quad_scratch.len() as u32;
1503
1504 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
1505 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
1506 continue;
1507 }
1508 let layers = text.record_runs(
1509 *rect,
1510 phys,
1511 runs,
1512 *size,
1513 *line_height,
1514 *wrap,
1515 *anchor,
1516 scale_factor,
1517 );
1518 for index in layers {
1519 self.paint_items.push(PaintItem::Text(index));
1520 }
1521 }
1522 DrawOp::Icon {
1523 rect,
1524 scissor,
1525 source,
1526 color,
1527 size,
1528 stroke_width,
1529 ..
1530 } => {
1531 close_run(
1532 &mut self.runs,
1533 &mut self.paint_items,
1534 current,
1535 run_first,
1536 self.quad_scratch.len() as u32,
1537 );
1538 current = None;
1539 run_first = self.quad_scratch.len() as u32;
1540
1541 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
1542 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
1543 continue;
1544 }
1545 let recorded = text.record_icon(
1546 *rect,
1547 phys,
1548 source,
1549 *color,
1550 *size,
1551 *stroke_width,
1552 scale_factor,
1553 );
1554 match recorded {
1555 RecordedPaint::Text(layers) => {
1556 for index in layers {
1557 self.paint_items.push(PaintItem::Text(index));
1558 }
1559 }
1560 RecordedPaint::Icon(runs) => {
1561 for index in runs {
1562 self.paint_items.push(PaintItem::IconRun(index));
1563 }
1564 }
1565 }
1566 }
1567 DrawOp::Image {
1568 rect,
1569 scissor,
1570 image,
1571 tint,
1572 radius,
1573 fit,
1574 ..
1575 } => {
1576 close_run(
1577 &mut self.runs,
1578 &mut self.paint_items,
1579 current,
1580 run_first,
1581 self.quad_scratch.len() as u32,
1582 );
1583 current = None;
1584 run_first = self.quad_scratch.len() as u32;
1585
1586 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
1587 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
1588 continue;
1589 }
1590 let recorded =
1591 text.record_image(*rect, phys, image, *tint, *radius, *fit, scale_factor);
1592 for index in recorded {
1593 self.paint_items.push(PaintItem::Image(index));
1594 }
1595 }
1596 DrawOp::AppTexture {
1597 rect,
1598 scissor,
1599 texture,
1600 alpha,
1601 transform,
1602 ..
1603 } => {
1604 close_run(
1605 &mut self.runs,
1606 &mut self.paint_items,
1607 current,
1608 run_first,
1609 self.quad_scratch.len() as u32,
1610 );
1611 current = None;
1612 run_first = self.quad_scratch.len() as u32;
1613
1614 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
1615 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
1616 continue;
1617 }
1618 let recorded = text.record_app_texture(
1619 *rect,
1620 phys,
1621 texture,
1622 *alpha,
1623 *transform,
1624 scale_factor,
1625 );
1626 for index in recorded {
1627 self.paint_items.push(PaintItem::AppTexture(index));
1628 }
1629 }
1630 DrawOp::Vector {
1631 rect,
1632 scissor,
1633 asset,
1634 render_mode,
1635 ..
1636 } => {
1637 close_run(
1638 &mut self.runs,
1639 &mut self.paint_items,
1640 current,
1641 run_first,
1642 self.quad_scratch.len() as u32,
1643 );
1644 current = None;
1645 run_first = self.quad_scratch.len() as u32;
1646
1647 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
1648 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
1649 continue;
1650 }
1651 let recorded =
1652 text.record_vector(*rect, phys, asset, *render_mode, scale_factor);
1653 for index in recorded {
1654 self.paint_items.push(PaintItem::Vector(index));
1655 }
1656 }
1657 DrawOp::BackdropSnapshot => {
1658 close_run(
1659 &mut self.runs,
1660 &mut self.paint_items,
1661 current,
1662 run_first,
1663 self.quad_scratch.len() as u32,
1664 );
1665 current = None;
1666 run_first = self.quad_scratch.len() as u32;
1667 if !snapshot_emitted {
1670 self.paint_items.push(PaintItem::BackdropSnapshot);
1671 snapshot_emitted = true;
1672 }
1673 }
1674 }
1675 }
1676 close_run(
1677 &mut self.runs,
1678 &mut self.paint_items,
1679 current,
1680 run_first,
1681 self.quad_scratch.len() as u32,
1682 );
1683 timings.paint = Instant::now() - t0;
1684 }
1685
1686 pub fn snapshot(&mut self, root: &El, timings: &mut PrepareTimings) {
1691 crate::profile_span!("prepare::snapshot");
1692 let t0 = Instant::now();
1693 self.last_tree = Some(root.clone());
1694 timings.snapshot = Instant::now() - t0;
1695 }
1696}
1697
1698fn op_is_continuous<F>(op: &DrawOp, samples_time: &F) -> bool
1705where
1706 F: Fn(&ShaderHandle) -> bool,
1707{
1708 match op.shader() {
1709 Some(handle @ ShaderHandle::Stock(s)) => s.is_continuous() || samples_time(handle),
1710 Some(handle @ ShaderHandle::Custom(_)) => samples_time(handle),
1711 None => false,
1712 }
1713}
1714
1715fn aggregate_redraw_within(
1721 node: &El,
1722 viewport: Rect,
1723 rects: &rustc_hash::FxHashMap<String, Rect>,
1724) -> Option<std::time::Duration> {
1725 let mut acc: Option<std::time::Duration> = None;
1726 visit_redraw_within(node, viewport, rects, VisibilityClip::Unclipped, &mut acc);
1727 acc
1728}
1729
1730#[derive(Clone, Copy)]
1731enum VisibilityClip {
1732 Unclipped,
1733 Clipped(Rect),
1734 Empty,
1735}
1736
1737impl VisibilityClip {
1738 fn intersect(self, rect: Rect) -> Self {
1739 if rect.w <= 0.0 || rect.h <= 0.0 {
1740 return Self::Empty;
1741 }
1742 match self {
1743 Self::Unclipped => Self::Clipped(rect),
1744 Self::Clipped(prev) => prev
1745 .intersect(rect)
1746 .map(Self::Clipped)
1747 .unwrap_or(Self::Empty),
1748 Self::Empty => Self::Empty,
1749 }
1750 }
1751
1752 fn permits(self, rect: Rect) -> bool {
1753 if rect.w <= 0.0 || rect.h <= 0.0 {
1754 return false;
1755 }
1756 match self {
1757 Self::Unclipped => true,
1758 Self::Clipped(clip) => rect.intersect(clip).is_some(),
1759 Self::Empty => false,
1760 }
1761 }
1762}
1763
1764fn visit_redraw_within(
1765 node: &El,
1766 viewport: Rect,
1767 rects: &rustc_hash::FxHashMap<String, Rect>,
1768 inherited_clip: VisibilityClip,
1769 acc: &mut Option<std::time::Duration>,
1770) {
1771 let rect = rects.get(&node.computed_id).copied();
1772 if let Some(d) = node.redraw_within {
1773 if let Some(rect) = rect
1774 && rect.w > 0.0
1775 && rect.h > 0.0
1776 && rect.intersect(viewport).is_some()
1777 && inherited_clip.permits(rect)
1778 {
1779 *acc = Some(match *acc {
1780 Some(prev) => prev.min(d),
1781 None => d,
1782 });
1783 }
1784 }
1785 let child_clip = if node.clip {
1786 rect.map(|r| inherited_clip.intersect(r))
1787 .unwrap_or(VisibilityClip::Empty)
1788 } else {
1789 inherited_clip
1790 };
1791 for child in &node.children {
1792 visit_redraw_within(child, viewport, rects, child_clip, acc);
1793 }
1794}
1795
1796pub(crate) fn find_capture_keys(node: &El, id: &str) -> Option<bool> {
1801 if node.computed_id == id {
1802 return Some(node.capture_keys);
1803 }
1804 node.children.iter().find_map(|c| find_capture_keys(c, id))
1805}
1806
1807fn selection_event(
1809 new_sel: crate::selection::Selection,
1810 modifiers: KeyModifiers,
1811 pointer: Option<(f32, f32)>,
1812) -> UiEvent {
1813 UiEvent {
1814 kind: UiEventKind::SelectionChanged,
1815 key: None,
1816 target: None,
1817 pointer,
1818 key_press: None,
1819 text: None,
1820 selection: Some(new_sel),
1821 modifiers,
1822 click_count: 0,
1823 path: None,
1824 }
1825}
1826
1827fn head_for_drag(
1839 root: &El,
1840 ui_state: &UiState,
1841 point: (f32, f32),
1842) -> Option<crate::selection::SelectionPoint> {
1843 if let Some(p) = hit_test::selection_point_at(root, ui_state, point) {
1844 return Some(p);
1845 }
1846
1847 let order = &ui_state.selection.order;
1848 if order.is_empty() {
1849 return None;
1850 }
1851 let target = order
1856 .iter()
1857 .find(|t| point.1 >= t.rect.y && point.1 < t.rect.y + t.rect.h)
1858 .or_else(|| {
1859 order.iter().min_by(|a, b| {
1860 let da = y_distance(a.rect, point.1);
1861 let db = y_distance(b.rect, point.1);
1862 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
1863 })
1864 })?;
1865 let target_rect = target.rect;
1866 let cy = point
1867 .1
1868 .clamp(target_rect.y, target_rect.y + target_rect.h - 1.0);
1869 if let Some(p) = hit_test::selection_point_at(root, ui_state, (point.0, cy)) {
1870 return Some(p);
1871 }
1872 let leaf_len = find_text_len(root, &target.node_id).unwrap_or(0);
1875 let byte = if point.0 < target_rect.x { 0 } else { leaf_len };
1876 Some(crate::selection::SelectionPoint {
1877 key: target.key.clone(),
1878 byte,
1879 })
1880}
1881
1882fn y_distance(rect: Rect, y: f32) -> f32 {
1883 if y < rect.y {
1884 rect.y - y
1885 } else if y > rect.y + rect.h {
1886 y - (rect.y + rect.h)
1887 } else {
1888 0.0
1889 }
1890}
1891
1892fn find_text_len(node: &El, id: &str) -> Option<usize> {
1893 if node.computed_id == id {
1894 return node.text.as_ref().map(|t| t.len());
1895 }
1896 node.children.iter().find_map(|c| find_text_len(c, id))
1897}
1898
1899pub enum RecordedPaint {
1902 Text(Range<usize>),
1903 Icon(Range<usize>),
1904}
1905
1906pub trait TextRecorder {
1910 #[allow(clippy::too_many_arguments)]
1918 fn record(
1919 &mut self,
1920 rect: Rect,
1921 scissor: Option<PhysicalScissor>,
1922 style: &RunStyle,
1923 text: &str,
1924 size: f32,
1925 line_height: f32,
1926 wrap: TextWrap,
1927 anchor: TextAnchor,
1928 scale_factor: f32,
1929 ) -> Range<usize>;
1930
1931 #[allow(clippy::too_many_arguments)]
1936 fn record_runs(
1937 &mut self,
1938 rect: Rect,
1939 scissor: Option<PhysicalScissor>,
1940 runs: &[(String, RunStyle)],
1941 size: f32,
1942 line_height: f32,
1943 wrap: TextWrap,
1944 anchor: TextAnchor,
1945 scale_factor: f32,
1946 ) -> Range<usize>;
1947
1948 #[allow(clippy::too_many_arguments)]
1954 fn record_icon(
1955 &mut self,
1956 rect: Rect,
1957 scissor: Option<PhysicalScissor>,
1958 source: &crate::svg_icon::IconSource,
1959 color: Color,
1960 size: f32,
1961 _stroke_width: f32,
1962 scale_factor: f32,
1963 ) -> RecordedPaint {
1964 let glyph = match source {
1965 crate::svg_icon::IconSource::Builtin(name) => name.fallback_glyph(),
1966 crate::svg_icon::IconSource::Custom(_) => "?",
1967 };
1968 RecordedPaint::Text(self.record(
1969 rect,
1970 scissor,
1971 &RunStyle::new(FontWeight::Regular, color),
1972 glyph,
1973 size,
1974 crate::text::metrics::line_height(size),
1975 TextWrap::NoWrap,
1976 TextAnchor::Middle,
1977 scale_factor,
1978 ))
1979 }
1980
1981 #[allow(clippy::too_many_arguments)]
1988 fn record_image(
1989 &mut self,
1990 _rect: Rect,
1991 _scissor: Option<PhysicalScissor>,
1992 _image: &crate::image::Image,
1993 _tint: Option<Color>,
1994 _radius: crate::tree::Corners,
1995 _fit: crate::image::ImageFit,
1996 _scale_factor: f32,
1997 ) -> Range<usize> {
1998 0..0
1999 }
2000
2001 fn record_app_texture(
2007 &mut self,
2008 _rect: Rect,
2009 _scissor: Option<PhysicalScissor>,
2010 _texture: &crate::surface::AppTexture,
2011 _alpha: crate::surface::SurfaceAlpha,
2012 _transform: crate::affine::Affine2,
2013 _scale_factor: f32,
2014 ) -> Range<usize> {
2015 0..0
2016 }
2017
2018 fn record_vector(
2024 &mut self,
2025 _rect: Rect,
2026 _scissor: Option<PhysicalScissor>,
2027 _asset: &crate::vector::VectorAsset,
2028 _render_mode: crate::vector::VectorRenderMode,
2029 _scale_factor: f32,
2030 ) -> Range<usize> {
2031 0..0
2032 }
2033}
2034
2035#[cfg(test)]
2036mod tests {
2037 use super::*;
2038 use crate::shader::{ShaderHandle, StockShader, UniformBlock};
2039
2040 struct NoText;
2042 impl TextRecorder for NoText {
2043 fn record(
2044 &mut self,
2045 _rect: Rect,
2046 _scissor: Option<PhysicalScissor>,
2047 _style: &RunStyle,
2048 _text: &str,
2049 _size: f32,
2050 _line_height: f32,
2051 _wrap: TextWrap,
2052 _anchor: TextAnchor,
2053 _scale_factor: f32,
2054 ) -> Range<usize> {
2055 0..0
2056 }
2057 fn record_runs(
2058 &mut self,
2059 _rect: Rect,
2060 _scissor: Option<PhysicalScissor>,
2061 _runs: &[(String, RunStyle)],
2062 _size: f32,
2063 _line_height: f32,
2064 _wrap: TextWrap,
2065 _anchor: TextAnchor,
2066 _scale_factor: f32,
2067 ) -> Range<usize> {
2068 0..0
2069 }
2070 }
2071
2072 fn lay_out_input_tree(capture: bool) -> RunnerCore {
2079 use crate::tree::*;
2080 let ti = if capture {
2081 crate::widgets::text::text("input").key("ti").capture_keys()
2082 } else {
2083 crate::widgets::text::text("noop").key("ti").focusable()
2084 };
2085 let mut tree =
2086 crate::column([crate::widgets::button::button("Btn").key("btn"), ti]).padding(10.0);
2087 let mut core = RunnerCore::new();
2088 crate::layout::layout(
2089 &mut tree,
2090 &mut core.ui_state,
2091 Rect::new(0.0, 0.0, 200.0, 200.0),
2092 );
2093 core.ui_state.sync_focus_order(&tree);
2094 let mut t = PrepareTimings::default();
2095 core.snapshot(&tree, &mut t);
2096 core
2097 }
2098
2099 #[test]
2100 fn pointer_up_emits_pointer_up_then_click() {
2101 let mut core = lay_out_input_tree(false);
2102 let btn_rect = core.rect_of_key("btn").expect("btn rect");
2103 let cx = btn_rect.x + btn_rect.w * 0.5;
2104 let cy = btn_rect.y + btn_rect.h * 0.5;
2105 core.pointer_moved(cx, cy);
2106 core.pointer_down(cx, cy, PointerButton::Primary);
2107 let events = core.pointer_up(cx, cy, PointerButton::Primary);
2108 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
2109 assert_eq!(kinds, vec![UiEventKind::PointerUp, UiEventKind::Click]);
2110 }
2111
2112 fn lay_out_link_tree() -> (RunnerCore, Rect, &'static str) {
2118 use crate::tree::*;
2119 const URL: &str = "https://github.com/computer-whisperer/aetna";
2120 let mut tree = crate::column([crate::text_runs([
2121 crate::text("Visit "),
2122 crate::text("github.com/computer-whisperer/aetna").link(URL),
2123 crate::text("."),
2124 ])])
2125 .padding(10.0);
2126 let mut core = RunnerCore::new();
2127 crate::layout::layout(
2128 &mut tree,
2129 &mut core.ui_state,
2130 Rect::new(0.0, 0.0, 600.0, 200.0),
2131 );
2132 core.ui_state.sync_focus_order(&tree);
2133 let mut t = PrepareTimings::default();
2134 core.snapshot(&tree, &mut t);
2135 let para = core
2136 .last_tree
2137 .as_ref()
2138 .and_then(|t| t.children.first())
2139 .map(|p| core.ui_state.rect(&p.computed_id))
2140 .expect("paragraph rect");
2141 (core, para, URL)
2142 }
2143
2144 #[test]
2145 fn pointer_up_on_link_emits_link_activated_with_url() {
2146 let (mut core, para, url) = lay_out_link_tree();
2147 let cx = para.x + 100.0;
2151 let cy = para.y + para.h * 0.5;
2152 core.pointer_moved(cx, cy);
2153 core.pointer_down(cx, cy, PointerButton::Primary);
2154 let events = core.pointer_up(cx, cy, PointerButton::Primary);
2155 let link = events
2156 .iter()
2157 .find(|e| e.kind == UiEventKind::LinkActivated)
2158 .expect("LinkActivated event");
2159 assert_eq!(link.key.as_deref(), Some(url));
2160 }
2161
2162 #[test]
2163 fn pointer_up_after_drag_off_link_does_not_activate() {
2164 let (mut core, para, _url) = lay_out_link_tree();
2165 let press_x = para.x + 100.0;
2166 let cy = para.y + para.h * 0.5;
2167 core.pointer_moved(press_x, cy);
2168 core.pointer_down(press_x, cy, PointerButton::Primary);
2169 let events = core.pointer_up(press_x, 180.0, PointerButton::Primary);
2173 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
2174 assert!(
2175 !kinds.contains(&UiEventKind::LinkActivated),
2176 "drag-off-link should cancel the link activation; got {kinds:?}",
2177 );
2178 }
2179
2180 #[test]
2181 fn pointer_moved_over_link_resolves_cursor_to_pointer_and_requests_redraw() {
2182 use crate::cursor::Cursor;
2183 let (mut core, para, _url) = lay_out_link_tree();
2184 let cx = para.x + 100.0;
2185 let cy = para.y + para.h * 0.5;
2186 let initial = core.pointer_moved(para.x - 50.0, cy);
2188 assert!(
2189 !initial.needs_redraw,
2190 "moving in empty space shouldn't request a redraw"
2191 );
2192 let tree = core.last_tree.as_ref().expect("tree").clone();
2193 assert_eq!(
2194 core.ui_state.cursor(&tree),
2195 Cursor::Default,
2196 "no link under pointer → default cursor"
2197 );
2198 let onto = core.pointer_moved(cx, cy);
2201 assert!(
2202 onto.needs_redraw,
2203 "entering a link region should flag a redraw so the cursor refresh isn't stale"
2204 );
2205 assert_eq!(
2206 core.ui_state.cursor(&tree),
2207 Cursor::Pointer,
2208 "pointer over a link → Pointer cursor"
2209 );
2210 let off = core.pointer_moved(para.x - 50.0, cy);
2213 assert!(
2214 off.needs_redraw,
2215 "leaving a link region should flag a redraw"
2216 );
2217 assert_eq!(core.ui_state.cursor(&tree), Cursor::Default);
2218 }
2219
2220 #[test]
2221 fn pointer_up_on_unlinked_text_does_not_emit_link_activated() {
2222 let (mut core, para, _url) = lay_out_link_tree();
2223 let cx = para.x + 1.0;
2226 let cy = para.y + para.h * 0.5;
2227 core.pointer_moved(cx, cy);
2228 core.pointer_down(cx, cy, PointerButton::Primary);
2229 let events = core.pointer_up(cx, cy, PointerButton::Primary);
2230 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
2231 assert!(
2232 !kinds.contains(&UiEventKind::LinkActivated),
2233 "click on the unlinked prefix should not surface a link event; got {kinds:?}",
2234 );
2235 }
2236
2237 #[test]
2238 fn pointer_up_off_target_emits_only_pointer_up() {
2239 let mut core = lay_out_input_tree(false);
2240 let btn_rect = core.rect_of_key("btn").expect("btn rect");
2241 let cx = btn_rect.x + btn_rect.w * 0.5;
2242 let cy = btn_rect.y + btn_rect.h * 0.5;
2243 core.pointer_down(cx, cy, PointerButton::Primary);
2244 let events = core.pointer_up(180.0, 180.0, PointerButton::Primary);
2246 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
2247 assert_eq!(
2248 kinds,
2249 vec![UiEventKind::PointerUp],
2250 "drag-off-target should still surface PointerUp so widgets see drag-end"
2251 );
2252 }
2253
2254 #[test]
2255 fn pointer_moved_while_pressed_emits_drag() {
2256 let mut core = lay_out_input_tree(false);
2257 let btn_rect = core.rect_of_key("btn").expect("btn rect");
2258 let cx = btn_rect.x + btn_rect.w * 0.5;
2259 let cy = btn_rect.y + btn_rect.h * 0.5;
2260 core.pointer_down(cx, cy, PointerButton::Primary);
2261 let drag = core
2262 .pointer_moved(cx + 30.0, cy)
2263 .events
2264 .into_iter()
2265 .find(|e| e.kind == UiEventKind::Drag)
2266 .expect("drag while pressed");
2267 assert_eq!(drag.target.as_ref().map(|t| t.key.as_str()), Some("btn"));
2268 assert_eq!(drag.pointer, Some((cx + 30.0, cy)));
2269 }
2270
2271 #[test]
2272 fn toast_dismiss_click_removes_toast_and_suppresses_click_event() {
2273 use crate::toast::ToastSpec;
2274 use crate::tree::Size;
2275 let mut core = RunnerCore::new();
2279 core.ui_state
2280 .push_toast(ToastSpec::success("hi"), Instant::now());
2281 let toast_id = core.ui_state.toasts()[0].id;
2282
2283 let mut tree: El = crate::stack(std::iter::empty::<El>())
2287 .width(Size::Fill(1.0))
2288 .height(Size::Fill(1.0));
2289 crate::layout::assign_ids(&mut tree);
2290 let _ = crate::toast::synthesize_toasts(&mut tree, &mut core.ui_state, Instant::now());
2291 crate::layout::layout(
2292 &mut tree,
2293 &mut core.ui_state,
2294 Rect::new(0.0, 0.0, 800.0, 600.0),
2295 );
2296 core.ui_state.sync_focus_order(&tree);
2297 let mut t = PrepareTimings::default();
2298 core.snapshot(&tree, &mut t);
2299
2300 let dismiss_key = format!("toast-dismiss-{toast_id}");
2301 let dismiss_rect = core.rect_of_key(&dismiss_key).expect("dismiss button");
2302 let cx = dismiss_rect.x + dismiss_rect.w * 0.5;
2303 let cy = dismiss_rect.y + dismiss_rect.h * 0.5;
2304
2305 core.pointer_down(cx, cy, PointerButton::Primary);
2306 let events = core.pointer_up(cx, cy, PointerButton::Primary);
2307 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
2308 assert!(
2312 !kinds.contains(&UiEventKind::Click),
2313 "Click on toast-dismiss should not be surfaced: {kinds:?}",
2314 );
2315 assert!(
2316 core.ui_state.toasts().iter().all(|t| t.id != toast_id),
2317 "toast {toast_id} should be dropped after dismiss-click",
2318 );
2319 }
2320
2321 #[test]
2322 fn pointer_moved_without_press_emits_no_drag() {
2323 let mut core = lay_out_input_tree(false);
2324 let events = core.pointer_moved(50.0, 50.0).events;
2325 assert!(!events.iter().any(|e| e.kind == UiEventKind::Drag));
2329 }
2330
2331 #[test]
2332 fn spinner_in_tree_keeps_needs_redraw_set() {
2333 use crate::widgets::spinner::spinner;
2338 let mut tree = crate::column([spinner()]);
2339 let mut core = RunnerCore::new();
2340 let mut t = PrepareTimings::default();
2341 let LayoutPrepared { needs_redraw, .. } = core.prepare_layout(
2342 &mut tree,
2343 Rect::new(0.0, 0.0, 200.0, 200.0),
2344 1.0,
2345 &mut t,
2346 RunnerCore::no_time_shaders,
2347 );
2348 assert!(
2349 needs_redraw,
2350 "tree with a spinner must request continuous redraw",
2351 );
2352
2353 let mut bare = crate::column([crate::widgets::text::text("idle")]);
2357 let mut core2 = RunnerCore::new();
2358 let mut t2 = PrepareTimings::default();
2359 let LayoutPrepared {
2360 needs_redraw: needs_redraw2,
2361 ..
2362 } = core2.prepare_layout(
2363 &mut bare,
2364 Rect::new(0.0, 0.0, 200.0, 200.0),
2365 1.0,
2366 &mut t2,
2367 RunnerCore::no_time_shaders,
2368 );
2369 assert!(
2370 !needs_redraw2,
2371 "tree without time-driven shaders should idle: got needs_redraw={needs_redraw2}",
2372 );
2373 }
2374
2375 #[test]
2376 fn custom_samples_time_shader_keeps_needs_redraw_set() {
2377 let mut tree = crate::column([crate::tree::El::new(crate::tree::Kind::Custom("anim"))
2381 .shader(crate::shader::ShaderBinding::custom("my_animated_glow"))
2382 .width(crate::tree::Size::Fixed(32.0))
2383 .height(crate::tree::Size::Fixed(32.0))]);
2384 let mut core = RunnerCore::new();
2385 let mut t = PrepareTimings::default();
2386
2387 let LayoutPrepared {
2388 needs_redraw: idle, ..
2389 } = core.prepare_layout(
2390 &mut tree,
2391 Rect::new(0.0, 0.0, 200.0, 200.0),
2392 1.0,
2393 &mut t,
2394 RunnerCore::no_time_shaders,
2395 );
2396 assert!(
2397 !idle,
2398 "without a samples_time registration the host should idle",
2399 );
2400
2401 let mut t2 = PrepareTimings::default();
2402 let LayoutPrepared {
2403 needs_redraw: animated,
2404 ..
2405 } = core.prepare_layout(
2406 &mut tree,
2407 Rect::new(0.0, 0.0, 200.0, 200.0),
2408 1.0,
2409 &mut t2,
2410 |handle| matches!(handle, ShaderHandle::Custom("my_animated_glow")),
2411 );
2412 assert!(
2413 animated,
2414 "custom shader registered as samples_time=true must request continuous redraw",
2415 );
2416 }
2417
2418 #[test]
2419 fn redraw_within_aggregates_to_minimum_visible_deadline() {
2420 use std::time::Duration;
2421 let mut tree = crate::column([
2422 crate::widgets::text::text("a")
2424 .redraw_within(Duration::from_millis(16))
2425 .width(crate::tree::Size::Fixed(20.0))
2426 .height(crate::tree::Size::Fixed(20.0)),
2427 crate::widgets::text::text("b")
2429 .redraw_within(Duration::from_millis(50))
2430 .width(crate::tree::Size::Fixed(20.0))
2431 .height(crate::tree::Size::Fixed(20.0)),
2432 ]);
2433 let mut core = RunnerCore::new();
2434 let mut t = PrepareTimings::default();
2435 let LayoutPrepared {
2436 needs_redraw,
2437 next_layout_redraw_in,
2438 ..
2439 } = core.prepare_layout(
2440 &mut tree,
2441 Rect::new(0.0, 0.0, 200.0, 200.0),
2442 1.0,
2443 &mut t,
2444 RunnerCore::no_time_shaders,
2445 );
2446 assert!(needs_redraw, "redraw_within must lift the legacy bool");
2447 assert_eq!(
2448 next_layout_redraw_in,
2449 Some(Duration::from_millis(16)),
2450 "tightest visible deadline wins, on the layout lane",
2451 );
2452 }
2453
2454 #[test]
2455 fn redraw_within_off_screen_widget_is_ignored() {
2456 use std::time::Duration;
2457 let mut tree = crate::column([
2463 crate::tree::spacer().height(crate::tree::Size::Fixed(150.0)),
2464 crate::widgets::text::text("offscreen")
2465 .redraw_within(Duration::from_millis(16))
2466 .width(crate::tree::Size::Fixed(10.0))
2467 .height(crate::tree::Size::Fixed(10.0)),
2468 ]);
2469 let mut core = RunnerCore::new();
2470 let mut t = PrepareTimings::default();
2471 let LayoutPrepared {
2472 next_layout_redraw_in,
2473 ..
2474 } = core.prepare_layout(
2475 &mut tree,
2476 Rect::new(0.0, 0.0, 100.0, 100.0),
2477 1.0,
2478 &mut t,
2479 RunnerCore::no_time_shaders,
2480 );
2481 assert_eq!(
2482 next_layout_redraw_in, None,
2483 "off-screen redraw_within must not contribute to the aggregate",
2484 );
2485 }
2486
2487 #[test]
2488 fn redraw_within_clipped_out_widget_is_ignored() {
2489 use std::time::Duration;
2490
2491 let clipped = crate::column([crate::widgets::text::text("clipped")
2492 .redraw_within(Duration::from_millis(16))
2493 .width(crate::tree::Size::Fixed(10.0))
2494 .height(crate::tree::Size::Fixed(10.0))])
2495 .clip()
2496 .width(crate::tree::Size::Fixed(100.0))
2497 .height(crate::tree::Size::Fixed(20.0))
2498 .layout(|ctx| {
2499 vec![Rect::new(
2500 ctx.container.x,
2501 ctx.container.y + 30.0,
2502 10.0,
2503 10.0,
2504 )]
2505 });
2506 let mut tree = crate::column([clipped]);
2507
2508 let mut core = RunnerCore::new();
2509 let mut t = PrepareTimings::default();
2510 let LayoutPrepared {
2511 next_layout_redraw_in,
2512 ..
2513 } = core.prepare_layout(
2514 &mut tree,
2515 Rect::new(0.0, 0.0, 100.0, 100.0),
2516 1.0,
2517 &mut t,
2518 RunnerCore::no_time_shaders,
2519 );
2520 assert_eq!(
2521 next_layout_redraw_in, None,
2522 "redraw_within inside an inherited clip but outside the clip rect must not contribute",
2523 );
2524 }
2525
2526 #[test]
2527 fn pointer_moved_within_same_hovered_node_does_not_request_redraw() {
2528 let mut core = lay_out_input_tree(false);
2534 let btn = core.rect_of_key("btn").expect("btn rect");
2535 let (cx, cy) = (btn.x + btn.w * 0.5, btn.y + btn.h * 0.5);
2536
2537 let first = core.pointer_moved(cx, cy);
2541 assert_eq!(first.events.len(), 1);
2542 assert_eq!(first.events[0].kind, UiEventKind::PointerEnter);
2543 assert_eq!(first.events[0].key.as_deref(), Some("btn"));
2544 assert!(
2545 first.needs_redraw,
2546 "entering a focusable should warrant a redraw",
2547 );
2548
2549 let second = core.pointer_moved(cx + 1.0, cy);
2553 assert!(second.events.is_empty());
2554 assert!(
2555 !second.needs_redraw,
2556 "identical hover, no drag → host should idle",
2557 );
2558
2559 let off = core.pointer_moved(0.0, 0.0);
2563 assert_eq!(off.events.len(), 1);
2564 assert_eq!(off.events[0].kind, UiEventKind::PointerLeave);
2565 assert_eq!(off.events[0].key.as_deref(), Some("btn"));
2566 assert!(
2567 off.needs_redraw,
2568 "leaving a hovered node still warrants a redraw",
2569 );
2570 }
2571
2572 #[test]
2573 fn pointer_moved_between_keyed_targets_emits_leave_then_enter() {
2574 let mut core = lay_out_input_tree(false);
2581 let btn = core.rect_of_key("btn").expect("btn rect");
2582 let ti = core.rect_of_key("ti").expect("ti rect");
2583
2584 let _ = core.pointer_moved(btn.x + 4.0, btn.y + 4.0);
2586
2587 let cross = core.pointer_moved(ti.x + 4.0, ti.y + 4.0);
2589 let kinds: Vec<UiEventKind> = cross.events.iter().map(|e| e.kind).collect();
2590 assert_eq!(
2591 kinds,
2592 vec![UiEventKind::PointerLeave, UiEventKind::PointerEnter],
2593 "paired Leave-then-Enter on cross-target hover transition",
2594 );
2595 assert_eq!(cross.events[0].key.as_deref(), Some("btn"));
2596 assert_eq!(cross.events[1].key.as_deref(), Some("ti"));
2597 assert!(cross.needs_redraw);
2598 }
2599
2600 #[test]
2601 fn pointer_left_emits_leave_for_prior_hover() {
2602 let mut core = lay_out_input_tree(false);
2603 let btn = core.rect_of_key("btn").expect("btn rect");
2604 let _ = core.pointer_moved(btn.x + 4.0, btn.y + 4.0);
2605
2606 let events = core.pointer_left();
2607 assert_eq!(events.len(), 1);
2608 assert_eq!(events[0].kind, UiEventKind::PointerLeave);
2609 assert_eq!(events[0].key.as_deref(), Some("btn"));
2610 }
2611
2612 #[test]
2613 fn pointer_left_with_no_prior_hover_emits_nothing() {
2614 let mut core = lay_out_input_tree(false);
2615 let events = core.pointer_left();
2618 assert!(events.is_empty());
2619 }
2620
2621 #[test]
2622 fn ui_state_hovered_key_returns_leaf_key() {
2623 let mut core = lay_out_input_tree(false);
2624 assert_eq!(core.ui_state().hovered_key(), None);
2625
2626 let btn = core.rect_of_key("btn").expect("btn rect");
2627 core.pointer_moved(btn.x + 4.0, btn.y + 4.0);
2628 assert_eq!(core.ui_state().hovered_key(), Some("btn"));
2629
2630 core.pointer_moved(0.0, 0.0);
2632 assert_eq!(core.ui_state().hovered_key(), None);
2633 }
2634
2635 #[test]
2636 fn ui_state_is_hovering_within_walks_subtree() {
2637 use crate::tree::*;
2641 let mut tree = crate::column([crate::stack([
2642 crate::widgets::button::button("Inner").key("inner_btn")
2643 ])
2644 .key("card")
2645 .focusable()
2646 .width(Size::Fixed(120.0))
2647 .height(Size::Fixed(60.0))])
2648 .padding(20.0);
2649 let mut core = RunnerCore::new();
2650 crate::layout::layout(
2651 &mut tree,
2652 &mut core.ui_state,
2653 Rect::new(0.0, 0.0, 400.0, 200.0),
2654 );
2655 core.ui_state.sync_focus_order(&tree);
2656 let mut t = PrepareTimings::default();
2657 core.snapshot(&tree, &mut t);
2658
2659 assert!(!core.ui_state().is_hovering_within("card"));
2661 assert!(!core.ui_state().is_hovering_within("inner_btn"));
2662
2663 let inner = core.rect_of_key("inner_btn").expect("inner rect");
2666 core.pointer_moved(inner.x + 4.0, inner.y + 4.0);
2667 assert!(core.ui_state().is_hovering_within("card"));
2668 assert!(core.ui_state().is_hovering_within("inner_btn"));
2669
2670 assert!(!core.ui_state().is_hovering_within("not_a_key"));
2672
2673 core.pointer_moved(0.0, 0.0);
2675 assert!(!core.ui_state().is_hovering_within("card"));
2676 assert!(!core.ui_state().is_hovering_within("inner_btn"));
2677 }
2678
2679 #[test]
2680 fn hover_driven_scale_via_is_hovering_within_plus_animate() {
2681 use crate::Theme;
2688 use crate::anim::Timing;
2689 use crate::tree::*;
2690
2691 let build_card = |hovering: bool| -> El {
2694 let scale = if hovering { 1.05 } else { 1.0 };
2695 crate::column([crate::stack(
2696 [crate::widgets::button::button("Inner").key("inner_btn")],
2697 )
2698 .key("card")
2699 .focusable()
2700 .scale(scale)
2701 .animate(Timing::SPRING_QUICK)
2702 .width(Size::Fixed(120.0))
2703 .height(Size::Fixed(60.0))])
2704 .padding(20.0)
2705 };
2706
2707 let mut core = RunnerCore::new();
2708 core.ui_state
2711 .set_animation_mode(crate::state::AnimationMode::Settled);
2712
2713 let theme = Theme::default();
2715 let cx_pre = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
2716 assert!(!cx_pre.is_hovering_within("card"));
2717 let mut tree = build_card(cx_pre.is_hovering_within("card"));
2718 crate::layout::layout(
2719 &mut tree,
2720 &mut core.ui_state,
2721 Rect::new(0.0, 0.0, 400.0, 200.0),
2722 );
2723 core.ui_state.sync_focus_order(&tree);
2724 let mut t = PrepareTimings::default();
2725 core.snapshot(&tree, &mut t);
2726 core.ui_state
2727 .tick_visual_animations(&mut tree, web_time::Instant::now());
2728 let card_at_rest = tree.children[0].clone();
2729 assert!((card_at_rest.scale - 1.0).abs() < 1e-3);
2730
2731 let card_rect = core.rect_of_key("card").expect("card rect");
2733 core.pointer_moved(card_rect.x + 4.0, card_rect.y + 4.0);
2734
2735 let cx_hot = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
2738 assert!(cx_hot.is_hovering_within("card"));
2739 let mut tree = build_card(cx_hot.is_hovering_within("card"));
2740 crate::layout::layout(
2741 &mut tree,
2742 &mut core.ui_state,
2743 Rect::new(0.0, 0.0, 400.0, 200.0),
2744 );
2745 core.ui_state.sync_focus_order(&tree);
2746 core.snapshot(&tree, &mut t);
2747 core.ui_state
2748 .tick_visual_animations(&mut tree, web_time::Instant::now());
2749 let card_hot = tree.children[0].clone();
2750 assert!(
2751 (card_hot.scale - 1.05).abs() < 1e-3,
2752 "hover should drive card scale to 1.05 via animate; got {}",
2753 card_hot.scale,
2754 );
2755
2756 core.pointer_moved(0.0, 0.0);
2758 let cx_cold = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
2759 assert!(!cx_cold.is_hovering_within("card"));
2760 let mut tree = build_card(cx_cold.is_hovering_within("card"));
2761 crate::layout::layout(
2762 &mut tree,
2763 &mut core.ui_state,
2764 Rect::new(0.0, 0.0, 400.0, 200.0),
2765 );
2766 core.ui_state.sync_focus_order(&tree);
2767 core.snapshot(&tree, &mut t);
2768 core.ui_state
2769 .tick_visual_animations(&mut tree, web_time::Instant::now());
2770 let card_after = tree.children[0].clone();
2771 assert!((card_after.scale - 1.0).abs() < 1e-3);
2772 }
2773
2774 #[test]
2775 fn file_dropped_routes_to_keyed_leaf_at_pointer() {
2776 let mut core = lay_out_input_tree(false);
2777 let btn = core.rect_of_key("btn").expect("btn rect");
2778 let path = std::path::PathBuf::from("/tmp/screenshot.png");
2779 let events = core.file_dropped(path.clone(), btn.x + 4.0, btn.y + 4.0);
2780 assert_eq!(events.len(), 1);
2781 let event = &events[0];
2782 assert_eq!(event.kind, UiEventKind::FileDropped);
2783 assert_eq!(event.key.as_deref(), Some("btn"));
2784 assert_eq!(event.path.as_deref(), Some(path.as_path()));
2785 assert_eq!(event.pointer, Some((btn.x + 4.0, btn.y + 4.0)));
2786 }
2787
2788 #[test]
2789 fn file_dropped_outside_keyed_surface_emits_window_level_event() {
2790 let mut core = lay_out_input_tree(false);
2791 let path = std::path::PathBuf::from("/tmp/screenshot.png");
2793 let events = core.file_dropped(path.clone(), 1.0, 1.0);
2794 assert_eq!(events.len(), 1);
2795 let event = &events[0];
2796 assert_eq!(event.kind, UiEventKind::FileDropped);
2797 assert!(
2798 event.target.is_none(),
2799 "drop outside any keyed surface routes window-level",
2800 );
2801 assert!(event.key.is_none());
2802 assert_eq!(event.path.as_deref(), Some(path.as_path()));
2804 }
2805
2806 #[test]
2807 fn file_hovered_then_cancelled_pair() {
2808 let mut core = lay_out_input_tree(false);
2809 let btn = core.rect_of_key("btn").expect("btn rect");
2810 let path = std::path::PathBuf::from("/tmp/a.png");
2811
2812 let hover = core.file_hovered(path.clone(), btn.x + 4.0, btn.y + 4.0);
2813 assert_eq!(hover.len(), 1);
2814 assert_eq!(hover[0].kind, UiEventKind::FileHovered);
2815 assert_eq!(hover[0].key.as_deref(), Some("btn"));
2816 assert_eq!(hover[0].path.as_deref(), Some(path.as_path()));
2817
2818 let cancel = core.file_hover_cancelled();
2819 assert_eq!(cancel.len(), 1);
2820 assert_eq!(cancel[0].kind, UiEventKind::FileHoverCancelled);
2821 assert!(cancel[0].target.is_none());
2822 assert!(cancel[0].path.is_none());
2823 }
2824
2825 #[test]
2826 fn build_cx_hover_accessors_default_off_without_state() {
2827 use crate::Theme;
2828 let theme = Theme::default();
2829 let cx = crate::BuildCx::new(&theme);
2830 assert_eq!(cx.hovered_key(), None);
2831 assert!(!cx.is_hovering_within("anything"));
2832 }
2833
2834 #[test]
2835 fn build_cx_hover_accessors_delegate_when_state_attached() {
2836 use crate::Theme;
2837 let mut core = lay_out_input_tree(false);
2838 let btn = core.rect_of_key("btn").expect("btn rect");
2839 core.pointer_moved(btn.x + 4.0, btn.y + 4.0);
2840
2841 let theme = Theme::default();
2842 let cx = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
2843 assert_eq!(cx.hovered_key(), Some("btn"));
2844 assert!(cx.is_hovering_within("btn"));
2845 assert!(!cx.is_hovering_within("ti"));
2846 }
2847
2848 fn lay_out_paragraph_tree() -> RunnerCore {
2849 use crate::tree::*;
2850 let mut tree = crate::column([
2851 crate::widgets::text::text("First paragraph of text.")
2852 .key("p1")
2853 .selectable(),
2854 crate::widgets::text::text("Second paragraph of text.")
2855 .key("p2")
2856 .selectable(),
2857 ])
2858 .padding(20.0);
2859 let mut core = RunnerCore::new();
2860 crate::layout::layout(
2861 &mut tree,
2862 &mut core.ui_state,
2863 Rect::new(0.0, 0.0, 400.0, 300.0),
2864 );
2865 core.ui_state.sync_focus_order(&tree);
2866 core.ui_state.sync_selection_order(&tree);
2867 let mut t = PrepareTimings::default();
2868 core.snapshot(&tree, &mut t);
2869 core
2870 }
2871
2872 #[test]
2873 fn pointer_down_on_selectable_text_emits_selection_changed() {
2874 let mut core = lay_out_paragraph_tree();
2875 let p1 = core.rect_of_key("p1").expect("p1 rect");
2876 let cx = p1.x + 4.0;
2877 let cy = p1.y + p1.h * 0.5;
2878 let events = core.pointer_down(cx, cy, PointerButton::Primary);
2879 let sel_event = events
2880 .iter()
2881 .find(|e| e.kind == UiEventKind::SelectionChanged)
2882 .expect("SelectionChanged emitted");
2883 let new_sel = sel_event
2884 .selection
2885 .as_ref()
2886 .expect("SelectionChanged carries a selection");
2887 let range = new_sel.range.as_ref().expect("collapsed selection at hit");
2888 assert_eq!(range.anchor.key, "p1");
2889 assert_eq!(range.head.key, "p1");
2890 assert_eq!(range.anchor.byte, range.head.byte);
2891 assert!(core.ui_state.selection.drag.is_some());
2892 }
2893
2894 #[test]
2895 fn pointer_drag_on_selectable_text_extends_head() {
2896 let mut core = lay_out_paragraph_tree();
2897 let p1 = core.rect_of_key("p1").expect("p1 rect");
2898 let cx = p1.x + 4.0;
2899 let cy = p1.y + p1.h * 0.5;
2900 core.pointer_moved(cx, cy);
2901 core.pointer_down(cx, cy, PointerButton::Primary);
2902
2903 let events = core.pointer_moved(p1.x + p1.w - 10.0, cy).events;
2905 let sel_event = events
2906 .iter()
2907 .find(|e| e.kind == UiEventKind::SelectionChanged)
2908 .expect("Drag emits SelectionChanged");
2909 let new_sel = sel_event.selection.as_ref().unwrap();
2910 let range = new_sel.range.as_ref().unwrap();
2911 assert_eq!(range.anchor.key, "p1");
2912 assert_eq!(range.head.key, "p1");
2913 assert!(
2914 range.head.byte > range.anchor.byte,
2915 "head should advance past anchor (anchor={}, head={})",
2916 range.anchor.byte,
2917 range.head.byte
2918 );
2919 }
2920
2921 #[test]
2922 fn pointer_up_clears_drag_but_keeps_selection() {
2923 let mut core = lay_out_paragraph_tree();
2924 let p1 = core.rect_of_key("p1").expect("p1 rect");
2925 let cx = p1.x + 4.0;
2926 let cy = p1.y + p1.h * 0.5;
2927 core.pointer_down(cx, cy, PointerButton::Primary);
2928 core.pointer_moved(p1.x + p1.w - 10.0, cy);
2929 let _ = core.pointer_up(p1.x + p1.w - 10.0, cy, PointerButton::Primary);
2930 assert!(
2931 core.ui_state.selection.drag.is_none(),
2932 "drag flag should clear on pointer_up"
2933 );
2934 assert!(
2935 !core.ui_state.current_selection.is_empty(),
2936 "selection itself should persist after pointer_up"
2937 );
2938 }
2939
2940 #[test]
2941 fn drag_past_a_leaf_bottom_keeps_head_in_that_leaf_not_anchor() {
2942 let mut core = lay_out_paragraph_tree();
2948 let p1 = core.rect_of_key("p1").expect("p1 rect");
2949 let p2 = core.rect_of_key("p2").expect("p2 rect");
2950 core.pointer_down(p1.x + 4.0, p1.y + p1.h * 0.5, PointerButton::Primary);
2952 core.pointer_moved(p2.x + 8.0, p2.y + p2.h * 0.5);
2954 let events = core.pointer_moved(p2.x + 8.0, p2.y + p2.h + 200.0).events;
2957 let sel = events
2958 .iter()
2959 .find(|e| e.kind == UiEventKind::SelectionChanged)
2960 .map(|e| e.selection.as_ref().unwrap().clone())
2961 .unwrap_or_else(|| core.ui_state.current_selection.clone());
2964 let r = sel.range.as_ref().expect("selection still active");
2965 assert_eq!(r.anchor.key, "p1", "anchor unchanged");
2966 assert_eq!(
2967 r.head.key, "p2",
2968 "head must stay in p2 even when pointer is below p2's rect"
2969 );
2970 }
2971
2972 #[test]
2973 fn drag_into_a_sibling_selectable_extends_head_into_that_leaf() {
2974 let mut core = lay_out_paragraph_tree();
2975 let p1 = core.rect_of_key("p1").expect("p1 rect");
2976 let p2 = core.rect_of_key("p2").expect("p2 rect");
2977 core.pointer_down(p1.x + 4.0, p1.y + p1.h * 0.5, PointerButton::Primary);
2979 let events = core.pointer_moved(p2.x + 8.0, p2.y + p2.h * 0.5).events;
2981 let sel_event = events
2982 .iter()
2983 .find(|e| e.kind == UiEventKind::SelectionChanged)
2984 .expect("Drag emits SelectionChanged");
2985 let new_sel = sel_event.selection.as_ref().unwrap();
2986 let range = new_sel.range.as_ref().unwrap();
2987 assert_eq!(range.anchor.key, "p1", "anchor stays in p1");
2988 assert_eq!(range.head.key, "p2", "head migrates into p2");
2989 }
2990
2991 #[test]
2992 fn pointer_down_on_focusable_owning_selection_does_not_clear_it() {
2993 let mut core = lay_out_input_tree(true);
3001 core.set_selection(crate::selection::Selection::caret("ti", 3));
3004 let ti = core.rect_of_key("ti").expect("ti rect");
3005 let cx = ti.x + ti.w * 0.5;
3006 let cy = ti.y + ti.h * 0.5;
3007
3008 let events = core.pointer_down(cx, cy, PointerButton::Primary);
3009 let cleared = events.iter().find(|e| {
3010 e.kind == UiEventKind::SelectionChanged
3011 && e.selection.as_ref().map(|s| s.is_empty()).unwrap_or(false)
3012 });
3013 assert!(
3014 cleared.is_none(),
3015 "click on the selection-owning input must not emit a clearing SelectionChanged"
3016 );
3017 assert_eq!(
3018 core.ui_state.current_selection,
3019 crate::selection::Selection::caret("ti", 3),
3020 "runtime mirror is preserved when the click owns the selection"
3021 );
3022 }
3023
3024 #[test]
3025 fn pointer_down_into_a_different_capture_keys_widget_does_not_clear_first() {
3026 let mut core = lay_out_input_tree(true);
3036 core.set_selection(crate::selection::Selection::caret("other", 4));
3038 let ti = core.rect_of_key("ti").expect("ti rect");
3039 let cx = ti.x + ti.w * 0.5;
3040 let cy = ti.y + ti.h * 0.5;
3041
3042 let events = core.pointer_down(cx, cy, PointerButton::Primary);
3043 let cleared = events.iter().any(|e| {
3044 e.kind == UiEventKind::SelectionChanged
3045 && e.selection.as_ref().map(|s| s.is_empty()).unwrap_or(false)
3046 });
3047 assert!(
3048 !cleared,
3049 "click on a different capture_keys widget must not race-clear the selection"
3050 );
3051 }
3052
3053 #[test]
3054 fn pointer_down_on_non_selectable_clears_existing_selection() {
3055 let mut core = lay_out_paragraph_tree();
3056 let p1 = core.rect_of_key("p1").expect("p1 rect");
3057 let cy = p1.y + p1.h * 0.5;
3058 core.pointer_down(p1.x + 4.0, cy, PointerButton::Primary);
3060 core.pointer_up(p1.x + 4.0, cy, PointerButton::Primary);
3061 assert!(!core.ui_state.current_selection.is_empty());
3062
3063 let events = core.pointer_down(2.0, 2.0, PointerButton::Primary);
3065 let cleared = events
3066 .iter()
3067 .find(|e| e.kind == UiEventKind::SelectionChanged)
3068 .expect("clearing emits SelectionChanged");
3069 let new_sel = cleared.selection.as_ref().unwrap();
3070 assert!(new_sel.is_empty(), "new selection should be empty");
3071 assert!(core.ui_state.current_selection.is_empty());
3072 }
3073
3074 #[test]
3075 fn key_down_bumps_caret_activity_when_focused_widget_captures_keys() {
3076 let mut core = lay_out_input_tree(true);
3081 let target = core
3082 .ui_state
3083 .focus
3084 .order
3085 .iter()
3086 .find(|t| t.key == "ti")
3087 .cloned();
3088 core.ui_state.set_focus(target); let after_focus = core.ui_state.caret.activity_at.expect("focus bump");
3090
3091 std::thread::sleep(std::time::Duration::from_millis(2));
3092 let _ = core.key_down(UiKey::ArrowRight, KeyModifiers::default(), false);
3093 let after_arrow = core
3094 .ui_state
3095 .caret
3096 .activity_at
3097 .expect("arrow key bumps even without app-side selection");
3098 assert!(
3099 after_arrow > after_focus,
3100 "ArrowRight to a capture_keys focused widget bumps caret activity"
3101 );
3102 }
3103
3104 #[test]
3105 fn text_input_bumps_caret_activity_when_focused() {
3106 let mut core = lay_out_input_tree(true);
3107 let target = core
3108 .ui_state
3109 .focus
3110 .order
3111 .iter()
3112 .find(|t| t.key == "ti")
3113 .cloned();
3114 core.ui_state.set_focus(target);
3115 let after_focus = core.ui_state.caret.activity_at.unwrap();
3116
3117 std::thread::sleep(std::time::Duration::from_millis(2));
3118 let _ = core.text_input("a".into());
3119 let after_text = core.ui_state.caret.activity_at.unwrap();
3120 assert!(
3121 after_text > after_focus,
3122 "TextInput to focused widget bumps caret activity"
3123 );
3124 }
3125
3126 #[test]
3127 fn pointer_down_inside_focused_input_bumps_caret_activity() {
3128 let mut core = lay_out_input_tree(true);
3133 let ti = core.rect_of_key("ti").expect("ti rect");
3134 let cx = ti.x + ti.w * 0.5;
3135 let cy = ti.y + ti.h * 0.5;
3136
3137 core.pointer_down(cx, cy, PointerButton::Primary);
3139 let _ = core.pointer_up(cx, cy, PointerButton::Primary);
3140 let after_first = core.ui_state.caret.activity_at.unwrap();
3141
3142 std::thread::sleep(std::time::Duration::from_millis(2));
3145 core.pointer_down(cx + 1.0, cy, PointerButton::Primary);
3146 let after_second = core
3147 .ui_state
3148 .caret
3149 .activity_at
3150 .expect("second click bumps too");
3151 assert!(
3152 after_second > after_first,
3153 "click within already-focused capture_keys widget still bumps"
3154 );
3155 }
3156
3157 #[test]
3158 fn arrow_key_through_apply_event_mutates_selection_and_bumps_on_set() {
3159 use crate::widgets::text_input;
3165 let mut sel = crate::selection::Selection::caret("ti", 2);
3166 let mut value = String::from("hello");
3167
3168 let mut core = RunnerCore::new();
3169 core.set_selection(sel.clone());
3172 let baseline = core.ui_state.caret.activity_at;
3173
3174 let arrow_right = UiEvent {
3176 key: Some("ti".into()),
3177 target: None,
3178 pointer: None,
3179 key_press: Some(crate::event::KeyPress {
3180 key: UiKey::ArrowRight,
3181 modifiers: KeyModifiers::default(),
3182 repeat: false,
3183 }),
3184 text: None,
3185 selection: None,
3186 modifiers: KeyModifiers::default(),
3187 click_count: 0,
3188 path: None,
3189 kind: UiEventKind::KeyDown,
3190 };
3191
3192 let mutated = text_input::apply_event(&mut value, &mut sel, "ti", &arrow_right);
3194 assert!(mutated, "ArrowRight should mutate selection");
3195 assert_eq!(
3196 sel.within("ti").unwrap().head,
3197 3,
3198 "head moved one char right (h-e-l-l-o, byte 2 → 3)"
3199 );
3200
3201 std::thread::sleep(std::time::Duration::from_millis(2));
3203 core.set_selection(sel);
3204 let after = core.ui_state.caret.activity_at.unwrap();
3205 if let Some(b) = baseline {
3209 assert!(after > b, "arrow-key flow should bump activity");
3210 }
3211 }
3212
3213 #[test]
3214 fn set_selection_bumps_caret_activity_only_when_value_changes() {
3215 let mut core = lay_out_paragraph_tree();
3216 core.set_selection(crate::selection::Selection::default());
3219 assert!(
3220 core.ui_state.caret.activity_at.is_none(),
3221 "no-op set_selection should not bump activity"
3222 );
3223
3224 let sel_a = crate::selection::Selection::caret("p1", 3);
3226 core.set_selection(sel_a.clone());
3227 let bumped_at = core
3228 .ui_state
3229 .caret
3230 .activity_at
3231 .expect("first real selection bumps");
3232
3233 core.set_selection(sel_a.clone());
3236 assert_eq!(
3237 core.ui_state.caret.activity_at,
3238 Some(bumped_at),
3239 "set_selection with same value is a no-op"
3240 );
3241
3242 std::thread::sleep(std::time::Duration::from_millis(2));
3245 let sel_b = crate::selection::Selection::caret("p1", 7);
3246 core.set_selection(sel_b);
3247 let new_bump = core.ui_state.caret.activity_at.expect("second bump");
3248 assert!(
3249 new_bump > bumped_at,
3250 "moving the caret bumps activity again",
3251 );
3252 }
3253
3254 #[test]
3255 fn escape_clears_active_selection_and_emits_selection_changed() {
3256 let mut core = lay_out_paragraph_tree();
3257 let p1 = core.rect_of_key("p1").expect("p1 rect");
3258 let cy = p1.y + p1.h * 0.5;
3259 core.pointer_down(p1.x + 4.0, cy, PointerButton::Primary);
3261 core.pointer_moved(p1.x + p1.w - 10.0, cy);
3262 core.pointer_up(p1.x + p1.w - 10.0, cy, PointerButton::Primary);
3263 assert!(!core.ui_state.current_selection.is_empty());
3264
3265 let events = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
3266 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3267 assert_eq!(
3268 kinds,
3269 vec![UiEventKind::Escape, UiEventKind::SelectionChanged],
3270 "Esc emits Escape (for popover dismiss) AND SelectionChanged"
3271 );
3272 let cleared = events
3273 .iter()
3274 .find(|e| e.kind == UiEventKind::SelectionChanged)
3275 .unwrap();
3276 assert!(cleared.selection.as_ref().unwrap().is_empty());
3277 assert!(core.ui_state.current_selection.is_empty());
3278 }
3279
3280 #[test]
3281 fn consecutive_clicks_on_same_target_extend_count() {
3282 let mut core = lay_out_input_tree(false);
3283 let btn = core.rect_of_key("btn").expect("btn rect");
3284 let cx = btn.x + btn.w * 0.5;
3285 let cy = btn.y + btn.h * 0.5;
3286
3287 let down1 = core.pointer_down(cx, cy, PointerButton::Primary);
3289 let pd1 = down1
3290 .iter()
3291 .find(|e| e.kind == UiEventKind::PointerDown)
3292 .expect("PointerDown emitted");
3293 assert_eq!(pd1.click_count, 1, "first press starts the sequence");
3294 let up1 = core.pointer_up(cx, cy, PointerButton::Primary);
3295 let click1 = up1
3296 .iter()
3297 .find(|e| e.kind == UiEventKind::Click)
3298 .expect("Click emitted");
3299 assert_eq!(
3300 click1.click_count, 1,
3301 "Click carries the same count as its PointerDown"
3302 );
3303
3304 let down2 = core.pointer_down(cx, cy, PointerButton::Primary);
3306 let pd2 = down2
3307 .iter()
3308 .find(|e| e.kind == UiEventKind::PointerDown)
3309 .unwrap();
3310 assert_eq!(pd2.click_count, 2, "second press extends the sequence");
3311 let up2 = core.pointer_up(cx, cy, PointerButton::Primary);
3312 assert_eq!(
3313 up2.iter()
3314 .find(|e| e.kind == UiEventKind::Click)
3315 .unwrap()
3316 .click_count,
3317 2
3318 );
3319
3320 let down3 = core.pointer_down(cx, cy, PointerButton::Primary);
3322 let pd3 = down3
3323 .iter()
3324 .find(|e| e.kind == UiEventKind::PointerDown)
3325 .unwrap();
3326 assert_eq!(pd3.click_count, 3, "third press → triple-click");
3327 core.pointer_up(cx, cy, PointerButton::Primary);
3328 }
3329
3330 #[test]
3331 fn click_count_resets_when_target_changes() {
3332 let mut core = lay_out_input_tree(false);
3333 let btn = core.rect_of_key("btn").expect("btn rect");
3334 let ti = core.rect_of_key("ti").expect("ti rect");
3335
3336 let down1 = core.pointer_down(
3338 btn.x + btn.w * 0.5,
3339 btn.y + btn.h * 0.5,
3340 PointerButton::Primary,
3341 );
3342 assert_eq!(
3343 down1
3344 .iter()
3345 .find(|e| e.kind == UiEventKind::PointerDown)
3346 .unwrap()
3347 .click_count,
3348 1
3349 );
3350 let _ = core.pointer_up(
3351 btn.x + btn.w * 0.5,
3352 btn.y + btn.h * 0.5,
3353 PointerButton::Primary,
3354 );
3355
3356 let down2 = core.pointer_down(ti.x + ti.w * 0.5, ti.y + ti.h * 0.5, PointerButton::Primary);
3358 let pd2 = down2
3359 .iter()
3360 .find(|e| e.kind == UiEventKind::PointerDown)
3361 .unwrap();
3362 assert_eq!(
3363 pd2.click_count, 1,
3364 "press on a new target resets the multi-click sequence"
3365 );
3366 }
3367
3368 #[test]
3369 fn double_click_on_selectable_text_selects_word_at_hit() {
3370 let mut core = lay_out_paragraph_tree();
3371 let p1 = core.rect_of_key("p1").expect("p1 rect");
3372 let cy = p1.y + p1.h * 0.5;
3373 let cx = p1.x + 4.0;
3376 core.pointer_down(cx, cy, PointerButton::Primary);
3377 core.pointer_up(cx, cy, PointerButton::Primary);
3378 core.pointer_down(cx, cy, PointerButton::Primary);
3379 let sel = &core.ui_state.current_selection;
3381 let r = sel.range.as_ref().expect("selection set");
3382 assert_eq!(r.anchor.key, "p1");
3383 assert_eq!(r.head.key, "p1");
3384 assert_eq!(r.anchor.byte.min(r.head.byte), 0);
3386 assert_eq!(r.anchor.byte.max(r.head.byte), 5);
3387 }
3388
3389 #[test]
3390 fn triple_click_on_selectable_text_selects_whole_leaf() {
3391 let mut core = lay_out_paragraph_tree();
3392 let p1 = core.rect_of_key("p1").expect("p1 rect");
3393 let cy = p1.y + p1.h * 0.5;
3394 let cx = p1.x + 4.0;
3395 core.pointer_down(cx, cy, PointerButton::Primary);
3396 core.pointer_up(cx, cy, PointerButton::Primary);
3397 core.pointer_down(cx, cy, PointerButton::Primary);
3398 core.pointer_up(cx, cy, PointerButton::Primary);
3399 core.pointer_down(cx, cy, PointerButton::Primary);
3400 let sel = &core.ui_state.current_selection;
3401 let r = sel.range.as_ref().expect("selection set");
3402 assert_eq!(r.anchor.byte, 0);
3403 assert_eq!(r.head.byte, 24);
3405 }
3406
3407 #[test]
3408 fn click_count_resets_when_press_drifts_outside_distance_window() {
3409 let mut core = lay_out_input_tree(false);
3410 let btn = core.rect_of_key("btn").expect("btn rect");
3411 let cx = btn.x + btn.w * 0.5;
3412 let cy = btn.y + btn.h * 0.5;
3413
3414 let _ = core.pointer_down(cx, cy, PointerButton::Primary);
3415 let _ = core.pointer_up(cx, cy, PointerButton::Primary);
3416
3417 let down2 = core.pointer_down(cx + 10.0, cy, PointerButton::Primary);
3420 let pd2 = down2
3421 .iter()
3422 .find(|e| e.kind == UiEventKind::PointerDown)
3423 .unwrap();
3424 assert_eq!(pd2.click_count, 1);
3425 }
3426
3427 #[test]
3428 fn escape_with_no_selection_emits_only_escape() {
3429 let mut core = lay_out_paragraph_tree();
3430 assert!(core.ui_state.current_selection.is_empty());
3431 let events = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
3432 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3433 assert_eq!(
3434 kinds,
3435 vec![UiEventKind::Escape],
3436 "no selection → no SelectionChanged side-effect"
3437 );
3438 }
3439
3440 fn lay_out_scroll_tree() -> (RunnerCore, String) {
3443 use crate::tree::*;
3444 let mut tree = crate::scroll(
3445 (0..6)
3446 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
3447 )
3448 .gap(12.0)
3449 .height(Size::Fixed(200.0));
3450 let mut core = RunnerCore::new();
3451 crate::layout::layout(
3452 &mut tree,
3453 &mut core.ui_state,
3454 Rect::new(0.0, 0.0, 300.0, 200.0),
3455 );
3456 let scroll_id = tree.computed_id.clone();
3457 let mut t = PrepareTimings::default();
3458 core.snapshot(&tree, &mut t);
3459 (core, scroll_id)
3460 }
3461
3462 #[test]
3463 fn thumb_pointer_down_captures_drag_and_suppresses_events() {
3464 let (mut core, scroll_id) = lay_out_scroll_tree();
3465 let thumb = core
3466 .ui_state
3467 .scroll
3468 .thumb_rects
3469 .get(&scroll_id)
3470 .copied()
3471 .expect("scrollable should have a thumb");
3472 let event = core.pointer_down(
3473 thumb.x + thumb.w * 0.5,
3474 thumb.y + thumb.h * 0.5,
3475 PointerButton::Primary,
3476 );
3477 assert!(
3478 event.is_empty(),
3479 "thumb press should not emit PointerDown to the app"
3480 );
3481 let drag = core
3482 .ui_state
3483 .scroll
3484 .thumb_drag
3485 .as_ref()
3486 .expect("scroll.thumb_drag should be set after pointer_down on thumb");
3487 assert_eq!(drag.scroll_id, scroll_id);
3488 }
3489
3490 #[test]
3491 fn track_click_above_thumb_pages_up_below_pages_down() {
3492 let (mut core, scroll_id) = lay_out_scroll_tree();
3493 let track = core
3494 .ui_state
3495 .scroll
3496 .thumb_tracks
3497 .get(&scroll_id)
3498 .copied()
3499 .expect("scrollable should have a track");
3500 let thumb = core
3501 .ui_state
3502 .scroll
3503 .thumb_rects
3504 .get(&scroll_id)
3505 .copied()
3506 .unwrap();
3507 let metrics = core
3508 .ui_state
3509 .scroll
3510 .metrics
3511 .get(&scroll_id)
3512 .copied()
3513 .unwrap();
3514
3515 let evt = core.pointer_down(
3517 track.x + track.w * 0.5,
3518 thumb.y + thumb.h + 10.0,
3519 PointerButton::Primary,
3520 );
3521 assert!(evt.is_empty(), "track press should not surface PointerDown");
3522 assert!(
3523 core.ui_state.scroll.thumb_drag.is_none(),
3524 "track click outside the thumb should not start a drag",
3525 );
3526 let after_down = core.ui_state.scroll_offset(&scroll_id);
3527 let expected_page = (metrics.viewport_h - SCROLL_PAGE_OVERLAP).max(0.0);
3528 assert!(
3529 (after_down - expected_page.min(metrics.max_offset)).abs() < 0.5,
3530 "page-down offset = {after_down} (expected ~{expected_page})",
3531 );
3532 let _ = core.pointer_up(0.0, 0.0, PointerButton::Primary);
3534
3535 let mut tree = lay_out_scroll_tree_only();
3538 crate::layout::layout(
3539 &mut tree,
3540 &mut core.ui_state,
3541 Rect::new(0.0, 0.0, 300.0, 200.0),
3542 );
3543 let mut t = PrepareTimings::default();
3544 core.snapshot(&tree, &mut t);
3545 let track = core
3546 .ui_state
3547 .scroll
3548 .thumb_tracks
3549 .get(&tree.computed_id)
3550 .copied()
3551 .unwrap();
3552 let thumb = core
3553 .ui_state
3554 .scroll
3555 .thumb_rects
3556 .get(&tree.computed_id)
3557 .copied()
3558 .unwrap();
3559
3560 core.pointer_down(
3561 track.x + track.w * 0.5,
3562 thumb.y - 4.0,
3563 PointerButton::Primary,
3564 );
3565 let after_up = core.ui_state.scroll_offset(&tree.computed_id);
3566 assert!(
3567 after_up < after_down,
3568 "page-up should reduce offset: before={after_down} after={after_up}",
3569 );
3570 }
3571
3572 fn lay_out_scroll_tree_only() -> El {
3577 use crate::tree::*;
3578 crate::scroll(
3579 (0..6)
3580 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
3581 )
3582 .gap(12.0)
3583 .height(Size::Fixed(200.0))
3584 }
3585
3586 #[test]
3587 fn thumb_drag_translates_pointer_delta_into_scroll_offset() {
3588 let (mut core, scroll_id) = lay_out_scroll_tree();
3589 let thumb = core
3590 .ui_state
3591 .scroll
3592 .thumb_rects
3593 .get(&scroll_id)
3594 .copied()
3595 .unwrap();
3596 let metrics = core
3597 .ui_state
3598 .scroll
3599 .metrics
3600 .get(&scroll_id)
3601 .copied()
3602 .unwrap();
3603 let track_remaining = (metrics.viewport_h - thumb.h).max(0.0);
3604
3605 let press_y = thumb.y + thumb.h * 0.5;
3606 core.pointer_down(thumb.x + thumb.w * 0.5, press_y, PointerButton::Primary);
3607 let evt = core.pointer_moved(thumb.x + thumb.w * 0.5, press_y + 20.0);
3609 assert!(
3610 evt.events.is_empty(),
3611 "thumb-drag move should suppress Drag event",
3612 );
3613 let offset = core.ui_state.scroll_offset(&scroll_id);
3614 let expected = 20.0 * (metrics.max_offset / track_remaining);
3615 assert!(
3616 (offset - expected).abs() < 0.5,
3617 "offset {offset} (expected {expected})",
3618 );
3619 core.pointer_moved(thumb.x + thumb.w * 0.5, press_y + 9999.0);
3621 let offset = core.ui_state.scroll_offset(&scroll_id);
3622 assert!(
3623 (offset - metrics.max_offset).abs() < 0.5,
3624 "overshoot offset {offset} (expected {})",
3625 metrics.max_offset
3626 );
3627 let events = core.pointer_up(thumb.x, press_y, PointerButton::Primary);
3629 assert!(events.is_empty(), "thumb release shouldn't emit events");
3630 assert!(core.ui_state.scroll.thumb_drag.is_none());
3631 }
3632
3633 #[test]
3634 fn secondary_click_does_not_steal_focus_or_press() {
3635 let mut core = lay_out_input_tree(false);
3636 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3637 let cx = btn_rect.x + btn_rect.w * 0.5;
3638 let cy = btn_rect.y + btn_rect.h * 0.5;
3639 let ti_rect = core.rect_of_key("ti").expect("ti rect");
3641 let tx = ti_rect.x + ti_rect.w * 0.5;
3642 let ty = ti_rect.y + ti_rect.h * 0.5;
3643 core.pointer_down(tx, ty, PointerButton::Primary);
3644 let _ = core.pointer_up(tx, ty, PointerButton::Primary);
3645 let focused_before = core.ui_state.focused.as_ref().map(|t| t.key.clone());
3646 core.pointer_down(cx, cy, PointerButton::Secondary);
3648 let events = core.pointer_up(cx, cy, PointerButton::Secondary);
3649 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3650 assert_eq!(kinds, vec![UiEventKind::SecondaryClick]);
3651 let focused_after = core.ui_state.focused.as_ref().map(|t| t.key.clone());
3652 assert_eq!(
3653 focused_before, focused_after,
3654 "right-click must not steal focus"
3655 );
3656 assert!(
3657 core.ui_state.pressed.is_none(),
3658 "right-click must not set primary press"
3659 );
3660 }
3661
3662 #[test]
3663 fn text_input_routes_to_focused_only() {
3664 let mut core = lay_out_input_tree(false);
3665 assert!(core.text_input("a".into()).is_none());
3667 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3669 let cx = btn_rect.x + btn_rect.w * 0.5;
3670 let cy = btn_rect.y + btn_rect.h * 0.5;
3671 core.pointer_down(cx, cy, PointerButton::Primary);
3672 let _ = core.pointer_up(cx, cy, PointerButton::Primary);
3673 let event = core.text_input("hi".into()).expect("focused → event");
3674 assert_eq!(event.kind, UiEventKind::TextInput);
3675 assert_eq!(event.text.as_deref(), Some("hi"));
3676 assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("btn"));
3677 assert!(core.text_input(String::new()).is_none());
3679 }
3680
3681 #[test]
3682 fn capture_keys_bypasses_tab_traversal_for_focused_node() {
3683 let mut core = lay_out_input_tree(true);
3686 let ti_rect = core.rect_of_key("ti").expect("ti rect");
3687 let tx = ti_rect.x + ti_rect.w * 0.5;
3688 let ty = ti_rect.y + ti_rect.h * 0.5;
3689 core.pointer_down(tx, ty, PointerButton::Primary);
3690 let _ = core.pointer_up(tx, ty, PointerButton::Primary);
3691 assert_eq!(
3692 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3693 Some("ti"),
3694 "primary click on capture_keys node still focuses it"
3695 );
3696
3697 let events = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
3698 assert_eq!(events.len(), 1, "Tab → exactly one KeyDown");
3699 let event = &events[0];
3700 assert_eq!(event.kind, UiEventKind::KeyDown);
3701 assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("ti"));
3702 assert_eq!(
3703 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3704 Some("ti"),
3705 "Tab inside capture_keys must NOT move focus"
3706 );
3707 }
3708
3709 #[test]
3710 fn pointer_down_focus_does_not_raise_focus_visible() {
3711 let mut core = lay_out_input_tree(false);
3714 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3715 let cx = btn_rect.x + btn_rect.w * 0.5;
3716 let cy = btn_rect.y + btn_rect.h * 0.5;
3717 core.pointer_down(cx, cy, PointerButton::Primary);
3718 assert_eq!(
3719 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3720 Some("btn"),
3721 "primary click focuses the button",
3722 );
3723 assert!(
3724 !core.ui_state.focus_visible,
3725 "click focus must not raise focus_visible — ring stays off",
3726 );
3727 }
3728
3729 #[test]
3730 fn tab_key_raises_focus_visible_so_ring_appears() {
3731 let mut core = lay_out_input_tree(false);
3732 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3734 let cx = btn_rect.x + btn_rect.w * 0.5;
3735 let cy = btn_rect.y + btn_rect.h * 0.5;
3736 core.pointer_down(cx, cy, PointerButton::Primary);
3737 assert!(!core.ui_state.focus_visible);
3738 let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
3740 assert!(
3741 core.ui_state.focus_visible,
3742 "Tab must raise focus_visible so the ring paints on the new target",
3743 );
3744 }
3745
3746 #[test]
3747 fn click_after_tab_clears_focus_visible_again() {
3748 let mut core = lay_out_input_tree(false);
3751 let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
3752 assert!(core.ui_state.focus_visible, "Tab raises ring");
3753 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3754 let cx = btn_rect.x + btn_rect.w * 0.5;
3755 let cy = btn_rect.y + btn_rect.h * 0.5;
3756 core.pointer_down(cx, cy, PointerButton::Primary);
3757 assert!(
3758 !core.ui_state.focus_visible,
3759 "pointer-down clears focus_visible — ring fades back out",
3760 );
3761 }
3762
3763 #[test]
3764 fn keypress_on_focused_widget_raises_focus_visible_after_click() {
3765 let mut core = lay_out_input_tree(false);
3769 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3770 let cx = btn_rect.x + btn_rect.w * 0.5;
3771 let cy = btn_rect.y + btn_rect.h * 0.5;
3772 core.pointer_down(cx, cy, PointerButton::Primary);
3773 assert!(!core.ui_state.focus_visible);
3774 let _ = core.key_down(UiKey::ArrowRight, KeyModifiers::default(), false);
3775 assert!(
3776 core.ui_state.focus_visible,
3777 "non-Tab key on focused widget raises focus_visible",
3778 );
3779 }
3780
3781 #[test]
3782 fn arrow_nav_in_sibling_group_raises_focus_visible() {
3783 let mut core = lay_out_arrow_nav_tree();
3784 core.ui_state.set_focus_visible(false);
3787 let _ = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
3788 assert!(
3789 core.ui_state.focus_visible,
3790 "arrow-nav within an arrow_nav_siblings group is keyboard navigation",
3791 );
3792 }
3793
3794 #[test]
3795 fn capture_keys_falls_back_to_default_when_focus_off_capturing_node() {
3796 let mut core = lay_out_input_tree(true);
3800 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3801 let cx = btn_rect.x + btn_rect.w * 0.5;
3802 let cy = btn_rect.y + btn_rect.h * 0.5;
3803 core.pointer_down(cx, cy, PointerButton::Primary);
3804 let _ = core.pointer_up(cx, cy, PointerButton::Primary);
3805 assert_eq!(
3806 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3807 Some("btn"),
3808 "primary click focuses button"
3809 );
3810 let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
3812 assert_eq!(
3813 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3814 Some("ti"),
3815 "Tab from non-capturing focused does library-default traversal"
3816 );
3817 }
3818
3819 fn lay_out_arrow_nav_tree() -> RunnerCore {
3824 use crate::tree::*;
3825 let mut tree = crate::column([
3826 crate::widgets::button::button("Red").key("opt-red"),
3827 crate::widgets::button::button("Green").key("opt-green"),
3828 crate::widgets::button::button("Blue").key("opt-blue"),
3829 ])
3830 .arrow_nav_siblings()
3831 .padding(10.0);
3832 let mut core = RunnerCore::new();
3833 crate::layout::layout(
3834 &mut tree,
3835 &mut core.ui_state,
3836 Rect::new(0.0, 0.0, 200.0, 300.0),
3837 );
3838 core.ui_state.sync_focus_order(&tree);
3839 let mut t = PrepareTimings::default();
3840 core.snapshot(&tree, &mut t);
3841 let target = core
3844 .ui_state
3845 .focus
3846 .order
3847 .iter()
3848 .find(|t| t.key == "opt-green")
3849 .cloned();
3850 core.ui_state.set_focus(target);
3851 core
3852 }
3853
3854 #[test]
3855 fn arrow_nav_moves_focus_among_siblings() {
3856 let mut core = lay_out_arrow_nav_tree();
3857
3858 let down = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
3861 assert!(down.is_empty(), "arrow-nav consumes the key event");
3862 assert_eq!(
3863 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3864 Some("opt-blue"),
3865 );
3866
3867 core.key_down(UiKey::ArrowUp, KeyModifiers::default(), false);
3869 assert_eq!(
3870 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3871 Some("opt-green"),
3872 );
3873
3874 core.key_down(UiKey::Home, KeyModifiers::default(), false);
3876 assert_eq!(
3877 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3878 Some("opt-red"),
3879 );
3880
3881 core.key_down(UiKey::End, KeyModifiers::default(), false);
3883 assert_eq!(
3884 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3885 Some("opt-blue"),
3886 );
3887 }
3888
3889 #[test]
3890 fn arrow_nav_saturates_at_ends() {
3891 let mut core = lay_out_arrow_nav_tree();
3892 core.key_down(UiKey::Home, KeyModifiers::default(), false);
3894 core.key_down(UiKey::ArrowUp, KeyModifiers::default(), false);
3895 assert_eq!(
3896 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3897 Some("opt-red"),
3898 "ArrowUp at top stays at top — no wrap",
3899 );
3900 core.key_down(UiKey::End, KeyModifiers::default(), false);
3902 core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
3903 assert_eq!(
3904 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3905 Some("opt-blue"),
3906 "ArrowDown at bottom stays at bottom — no wrap",
3907 );
3908 }
3909
3910 fn build_popover_tree(open: bool) -> El {
3914 use crate::widgets::button::button;
3915 use crate::widgets::overlay::overlay;
3916 use crate::widgets::popover::{dropdown, menu_item};
3917 let mut layers: Vec<El> = vec![button("Trigger").key("trigger")];
3918 if open {
3919 layers.push(dropdown(
3920 "menu",
3921 "trigger",
3922 [
3923 menu_item("A").key("item-a"),
3924 menu_item("B").key("item-b"),
3925 menu_item("C").key("item-c"),
3926 ],
3927 ));
3928 }
3929 overlay(layers).padding(20.0)
3930 }
3931
3932 fn run_frame(core: &mut RunnerCore, tree: &mut El) {
3936 let mut t = PrepareTimings::default();
3937 core.prepare_layout(
3938 tree,
3939 Rect::new(0.0, 0.0, 400.0, 300.0),
3940 1.0,
3941 &mut t,
3942 RunnerCore::no_time_shaders,
3943 );
3944 core.snapshot(tree, &mut t);
3945 }
3946
3947 #[test]
3948 fn popover_open_pushes_focus_and_auto_focuses_first_item() {
3949 let mut core = RunnerCore::new();
3950 let mut closed = build_popover_tree(false);
3951 run_frame(&mut core, &mut closed);
3952 let trigger = core
3955 .ui_state
3956 .focus
3957 .order
3958 .iter()
3959 .find(|t| t.key == "trigger")
3960 .cloned();
3961 core.ui_state.set_focus(trigger);
3962 assert_eq!(
3963 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3964 Some("trigger"),
3965 );
3966
3967 let mut open = build_popover_tree(true);
3970 run_frame(&mut core, &mut open);
3971 assert_eq!(
3972 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3973 Some("item-a"),
3974 "popover open should auto-focus the first menu item",
3975 );
3976 assert_eq!(
3977 core.ui_state.popover_focus.focus_stack.len(),
3978 1,
3979 "trigger should be saved on the focus stack",
3980 );
3981 assert_eq!(
3982 core.ui_state.popover_focus.focus_stack[0].key.as_str(),
3983 "trigger",
3984 "saved focus should be the pre-open target",
3985 );
3986 }
3987
3988 #[test]
3989 fn popover_close_restores_focus_to_trigger() {
3990 let mut core = RunnerCore::new();
3991 let mut closed = build_popover_tree(false);
3992 run_frame(&mut core, &mut closed);
3993 let trigger = core
3994 .ui_state
3995 .focus
3996 .order
3997 .iter()
3998 .find(|t| t.key == "trigger")
3999 .cloned();
4000 core.ui_state.set_focus(trigger);
4001
4002 let mut open = build_popover_tree(true);
4004 run_frame(&mut core, &mut open);
4005 assert_eq!(
4006 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4007 Some("item-a"),
4008 );
4009
4010 let mut closed_again = build_popover_tree(false);
4012 run_frame(&mut core, &mut closed_again);
4013 assert_eq!(
4014 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4015 Some("trigger"),
4016 "closing the popover should pop the saved focus",
4017 );
4018 assert!(
4019 core.ui_state.popover_focus.focus_stack.is_empty(),
4020 "focus stack should be drained after restore",
4021 );
4022 }
4023
4024 #[test]
4025 fn popover_close_does_not_override_intentional_focus_move() {
4026 let mut core = RunnerCore::new();
4027 let build = |open: bool| -> El {
4030 use crate::widgets::button::button;
4031 use crate::widgets::overlay::overlay;
4032 use crate::widgets::popover::{dropdown, menu_item};
4033 let main = crate::row([
4034 button("Trigger").key("trigger"),
4035 button("Other").key("other"),
4036 ]);
4037 let mut layers: Vec<El> = vec![main];
4038 if open {
4039 layers.push(dropdown("menu", "trigger", [menu_item("A").key("item-a")]));
4040 }
4041 overlay(layers).padding(20.0)
4042 };
4043
4044 let mut closed = build(false);
4045 run_frame(&mut core, &mut closed);
4046 let trigger = core
4047 .ui_state
4048 .focus
4049 .order
4050 .iter()
4051 .find(|t| t.key == "trigger")
4052 .cloned();
4053 core.ui_state.set_focus(trigger);
4054
4055 let mut open = build(true);
4056 run_frame(&mut core, &mut open);
4057 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
4058
4059 let other = core
4064 .ui_state
4065 .focus
4066 .order
4067 .iter()
4068 .find(|t| t.key == "other")
4069 .cloned();
4070 core.ui_state.set_focus(other);
4071
4072 let mut closed_again = build(false);
4073 run_frame(&mut core, &mut closed_again);
4074 assert_eq!(
4075 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4076 Some("other"),
4077 "focus moved before close should not be overridden by restore",
4078 );
4079 assert!(core.ui_state.popover_focus.focus_stack.is_empty());
4080 }
4081
4082 #[test]
4083 fn nested_popovers_stack_and_unwind_focus_correctly() {
4084 let mut core = RunnerCore::new();
4085 let build = |outer: bool, inner: bool| -> El {
4090 use crate::widgets::button::button;
4091 use crate::widgets::overlay::overlay;
4092 use crate::widgets::popover::{Anchor, popover, popover_panel};
4093 let main = button("Trigger").key("trigger");
4094 let mut layers: Vec<El> = vec![main];
4095 if outer {
4096 layers.push(popover(
4097 "outer",
4098 Anchor::below_key("trigger"),
4099 popover_panel([button("Open inner").key("inner-trigger")]),
4100 ));
4101 }
4102 if inner {
4103 layers.push(popover(
4104 "inner",
4105 Anchor::below_key("inner-trigger"),
4106 popover_panel([button("X").key("inner-a"), button("Y").key("inner-b")]),
4107 ));
4108 }
4109 overlay(layers).padding(20.0)
4110 };
4111
4112 let mut closed = build(false, false);
4114 run_frame(&mut core, &mut closed);
4115 let trigger = core
4116 .ui_state
4117 .focus
4118 .order
4119 .iter()
4120 .find(|t| t.key == "trigger")
4121 .cloned();
4122 core.ui_state.set_focus(trigger);
4123
4124 let mut outer = build(true, false);
4126 run_frame(&mut core, &mut outer);
4127 assert_eq!(
4128 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4129 Some("inner-trigger"),
4130 );
4131 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
4132
4133 let mut both = build(true, true);
4135 run_frame(&mut core, &mut both);
4136 assert_eq!(
4137 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4138 Some("inner-a"),
4139 );
4140 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 2);
4141
4142 let mut outer_only = build(true, false);
4144 run_frame(&mut core, &mut outer_only);
4145 assert_eq!(
4146 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4147 Some("inner-trigger"),
4148 );
4149 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
4150
4151 let mut none = build(false, false);
4153 run_frame(&mut core, &mut none);
4154 assert_eq!(
4155 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4156 Some("trigger"),
4157 );
4158 assert!(core.ui_state.popover_focus.focus_stack.is_empty());
4159 }
4160
4161 #[test]
4162 fn arrow_nav_does_not_intercept_outside_navigable_groups() {
4163 let mut core = lay_out_input_tree(false);
4167 let target = core
4168 .ui_state
4169 .focus
4170 .order
4171 .iter()
4172 .find(|t| t.key == "btn")
4173 .cloned();
4174 core.ui_state.set_focus(target);
4175 let events = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
4176 assert_eq!(
4177 events.len(),
4178 1,
4179 "ArrowDown without navigable parent → event"
4180 );
4181 assert_eq!(events[0].kind, UiEventKind::KeyDown);
4182 }
4183
4184 fn quad(shader: ShaderHandle) -> DrawOp {
4185 DrawOp::Quad {
4186 id: "q".into(),
4187 rect: Rect::new(0.0, 0.0, 10.0, 10.0),
4188 scissor: None,
4189 shader,
4190 uniforms: UniformBlock::new(),
4191 }
4192 }
4193
4194 #[test]
4195 fn samples_backdrop_inserts_snapshot_before_first_glass_quad() {
4196 let mut core = RunnerCore::new();
4197 core.set_surface_size(100, 100);
4198 let ops = vec![
4199 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4200 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4201 quad(ShaderHandle::Custom("liquid_glass")),
4202 quad(ShaderHandle::Custom("liquid_glass")),
4203 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4204 ];
4205 let mut timings = PrepareTimings::default();
4206 core.prepare_paint(
4207 &ops,
4208 |_| true,
4209 |s| matches!(s, ShaderHandle::Custom(name) if *name == "liquid_glass"),
4210 &mut NoText,
4211 1.0,
4212 &mut timings,
4213 );
4214
4215 let kinds: Vec<&'static str> = core
4216 .paint_items
4217 .iter()
4218 .map(|p| match p {
4219 PaintItem::QuadRun(_) => "Q",
4220 PaintItem::IconRun(_) => "I",
4221 PaintItem::Text(_) => "T",
4222 PaintItem::Image(_) => "M",
4223 PaintItem::AppTexture(_) => "A",
4224 PaintItem::Vector(_) => "V",
4225 PaintItem::BackdropSnapshot => "S",
4226 })
4227 .collect();
4228 assert_eq!(
4229 kinds,
4230 vec!["Q", "S", "Q", "Q"],
4231 "expected one stock run, snapshot, then a glass run, then a foreground stock run"
4232 );
4233 }
4234
4235 #[test]
4236 fn no_snapshot_when_no_glass_drawn() {
4237 let mut core = RunnerCore::new();
4238 core.set_surface_size(100, 100);
4239 let ops = vec![
4240 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4241 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4242 ];
4243 let mut timings = PrepareTimings::default();
4244 core.prepare_paint(&ops, |_| true, |_| false, &mut NoText, 1.0, &mut timings);
4245 assert!(
4246 !core
4247 .paint_items
4248 .iter()
4249 .any(|p| matches!(p, PaintItem::BackdropSnapshot)),
4250 "no glass shader registered → no snapshot"
4251 );
4252 }
4253
4254 #[test]
4255 fn at_most_one_snapshot_per_frame() {
4256 let mut core = RunnerCore::new();
4257 core.set_surface_size(100, 100);
4258 let ops = vec![
4259 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4260 quad(ShaderHandle::Custom("g")),
4261 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4262 quad(ShaderHandle::Custom("g")),
4263 ];
4264 let mut timings = PrepareTimings::default();
4265 core.prepare_paint(
4266 &ops,
4267 |_| true,
4268 |s| matches!(s, ShaderHandle::Custom("g")),
4269 &mut NoText,
4270 1.0,
4271 &mut timings,
4272 );
4273 let snapshots = core
4274 .paint_items
4275 .iter()
4276 .filter(|p| matches!(p, PaintItem::BackdropSnapshot))
4277 .count();
4278 assert_eq!(snapshots, 1, "backdrop depth is capped at 1");
4279 }
4280}