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 if let Some(source) = &node.selection_source {
1895 return Some(source.visible_len());
1896 }
1897 return node.text.as_ref().map(|t| t.len());
1898 }
1899 node.children.iter().find_map(|c| find_text_len(c, id))
1900}
1901
1902pub enum RecordedPaint {
1905 Text(Range<usize>),
1906 Icon(Range<usize>),
1907}
1908
1909pub trait TextRecorder {
1913 #[allow(clippy::too_many_arguments)]
1921 fn record(
1922 &mut self,
1923 rect: Rect,
1924 scissor: Option<PhysicalScissor>,
1925 style: &RunStyle,
1926 text: &str,
1927 size: f32,
1928 line_height: f32,
1929 wrap: TextWrap,
1930 anchor: TextAnchor,
1931 scale_factor: f32,
1932 ) -> Range<usize>;
1933
1934 #[allow(clippy::too_many_arguments)]
1939 fn record_runs(
1940 &mut self,
1941 rect: Rect,
1942 scissor: Option<PhysicalScissor>,
1943 runs: &[(String, RunStyle)],
1944 size: f32,
1945 line_height: f32,
1946 wrap: TextWrap,
1947 anchor: TextAnchor,
1948 scale_factor: f32,
1949 ) -> Range<usize>;
1950
1951 #[allow(clippy::too_many_arguments)]
1957 fn record_icon(
1958 &mut self,
1959 rect: Rect,
1960 scissor: Option<PhysicalScissor>,
1961 source: &crate::svg_icon::IconSource,
1962 color: Color,
1963 size: f32,
1964 _stroke_width: f32,
1965 scale_factor: f32,
1966 ) -> RecordedPaint {
1967 let glyph = match source {
1968 crate::svg_icon::IconSource::Builtin(name) => name.fallback_glyph(),
1969 crate::svg_icon::IconSource::Custom(_) => "?",
1970 };
1971 RecordedPaint::Text(self.record(
1972 rect,
1973 scissor,
1974 &RunStyle::new(FontWeight::Regular, color),
1975 glyph,
1976 size,
1977 crate::text::metrics::line_height(size),
1978 TextWrap::NoWrap,
1979 TextAnchor::Middle,
1980 scale_factor,
1981 ))
1982 }
1983
1984 #[allow(clippy::too_many_arguments)]
1991 fn record_image(
1992 &mut self,
1993 _rect: Rect,
1994 _scissor: Option<PhysicalScissor>,
1995 _image: &crate::image::Image,
1996 _tint: Option<Color>,
1997 _radius: crate::tree::Corners,
1998 _fit: crate::image::ImageFit,
1999 _scale_factor: f32,
2000 ) -> Range<usize> {
2001 0..0
2002 }
2003
2004 fn record_app_texture(
2010 &mut self,
2011 _rect: Rect,
2012 _scissor: Option<PhysicalScissor>,
2013 _texture: &crate::surface::AppTexture,
2014 _alpha: crate::surface::SurfaceAlpha,
2015 _transform: crate::affine::Affine2,
2016 _scale_factor: f32,
2017 ) -> Range<usize> {
2018 0..0
2019 }
2020
2021 fn record_vector(
2027 &mut self,
2028 _rect: Rect,
2029 _scissor: Option<PhysicalScissor>,
2030 _asset: &crate::vector::VectorAsset,
2031 _render_mode: crate::vector::VectorRenderMode,
2032 _scale_factor: f32,
2033 ) -> Range<usize> {
2034 0..0
2035 }
2036}
2037
2038#[cfg(test)]
2039mod tests {
2040 use super::*;
2041 use crate::shader::{ShaderHandle, StockShader, UniformBlock};
2042
2043 struct NoText;
2045 impl TextRecorder for NoText {
2046 fn record(
2047 &mut self,
2048 _rect: Rect,
2049 _scissor: Option<PhysicalScissor>,
2050 _style: &RunStyle,
2051 _text: &str,
2052 _size: f32,
2053 _line_height: f32,
2054 _wrap: TextWrap,
2055 _anchor: TextAnchor,
2056 _scale_factor: f32,
2057 ) -> Range<usize> {
2058 0..0
2059 }
2060 fn record_runs(
2061 &mut self,
2062 _rect: Rect,
2063 _scissor: Option<PhysicalScissor>,
2064 _runs: &[(String, RunStyle)],
2065 _size: f32,
2066 _line_height: f32,
2067 _wrap: TextWrap,
2068 _anchor: TextAnchor,
2069 _scale_factor: f32,
2070 ) -> Range<usize> {
2071 0..0
2072 }
2073 }
2074
2075 fn lay_out_input_tree(capture: bool) -> RunnerCore {
2082 use crate::tree::*;
2083 let ti = if capture {
2084 crate::widgets::text::text("input").key("ti").capture_keys()
2085 } else {
2086 crate::widgets::text::text("noop").key("ti").focusable()
2087 };
2088 let mut tree =
2089 crate::column([crate::widgets::button::button("Btn").key("btn"), ti]).padding(10.0);
2090 let mut core = RunnerCore::new();
2091 crate::layout::layout(
2092 &mut tree,
2093 &mut core.ui_state,
2094 Rect::new(0.0, 0.0, 200.0, 200.0),
2095 );
2096 core.ui_state.sync_focus_order(&tree);
2097 let mut t = PrepareTimings::default();
2098 core.snapshot(&tree, &mut t);
2099 core
2100 }
2101
2102 #[test]
2103 fn pointer_up_emits_pointer_up_then_click() {
2104 let mut core = lay_out_input_tree(false);
2105 let btn_rect = core.rect_of_key("btn").expect("btn rect");
2106 let cx = btn_rect.x + btn_rect.w * 0.5;
2107 let cy = btn_rect.y + btn_rect.h * 0.5;
2108 core.pointer_moved(cx, cy);
2109 core.pointer_down(cx, cy, PointerButton::Primary);
2110 let events = core.pointer_up(cx, cy, PointerButton::Primary);
2111 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
2112 assert_eq!(kinds, vec![UiEventKind::PointerUp, UiEventKind::Click]);
2113 }
2114
2115 fn lay_out_link_tree() -> (RunnerCore, Rect, &'static str) {
2121 use crate::tree::*;
2122 const URL: &str = "https://github.com/computer-whisperer/aetna";
2123 let mut tree = crate::column([crate::text_runs([
2124 crate::text("Visit "),
2125 crate::text("github.com/computer-whisperer/aetna").link(URL),
2126 crate::text("."),
2127 ])])
2128 .padding(10.0);
2129 let mut core = RunnerCore::new();
2130 crate::layout::layout(
2131 &mut tree,
2132 &mut core.ui_state,
2133 Rect::new(0.0, 0.0, 600.0, 200.0),
2134 );
2135 core.ui_state.sync_focus_order(&tree);
2136 let mut t = PrepareTimings::default();
2137 core.snapshot(&tree, &mut t);
2138 let para = core
2139 .last_tree
2140 .as_ref()
2141 .and_then(|t| t.children.first())
2142 .map(|p| core.ui_state.rect(&p.computed_id))
2143 .expect("paragraph rect");
2144 (core, para, URL)
2145 }
2146
2147 #[test]
2148 fn pointer_up_on_link_emits_link_activated_with_url() {
2149 let (mut core, para, url) = lay_out_link_tree();
2150 let cx = para.x + 100.0;
2154 let cy = para.y + para.h * 0.5;
2155 core.pointer_moved(cx, cy);
2156 core.pointer_down(cx, cy, PointerButton::Primary);
2157 let events = core.pointer_up(cx, cy, PointerButton::Primary);
2158 let link = events
2159 .iter()
2160 .find(|e| e.kind == UiEventKind::LinkActivated)
2161 .expect("LinkActivated event");
2162 assert_eq!(link.key.as_deref(), Some(url));
2163 }
2164
2165 #[test]
2166 fn pointer_up_after_drag_off_link_does_not_activate() {
2167 let (mut core, para, _url) = lay_out_link_tree();
2168 let press_x = para.x + 100.0;
2169 let cy = para.y + para.h * 0.5;
2170 core.pointer_moved(press_x, cy);
2171 core.pointer_down(press_x, cy, PointerButton::Primary);
2172 let events = core.pointer_up(press_x, 180.0, PointerButton::Primary);
2176 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
2177 assert!(
2178 !kinds.contains(&UiEventKind::LinkActivated),
2179 "drag-off-link should cancel the link activation; got {kinds:?}",
2180 );
2181 }
2182
2183 #[test]
2184 fn pointer_moved_over_link_resolves_cursor_to_pointer_and_requests_redraw() {
2185 use crate::cursor::Cursor;
2186 let (mut core, para, _url) = lay_out_link_tree();
2187 let cx = para.x + 100.0;
2188 let cy = para.y + para.h * 0.5;
2189 let initial = core.pointer_moved(para.x - 50.0, cy);
2191 assert!(
2192 !initial.needs_redraw,
2193 "moving in empty space shouldn't request a redraw"
2194 );
2195 let tree = core.last_tree.as_ref().expect("tree").clone();
2196 assert_eq!(
2197 core.ui_state.cursor(&tree),
2198 Cursor::Default,
2199 "no link under pointer → default cursor"
2200 );
2201 let onto = core.pointer_moved(cx, cy);
2204 assert!(
2205 onto.needs_redraw,
2206 "entering a link region should flag a redraw so the cursor refresh isn't stale"
2207 );
2208 assert_eq!(
2209 core.ui_state.cursor(&tree),
2210 Cursor::Pointer,
2211 "pointer over a link → Pointer cursor"
2212 );
2213 let off = core.pointer_moved(para.x - 50.0, cy);
2216 assert!(
2217 off.needs_redraw,
2218 "leaving a link region should flag a redraw"
2219 );
2220 assert_eq!(core.ui_state.cursor(&tree), Cursor::Default);
2221 }
2222
2223 #[test]
2224 fn pointer_up_on_unlinked_text_does_not_emit_link_activated() {
2225 let (mut core, para, _url) = lay_out_link_tree();
2226 let cx = para.x + 1.0;
2229 let cy = para.y + para.h * 0.5;
2230 core.pointer_moved(cx, cy);
2231 core.pointer_down(cx, cy, PointerButton::Primary);
2232 let events = core.pointer_up(cx, cy, PointerButton::Primary);
2233 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
2234 assert!(
2235 !kinds.contains(&UiEventKind::LinkActivated),
2236 "click on the unlinked prefix should not surface a link event; got {kinds:?}",
2237 );
2238 }
2239
2240 #[test]
2241 fn pointer_up_off_target_emits_only_pointer_up() {
2242 let mut core = lay_out_input_tree(false);
2243 let btn_rect = core.rect_of_key("btn").expect("btn rect");
2244 let cx = btn_rect.x + btn_rect.w * 0.5;
2245 let cy = btn_rect.y + btn_rect.h * 0.5;
2246 core.pointer_down(cx, cy, PointerButton::Primary);
2247 let events = core.pointer_up(180.0, 180.0, PointerButton::Primary);
2249 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
2250 assert_eq!(
2251 kinds,
2252 vec![UiEventKind::PointerUp],
2253 "drag-off-target should still surface PointerUp so widgets see drag-end"
2254 );
2255 }
2256
2257 #[test]
2258 fn pointer_moved_while_pressed_emits_drag() {
2259 let mut core = lay_out_input_tree(false);
2260 let btn_rect = core.rect_of_key("btn").expect("btn rect");
2261 let cx = btn_rect.x + btn_rect.w * 0.5;
2262 let cy = btn_rect.y + btn_rect.h * 0.5;
2263 core.pointer_down(cx, cy, PointerButton::Primary);
2264 let drag = core
2265 .pointer_moved(cx + 30.0, cy)
2266 .events
2267 .into_iter()
2268 .find(|e| e.kind == UiEventKind::Drag)
2269 .expect("drag while pressed");
2270 assert_eq!(drag.target.as_ref().map(|t| t.key.as_str()), Some("btn"));
2271 assert_eq!(drag.pointer, Some((cx + 30.0, cy)));
2272 }
2273
2274 #[test]
2275 fn toast_dismiss_click_removes_toast_and_suppresses_click_event() {
2276 use crate::toast::ToastSpec;
2277 use crate::tree::Size;
2278 let mut core = RunnerCore::new();
2282 core.ui_state
2283 .push_toast(ToastSpec::success("hi"), Instant::now());
2284 let toast_id = core.ui_state.toasts()[0].id;
2285
2286 let mut tree: El = crate::stack(std::iter::empty::<El>())
2290 .width(Size::Fill(1.0))
2291 .height(Size::Fill(1.0));
2292 crate::layout::assign_ids(&mut tree);
2293 let _ = crate::toast::synthesize_toasts(&mut tree, &mut core.ui_state, Instant::now());
2294 crate::layout::layout(
2295 &mut tree,
2296 &mut core.ui_state,
2297 Rect::new(0.0, 0.0, 800.0, 600.0),
2298 );
2299 core.ui_state.sync_focus_order(&tree);
2300 let mut t = PrepareTimings::default();
2301 core.snapshot(&tree, &mut t);
2302
2303 let dismiss_key = format!("toast-dismiss-{toast_id}");
2304 let dismiss_rect = core.rect_of_key(&dismiss_key).expect("dismiss button");
2305 let cx = dismiss_rect.x + dismiss_rect.w * 0.5;
2306 let cy = dismiss_rect.y + dismiss_rect.h * 0.5;
2307
2308 core.pointer_down(cx, cy, PointerButton::Primary);
2309 let events = core.pointer_up(cx, cy, PointerButton::Primary);
2310 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
2311 assert!(
2315 !kinds.contains(&UiEventKind::Click),
2316 "Click on toast-dismiss should not be surfaced: {kinds:?}",
2317 );
2318 assert!(
2319 core.ui_state.toasts().iter().all(|t| t.id != toast_id),
2320 "toast {toast_id} should be dropped after dismiss-click",
2321 );
2322 }
2323
2324 #[test]
2325 fn pointer_moved_without_press_emits_no_drag() {
2326 let mut core = lay_out_input_tree(false);
2327 let events = core.pointer_moved(50.0, 50.0).events;
2328 assert!(!events.iter().any(|e| e.kind == UiEventKind::Drag));
2332 }
2333
2334 #[test]
2335 fn spinner_in_tree_keeps_needs_redraw_set() {
2336 use crate::widgets::spinner::spinner;
2341 let mut tree = crate::column([spinner()]);
2342 let mut core = RunnerCore::new();
2343 let mut t = PrepareTimings::default();
2344 let LayoutPrepared { needs_redraw, .. } = core.prepare_layout(
2345 &mut tree,
2346 Rect::new(0.0, 0.0, 200.0, 200.0),
2347 1.0,
2348 &mut t,
2349 RunnerCore::no_time_shaders,
2350 );
2351 assert!(
2352 needs_redraw,
2353 "tree with a spinner must request continuous redraw",
2354 );
2355
2356 let mut bare = crate::column([crate::widgets::text::text("idle")]);
2360 let mut core2 = RunnerCore::new();
2361 let mut t2 = PrepareTimings::default();
2362 let LayoutPrepared {
2363 needs_redraw: needs_redraw2,
2364 ..
2365 } = core2.prepare_layout(
2366 &mut bare,
2367 Rect::new(0.0, 0.0, 200.0, 200.0),
2368 1.0,
2369 &mut t2,
2370 RunnerCore::no_time_shaders,
2371 );
2372 assert!(
2373 !needs_redraw2,
2374 "tree without time-driven shaders should idle: got needs_redraw={needs_redraw2}",
2375 );
2376 }
2377
2378 #[test]
2379 fn custom_samples_time_shader_keeps_needs_redraw_set() {
2380 let mut tree = crate::column([crate::tree::El::new(crate::tree::Kind::Custom("anim"))
2384 .shader(crate::shader::ShaderBinding::custom("my_animated_glow"))
2385 .width(crate::tree::Size::Fixed(32.0))
2386 .height(crate::tree::Size::Fixed(32.0))]);
2387 let mut core = RunnerCore::new();
2388 let mut t = PrepareTimings::default();
2389
2390 let LayoutPrepared {
2391 needs_redraw: idle, ..
2392 } = core.prepare_layout(
2393 &mut tree,
2394 Rect::new(0.0, 0.0, 200.0, 200.0),
2395 1.0,
2396 &mut t,
2397 RunnerCore::no_time_shaders,
2398 );
2399 assert!(
2400 !idle,
2401 "without a samples_time registration the host should idle",
2402 );
2403
2404 let mut t2 = PrepareTimings::default();
2405 let LayoutPrepared {
2406 needs_redraw: animated,
2407 ..
2408 } = core.prepare_layout(
2409 &mut tree,
2410 Rect::new(0.0, 0.0, 200.0, 200.0),
2411 1.0,
2412 &mut t2,
2413 |handle| matches!(handle, ShaderHandle::Custom("my_animated_glow")),
2414 );
2415 assert!(
2416 animated,
2417 "custom shader registered as samples_time=true must request continuous redraw",
2418 );
2419 }
2420
2421 #[test]
2422 fn redraw_within_aggregates_to_minimum_visible_deadline() {
2423 use std::time::Duration;
2424 let mut tree = crate::column([
2425 crate::widgets::text::text("a")
2427 .redraw_within(Duration::from_millis(16))
2428 .width(crate::tree::Size::Fixed(20.0))
2429 .height(crate::tree::Size::Fixed(20.0)),
2430 crate::widgets::text::text("b")
2432 .redraw_within(Duration::from_millis(50))
2433 .width(crate::tree::Size::Fixed(20.0))
2434 .height(crate::tree::Size::Fixed(20.0)),
2435 ]);
2436 let mut core = RunnerCore::new();
2437 let mut t = PrepareTimings::default();
2438 let LayoutPrepared {
2439 needs_redraw,
2440 next_layout_redraw_in,
2441 ..
2442 } = core.prepare_layout(
2443 &mut tree,
2444 Rect::new(0.0, 0.0, 200.0, 200.0),
2445 1.0,
2446 &mut t,
2447 RunnerCore::no_time_shaders,
2448 );
2449 assert!(needs_redraw, "redraw_within must lift the legacy bool");
2450 assert_eq!(
2451 next_layout_redraw_in,
2452 Some(Duration::from_millis(16)),
2453 "tightest visible deadline wins, on the layout lane",
2454 );
2455 }
2456
2457 #[test]
2458 fn redraw_within_off_screen_widget_is_ignored() {
2459 use std::time::Duration;
2460 let mut tree = crate::column([
2466 crate::tree::spacer().height(crate::tree::Size::Fixed(150.0)),
2467 crate::widgets::text::text("offscreen")
2468 .redraw_within(Duration::from_millis(16))
2469 .width(crate::tree::Size::Fixed(10.0))
2470 .height(crate::tree::Size::Fixed(10.0)),
2471 ]);
2472 let mut core = RunnerCore::new();
2473 let mut t = PrepareTimings::default();
2474 let LayoutPrepared {
2475 next_layout_redraw_in,
2476 ..
2477 } = core.prepare_layout(
2478 &mut tree,
2479 Rect::new(0.0, 0.0, 100.0, 100.0),
2480 1.0,
2481 &mut t,
2482 RunnerCore::no_time_shaders,
2483 );
2484 assert_eq!(
2485 next_layout_redraw_in, None,
2486 "off-screen redraw_within must not contribute to the aggregate",
2487 );
2488 }
2489
2490 #[test]
2491 fn redraw_within_clipped_out_widget_is_ignored() {
2492 use std::time::Duration;
2493
2494 let clipped = crate::column([crate::widgets::text::text("clipped")
2495 .redraw_within(Duration::from_millis(16))
2496 .width(crate::tree::Size::Fixed(10.0))
2497 .height(crate::tree::Size::Fixed(10.0))])
2498 .clip()
2499 .width(crate::tree::Size::Fixed(100.0))
2500 .height(crate::tree::Size::Fixed(20.0))
2501 .layout(|ctx| {
2502 vec![Rect::new(
2503 ctx.container.x,
2504 ctx.container.y + 30.0,
2505 10.0,
2506 10.0,
2507 )]
2508 });
2509 let mut tree = crate::column([clipped]);
2510
2511 let mut core = RunnerCore::new();
2512 let mut t = PrepareTimings::default();
2513 let LayoutPrepared {
2514 next_layout_redraw_in,
2515 ..
2516 } = core.prepare_layout(
2517 &mut tree,
2518 Rect::new(0.0, 0.0, 100.0, 100.0),
2519 1.0,
2520 &mut t,
2521 RunnerCore::no_time_shaders,
2522 );
2523 assert_eq!(
2524 next_layout_redraw_in, None,
2525 "redraw_within inside an inherited clip but outside the clip rect must not contribute",
2526 );
2527 }
2528
2529 #[test]
2530 fn pointer_moved_within_same_hovered_node_does_not_request_redraw() {
2531 let mut core = lay_out_input_tree(false);
2537 let btn = core.rect_of_key("btn").expect("btn rect");
2538 let (cx, cy) = (btn.x + btn.w * 0.5, btn.y + btn.h * 0.5);
2539
2540 let first = core.pointer_moved(cx, cy);
2544 assert_eq!(first.events.len(), 1);
2545 assert_eq!(first.events[0].kind, UiEventKind::PointerEnter);
2546 assert_eq!(first.events[0].key.as_deref(), Some("btn"));
2547 assert!(
2548 first.needs_redraw,
2549 "entering a focusable should warrant a redraw",
2550 );
2551
2552 let second = core.pointer_moved(cx + 1.0, cy);
2556 assert!(second.events.is_empty());
2557 assert!(
2558 !second.needs_redraw,
2559 "identical hover, no drag → host should idle",
2560 );
2561
2562 let off = core.pointer_moved(0.0, 0.0);
2566 assert_eq!(off.events.len(), 1);
2567 assert_eq!(off.events[0].kind, UiEventKind::PointerLeave);
2568 assert_eq!(off.events[0].key.as_deref(), Some("btn"));
2569 assert!(
2570 off.needs_redraw,
2571 "leaving a hovered node still warrants a redraw",
2572 );
2573 }
2574
2575 #[test]
2576 fn pointer_moved_between_keyed_targets_emits_leave_then_enter() {
2577 let mut core = lay_out_input_tree(false);
2584 let btn = core.rect_of_key("btn").expect("btn rect");
2585 let ti = core.rect_of_key("ti").expect("ti rect");
2586
2587 let _ = core.pointer_moved(btn.x + 4.0, btn.y + 4.0);
2589
2590 let cross = core.pointer_moved(ti.x + 4.0, ti.y + 4.0);
2592 let kinds: Vec<UiEventKind> = cross.events.iter().map(|e| e.kind).collect();
2593 assert_eq!(
2594 kinds,
2595 vec![UiEventKind::PointerLeave, UiEventKind::PointerEnter],
2596 "paired Leave-then-Enter on cross-target hover transition",
2597 );
2598 assert_eq!(cross.events[0].key.as_deref(), Some("btn"));
2599 assert_eq!(cross.events[1].key.as_deref(), Some("ti"));
2600 assert!(cross.needs_redraw);
2601 }
2602
2603 #[test]
2604 fn pointer_left_emits_leave_for_prior_hover() {
2605 let mut core = lay_out_input_tree(false);
2606 let btn = core.rect_of_key("btn").expect("btn rect");
2607 let _ = core.pointer_moved(btn.x + 4.0, btn.y + 4.0);
2608
2609 let events = core.pointer_left();
2610 assert_eq!(events.len(), 1);
2611 assert_eq!(events[0].kind, UiEventKind::PointerLeave);
2612 assert_eq!(events[0].key.as_deref(), Some("btn"));
2613 }
2614
2615 #[test]
2616 fn pointer_left_with_no_prior_hover_emits_nothing() {
2617 let mut core = lay_out_input_tree(false);
2618 let events = core.pointer_left();
2621 assert!(events.is_empty());
2622 }
2623
2624 #[test]
2625 fn ui_state_hovered_key_returns_leaf_key() {
2626 let mut core = lay_out_input_tree(false);
2627 assert_eq!(core.ui_state().hovered_key(), None);
2628
2629 let btn = core.rect_of_key("btn").expect("btn rect");
2630 core.pointer_moved(btn.x + 4.0, btn.y + 4.0);
2631 assert_eq!(core.ui_state().hovered_key(), Some("btn"));
2632
2633 core.pointer_moved(0.0, 0.0);
2635 assert_eq!(core.ui_state().hovered_key(), None);
2636 }
2637
2638 #[test]
2639 fn ui_state_is_hovering_within_walks_subtree() {
2640 use crate::tree::*;
2644 let mut tree = crate::column([crate::stack([
2645 crate::widgets::button::button("Inner").key("inner_btn")
2646 ])
2647 .key("card")
2648 .focusable()
2649 .width(Size::Fixed(120.0))
2650 .height(Size::Fixed(60.0))])
2651 .padding(20.0);
2652 let mut core = RunnerCore::new();
2653 crate::layout::layout(
2654 &mut tree,
2655 &mut core.ui_state,
2656 Rect::new(0.0, 0.0, 400.0, 200.0),
2657 );
2658 core.ui_state.sync_focus_order(&tree);
2659 let mut t = PrepareTimings::default();
2660 core.snapshot(&tree, &mut t);
2661
2662 assert!(!core.ui_state().is_hovering_within("card"));
2664 assert!(!core.ui_state().is_hovering_within("inner_btn"));
2665
2666 let inner = core.rect_of_key("inner_btn").expect("inner rect");
2669 core.pointer_moved(inner.x + 4.0, inner.y + 4.0);
2670 assert!(core.ui_state().is_hovering_within("card"));
2671 assert!(core.ui_state().is_hovering_within("inner_btn"));
2672
2673 assert!(!core.ui_state().is_hovering_within("not_a_key"));
2675
2676 core.pointer_moved(0.0, 0.0);
2678 assert!(!core.ui_state().is_hovering_within("card"));
2679 assert!(!core.ui_state().is_hovering_within("inner_btn"));
2680 }
2681
2682 #[test]
2683 fn hover_driven_scale_via_is_hovering_within_plus_animate() {
2684 use crate::Theme;
2691 use crate::anim::Timing;
2692 use crate::tree::*;
2693
2694 let build_card = |hovering: bool| -> El {
2697 let scale = if hovering { 1.05 } else { 1.0 };
2698 crate::column([crate::stack(
2699 [crate::widgets::button::button("Inner").key("inner_btn")],
2700 )
2701 .key("card")
2702 .focusable()
2703 .scale(scale)
2704 .animate(Timing::SPRING_QUICK)
2705 .width(Size::Fixed(120.0))
2706 .height(Size::Fixed(60.0))])
2707 .padding(20.0)
2708 };
2709
2710 let mut core = RunnerCore::new();
2711 core.ui_state
2714 .set_animation_mode(crate::state::AnimationMode::Settled);
2715
2716 let theme = Theme::default();
2718 let cx_pre = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
2719 assert!(!cx_pre.is_hovering_within("card"));
2720 let mut tree = build_card(cx_pre.is_hovering_within("card"));
2721 crate::layout::layout(
2722 &mut tree,
2723 &mut core.ui_state,
2724 Rect::new(0.0, 0.0, 400.0, 200.0),
2725 );
2726 core.ui_state.sync_focus_order(&tree);
2727 let mut t = PrepareTimings::default();
2728 core.snapshot(&tree, &mut t);
2729 core.ui_state
2730 .tick_visual_animations(&mut tree, web_time::Instant::now());
2731 let card_at_rest = tree.children[0].clone();
2732 assert!((card_at_rest.scale - 1.0).abs() < 1e-3);
2733
2734 let card_rect = core.rect_of_key("card").expect("card rect");
2736 core.pointer_moved(card_rect.x + 4.0, card_rect.y + 4.0);
2737
2738 let cx_hot = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
2741 assert!(cx_hot.is_hovering_within("card"));
2742 let mut tree = build_card(cx_hot.is_hovering_within("card"));
2743 crate::layout::layout(
2744 &mut tree,
2745 &mut core.ui_state,
2746 Rect::new(0.0, 0.0, 400.0, 200.0),
2747 );
2748 core.ui_state.sync_focus_order(&tree);
2749 core.snapshot(&tree, &mut t);
2750 core.ui_state
2751 .tick_visual_animations(&mut tree, web_time::Instant::now());
2752 let card_hot = tree.children[0].clone();
2753 assert!(
2754 (card_hot.scale - 1.05).abs() < 1e-3,
2755 "hover should drive card scale to 1.05 via animate; got {}",
2756 card_hot.scale,
2757 );
2758
2759 core.pointer_moved(0.0, 0.0);
2761 let cx_cold = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
2762 assert!(!cx_cold.is_hovering_within("card"));
2763 let mut tree = build_card(cx_cold.is_hovering_within("card"));
2764 crate::layout::layout(
2765 &mut tree,
2766 &mut core.ui_state,
2767 Rect::new(0.0, 0.0, 400.0, 200.0),
2768 );
2769 core.ui_state.sync_focus_order(&tree);
2770 core.snapshot(&tree, &mut t);
2771 core.ui_state
2772 .tick_visual_animations(&mut tree, web_time::Instant::now());
2773 let card_after = tree.children[0].clone();
2774 assert!((card_after.scale - 1.0).abs() < 1e-3);
2775 }
2776
2777 #[test]
2778 fn file_dropped_routes_to_keyed_leaf_at_pointer() {
2779 let mut core = lay_out_input_tree(false);
2780 let btn = core.rect_of_key("btn").expect("btn rect");
2781 let path = std::path::PathBuf::from("/tmp/screenshot.png");
2782 let events = core.file_dropped(path.clone(), btn.x + 4.0, btn.y + 4.0);
2783 assert_eq!(events.len(), 1);
2784 let event = &events[0];
2785 assert_eq!(event.kind, UiEventKind::FileDropped);
2786 assert_eq!(event.key.as_deref(), Some("btn"));
2787 assert_eq!(event.path.as_deref(), Some(path.as_path()));
2788 assert_eq!(event.pointer, Some((btn.x + 4.0, btn.y + 4.0)));
2789 }
2790
2791 #[test]
2792 fn file_dropped_outside_keyed_surface_emits_window_level_event() {
2793 let mut core = lay_out_input_tree(false);
2794 let path = std::path::PathBuf::from("/tmp/screenshot.png");
2796 let events = core.file_dropped(path.clone(), 1.0, 1.0);
2797 assert_eq!(events.len(), 1);
2798 let event = &events[0];
2799 assert_eq!(event.kind, UiEventKind::FileDropped);
2800 assert!(
2801 event.target.is_none(),
2802 "drop outside any keyed surface routes window-level",
2803 );
2804 assert!(event.key.is_none());
2805 assert_eq!(event.path.as_deref(), Some(path.as_path()));
2807 }
2808
2809 #[test]
2810 fn file_hovered_then_cancelled_pair() {
2811 let mut core = lay_out_input_tree(false);
2812 let btn = core.rect_of_key("btn").expect("btn rect");
2813 let path = std::path::PathBuf::from("/tmp/a.png");
2814
2815 let hover = core.file_hovered(path.clone(), btn.x + 4.0, btn.y + 4.0);
2816 assert_eq!(hover.len(), 1);
2817 assert_eq!(hover[0].kind, UiEventKind::FileHovered);
2818 assert_eq!(hover[0].key.as_deref(), Some("btn"));
2819 assert_eq!(hover[0].path.as_deref(), Some(path.as_path()));
2820
2821 let cancel = core.file_hover_cancelled();
2822 assert_eq!(cancel.len(), 1);
2823 assert_eq!(cancel[0].kind, UiEventKind::FileHoverCancelled);
2824 assert!(cancel[0].target.is_none());
2825 assert!(cancel[0].path.is_none());
2826 }
2827
2828 #[test]
2829 fn build_cx_hover_accessors_default_off_without_state() {
2830 use crate::Theme;
2831 let theme = Theme::default();
2832 let cx = crate::BuildCx::new(&theme);
2833 assert_eq!(cx.hovered_key(), None);
2834 assert!(!cx.is_hovering_within("anything"));
2835 }
2836
2837 #[test]
2838 fn build_cx_hover_accessors_delegate_when_state_attached() {
2839 use crate::Theme;
2840 let mut core = lay_out_input_tree(false);
2841 let btn = core.rect_of_key("btn").expect("btn rect");
2842 core.pointer_moved(btn.x + 4.0, btn.y + 4.0);
2843
2844 let theme = Theme::default();
2845 let cx = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
2846 assert_eq!(cx.hovered_key(), Some("btn"));
2847 assert!(cx.is_hovering_within("btn"));
2848 assert!(!cx.is_hovering_within("ti"));
2849 }
2850
2851 fn lay_out_paragraph_tree() -> RunnerCore {
2852 use crate::tree::*;
2853 let mut tree = crate::column([
2854 crate::widgets::text::text("First paragraph of text.")
2855 .key("p1")
2856 .selectable(),
2857 crate::widgets::text::text("Second paragraph of text.")
2858 .key("p2")
2859 .selectable(),
2860 ])
2861 .padding(20.0);
2862 let mut core = RunnerCore::new();
2863 crate::layout::layout(
2864 &mut tree,
2865 &mut core.ui_state,
2866 Rect::new(0.0, 0.0, 400.0, 300.0),
2867 );
2868 core.ui_state.sync_focus_order(&tree);
2869 core.ui_state.sync_selection_order(&tree);
2870 let mut t = PrepareTimings::default();
2871 core.snapshot(&tree, &mut t);
2872 core
2873 }
2874
2875 #[test]
2876 fn pointer_down_on_selectable_text_emits_selection_changed() {
2877 let mut core = lay_out_paragraph_tree();
2878 let p1 = core.rect_of_key("p1").expect("p1 rect");
2879 let cx = p1.x + 4.0;
2880 let cy = p1.y + p1.h * 0.5;
2881 let events = core.pointer_down(cx, cy, PointerButton::Primary);
2882 let sel_event = events
2883 .iter()
2884 .find(|e| e.kind == UiEventKind::SelectionChanged)
2885 .expect("SelectionChanged emitted");
2886 let new_sel = sel_event
2887 .selection
2888 .as_ref()
2889 .expect("SelectionChanged carries a selection");
2890 let range = new_sel.range.as_ref().expect("collapsed selection at hit");
2891 assert_eq!(range.anchor.key, "p1");
2892 assert_eq!(range.head.key, "p1");
2893 assert_eq!(range.anchor.byte, range.head.byte);
2894 assert!(core.ui_state.selection.drag.is_some());
2895 }
2896
2897 #[test]
2898 fn pointer_drag_on_selectable_text_extends_head() {
2899 let mut core = lay_out_paragraph_tree();
2900 let p1 = core.rect_of_key("p1").expect("p1 rect");
2901 let cx = p1.x + 4.0;
2902 let cy = p1.y + p1.h * 0.5;
2903 core.pointer_moved(cx, cy);
2904 core.pointer_down(cx, cy, PointerButton::Primary);
2905
2906 let events = core.pointer_moved(p1.x + p1.w - 10.0, cy).events;
2908 let sel_event = events
2909 .iter()
2910 .find(|e| e.kind == UiEventKind::SelectionChanged)
2911 .expect("Drag emits SelectionChanged");
2912 let new_sel = sel_event.selection.as_ref().unwrap();
2913 let range = new_sel.range.as_ref().unwrap();
2914 assert_eq!(range.anchor.key, "p1");
2915 assert_eq!(range.head.key, "p1");
2916 assert!(
2917 range.head.byte > range.anchor.byte,
2918 "head should advance past anchor (anchor={}, head={})",
2919 range.anchor.byte,
2920 range.head.byte
2921 );
2922 }
2923
2924 #[test]
2925 fn pointer_up_clears_drag_but_keeps_selection() {
2926 let mut core = lay_out_paragraph_tree();
2927 let p1 = core.rect_of_key("p1").expect("p1 rect");
2928 let cx = p1.x + 4.0;
2929 let cy = p1.y + p1.h * 0.5;
2930 core.pointer_down(cx, cy, PointerButton::Primary);
2931 core.pointer_moved(p1.x + p1.w - 10.0, cy);
2932 let _ = core.pointer_up(p1.x + p1.w - 10.0, cy, PointerButton::Primary);
2933 assert!(
2934 core.ui_state.selection.drag.is_none(),
2935 "drag flag should clear on pointer_up"
2936 );
2937 assert!(
2938 !core.ui_state.current_selection.is_empty(),
2939 "selection itself should persist after pointer_up"
2940 );
2941 }
2942
2943 #[test]
2944 fn drag_past_a_leaf_bottom_keeps_head_in_that_leaf_not_anchor() {
2945 let mut core = lay_out_paragraph_tree();
2951 let p1 = core.rect_of_key("p1").expect("p1 rect");
2952 let p2 = core.rect_of_key("p2").expect("p2 rect");
2953 core.pointer_down(p1.x + 4.0, p1.y + p1.h * 0.5, PointerButton::Primary);
2955 core.pointer_moved(p2.x + 8.0, p2.y + p2.h * 0.5);
2957 let events = core.pointer_moved(p2.x + 8.0, p2.y + p2.h + 200.0).events;
2960 let sel = events
2961 .iter()
2962 .find(|e| e.kind == UiEventKind::SelectionChanged)
2963 .map(|e| e.selection.as_ref().unwrap().clone())
2964 .unwrap_or_else(|| core.ui_state.current_selection.clone());
2967 let r = sel.range.as_ref().expect("selection still active");
2968 assert_eq!(r.anchor.key, "p1", "anchor unchanged");
2969 assert_eq!(
2970 r.head.key, "p2",
2971 "head must stay in p2 even when pointer is below p2's rect"
2972 );
2973 }
2974
2975 #[test]
2976 fn drag_into_a_sibling_selectable_extends_head_into_that_leaf() {
2977 let mut core = lay_out_paragraph_tree();
2978 let p1 = core.rect_of_key("p1").expect("p1 rect");
2979 let p2 = core.rect_of_key("p2").expect("p2 rect");
2980 core.pointer_down(p1.x + 4.0, p1.y + p1.h * 0.5, PointerButton::Primary);
2982 let events = core.pointer_moved(p2.x + 8.0, p2.y + p2.h * 0.5).events;
2984 let sel_event = events
2985 .iter()
2986 .find(|e| e.kind == UiEventKind::SelectionChanged)
2987 .expect("Drag emits SelectionChanged");
2988 let new_sel = sel_event.selection.as_ref().unwrap();
2989 let range = new_sel.range.as_ref().unwrap();
2990 assert_eq!(range.anchor.key, "p1", "anchor stays in p1");
2991 assert_eq!(range.head.key, "p2", "head migrates into p2");
2992 }
2993
2994 #[test]
2995 fn pointer_down_on_focusable_owning_selection_does_not_clear_it() {
2996 let mut core = lay_out_input_tree(true);
3004 core.set_selection(crate::selection::Selection::caret("ti", 3));
3007 let ti = core.rect_of_key("ti").expect("ti rect");
3008 let cx = ti.x + ti.w * 0.5;
3009 let cy = ti.y + ti.h * 0.5;
3010
3011 let events = core.pointer_down(cx, cy, PointerButton::Primary);
3012 let cleared = events.iter().find(|e| {
3013 e.kind == UiEventKind::SelectionChanged
3014 && e.selection.as_ref().map(|s| s.is_empty()).unwrap_or(false)
3015 });
3016 assert!(
3017 cleared.is_none(),
3018 "click on the selection-owning input must not emit a clearing SelectionChanged"
3019 );
3020 assert_eq!(
3021 core.ui_state.current_selection,
3022 crate::selection::Selection::caret("ti", 3),
3023 "runtime mirror is preserved when the click owns the selection"
3024 );
3025 }
3026
3027 #[test]
3028 fn pointer_down_into_a_different_capture_keys_widget_does_not_clear_first() {
3029 let mut core = lay_out_input_tree(true);
3039 core.set_selection(crate::selection::Selection::caret("other", 4));
3041 let ti = core.rect_of_key("ti").expect("ti rect");
3042 let cx = ti.x + ti.w * 0.5;
3043 let cy = ti.y + ti.h * 0.5;
3044
3045 let events = core.pointer_down(cx, cy, PointerButton::Primary);
3046 let cleared = events.iter().any(|e| {
3047 e.kind == UiEventKind::SelectionChanged
3048 && e.selection.as_ref().map(|s| s.is_empty()).unwrap_or(false)
3049 });
3050 assert!(
3051 !cleared,
3052 "click on a different capture_keys widget must not race-clear the selection"
3053 );
3054 }
3055
3056 #[test]
3057 fn pointer_down_on_non_selectable_clears_existing_selection() {
3058 let mut core = lay_out_paragraph_tree();
3059 let p1 = core.rect_of_key("p1").expect("p1 rect");
3060 let cy = p1.y + p1.h * 0.5;
3061 core.pointer_down(p1.x + 4.0, cy, PointerButton::Primary);
3063 core.pointer_up(p1.x + 4.0, cy, PointerButton::Primary);
3064 assert!(!core.ui_state.current_selection.is_empty());
3065
3066 let events = core.pointer_down(2.0, 2.0, PointerButton::Primary);
3068 let cleared = events
3069 .iter()
3070 .find(|e| e.kind == UiEventKind::SelectionChanged)
3071 .expect("clearing emits SelectionChanged");
3072 let new_sel = cleared.selection.as_ref().unwrap();
3073 assert!(new_sel.is_empty(), "new selection should be empty");
3074 assert!(core.ui_state.current_selection.is_empty());
3075 }
3076
3077 #[test]
3078 fn key_down_bumps_caret_activity_when_focused_widget_captures_keys() {
3079 let mut core = lay_out_input_tree(true);
3084 let target = core
3085 .ui_state
3086 .focus
3087 .order
3088 .iter()
3089 .find(|t| t.key == "ti")
3090 .cloned();
3091 core.ui_state.set_focus(target); let after_focus = core.ui_state.caret.activity_at.expect("focus bump");
3093
3094 std::thread::sleep(std::time::Duration::from_millis(2));
3095 let _ = core.key_down(UiKey::ArrowRight, KeyModifiers::default(), false);
3096 let after_arrow = core
3097 .ui_state
3098 .caret
3099 .activity_at
3100 .expect("arrow key bumps even without app-side selection");
3101 assert!(
3102 after_arrow > after_focus,
3103 "ArrowRight to a capture_keys focused widget bumps caret activity"
3104 );
3105 }
3106
3107 #[test]
3108 fn text_input_bumps_caret_activity_when_focused() {
3109 let mut core = lay_out_input_tree(true);
3110 let target = core
3111 .ui_state
3112 .focus
3113 .order
3114 .iter()
3115 .find(|t| t.key == "ti")
3116 .cloned();
3117 core.ui_state.set_focus(target);
3118 let after_focus = core.ui_state.caret.activity_at.unwrap();
3119
3120 std::thread::sleep(std::time::Duration::from_millis(2));
3121 let _ = core.text_input("a".into());
3122 let after_text = core.ui_state.caret.activity_at.unwrap();
3123 assert!(
3124 after_text > after_focus,
3125 "TextInput to focused widget bumps caret activity"
3126 );
3127 }
3128
3129 #[test]
3130 fn pointer_down_inside_focused_input_bumps_caret_activity() {
3131 let mut core = lay_out_input_tree(true);
3136 let ti = core.rect_of_key("ti").expect("ti rect");
3137 let cx = ti.x + ti.w * 0.5;
3138 let cy = ti.y + ti.h * 0.5;
3139
3140 core.pointer_down(cx, cy, PointerButton::Primary);
3142 let _ = core.pointer_up(cx, cy, PointerButton::Primary);
3143 let after_first = core.ui_state.caret.activity_at.unwrap();
3144
3145 std::thread::sleep(std::time::Duration::from_millis(2));
3148 core.pointer_down(cx + 1.0, cy, PointerButton::Primary);
3149 let after_second = core
3150 .ui_state
3151 .caret
3152 .activity_at
3153 .expect("second click bumps too");
3154 assert!(
3155 after_second > after_first,
3156 "click within already-focused capture_keys widget still bumps"
3157 );
3158 }
3159
3160 #[test]
3161 fn arrow_key_through_apply_event_mutates_selection_and_bumps_on_set() {
3162 use crate::widgets::text_input;
3168 let mut sel = crate::selection::Selection::caret("ti", 2);
3169 let mut value = String::from("hello");
3170
3171 let mut core = RunnerCore::new();
3172 core.set_selection(sel.clone());
3175 let baseline = core.ui_state.caret.activity_at;
3176
3177 let arrow_right = UiEvent {
3179 key: Some("ti".into()),
3180 target: None,
3181 pointer: None,
3182 key_press: Some(crate::event::KeyPress {
3183 key: UiKey::ArrowRight,
3184 modifiers: KeyModifiers::default(),
3185 repeat: false,
3186 }),
3187 text: None,
3188 selection: None,
3189 modifiers: KeyModifiers::default(),
3190 click_count: 0,
3191 path: None,
3192 kind: UiEventKind::KeyDown,
3193 };
3194
3195 let mutated = text_input::apply_event(&mut value, &mut sel, "ti", &arrow_right);
3197 assert!(mutated, "ArrowRight should mutate selection");
3198 assert_eq!(
3199 sel.within("ti").unwrap().head,
3200 3,
3201 "head moved one char right (h-e-l-l-o, byte 2 → 3)"
3202 );
3203
3204 std::thread::sleep(std::time::Duration::from_millis(2));
3206 core.set_selection(sel);
3207 let after = core.ui_state.caret.activity_at.unwrap();
3208 if let Some(b) = baseline {
3212 assert!(after > b, "arrow-key flow should bump activity");
3213 }
3214 }
3215
3216 #[test]
3217 fn set_selection_bumps_caret_activity_only_when_value_changes() {
3218 let mut core = lay_out_paragraph_tree();
3219 core.set_selection(crate::selection::Selection::default());
3222 assert!(
3223 core.ui_state.caret.activity_at.is_none(),
3224 "no-op set_selection should not bump activity"
3225 );
3226
3227 let sel_a = crate::selection::Selection::caret("p1", 3);
3229 core.set_selection(sel_a.clone());
3230 let bumped_at = core
3231 .ui_state
3232 .caret
3233 .activity_at
3234 .expect("first real selection bumps");
3235
3236 core.set_selection(sel_a.clone());
3239 assert_eq!(
3240 core.ui_state.caret.activity_at,
3241 Some(bumped_at),
3242 "set_selection with same value is a no-op"
3243 );
3244
3245 std::thread::sleep(std::time::Duration::from_millis(2));
3248 let sel_b = crate::selection::Selection::caret("p1", 7);
3249 core.set_selection(sel_b);
3250 let new_bump = core.ui_state.caret.activity_at.expect("second bump");
3251 assert!(
3252 new_bump > bumped_at,
3253 "moving the caret bumps activity again",
3254 );
3255 }
3256
3257 #[test]
3258 fn escape_clears_active_selection_and_emits_selection_changed() {
3259 let mut core = lay_out_paragraph_tree();
3260 let p1 = core.rect_of_key("p1").expect("p1 rect");
3261 let cy = p1.y + p1.h * 0.5;
3262 core.pointer_down(p1.x + 4.0, cy, PointerButton::Primary);
3264 core.pointer_moved(p1.x + p1.w - 10.0, cy);
3265 core.pointer_up(p1.x + p1.w - 10.0, cy, PointerButton::Primary);
3266 assert!(!core.ui_state.current_selection.is_empty());
3267
3268 let events = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
3269 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3270 assert_eq!(
3271 kinds,
3272 vec![UiEventKind::Escape, UiEventKind::SelectionChanged],
3273 "Esc emits Escape (for popover dismiss) AND SelectionChanged"
3274 );
3275 let cleared = events
3276 .iter()
3277 .find(|e| e.kind == UiEventKind::SelectionChanged)
3278 .unwrap();
3279 assert!(cleared.selection.as_ref().unwrap().is_empty());
3280 assert!(core.ui_state.current_selection.is_empty());
3281 }
3282
3283 #[test]
3284 fn consecutive_clicks_on_same_target_extend_count() {
3285 let mut core = lay_out_input_tree(false);
3286 let btn = core.rect_of_key("btn").expect("btn rect");
3287 let cx = btn.x + btn.w * 0.5;
3288 let cy = btn.y + btn.h * 0.5;
3289
3290 let down1 = core.pointer_down(cx, cy, PointerButton::Primary);
3292 let pd1 = down1
3293 .iter()
3294 .find(|e| e.kind == UiEventKind::PointerDown)
3295 .expect("PointerDown emitted");
3296 assert_eq!(pd1.click_count, 1, "first press starts the sequence");
3297 let up1 = core.pointer_up(cx, cy, PointerButton::Primary);
3298 let click1 = up1
3299 .iter()
3300 .find(|e| e.kind == UiEventKind::Click)
3301 .expect("Click emitted");
3302 assert_eq!(
3303 click1.click_count, 1,
3304 "Click carries the same count as its PointerDown"
3305 );
3306
3307 let down2 = core.pointer_down(cx, cy, PointerButton::Primary);
3309 let pd2 = down2
3310 .iter()
3311 .find(|e| e.kind == UiEventKind::PointerDown)
3312 .unwrap();
3313 assert_eq!(pd2.click_count, 2, "second press extends the sequence");
3314 let up2 = core.pointer_up(cx, cy, PointerButton::Primary);
3315 assert_eq!(
3316 up2.iter()
3317 .find(|e| e.kind == UiEventKind::Click)
3318 .unwrap()
3319 .click_count,
3320 2
3321 );
3322
3323 let down3 = core.pointer_down(cx, cy, PointerButton::Primary);
3325 let pd3 = down3
3326 .iter()
3327 .find(|e| e.kind == UiEventKind::PointerDown)
3328 .unwrap();
3329 assert_eq!(pd3.click_count, 3, "third press → triple-click");
3330 core.pointer_up(cx, cy, PointerButton::Primary);
3331 }
3332
3333 #[test]
3334 fn click_count_resets_when_target_changes() {
3335 let mut core = lay_out_input_tree(false);
3336 let btn = core.rect_of_key("btn").expect("btn rect");
3337 let ti = core.rect_of_key("ti").expect("ti rect");
3338
3339 let down1 = core.pointer_down(
3341 btn.x + btn.w * 0.5,
3342 btn.y + btn.h * 0.5,
3343 PointerButton::Primary,
3344 );
3345 assert_eq!(
3346 down1
3347 .iter()
3348 .find(|e| e.kind == UiEventKind::PointerDown)
3349 .unwrap()
3350 .click_count,
3351 1
3352 );
3353 let _ = core.pointer_up(
3354 btn.x + btn.w * 0.5,
3355 btn.y + btn.h * 0.5,
3356 PointerButton::Primary,
3357 );
3358
3359 let down2 = core.pointer_down(ti.x + ti.w * 0.5, ti.y + ti.h * 0.5, PointerButton::Primary);
3361 let pd2 = down2
3362 .iter()
3363 .find(|e| e.kind == UiEventKind::PointerDown)
3364 .unwrap();
3365 assert_eq!(
3366 pd2.click_count, 1,
3367 "press on a new target resets the multi-click sequence"
3368 );
3369 }
3370
3371 #[test]
3372 fn double_click_on_selectable_text_selects_word_at_hit() {
3373 let mut core = lay_out_paragraph_tree();
3374 let p1 = core.rect_of_key("p1").expect("p1 rect");
3375 let cy = p1.y + p1.h * 0.5;
3376 let cx = p1.x + 4.0;
3379 core.pointer_down(cx, cy, PointerButton::Primary);
3380 core.pointer_up(cx, cy, PointerButton::Primary);
3381 core.pointer_down(cx, cy, PointerButton::Primary);
3382 let sel = &core.ui_state.current_selection;
3384 let r = sel.range.as_ref().expect("selection set");
3385 assert_eq!(r.anchor.key, "p1");
3386 assert_eq!(r.head.key, "p1");
3387 assert_eq!(r.anchor.byte.min(r.head.byte), 0);
3389 assert_eq!(r.anchor.byte.max(r.head.byte), 5);
3390 }
3391
3392 #[test]
3393 fn triple_click_on_selectable_text_selects_whole_leaf() {
3394 let mut core = lay_out_paragraph_tree();
3395 let p1 = core.rect_of_key("p1").expect("p1 rect");
3396 let cy = p1.y + p1.h * 0.5;
3397 let cx = p1.x + 4.0;
3398 core.pointer_down(cx, cy, PointerButton::Primary);
3399 core.pointer_up(cx, cy, PointerButton::Primary);
3400 core.pointer_down(cx, cy, PointerButton::Primary);
3401 core.pointer_up(cx, cy, PointerButton::Primary);
3402 core.pointer_down(cx, cy, PointerButton::Primary);
3403 let sel = &core.ui_state.current_selection;
3404 let r = sel.range.as_ref().expect("selection set");
3405 assert_eq!(r.anchor.byte, 0);
3406 assert_eq!(r.head.byte, 24);
3408 }
3409
3410 #[test]
3411 fn click_count_resets_when_press_drifts_outside_distance_window() {
3412 let mut core = lay_out_input_tree(false);
3413 let btn = core.rect_of_key("btn").expect("btn rect");
3414 let cx = btn.x + btn.w * 0.5;
3415 let cy = btn.y + btn.h * 0.5;
3416
3417 let _ = core.pointer_down(cx, cy, PointerButton::Primary);
3418 let _ = core.pointer_up(cx, cy, PointerButton::Primary);
3419
3420 let down2 = core.pointer_down(cx + 10.0, cy, PointerButton::Primary);
3423 let pd2 = down2
3424 .iter()
3425 .find(|e| e.kind == UiEventKind::PointerDown)
3426 .unwrap();
3427 assert_eq!(pd2.click_count, 1);
3428 }
3429
3430 #[test]
3431 fn escape_with_no_selection_emits_only_escape() {
3432 let mut core = lay_out_paragraph_tree();
3433 assert!(core.ui_state.current_selection.is_empty());
3434 let events = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
3435 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3436 assert_eq!(
3437 kinds,
3438 vec![UiEventKind::Escape],
3439 "no selection → no SelectionChanged side-effect"
3440 );
3441 }
3442
3443 fn lay_out_scroll_tree() -> (RunnerCore, String) {
3446 use crate::tree::*;
3447 let mut tree = crate::scroll(
3448 (0..6)
3449 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
3450 )
3451 .gap(12.0)
3452 .height(Size::Fixed(200.0));
3453 let mut core = RunnerCore::new();
3454 crate::layout::layout(
3455 &mut tree,
3456 &mut core.ui_state,
3457 Rect::new(0.0, 0.0, 300.0, 200.0),
3458 );
3459 let scroll_id = tree.computed_id.clone();
3460 let mut t = PrepareTimings::default();
3461 core.snapshot(&tree, &mut t);
3462 (core, scroll_id)
3463 }
3464
3465 #[test]
3466 fn thumb_pointer_down_captures_drag_and_suppresses_events() {
3467 let (mut core, scroll_id) = lay_out_scroll_tree();
3468 let thumb = core
3469 .ui_state
3470 .scroll
3471 .thumb_rects
3472 .get(&scroll_id)
3473 .copied()
3474 .expect("scrollable should have a thumb");
3475 let event = core.pointer_down(
3476 thumb.x + thumb.w * 0.5,
3477 thumb.y + thumb.h * 0.5,
3478 PointerButton::Primary,
3479 );
3480 assert!(
3481 event.is_empty(),
3482 "thumb press should not emit PointerDown to the app"
3483 );
3484 let drag = core
3485 .ui_state
3486 .scroll
3487 .thumb_drag
3488 .as_ref()
3489 .expect("scroll.thumb_drag should be set after pointer_down on thumb");
3490 assert_eq!(drag.scroll_id, scroll_id);
3491 }
3492
3493 #[test]
3494 fn track_click_above_thumb_pages_up_below_pages_down() {
3495 let (mut core, scroll_id) = lay_out_scroll_tree();
3496 let track = core
3497 .ui_state
3498 .scroll
3499 .thumb_tracks
3500 .get(&scroll_id)
3501 .copied()
3502 .expect("scrollable should have a track");
3503 let thumb = core
3504 .ui_state
3505 .scroll
3506 .thumb_rects
3507 .get(&scroll_id)
3508 .copied()
3509 .unwrap();
3510 let metrics = core
3511 .ui_state
3512 .scroll
3513 .metrics
3514 .get(&scroll_id)
3515 .copied()
3516 .unwrap();
3517
3518 let evt = core.pointer_down(
3520 track.x + track.w * 0.5,
3521 thumb.y + thumb.h + 10.0,
3522 PointerButton::Primary,
3523 );
3524 assert!(evt.is_empty(), "track press should not surface PointerDown");
3525 assert!(
3526 core.ui_state.scroll.thumb_drag.is_none(),
3527 "track click outside the thumb should not start a drag",
3528 );
3529 let after_down = core.ui_state.scroll_offset(&scroll_id);
3530 let expected_page = (metrics.viewport_h - SCROLL_PAGE_OVERLAP).max(0.0);
3531 assert!(
3532 (after_down - expected_page.min(metrics.max_offset)).abs() < 0.5,
3533 "page-down offset = {after_down} (expected ~{expected_page})",
3534 );
3535 let _ = core.pointer_up(0.0, 0.0, PointerButton::Primary);
3537
3538 let mut tree = lay_out_scroll_tree_only();
3541 crate::layout::layout(
3542 &mut tree,
3543 &mut core.ui_state,
3544 Rect::new(0.0, 0.0, 300.0, 200.0),
3545 );
3546 let mut t = PrepareTimings::default();
3547 core.snapshot(&tree, &mut t);
3548 let track = core
3549 .ui_state
3550 .scroll
3551 .thumb_tracks
3552 .get(&tree.computed_id)
3553 .copied()
3554 .unwrap();
3555 let thumb = core
3556 .ui_state
3557 .scroll
3558 .thumb_rects
3559 .get(&tree.computed_id)
3560 .copied()
3561 .unwrap();
3562
3563 core.pointer_down(
3564 track.x + track.w * 0.5,
3565 thumb.y - 4.0,
3566 PointerButton::Primary,
3567 );
3568 let after_up = core.ui_state.scroll_offset(&tree.computed_id);
3569 assert!(
3570 after_up < after_down,
3571 "page-up should reduce offset: before={after_down} after={after_up}",
3572 );
3573 }
3574
3575 fn lay_out_scroll_tree_only() -> El {
3580 use crate::tree::*;
3581 crate::scroll(
3582 (0..6)
3583 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
3584 )
3585 .gap(12.0)
3586 .height(Size::Fixed(200.0))
3587 }
3588
3589 #[test]
3590 fn thumb_drag_translates_pointer_delta_into_scroll_offset() {
3591 let (mut core, scroll_id) = lay_out_scroll_tree();
3592 let thumb = core
3593 .ui_state
3594 .scroll
3595 .thumb_rects
3596 .get(&scroll_id)
3597 .copied()
3598 .unwrap();
3599 let metrics = core
3600 .ui_state
3601 .scroll
3602 .metrics
3603 .get(&scroll_id)
3604 .copied()
3605 .unwrap();
3606 let track_remaining = (metrics.viewport_h - thumb.h).max(0.0);
3607
3608 let press_y = thumb.y + thumb.h * 0.5;
3609 core.pointer_down(thumb.x + thumb.w * 0.5, press_y, PointerButton::Primary);
3610 let evt = core.pointer_moved(thumb.x + thumb.w * 0.5, press_y + 20.0);
3612 assert!(
3613 evt.events.is_empty(),
3614 "thumb-drag move should suppress Drag event",
3615 );
3616 let offset = core.ui_state.scroll_offset(&scroll_id);
3617 let expected = 20.0 * (metrics.max_offset / track_remaining);
3618 assert!(
3619 (offset - expected).abs() < 0.5,
3620 "offset {offset} (expected {expected})",
3621 );
3622 core.pointer_moved(thumb.x + thumb.w * 0.5, press_y + 9999.0);
3624 let offset = core.ui_state.scroll_offset(&scroll_id);
3625 assert!(
3626 (offset - metrics.max_offset).abs() < 0.5,
3627 "overshoot offset {offset} (expected {})",
3628 metrics.max_offset
3629 );
3630 let events = core.pointer_up(thumb.x, press_y, PointerButton::Primary);
3632 assert!(events.is_empty(), "thumb release shouldn't emit events");
3633 assert!(core.ui_state.scroll.thumb_drag.is_none());
3634 }
3635
3636 #[test]
3637 fn secondary_click_does_not_steal_focus_or_press() {
3638 let mut core = lay_out_input_tree(false);
3639 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3640 let cx = btn_rect.x + btn_rect.w * 0.5;
3641 let cy = btn_rect.y + btn_rect.h * 0.5;
3642 let ti_rect = core.rect_of_key("ti").expect("ti rect");
3644 let tx = ti_rect.x + ti_rect.w * 0.5;
3645 let ty = ti_rect.y + ti_rect.h * 0.5;
3646 core.pointer_down(tx, ty, PointerButton::Primary);
3647 let _ = core.pointer_up(tx, ty, PointerButton::Primary);
3648 let focused_before = core.ui_state.focused.as_ref().map(|t| t.key.clone());
3649 core.pointer_down(cx, cy, PointerButton::Secondary);
3651 let events = core.pointer_up(cx, cy, PointerButton::Secondary);
3652 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3653 assert_eq!(kinds, vec![UiEventKind::SecondaryClick]);
3654 let focused_after = core.ui_state.focused.as_ref().map(|t| t.key.clone());
3655 assert_eq!(
3656 focused_before, focused_after,
3657 "right-click must not steal focus"
3658 );
3659 assert!(
3660 core.ui_state.pressed.is_none(),
3661 "right-click must not set primary press"
3662 );
3663 }
3664
3665 #[test]
3666 fn text_input_routes_to_focused_only() {
3667 let mut core = lay_out_input_tree(false);
3668 assert!(core.text_input("a".into()).is_none());
3670 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3672 let cx = btn_rect.x + btn_rect.w * 0.5;
3673 let cy = btn_rect.y + btn_rect.h * 0.5;
3674 core.pointer_down(cx, cy, PointerButton::Primary);
3675 let _ = core.pointer_up(cx, cy, PointerButton::Primary);
3676 let event = core.text_input("hi".into()).expect("focused → event");
3677 assert_eq!(event.kind, UiEventKind::TextInput);
3678 assert_eq!(event.text.as_deref(), Some("hi"));
3679 assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("btn"));
3680 assert!(core.text_input(String::new()).is_none());
3682 }
3683
3684 #[test]
3685 fn capture_keys_bypasses_tab_traversal_for_focused_node() {
3686 let mut core = lay_out_input_tree(true);
3689 let ti_rect = core.rect_of_key("ti").expect("ti rect");
3690 let tx = ti_rect.x + ti_rect.w * 0.5;
3691 let ty = ti_rect.y + ti_rect.h * 0.5;
3692 core.pointer_down(tx, ty, PointerButton::Primary);
3693 let _ = core.pointer_up(tx, ty, PointerButton::Primary);
3694 assert_eq!(
3695 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3696 Some("ti"),
3697 "primary click on capture_keys node still focuses it"
3698 );
3699
3700 let events = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
3701 assert_eq!(events.len(), 1, "Tab → exactly one KeyDown");
3702 let event = &events[0];
3703 assert_eq!(event.kind, UiEventKind::KeyDown);
3704 assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("ti"));
3705 assert_eq!(
3706 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3707 Some("ti"),
3708 "Tab inside capture_keys must NOT move focus"
3709 );
3710 }
3711
3712 #[test]
3713 fn pointer_down_focus_does_not_raise_focus_visible() {
3714 let mut core = lay_out_input_tree(false);
3717 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3718 let cx = btn_rect.x + btn_rect.w * 0.5;
3719 let cy = btn_rect.y + btn_rect.h * 0.5;
3720 core.pointer_down(cx, cy, PointerButton::Primary);
3721 assert_eq!(
3722 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3723 Some("btn"),
3724 "primary click focuses the button",
3725 );
3726 assert!(
3727 !core.ui_state.focus_visible,
3728 "click focus must not raise focus_visible — ring stays off",
3729 );
3730 }
3731
3732 #[test]
3733 fn tab_key_raises_focus_visible_so_ring_appears() {
3734 let mut core = lay_out_input_tree(false);
3735 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3737 let cx = btn_rect.x + btn_rect.w * 0.5;
3738 let cy = btn_rect.y + btn_rect.h * 0.5;
3739 core.pointer_down(cx, cy, PointerButton::Primary);
3740 assert!(!core.ui_state.focus_visible);
3741 let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
3743 assert!(
3744 core.ui_state.focus_visible,
3745 "Tab must raise focus_visible so the ring paints on the new target",
3746 );
3747 }
3748
3749 #[test]
3750 fn click_after_tab_clears_focus_visible_again() {
3751 let mut core = lay_out_input_tree(false);
3754 let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
3755 assert!(core.ui_state.focus_visible, "Tab raises ring");
3756 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3757 let cx = btn_rect.x + btn_rect.w * 0.5;
3758 let cy = btn_rect.y + btn_rect.h * 0.5;
3759 core.pointer_down(cx, cy, PointerButton::Primary);
3760 assert!(
3761 !core.ui_state.focus_visible,
3762 "pointer-down clears focus_visible — ring fades back out",
3763 );
3764 }
3765
3766 #[test]
3767 fn keypress_on_focused_widget_raises_focus_visible_after_click() {
3768 let mut core = lay_out_input_tree(false);
3772 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3773 let cx = btn_rect.x + btn_rect.w * 0.5;
3774 let cy = btn_rect.y + btn_rect.h * 0.5;
3775 core.pointer_down(cx, cy, PointerButton::Primary);
3776 assert!(!core.ui_state.focus_visible);
3777 let _ = core.key_down(UiKey::ArrowRight, KeyModifiers::default(), false);
3778 assert!(
3779 core.ui_state.focus_visible,
3780 "non-Tab key on focused widget raises focus_visible",
3781 );
3782 }
3783
3784 #[test]
3785 fn arrow_nav_in_sibling_group_raises_focus_visible() {
3786 let mut core = lay_out_arrow_nav_tree();
3787 core.ui_state.set_focus_visible(false);
3790 let _ = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
3791 assert!(
3792 core.ui_state.focus_visible,
3793 "arrow-nav within an arrow_nav_siblings group is keyboard navigation",
3794 );
3795 }
3796
3797 #[test]
3798 fn capture_keys_falls_back_to_default_when_focus_off_capturing_node() {
3799 let mut core = lay_out_input_tree(true);
3803 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3804 let cx = btn_rect.x + btn_rect.w * 0.5;
3805 let cy = btn_rect.y + btn_rect.h * 0.5;
3806 core.pointer_down(cx, cy, PointerButton::Primary);
3807 let _ = core.pointer_up(cx, cy, PointerButton::Primary);
3808 assert_eq!(
3809 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3810 Some("btn"),
3811 "primary click focuses button"
3812 );
3813 let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
3815 assert_eq!(
3816 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3817 Some("ti"),
3818 "Tab from non-capturing focused does library-default traversal"
3819 );
3820 }
3821
3822 fn lay_out_arrow_nav_tree() -> RunnerCore {
3827 use crate::tree::*;
3828 let mut tree = crate::column([
3829 crate::widgets::button::button("Red").key("opt-red"),
3830 crate::widgets::button::button("Green").key("opt-green"),
3831 crate::widgets::button::button("Blue").key("opt-blue"),
3832 ])
3833 .arrow_nav_siblings()
3834 .padding(10.0);
3835 let mut core = RunnerCore::new();
3836 crate::layout::layout(
3837 &mut tree,
3838 &mut core.ui_state,
3839 Rect::new(0.0, 0.0, 200.0, 300.0),
3840 );
3841 core.ui_state.sync_focus_order(&tree);
3842 let mut t = PrepareTimings::default();
3843 core.snapshot(&tree, &mut t);
3844 let target = core
3847 .ui_state
3848 .focus
3849 .order
3850 .iter()
3851 .find(|t| t.key == "opt-green")
3852 .cloned();
3853 core.ui_state.set_focus(target);
3854 core
3855 }
3856
3857 #[test]
3858 fn arrow_nav_moves_focus_among_siblings() {
3859 let mut core = lay_out_arrow_nav_tree();
3860
3861 let down = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
3864 assert!(down.is_empty(), "arrow-nav consumes the key event");
3865 assert_eq!(
3866 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3867 Some("opt-blue"),
3868 );
3869
3870 core.key_down(UiKey::ArrowUp, KeyModifiers::default(), false);
3872 assert_eq!(
3873 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3874 Some("opt-green"),
3875 );
3876
3877 core.key_down(UiKey::Home, KeyModifiers::default(), false);
3879 assert_eq!(
3880 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3881 Some("opt-red"),
3882 );
3883
3884 core.key_down(UiKey::End, KeyModifiers::default(), false);
3886 assert_eq!(
3887 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3888 Some("opt-blue"),
3889 );
3890 }
3891
3892 #[test]
3893 fn arrow_nav_saturates_at_ends() {
3894 let mut core = lay_out_arrow_nav_tree();
3895 core.key_down(UiKey::Home, KeyModifiers::default(), false);
3897 core.key_down(UiKey::ArrowUp, KeyModifiers::default(), false);
3898 assert_eq!(
3899 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3900 Some("opt-red"),
3901 "ArrowUp at top stays at top — no wrap",
3902 );
3903 core.key_down(UiKey::End, KeyModifiers::default(), false);
3905 core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
3906 assert_eq!(
3907 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3908 Some("opt-blue"),
3909 "ArrowDown at bottom stays at bottom — no wrap",
3910 );
3911 }
3912
3913 fn build_popover_tree(open: bool) -> El {
3917 use crate::widgets::button::button;
3918 use crate::widgets::overlay::overlay;
3919 use crate::widgets::popover::{dropdown, menu_item};
3920 let mut layers: Vec<El> = vec![button("Trigger").key("trigger")];
3921 if open {
3922 layers.push(dropdown(
3923 "menu",
3924 "trigger",
3925 [
3926 menu_item("A").key("item-a"),
3927 menu_item("B").key("item-b"),
3928 menu_item("C").key("item-c"),
3929 ],
3930 ));
3931 }
3932 overlay(layers).padding(20.0)
3933 }
3934
3935 fn run_frame(core: &mut RunnerCore, tree: &mut El) {
3939 let mut t = PrepareTimings::default();
3940 core.prepare_layout(
3941 tree,
3942 Rect::new(0.0, 0.0, 400.0, 300.0),
3943 1.0,
3944 &mut t,
3945 RunnerCore::no_time_shaders,
3946 );
3947 core.snapshot(tree, &mut t);
3948 }
3949
3950 #[test]
3951 fn popover_open_pushes_focus_and_auto_focuses_first_item() {
3952 let mut core = RunnerCore::new();
3953 let mut closed = build_popover_tree(false);
3954 run_frame(&mut core, &mut closed);
3955 let trigger = core
3958 .ui_state
3959 .focus
3960 .order
3961 .iter()
3962 .find(|t| t.key == "trigger")
3963 .cloned();
3964 core.ui_state.set_focus(trigger);
3965 assert_eq!(
3966 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3967 Some("trigger"),
3968 );
3969
3970 let mut open = build_popover_tree(true);
3973 run_frame(&mut core, &mut open);
3974 assert_eq!(
3975 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3976 Some("item-a"),
3977 "popover open should auto-focus the first menu item",
3978 );
3979 assert_eq!(
3980 core.ui_state.popover_focus.focus_stack.len(),
3981 1,
3982 "trigger should be saved on the focus stack",
3983 );
3984 assert_eq!(
3985 core.ui_state.popover_focus.focus_stack[0].key.as_str(),
3986 "trigger",
3987 "saved focus should be the pre-open target",
3988 );
3989 }
3990
3991 #[test]
3992 fn popover_close_restores_focus_to_trigger() {
3993 let mut core = RunnerCore::new();
3994 let mut closed = build_popover_tree(false);
3995 run_frame(&mut core, &mut closed);
3996 let trigger = core
3997 .ui_state
3998 .focus
3999 .order
4000 .iter()
4001 .find(|t| t.key == "trigger")
4002 .cloned();
4003 core.ui_state.set_focus(trigger);
4004
4005 let mut open = build_popover_tree(true);
4007 run_frame(&mut core, &mut open);
4008 assert_eq!(
4009 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4010 Some("item-a"),
4011 );
4012
4013 let mut closed_again = build_popover_tree(false);
4015 run_frame(&mut core, &mut closed_again);
4016 assert_eq!(
4017 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4018 Some("trigger"),
4019 "closing the popover should pop the saved focus",
4020 );
4021 assert!(
4022 core.ui_state.popover_focus.focus_stack.is_empty(),
4023 "focus stack should be drained after restore",
4024 );
4025 }
4026
4027 #[test]
4028 fn popover_close_does_not_override_intentional_focus_move() {
4029 let mut core = RunnerCore::new();
4030 let build = |open: bool| -> El {
4033 use crate::widgets::button::button;
4034 use crate::widgets::overlay::overlay;
4035 use crate::widgets::popover::{dropdown, menu_item};
4036 let main = crate::row([
4037 button("Trigger").key("trigger"),
4038 button("Other").key("other"),
4039 ]);
4040 let mut layers: Vec<El> = vec![main];
4041 if open {
4042 layers.push(dropdown("menu", "trigger", [menu_item("A").key("item-a")]));
4043 }
4044 overlay(layers).padding(20.0)
4045 };
4046
4047 let mut closed = build(false);
4048 run_frame(&mut core, &mut closed);
4049 let trigger = core
4050 .ui_state
4051 .focus
4052 .order
4053 .iter()
4054 .find(|t| t.key == "trigger")
4055 .cloned();
4056 core.ui_state.set_focus(trigger);
4057
4058 let mut open = build(true);
4059 run_frame(&mut core, &mut open);
4060 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
4061
4062 let other = core
4067 .ui_state
4068 .focus
4069 .order
4070 .iter()
4071 .find(|t| t.key == "other")
4072 .cloned();
4073 core.ui_state.set_focus(other);
4074
4075 let mut closed_again = build(false);
4076 run_frame(&mut core, &mut closed_again);
4077 assert_eq!(
4078 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4079 Some("other"),
4080 "focus moved before close should not be overridden by restore",
4081 );
4082 assert!(core.ui_state.popover_focus.focus_stack.is_empty());
4083 }
4084
4085 #[test]
4086 fn nested_popovers_stack_and_unwind_focus_correctly() {
4087 let mut core = RunnerCore::new();
4088 let build = |outer: bool, inner: bool| -> El {
4093 use crate::widgets::button::button;
4094 use crate::widgets::overlay::overlay;
4095 use crate::widgets::popover::{Anchor, popover, popover_panel};
4096 let main = button("Trigger").key("trigger");
4097 let mut layers: Vec<El> = vec![main];
4098 if outer {
4099 layers.push(popover(
4100 "outer",
4101 Anchor::below_key("trigger"),
4102 popover_panel([button("Open inner").key("inner-trigger")]),
4103 ));
4104 }
4105 if inner {
4106 layers.push(popover(
4107 "inner",
4108 Anchor::below_key("inner-trigger"),
4109 popover_panel([button("X").key("inner-a"), button("Y").key("inner-b")]),
4110 ));
4111 }
4112 overlay(layers).padding(20.0)
4113 };
4114
4115 let mut closed = build(false, false);
4117 run_frame(&mut core, &mut closed);
4118 let trigger = core
4119 .ui_state
4120 .focus
4121 .order
4122 .iter()
4123 .find(|t| t.key == "trigger")
4124 .cloned();
4125 core.ui_state.set_focus(trigger);
4126
4127 let mut outer = build(true, false);
4129 run_frame(&mut core, &mut outer);
4130 assert_eq!(
4131 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4132 Some("inner-trigger"),
4133 );
4134 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
4135
4136 let mut both = build(true, true);
4138 run_frame(&mut core, &mut both);
4139 assert_eq!(
4140 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4141 Some("inner-a"),
4142 );
4143 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 2);
4144
4145 let mut outer_only = build(true, false);
4147 run_frame(&mut core, &mut outer_only);
4148 assert_eq!(
4149 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4150 Some("inner-trigger"),
4151 );
4152 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
4153
4154 let mut none = build(false, false);
4156 run_frame(&mut core, &mut none);
4157 assert_eq!(
4158 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4159 Some("trigger"),
4160 );
4161 assert!(core.ui_state.popover_focus.focus_stack.is_empty());
4162 }
4163
4164 #[test]
4165 fn arrow_nav_does_not_intercept_outside_navigable_groups() {
4166 let mut core = lay_out_input_tree(false);
4170 let target = core
4171 .ui_state
4172 .focus
4173 .order
4174 .iter()
4175 .find(|t| t.key == "btn")
4176 .cloned();
4177 core.ui_state.set_focus(target);
4178 let events = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
4179 assert_eq!(
4180 events.len(),
4181 1,
4182 "ArrowDown without navigable parent → event"
4183 );
4184 assert_eq!(events[0].kind, UiEventKind::KeyDown);
4185 }
4186
4187 fn quad(shader: ShaderHandle) -> DrawOp {
4188 DrawOp::Quad {
4189 id: "q".into(),
4190 rect: Rect::new(0.0, 0.0, 10.0, 10.0),
4191 scissor: None,
4192 shader,
4193 uniforms: UniformBlock::new(),
4194 }
4195 }
4196
4197 #[test]
4198 fn samples_backdrop_inserts_snapshot_before_first_glass_quad() {
4199 let mut core = RunnerCore::new();
4200 core.set_surface_size(100, 100);
4201 let ops = vec![
4202 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4203 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4204 quad(ShaderHandle::Custom("liquid_glass")),
4205 quad(ShaderHandle::Custom("liquid_glass")),
4206 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4207 ];
4208 let mut timings = PrepareTimings::default();
4209 core.prepare_paint(
4210 &ops,
4211 |_| true,
4212 |s| matches!(s, ShaderHandle::Custom(name) if *name == "liquid_glass"),
4213 &mut NoText,
4214 1.0,
4215 &mut timings,
4216 );
4217
4218 let kinds: Vec<&'static str> = core
4219 .paint_items
4220 .iter()
4221 .map(|p| match p {
4222 PaintItem::QuadRun(_) => "Q",
4223 PaintItem::IconRun(_) => "I",
4224 PaintItem::Text(_) => "T",
4225 PaintItem::Image(_) => "M",
4226 PaintItem::AppTexture(_) => "A",
4227 PaintItem::Vector(_) => "V",
4228 PaintItem::BackdropSnapshot => "S",
4229 })
4230 .collect();
4231 assert_eq!(
4232 kinds,
4233 vec!["Q", "S", "Q", "Q"],
4234 "expected one stock run, snapshot, then a glass run, then a foreground stock run"
4235 );
4236 }
4237
4238 #[test]
4239 fn no_snapshot_when_no_glass_drawn() {
4240 let mut core = RunnerCore::new();
4241 core.set_surface_size(100, 100);
4242 let ops = vec![
4243 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4244 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4245 ];
4246 let mut timings = PrepareTimings::default();
4247 core.prepare_paint(&ops, |_| true, |_| false, &mut NoText, 1.0, &mut timings);
4248 assert!(
4249 !core
4250 .paint_items
4251 .iter()
4252 .any(|p| matches!(p, PaintItem::BackdropSnapshot)),
4253 "no glass shader registered → no snapshot"
4254 );
4255 }
4256
4257 #[test]
4258 fn at_most_one_snapshot_per_frame() {
4259 let mut core = RunnerCore::new();
4260 core.set_surface_size(100, 100);
4261 let ops = vec![
4262 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4263 quad(ShaderHandle::Custom("g")),
4264 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4265 quad(ShaderHandle::Custom("g")),
4266 ];
4267 let mut timings = PrepareTimings::default();
4268 core.prepare_paint(
4269 &ops,
4270 |_| true,
4271 |s| matches!(s, ShaderHandle::Custom("g")),
4272 &mut NoText,
4273 1.0,
4274 &mut timings,
4275 );
4276 let snapshots = core
4277 .paint_items
4278 .iter()
4279 .filter(|p| matches!(p, PaintItem::BackdropSnapshot))
4280 .count();
4281 assert_eq!(snapshots, 1, "backdrop depth is capped at 1");
4282 }
4283}