1use std::cmp::Ordering;
51use std::ops::Range;
52use std::time::Duration;
53
54use web_time::Instant;
55
56use crate::draw_ops::{self, DrawOpsStats};
57use crate::event::{
58 KeyChord, KeyModifiers, Pointer, PointerButton, PointerKind, UiEvent, UiEventKind, UiKey,
59 UiTarget,
60};
61use crate::focus;
62use crate::hit_test;
63use crate::ir::{DrawOp, TextAnchor};
64use crate::layout;
65use crate::paint::{
66 InstanceRun, PaintItem, PhysicalScissor, QuadInstance, close_run, pack_instance,
67 physical_scissor,
68};
69use crate::shader::ShaderHandle;
70use crate::state::{
71 AnimationMode, LONG_PRESS_DELAY, SelectionDragGranularity, TOUCH_DRAG_THRESHOLD,
72 TouchGestureState, UiState,
73};
74use crate::text::atlas::RunStyle;
75use crate::text::metrics::TextLayoutCacheStats;
76use crate::theme::Theme;
77use crate::toast;
78use crate::tooltip;
79use crate::tree::{Color, El, FontWeight, Rect, TextWrap};
80
81const SCROLL_PAGE_OVERLAP: f32 = 24.0;
87
88#[derive(Clone, Copy, Debug, Default)]
111pub struct PrepareResult {
112 pub needs_redraw: bool,
116 pub next_redraw_in: Option<std::time::Duration>,
120 pub next_layout_redraw_in: Option<std::time::Duration>,
124 pub next_paint_redraw_in: Option<std::time::Duration>,
129 pub timings: PrepareTimings,
130}
131
132#[derive(Debug, Default)]
144pub struct PointerMove {
145 pub events: Vec<UiEvent>,
148 pub needs_redraw: bool,
152}
153
154pub struct LayoutPrepared {
161 pub ops: Vec<DrawOp>,
162 pub needs_redraw: bool,
163 pub next_layout_redraw_in: Option<std::time::Duration>,
164 pub next_paint_redraw_in: Option<std::time::Duration>,
165}
166
167#[derive(Clone, Copy, Debug, Default)]
178pub struct PrepareTimings {
179 pub layout: Duration,
180 pub layout_intrinsic_cache: layout::LayoutIntrinsicCacheStats,
181 pub layout_prune: layout::LayoutPruneStats,
182 pub draw_ops: Duration,
183 pub draw_ops_culled_text_ops: u64,
184 pub paint: Duration,
185 pub paint_culled_ops: u64,
186 pub gpu_upload: Duration,
187 pub snapshot: Duration,
188 pub text_layout_cache: TextLayoutCacheStats,
189}
190
191pub struct RunnerCore {
199 pub ui_state: UiState,
200 pub last_tree: Option<El>,
204
205 pub quad_scratch: Vec<QuadInstance>,
208 pub runs: Vec<InstanceRun>,
209 pub paint_items: Vec<PaintItem>,
210
211 pub last_ops: Vec<DrawOp>,
218
219 pub viewport_px: (u32, u32),
223 pub surface_size_override: Option<(u32, u32)>,
229
230 pub theme: Theme,
232}
233
234impl Default for RunnerCore {
235 fn default() -> Self {
236 Self::new()
237 }
238}
239
240impl RunnerCore {
241 pub fn new() -> Self {
242 Self {
243 ui_state: UiState::default(),
244 last_tree: None,
245 quad_scratch: Vec::new(),
246 runs: Vec::new(),
247 paint_items: Vec::new(),
248 last_ops: Vec::new(),
249 viewport_px: (1, 1),
250 surface_size_override: None,
251 theme: Theme::default(),
252 }
253 }
254
255 pub fn set_theme(&mut self, theme: Theme) {
256 self.theme = theme;
257 }
258
259 pub fn theme(&self) -> &Theme {
260 &self.theme
261 }
262
263 pub fn set_surface_size(&mut self, width: u32, height: u32) {
269 self.surface_size_override = Some((width.max(1), height.max(1)));
270 }
271
272 pub fn ui_state(&self) -> &UiState {
273 &self.ui_state
274 }
275
276 pub fn debug_summary(&self) -> String {
277 self.ui_state.debug_summary()
278 }
279
280 pub fn rect_of_key(&self, key: &str) -> Option<Rect> {
281 self.last_tree
282 .as_ref()
283 .and_then(|t| self.ui_state.rect_of_key(t, key))
284 }
285
286 pub fn would_press_focus_text_input(&self, x: f32, y: f32) -> bool {
303 let Some(tree) = self.last_tree.as_ref() else {
304 return false;
305 };
306 let Some(target) = hit_test::hit_test_target(tree, &self.ui_state, (x, y)) else {
307 return false;
308 };
309 find_capture_keys(tree, &target.node_id).unwrap_or(false)
310 }
311
312 pub fn pointer_moved(&mut self, p: Pointer) -> PointerMove {
326 let Pointer { x, y, kind, .. } = p;
327 self.ui_state.pointer_pos = Some((x, y));
328 self.ui_state.pointer_kind = kind;
329
330 if let Some(drag) = self.ui_state.scroll.thumb_drag.clone() {
336 let dy = y - drag.start_pointer_y;
337 let new_offset = if drag.track_remaining > 0.0 {
338 drag.start_offset + dy * (drag.max_offset / drag.track_remaining)
339 } else {
340 drag.start_offset
341 };
342 let clamped = new_offset.clamp(0.0, drag.max_offset);
343 let prev = self.ui_state.scroll.offsets.insert(drag.scroll_id, clamped);
344 let changed = prev.is_none_or(|old| (old - clamped).abs() > f32::EPSILON);
345 return PointerMove {
346 events: Vec::new(),
347 needs_redraw: changed,
348 };
349 }
350
351 let hit = self
352 .last_tree
353 .as_ref()
354 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
355 let prev_hover = self.ui_state.hovered.clone();
359 let hover_changed = self.ui_state.set_hovered(hit, Instant::now());
360 let prev_hovered_link = self.ui_state.hovered_link.clone();
366 let new_hovered_link = self
367 .last_tree
368 .as_ref()
369 .and_then(|t| hit_test::link_at(t, &self.ui_state, (x, y)));
370 let link_hover_changed = new_hovered_link != prev_hovered_link;
371 self.ui_state.hovered_link = new_hovered_link;
372 let modifiers = self.ui_state.modifiers;
373
374 let mut out = Vec::new();
375
376 let touch_no_press = matches!(kind, PointerKind::Touch) && self.ui_state.pressed.is_none();
392 if hover_changed && !touch_no_press {
393 if let Some(prev) = prev_hover {
394 out.push(UiEvent {
395 key: Some(prev.key.clone()),
396 target: Some(prev),
397 pointer: Some((x, y)),
398 key_press: None,
399 text: None,
400 selection: None,
401 modifiers,
402 click_count: 0,
403 path: None,
404 pointer_kind: Some(kind),
405 kind: UiEventKind::PointerLeave,
406 });
407 }
408 if let Some(new) = self.ui_state.hovered.clone() {
409 out.push(UiEvent {
410 key: Some(new.key.clone()),
411 target: Some(new),
412 pointer: Some((x, y)),
413 key_press: None,
414 text: None,
415 selection: None,
416 modifiers,
417 click_count: 0,
418 path: None,
419 pointer_kind: Some(kind),
420 kind: UiEventKind::PointerEnter,
421 });
422 }
423 }
424
425 if matches!(kind, PointerKind::Touch) {
430 match self.ui_state.touch_gesture {
431 TouchGestureState::Pending {
432 initial,
433 consumes_drag,
434 started_at: _,
435 } => {
436 let dx = x - initial.0;
437 let dy = y - initial.1;
438 if (dx * dx + dy * dy).sqrt() < TOUCH_DRAG_THRESHOLD {
439 let needs_redraw = hover_changed || link_hover_changed || !out.is_empty();
444 return PointerMove {
445 events: out,
446 needs_redraw,
447 };
448 }
449 if consumes_drag {
450 self.ui_state.touch_gesture = TouchGestureState::None;
455 } else {
456 self.ui_state.touch_gesture =
463 TouchGestureState::Scrolling { last_pos: (x, y) };
464 self.cancel_press_for_scroll(&mut out, x, y, kind, modifiers);
465 let scroll_dy = initial.1 - y;
471 if let Some(tree) = self.last_tree.as_ref() {
472 self.ui_state.pointer_wheel(tree, (x, y), scroll_dy);
473 }
474 return PointerMove {
475 events: out,
476 needs_redraw: true,
477 };
478 }
479 }
480 TouchGestureState::Scrolling { last_pos } => {
481 let scroll_dy = last_pos.1 - y;
482 self.ui_state.touch_gesture = TouchGestureState::Scrolling { last_pos: (x, y) };
483 if let Some(tree) = self.last_tree.as_ref() {
484 self.ui_state.pointer_wheel(tree, (x, y), scroll_dy);
485 }
486 return PointerMove {
487 events: out,
488 needs_redraw: true,
489 };
490 }
491 TouchGestureState::None => {
492 }
495 TouchGestureState::LongPressed => {
496 let needs_redraw = hover_changed || link_hover_changed || !out.is_empty();
500 return PointerMove {
501 events: out,
502 needs_redraw,
503 };
504 }
505 }
506 }
507
508 if let Some(drag) = self.ui_state.selection.drag.clone()
515 && let Some(tree) = self.last_tree.as_ref()
516 {
517 let raw_head =
518 head_for_drag(tree, &self.ui_state, (x, y)).unwrap_or_else(|| drag.anchor.clone());
519 let (anchor, head) = selection_range_for_drag(tree, &self.ui_state, &drag, raw_head);
520 let new_sel = crate::selection::Selection {
521 range: Some(crate::selection::SelectionRange { anchor, head }),
522 };
523 if new_sel != self.ui_state.current_selection {
524 self.ui_state.current_selection = new_sel.clone();
525 out.push(selection_event(
526 new_sel,
527 modifiers,
528 Some((x, y)),
529 Some(kind),
530 ));
531 }
532 }
533
534 if let Some(p) = self.ui_state.pressed.clone() {
540 if self.focused_captures_keys() {
544 self.ui_state.bump_caret_activity(Instant::now());
545 }
546 out.push(UiEvent {
547 key: Some(p.key.clone()),
548 target: Some(p),
549 pointer: Some((x, y)),
550 key_press: None,
551 text: None,
552 selection: None,
553 modifiers,
554 click_count: self.ui_state.current_click_count(),
555 path: None,
556 pointer_kind: Some(kind),
557 kind: UiEventKind::Drag,
558 });
559 }
560
561 let needs_redraw = hover_changed || link_hover_changed || !out.is_empty();
562 PointerMove {
563 events: out,
564 needs_redraw,
565 }
566 }
567
568 pub fn pointer_left(&mut self) -> Vec<UiEvent> {
576 let last_pos = self.ui_state.pointer_pos;
577 let prev_hover = self.ui_state.hovered.clone();
578 let modifiers = self.ui_state.modifiers;
579 let kind = self.ui_state.pointer_kind;
584 self.ui_state.pointer_pos = None;
585 self.ui_state.set_hovered(None, Instant::now());
586 self.ui_state.pressed = None;
587 self.ui_state.pressed_secondary = None;
588 self.ui_state.touch_gesture = TouchGestureState::None;
589 self.ui_state.hovered_link = None;
595 self.ui_state.pressed_link = None;
596
597 let mut out = Vec::new();
598 if let Some(prev) = prev_hover {
599 out.push(UiEvent {
600 key: Some(prev.key.clone()),
601 target: Some(prev),
602 pointer: last_pos,
603 key_press: None,
604 text: None,
605 selection: None,
606 modifiers,
607 click_count: 0,
608 path: None,
609 pointer_kind: Some(kind),
610 kind: UiEventKind::PointerLeave,
611 });
612 }
613 out
614 }
615
616 pub fn file_hovered(&mut self, path: std::path::PathBuf, x: f32, y: f32) -> Vec<UiEvent> {
630 self.ui_state.pointer_pos = Some((x, y));
631 let target = self
632 .last_tree
633 .as_ref()
634 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
635 let key = target.as_ref().map(|t| t.key.clone());
636 vec![UiEvent {
637 key,
638 target,
639 pointer: Some((x, y)),
640 key_press: None,
641 text: None,
642 selection: None,
643 modifiers: self.ui_state.modifiers,
644 click_count: 0,
645 path: Some(path),
646 pointer_kind: None,
647 kind: UiEventKind::FileHovered,
648 }]
649 }
650
651 pub fn file_hover_cancelled(&mut self) -> Vec<UiEvent> {
656 vec![UiEvent {
657 key: None,
658 target: None,
659 pointer: self.ui_state.pointer_pos,
660 key_press: None,
661 text: None,
662 selection: None,
663 modifiers: self.ui_state.modifiers,
664 click_count: 0,
665 path: None,
666 pointer_kind: None,
667 kind: UiEventKind::FileHoverCancelled,
668 }]
669 }
670
671 pub fn file_dropped(&mut self, path: std::path::PathBuf, x: f32, y: f32) -> Vec<UiEvent> {
676 self.ui_state.pointer_pos = Some((x, y));
677 let target = self
678 .last_tree
679 .as_ref()
680 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
681 let key = target.as_ref().map(|t| t.key.clone());
682 vec![UiEvent {
683 key,
684 target,
685 pointer: Some((x, y)),
686 key_press: None,
687 text: None,
688 selection: None,
689 modifiers: self.ui_state.modifiers,
690 click_count: 0,
691 path: Some(path),
692 pointer_kind: None,
693 kind: UiEventKind::FileDropped,
694 }]
695 }
696
697 pub fn pointer_down(&mut self, p: Pointer) -> Vec<UiEvent> {
710 let Pointer {
711 x, y, button, kind, ..
712 } = p;
713 self.ui_state.pointer_kind = kind;
714 if matches!(button, PointerButton::Primary)
723 && let Some((scroll_id, _track, thumb_rect)) = self.ui_state.thumb_at(x, y)
724 {
725 let metrics = self
726 .ui_state
727 .scroll
728 .metrics
729 .get(&scroll_id)
730 .copied()
731 .unwrap_or_default();
732 let start_offset = self
733 .ui_state
734 .scroll
735 .offsets
736 .get(&scroll_id)
737 .copied()
738 .unwrap_or(0.0);
739
740 let grabbed = y >= thumb_rect.y && y <= thumb_rect.y + thumb_rect.h;
744 if grabbed {
745 let track_remaining = (metrics.viewport_h - thumb_rect.h).max(0.0);
746 self.ui_state.scroll.thumb_drag = Some(crate::state::ThumbDrag {
747 scroll_id,
748 start_pointer_y: y,
749 start_offset,
750 track_remaining,
751 max_offset: metrics.max_offset,
752 });
753 } else {
754 let page = (metrics.viewport_h - SCROLL_PAGE_OVERLAP).max(0.0);
760 let delta = if y < thumb_rect.y { -page } else { page };
761 let new_offset = (start_offset + delta).clamp(0.0, metrics.max_offset);
762 self.ui_state.scroll.offsets.insert(scroll_id, new_offset);
763 }
764 return Vec::new();
765 }
766
767 let hit = self
768 .last_tree
769 .as_ref()
770 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
771 if !matches!(button, PointerButton::Primary) {
776 self.ui_state.pressed_secondary = hit.map(|h| (h, button));
779 return Vec::new();
780 }
781
782 self.ui_state.pressed_link = self
790 .last_tree
791 .as_ref()
792 .and_then(|t| hit_test::link_at(t, &self.ui_state, (x, y)));
793 self.ui_state.set_focus(hit.clone());
794 self.ui_state.set_focus_visible(false);
798 self.ui_state.pressed = hit.clone();
799 self.ui_state.tooltip.dismissed_for_hover = true;
802 let modifiers = self.ui_state.modifiers;
803
804 let now = Instant::now();
807 let click_count =
808 self.ui_state
809 .next_click_count(now, (x, y), hit.as_ref().map(|t| t.node_id.as_str()));
810
811 let mut out = Vec::new();
812
813 if matches!(kind, PointerKind::Touch) {
821 let prev_hover = self.ui_state.hovered.clone();
822 let hover_changed = self.ui_state.set_hovered(hit.clone(), now);
823 if hover_changed {
824 if let Some(prev) = prev_hover {
825 out.push(UiEvent {
826 key: Some(prev.key.clone()),
827 target: Some(prev),
828 pointer: Some((x, y)),
829 key_press: None,
830 text: None,
831 selection: None,
832 modifiers,
833 click_count: 0,
834 path: None,
835 pointer_kind: Some(kind),
836 kind: UiEventKind::PointerLeave,
837 });
838 }
839 if let Some(new) = hit.clone() {
840 out.push(UiEvent {
841 key: Some(new.key.clone()),
842 target: Some(new),
843 pointer: Some((x, y)),
844 key_press: None,
845 text: None,
846 selection: None,
847 modifiers,
848 click_count: 0,
849 path: None,
850 pointer_kind: Some(kind),
851 kind: UiEventKind::PointerEnter,
852 });
853 }
854 }
855 let consumes_drag = hit
863 .as_ref()
864 .and_then(|t| {
865 self.last_tree
866 .as_ref()
867 .and_then(|tree| find_consumes_touch_drag(tree, &t.node_id, false))
868 })
869 .unwrap_or(false);
870 self.ui_state.touch_gesture = TouchGestureState::Pending {
871 initial: (x, y),
872 consumes_drag,
873 started_at: now,
874 };
875 }
876
877 if let Some(p) = hit.clone() {
878 if self.focused_captures_keys() {
885 self.ui_state.bump_caret_activity(now);
886 }
887 out.push(UiEvent {
888 key: Some(p.key.clone()),
889 target: Some(p),
890 pointer: Some((x, y)),
891 key_press: None,
892 text: None,
893 selection: None,
894 modifiers,
895 click_count,
896 path: None,
897 pointer_kind: Some(kind),
898 kind: UiEventKind::PointerDown,
899 });
900 }
901
902 if let Some(point) = self
910 .last_tree
911 .as_ref()
912 .and_then(|t| hit_test::selection_point_at(t, &self.ui_state, (x, y)))
913 {
914 self.start_selection_drag(point, &mut out, modifiers, (x, y), click_count, kind);
915 } else if !self.ui_state.current_selection.is_empty() {
916 let click_handles_selection = match (&hit, &self.ui_state.current_selection.range) {
938 (Some(h), Some(range)) => {
939 h.key == range.anchor.key
940 || h.key == range.head.key
941 || self
942 .last_tree
943 .as_ref()
944 .and_then(|t| find_capture_keys(t, &h.node_id))
945 .unwrap_or(false)
946 }
947 _ => false,
948 };
949 if !click_handles_selection {
950 out.push(selection_event(
951 crate::selection::Selection::default(),
952 modifiers,
953 Some((x, y)),
954 Some(kind),
955 ));
956 self.ui_state.current_selection = crate::selection::Selection::default();
957 self.ui_state.selection.drag = None;
958 }
959 }
960
961 out
962 }
963
964 fn start_selection_drag(
972 &mut self,
973 point: crate::selection::SelectionPoint,
974 out: &mut Vec<UiEvent>,
975 modifiers: KeyModifiers,
976 pointer: (f32, f32),
977 click_count: u8,
978 kind: PointerKind,
979 ) {
980 let leaf_text = self
981 .last_tree
982 .as_ref()
983 .and_then(|t| crate::selection::find_keyed_text(t, &point.key))
984 .unwrap_or_default();
985 let (anchor_byte, head_byte) = match click_count {
986 2 => crate::selection::word_range_at(&leaf_text, point.byte),
987 n if n >= 3 => (0, leaf_text.len()),
988 _ => (point.byte, point.byte),
989 };
990 let granularity = match click_count {
991 2 => SelectionDragGranularity::Word,
992 n if n >= 3 => SelectionDragGranularity::Leaf,
993 _ => SelectionDragGranularity::Character,
994 };
995 let anchor = crate::selection::SelectionPoint::new(point.key.clone(), anchor_byte);
996 let head = crate::selection::SelectionPoint::new(point.key.clone(), head_byte);
997 let new_sel = crate::selection::Selection {
998 range: Some(crate::selection::SelectionRange {
999 anchor: anchor.clone(),
1000 head: head.clone(),
1001 }),
1002 };
1003 self.ui_state.current_selection = new_sel.clone();
1004 self.ui_state.selection.drag = Some(crate::state::SelectionDrag {
1005 anchor,
1006 head,
1007 granularity,
1008 });
1009 out.push(selection_event(
1010 new_sel,
1011 modifiers,
1012 Some(pointer),
1013 Some(kind),
1014 ));
1015 }
1016
1017 fn cancel_press_for_scroll(
1027 &mut self,
1028 out: &mut Vec<UiEvent>,
1029 x: f32,
1030 y: f32,
1031 kind: PointerKind,
1032 modifiers: KeyModifiers,
1033 ) {
1034 let pressed = self.ui_state.pressed.take();
1035 let hovered = self.ui_state.hovered.clone();
1036 self.ui_state.set_hovered(None, Instant::now());
1037 self.ui_state.pressed_secondary = None;
1038 self.ui_state.pressed_link = None;
1039 self.ui_state.selection.drag = None;
1040 if let Some(p) = pressed {
1041 out.push(UiEvent {
1042 key: Some(p.key.clone()),
1043 target: Some(p),
1044 pointer: Some((x, y)),
1045 key_press: None,
1046 text: None,
1047 selection: None,
1048 modifiers,
1049 click_count: 0,
1050 path: None,
1051 pointer_kind: Some(kind),
1052 kind: UiEventKind::PointerCancel,
1053 });
1054 }
1055 if let Some(h) = hovered {
1056 out.push(UiEvent {
1057 key: Some(h.key.clone()),
1058 target: Some(h),
1059 pointer: Some((x, y)),
1060 key_press: None,
1061 text: None,
1062 selection: None,
1063 modifiers,
1064 click_count: 0,
1065 path: None,
1066 pointer_kind: Some(kind),
1067 kind: UiEventKind::PointerLeave,
1068 });
1069 }
1070 }
1071
1072 pub fn pointer_up(&mut self, p: Pointer) -> Vec<UiEvent> {
1080 let Pointer {
1081 x, y, button, kind, ..
1082 } = p;
1083 self.ui_state.pointer_kind = kind;
1084 if matches!(button, PointerButton::Primary) && self.ui_state.scroll.thumb_drag.is_some() {
1089 self.ui_state.scroll.thumb_drag = None;
1090 self.ui_state.touch_gesture = TouchGestureState::None;
1091 return Vec::new();
1092 }
1093
1094 let was_scrolling_or_long = matches!(
1102 self.ui_state.touch_gesture,
1103 TouchGestureState::Scrolling { .. } | TouchGestureState::LongPressed
1104 );
1105 self.ui_state.touch_gesture = TouchGestureState::None;
1106 if was_scrolling_or_long {
1107 return Vec::new();
1108 }
1109
1110 if matches!(button, PointerButton::Primary) {
1113 self.ui_state.selection.drag = None;
1114 }
1115
1116 let hit = self
1117 .last_tree
1118 .as_ref()
1119 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
1120 let modifiers = self.ui_state.modifiers;
1121 let mut out = Vec::new();
1122 match button {
1123 PointerButton::Primary => {
1124 let pressed = self.ui_state.pressed.take();
1125 let click_count = self.ui_state.current_click_count();
1126 if let Some(p) = pressed.clone() {
1127 out.push(UiEvent {
1128 key: Some(p.key.clone()),
1129 target: Some(p),
1130 pointer: Some((x, y)),
1131 key_press: None,
1132 text: None,
1133 selection: None,
1134 modifiers,
1135 click_count,
1136 path: None,
1137 pointer_kind: Some(kind),
1138 kind: UiEventKind::PointerUp,
1139 });
1140 }
1141 if let (Some(p), Some(h)) = (pressed, hit)
1142 && p.node_id == h.node_id
1143 {
1144 if let Some(id) = toast::parse_dismiss_key(&p.key) {
1150 self.ui_state.dismiss_toast(id);
1151 } else {
1152 out.push(UiEvent {
1153 key: Some(p.key.clone()),
1154 target: Some(p),
1155 pointer: Some((x, y)),
1156 key_press: None,
1157 text: None,
1158 selection: None,
1159 modifiers,
1160 click_count,
1161 path: None,
1162 pointer_kind: Some(kind),
1163 kind: UiEventKind::Click,
1164 });
1165 }
1166 }
1167 if let Some(pressed_url) = self.ui_state.pressed_link.take() {
1173 let up_link = self
1174 .last_tree
1175 .as_ref()
1176 .and_then(|t| hit_test::link_at(t, &self.ui_state, (x, y)));
1177 if up_link.as_ref() == Some(&pressed_url) {
1178 out.push(UiEvent {
1179 key: Some(pressed_url),
1180 target: None,
1181 pointer: Some((x, y)),
1182 key_press: None,
1183 text: None,
1184 selection: None,
1185 modifiers,
1186 click_count: 1,
1187 path: None,
1188 pointer_kind: Some(kind),
1189 kind: UiEventKind::LinkActivated,
1190 });
1191 }
1192 }
1193 }
1194 PointerButton::Secondary | PointerButton::Middle => {
1195 let pressed = self.ui_state.pressed_secondary.take();
1196 if let (Some((p, b)), Some(h)) = (pressed, hit)
1197 && b == button
1198 && p.node_id == h.node_id
1199 {
1200 let event_kind = match button {
1201 PointerButton::Secondary => UiEventKind::SecondaryClick,
1202 PointerButton::Middle => UiEventKind::MiddleClick,
1203 PointerButton::Primary => unreachable!(),
1204 };
1205 out.push(UiEvent {
1206 key: Some(p.key.clone()),
1207 target: Some(p),
1208 pointer: Some((x, y)),
1209 key_press: None,
1210 text: None,
1211 selection: None,
1212 modifiers,
1213 click_count: 1,
1214 path: None,
1215 pointer_kind: Some(kind),
1216 kind: event_kind,
1217 });
1218 }
1219 }
1220 }
1221
1222 if matches!(kind, PointerKind::Touch)
1228 && let Some(prev) = self.ui_state.hovered.clone()
1229 {
1230 self.ui_state.set_hovered(None, Instant::now());
1231 out.push(UiEvent {
1232 key: Some(prev.key.clone()),
1233 target: Some(prev),
1234 pointer: Some((x, y)),
1235 key_press: None,
1236 text: None,
1237 selection: None,
1238 modifiers,
1239 click_count: 0,
1240 path: None,
1241 pointer_kind: Some(kind),
1242 kind: UiEventKind::PointerLeave,
1243 });
1244 }
1245
1246 out
1247 }
1248
1249 pub fn key_down(&mut self, key: UiKey, modifiers: KeyModifiers, repeat: bool) -> Vec<UiEvent> {
1250 if self.focused_captures_keys() {
1258 if let Some(event) = self.ui_state.try_hotkey(&key, modifiers, repeat) {
1259 return vec![event];
1260 }
1261 self.ui_state.bump_caret_activity(Instant::now());
1268 self.ui_state.set_focus_visible(true);
1269 let blur_after = matches!(key, UiKey::Escape);
1270 let out = self
1271 .ui_state
1272 .key_down_raw(key, modifiers, repeat)
1273 .into_iter()
1274 .collect();
1275 if blur_after {
1276 self.ui_state.set_focus(None);
1277 self.ui_state.set_focus_visible(false);
1278 }
1279 return out;
1280 }
1281
1282 if matches!(
1288 key,
1289 UiKey::ArrowUp | UiKey::ArrowDown | UiKey::Home | UiKey::End
1290 ) && let Some(siblings) = self.focused_arrow_nav_group()
1291 {
1292 if let Some(event) = self.ui_state.try_hotkey(&key, modifiers, repeat) {
1293 return vec![event];
1294 }
1295 self.move_focus_in_group(&key, &siblings);
1296 return Vec::new();
1297 }
1298
1299 let mut out: Vec<UiEvent> = self
1300 .ui_state
1301 .key_down(key, modifiers, repeat)
1302 .into_iter()
1303 .collect();
1304
1305 if matches!(out.first().map(|e| e.kind), Some(UiEventKind::Escape))
1313 && !self.ui_state.current_selection.is_empty()
1314 {
1315 self.ui_state.current_selection = crate::selection::Selection::default();
1316 self.ui_state.selection.drag = None;
1317 out.push(selection_event(
1318 crate::selection::Selection::default(),
1319 modifiers,
1320 None,
1321 None,
1322 ));
1323 }
1324
1325 out
1326 }
1327
1328 fn focused_arrow_nav_group(&self) -> Option<Vec<UiTarget>> {
1335 let focused = self.ui_state.focused.as_ref()?;
1336 let tree = self.last_tree.as_ref()?;
1337 focus::arrow_nav_group(tree, &self.ui_state, &focused.node_id)
1338 }
1339
1340 fn move_focus_in_group(&mut self, key: &UiKey, siblings: &[UiTarget]) {
1345 if siblings.is_empty() {
1346 return;
1347 }
1348 let focused_id = match self.ui_state.focused.as_ref() {
1349 Some(t) => t.node_id.clone(),
1350 None => return,
1351 };
1352 let idx = siblings.iter().position(|t| t.node_id == focused_id);
1353 let next_idx = match (key, idx) {
1354 (UiKey::ArrowUp, Some(i)) => i.saturating_sub(1),
1355 (UiKey::ArrowDown, Some(i)) => (i + 1).min(siblings.len() - 1),
1356 (UiKey::Home, _) => 0,
1357 (UiKey::End, _) => siblings.len() - 1,
1358 _ => return,
1359 };
1360 if Some(next_idx) != idx {
1361 self.ui_state.set_focus(Some(siblings[next_idx].clone()));
1362 self.ui_state.set_focus_visible(true);
1363 }
1364 }
1365
1366 pub fn focused_captures_keys(&self) -> bool {
1373 let Some(focused) = self.ui_state.focused.as_ref() else {
1374 return false;
1375 };
1376 let Some(tree) = self.last_tree.as_ref() else {
1377 return false;
1378 };
1379 find_capture_keys(tree, &focused.node_id).unwrap_or(false)
1380 }
1381
1382 pub fn text_input(&mut self, text: String) -> Option<UiEvent> {
1388 if text.is_empty() {
1389 return None;
1390 }
1391 let target = self.ui_state.focused.clone()?;
1392 let modifiers = self.ui_state.modifiers;
1393 self.ui_state.bump_caret_activity(Instant::now());
1396 Some(UiEvent {
1397 key: Some(target.key.clone()),
1398 target: Some(target),
1399 pointer: None,
1400 key_press: None,
1401 text: Some(text),
1402 selection: None,
1403 modifiers,
1404 click_count: 0,
1405 path: None,
1406 pointer_kind: None,
1407 kind: UiEventKind::TextInput,
1408 })
1409 }
1410
1411 pub fn set_hotkeys(&mut self, hotkeys: Vec<(KeyChord, String)>) {
1412 self.ui_state.set_hotkeys(hotkeys);
1413 }
1414
1415 pub fn set_selection(&mut self, selection: crate::selection::Selection) {
1420 if self.ui_state.current_selection != selection {
1421 self.ui_state.bump_caret_activity(Instant::now());
1422 }
1423 self.ui_state.current_selection = selection;
1424 }
1425
1426 pub fn selected_text(&self) -> Option<String> {
1440 self.selected_text_for(&self.ui_state.current_selection)
1441 }
1442
1443 pub fn selected_text_for(&self, selection: &crate::selection::Selection) -> Option<String> {
1449 let tree = self.last_tree.as_ref()?;
1450 crate::selection::selected_text(tree, selection)
1451 }
1452
1453 pub fn push_toasts(&mut self, specs: Vec<crate::toast::ToastSpec>) {
1459 let now = Instant::now();
1460 for spec in specs {
1461 self.ui_state.push_toast(spec, now);
1462 }
1463 }
1464
1465 pub fn dismiss_toast(&mut self, id: u64) {
1469 self.ui_state.dismiss_toast(id);
1470 }
1471
1472 pub fn push_focus_requests(&mut self, keys: Vec<String>) {
1478 self.ui_state.push_focus_requests(keys);
1479 }
1480
1481 pub fn push_scroll_requests(&mut self, requests: Vec<crate::scroll::ScrollRequest>) {
1487 self.ui_state.push_scroll_requests(requests);
1488 }
1489
1490 pub fn set_animation_mode(&mut self, mode: AnimationMode) {
1491 self.ui_state.set_animation_mode(mode);
1492 }
1493
1494 pub fn pointer_wheel(&mut self, x: f32, y: f32, dy: f32) -> bool {
1495 let Some(tree) = self.last_tree.as_ref() else {
1496 return false;
1497 };
1498 self.ui_state.pointer_wheel(tree, (x, y), dy)
1499 }
1500
1501 pub fn poll_input(&mut self, now: Instant) -> Vec<UiEvent> {
1515 let TouchGestureState::Pending {
1516 initial,
1517 started_at,
1518 ..
1519 } = self.ui_state.touch_gesture
1520 else {
1521 return Vec::new();
1522 };
1523 if now.duration_since(started_at) < LONG_PRESS_DELAY {
1524 return Vec::new();
1525 }
1526 let mut out = Vec::new();
1527 let modifiers = self.ui_state.modifiers;
1528 let kind = PointerKind::Touch;
1529 let (x, y) = initial;
1530 let press_target = self.ui_state.pressed.clone();
1536 self.cancel_press_for_scroll(&mut out, x, y, kind, modifiers);
1537 if let Some(t) = press_target {
1538 out.push(UiEvent {
1539 key: Some(t.key.clone()),
1540 target: Some(t),
1541 pointer: Some((x, y)),
1542 key_press: None,
1543 text: None,
1544 selection: None,
1545 modifiers,
1546 click_count: 0,
1547 path: None,
1548 pointer_kind: Some(kind),
1549 kind: UiEventKind::LongPress,
1550 });
1551 } else {
1552 out.push(UiEvent {
1556 key: None,
1557 target: None,
1558 pointer: Some((x, y)),
1559 key_press: None,
1560 text: None,
1561 selection: None,
1562 modifiers,
1563 click_count: 0,
1564 path: None,
1565 pointer_kind: Some(kind),
1566 kind: UiEventKind::LongPress,
1567 });
1568 }
1569 self.ui_state.touch_gesture = TouchGestureState::LongPressed;
1570 out
1571 }
1572
1573 pub fn next_input_deadline(&self, now: Instant) -> Option<std::time::Duration> {
1583 let TouchGestureState::Pending { started_at, .. } = self.ui_state.touch_gesture else {
1584 return None;
1585 };
1586 let elapsed = now.duration_since(started_at);
1587 Some(LONG_PRESS_DELAY.saturating_sub(elapsed))
1588 }
1589
1590 pub fn prepare_layout<F>(
1607 &mut self,
1608 root: &mut El,
1609 viewport: Rect,
1610 scale_factor: f32,
1611 timings: &mut PrepareTimings,
1612 samples_time: F,
1613 ) -> LayoutPrepared
1614 where
1615 F: Fn(&ShaderHandle) -> bool,
1616 {
1617 let t0 = Instant::now();
1618 let mut needs_redraw = {
1625 crate::profile_span!("prepare::layout");
1626 {
1627 crate::profile_span!("prepare::layout::assign_ids");
1628 layout::assign_ids(root);
1629 }
1630 let tooltip_pending = {
1631 crate::profile_span!("prepare::layout::tooltip");
1632 tooltip::synthesize_tooltip(root, &self.ui_state, t0)
1633 };
1634 let toast_pending = {
1635 crate::profile_span!("prepare::layout::toast");
1636 toast::synthesize_toasts(root, &mut self.ui_state, t0)
1637 };
1638 {
1639 crate::profile_span!("prepare::layout::apply_metrics");
1640 self.theme.apply_metrics(root);
1641 }
1642 {
1643 crate::profile_span!("prepare::layout::layout");
1644 layout::layout_post_assign(root, &mut self.ui_state, viewport);
1651 self.ui_state.clear_pending_scroll_requests();
1656 }
1657 {
1658 crate::profile_span!("prepare::layout::sync_focus_order");
1659 self.ui_state.sync_focus_order(root);
1660 }
1661 {
1662 crate::profile_span!("prepare::layout::sync_selection_order");
1663 self.ui_state.sync_selection_order(root);
1664 }
1665 {
1666 crate::profile_span!("prepare::layout::sync_popover_focus");
1667 focus::sync_popover_focus(root, &mut self.ui_state);
1668 }
1669 {
1670 crate::profile_span!("prepare::layout::drain_focus_requests");
1675 self.ui_state.drain_focus_requests();
1676 }
1677 {
1678 crate::profile_span!("prepare::layout::apply_state");
1679 self.ui_state.apply_to_state();
1680 }
1681 self.viewport_px = self.surface_size_override.unwrap_or_else(|| {
1682 (
1683 (viewport.w * scale_factor).ceil().max(1.0) as u32,
1684 (viewport.h * scale_factor).ceil().max(1.0) as u32,
1685 )
1686 });
1687 let animations = {
1688 crate::profile_span!("prepare::layout::tick_animations");
1689 self.ui_state.tick_visual_animations(root, Instant::now())
1690 };
1691 animations || tooltip_pending || toast_pending
1692 };
1693 let t_after_layout = Instant::now();
1694 timings.layout_intrinsic_cache = layout::take_intrinsic_cache_stats();
1695 timings.layout_prune = layout::take_prune_stats();
1696 let (ops, draw_ops_stats) = {
1697 crate::profile_span!("prepare::draw_ops");
1698 let mut stats = DrawOpsStats::default();
1699 let ops = draw_ops::draw_ops_with_theme_and_stats(
1700 root,
1701 &self.ui_state,
1702 &self.theme,
1703 &mut stats,
1704 );
1705 (ops, stats)
1706 };
1707 let t_after_draw_ops = Instant::now();
1708 timings.layout = t_after_layout - t0;
1709 timings.draw_ops = t_after_draw_ops - t_after_layout;
1710 timings.draw_ops_culled_text_ops = draw_ops_stats.culled_text_ops;
1711 timings.text_layout_cache = crate::text::metrics::take_shape_cache_stats();
1712
1713 let shader_needs_redraw = ops.iter().any(|op| op_is_continuous(op, &samples_time));
1730 let widget_redraw =
1731 aggregate_redraw_within(root, viewport, &self.ui_state.layout.computed_rects);
1732 let input_deadline = self.next_input_deadline(Instant::now());
1738 let widget_redraw = match (widget_redraw, input_deadline) {
1739 (Some(a), Some(b)) => Some(a.min(b)),
1740 (a, b) => a.or(b),
1741 };
1742
1743 let next_layout_redraw_in = match (needs_redraw, widget_redraw) {
1744 (true, Some(d)) => Some(d.min(std::time::Duration::ZERO)),
1745 (true, None) => Some(std::time::Duration::ZERO),
1746 (false, d) => d,
1747 };
1748 let next_paint_redraw_in = if shader_needs_redraw {
1749 Some(std::time::Duration::ZERO)
1750 } else {
1751 None
1752 };
1753 if next_layout_redraw_in.is_some() || next_paint_redraw_in.is_some() {
1754 needs_redraw = true;
1755 }
1756
1757 LayoutPrepared {
1762 ops,
1763 needs_redraw,
1764 next_layout_redraw_in,
1765 next_paint_redraw_in,
1766 }
1767 }
1768
1769 pub fn prepare_paint_cached<F1, F2>(
1782 &mut self,
1783 is_registered: F1,
1784 samples_backdrop: F2,
1785 text: &mut dyn TextRecorder,
1786 scale_factor: f32,
1787 timings: &mut PrepareTimings,
1788 ) where
1789 F1: Fn(&ShaderHandle) -> bool,
1790 F2: Fn(&ShaderHandle) -> bool,
1791 {
1792 let ops = std::mem::take(&mut self.last_ops);
1796 self.prepare_paint(
1797 &ops,
1798 is_registered,
1799 samples_backdrop,
1800 text,
1801 scale_factor,
1802 timings,
1803 );
1804 self.last_ops = ops;
1805 }
1806
1807 pub fn no_time_shaders(_shader: &ShaderHandle) -> bool {
1812 false
1813 }
1814
1815 pub fn scan_continuous_shaders<F>(&self, samples_time: F) -> Option<std::time::Duration>
1822 where
1823 F: Fn(&ShaderHandle) -> bool,
1824 {
1825 let any = self
1826 .last_ops
1827 .iter()
1828 .any(|op| op_is_continuous(op, &samples_time));
1829 if any {
1830 Some(std::time::Duration::ZERO)
1831 } else {
1832 None
1833 }
1834 }
1835
1836 pub fn prepare_paint<F1, F2>(
1847 &mut self,
1848 ops: &[DrawOp],
1849 is_registered: F1,
1850 samples_backdrop: F2,
1851 text: &mut dyn TextRecorder,
1852 scale_factor: f32,
1853 timings: &mut PrepareTimings,
1854 ) where
1855 F1: Fn(&ShaderHandle) -> bool,
1856 F2: Fn(&ShaderHandle) -> bool,
1857 {
1858 crate::profile_span!("prepare::paint");
1859 let t0 = Instant::now();
1860 self.quad_scratch.clear();
1861 self.runs.clear();
1862 self.paint_items.clear();
1863
1864 let mut current: Option<(ShaderHandle, Option<PhysicalScissor>)> = None;
1865 let mut run_first: u32 = 0;
1866 let mut snapshot_emitted = false;
1869
1870 for op in ops {
1871 match op {
1872 DrawOp::Quad {
1873 rect,
1874 scissor,
1875 shader,
1876 uniforms,
1877 ..
1878 } => {
1879 if !is_registered(shader) {
1880 continue;
1881 }
1882 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
1883 timings.paint_culled_ops += 1;
1884 continue;
1885 }
1886 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
1887 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
1888 timings.paint_culled_ops += 1;
1889 continue;
1890 }
1891 if !snapshot_emitted && samples_backdrop(shader) {
1892 close_run(
1893 &mut self.runs,
1894 &mut self.paint_items,
1895 current,
1896 run_first,
1897 self.quad_scratch.len() as u32,
1898 );
1899 current = None;
1900 run_first = self.quad_scratch.len() as u32;
1901 self.paint_items.push(PaintItem::BackdropSnapshot);
1902 snapshot_emitted = true;
1903 }
1904 let inst = pack_instance(*rect, *shader, uniforms);
1905
1906 let key = (*shader, phys);
1907 if current != Some(key) {
1908 close_run(
1909 &mut self.runs,
1910 &mut self.paint_items,
1911 current,
1912 run_first,
1913 self.quad_scratch.len() as u32,
1914 );
1915 current = Some(key);
1916 run_first = self.quad_scratch.len() as u32;
1917 }
1918 self.quad_scratch.push(inst);
1919 }
1920 DrawOp::GlyphRun {
1921 rect,
1922 scissor,
1923 color,
1924 text: glyph_text,
1925 size,
1926 line_height,
1927 family,
1928 mono_family,
1929 weight,
1930 mono,
1931 wrap,
1932 anchor,
1933 underline,
1934 strikethrough,
1935 link,
1936 ..
1937 } => {
1938 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
1939 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
1940 timings.paint_culled_ops += 1;
1941 continue;
1942 }
1943 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
1944 timings.paint_culled_ops += 1;
1945 continue;
1946 }
1947 close_run(
1948 &mut self.runs,
1949 &mut self.paint_items,
1950 current,
1951 run_first,
1952 self.quad_scratch.len() as u32,
1953 );
1954 current = None;
1955 run_first = self.quad_scratch.len() as u32;
1956
1957 let mut style = crate::text::atlas::RunStyle::new(*weight, *color)
1958 .family(*family)
1959 .mono_family(*mono_family);
1960 if *mono {
1961 style = style.mono();
1962 }
1963 if *underline {
1964 style = style.underline();
1965 }
1966 if *strikethrough {
1967 style = style.strikethrough();
1968 }
1969 if let Some(url) = link {
1970 style = style.with_link(url.clone());
1971 }
1972 let layers = text.record(
1973 *rect,
1974 phys,
1975 &style,
1976 glyph_text,
1977 *size,
1978 *line_height,
1979 *wrap,
1980 *anchor,
1981 scale_factor,
1982 );
1983 for index in layers {
1984 self.paint_items.push(PaintItem::Text(index));
1985 }
1986 }
1987 DrawOp::AttributedText {
1988 rect,
1989 scissor,
1990 runs,
1991 size,
1992 line_height,
1993 wrap,
1994 anchor,
1995 ..
1996 } => {
1997 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
1998 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
1999 timings.paint_culled_ops += 1;
2000 continue;
2001 }
2002 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2003 timings.paint_culled_ops += 1;
2004 continue;
2005 }
2006 close_run(
2007 &mut self.runs,
2008 &mut self.paint_items,
2009 current,
2010 run_first,
2011 self.quad_scratch.len() as u32,
2012 );
2013 current = None;
2014 run_first = self.quad_scratch.len() as u32;
2015
2016 let layers = text.record_runs(
2017 *rect,
2018 phys,
2019 runs,
2020 *size,
2021 *line_height,
2022 *wrap,
2023 *anchor,
2024 scale_factor,
2025 );
2026 for index in layers {
2027 self.paint_items.push(PaintItem::Text(index));
2028 }
2029 }
2030 DrawOp::Icon {
2031 rect,
2032 scissor,
2033 source,
2034 color,
2035 size,
2036 stroke_width,
2037 ..
2038 } => {
2039 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2040 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2041 timings.paint_culled_ops += 1;
2042 continue;
2043 }
2044 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2045 timings.paint_culled_ops += 1;
2046 continue;
2047 }
2048 close_run(
2049 &mut self.runs,
2050 &mut self.paint_items,
2051 current,
2052 run_first,
2053 self.quad_scratch.len() as u32,
2054 );
2055 current = None;
2056 run_first = self.quad_scratch.len() as u32;
2057
2058 let recorded = text.record_icon(
2059 *rect,
2060 phys,
2061 source,
2062 *color,
2063 *size,
2064 *stroke_width,
2065 scale_factor,
2066 );
2067 match recorded {
2068 RecordedPaint::Text(layers) => {
2069 for index in layers {
2070 self.paint_items.push(PaintItem::Text(index));
2071 }
2072 }
2073 RecordedPaint::Icon(runs) => {
2074 for index in runs {
2075 self.paint_items.push(PaintItem::IconRun(index));
2076 }
2077 }
2078 }
2079 }
2080 DrawOp::Image {
2081 rect,
2082 scissor,
2083 image,
2084 tint,
2085 radius,
2086 fit,
2087 ..
2088 } => {
2089 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2090 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2091 timings.paint_culled_ops += 1;
2092 continue;
2093 }
2094 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2095 timings.paint_culled_ops += 1;
2096 continue;
2097 }
2098 close_run(
2099 &mut self.runs,
2100 &mut self.paint_items,
2101 current,
2102 run_first,
2103 self.quad_scratch.len() as u32,
2104 );
2105 current = None;
2106 run_first = self.quad_scratch.len() as u32;
2107
2108 let recorded =
2109 text.record_image(*rect, phys, image, *tint, *radius, *fit, scale_factor);
2110 for index in recorded {
2111 self.paint_items.push(PaintItem::Image(index));
2112 }
2113 }
2114 DrawOp::AppTexture {
2115 rect,
2116 scissor,
2117 texture,
2118 alpha,
2119 transform,
2120 ..
2121 } => {
2122 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2123 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2124 timings.paint_culled_ops += 1;
2125 continue;
2126 }
2127 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2128 timings.paint_culled_ops += 1;
2129 continue;
2130 }
2131 close_run(
2132 &mut self.runs,
2133 &mut self.paint_items,
2134 current,
2135 run_first,
2136 self.quad_scratch.len() as u32,
2137 );
2138 current = None;
2139 run_first = self.quad_scratch.len() as u32;
2140
2141 let recorded = text.record_app_texture(
2142 *rect,
2143 phys,
2144 texture,
2145 *alpha,
2146 *transform,
2147 scale_factor,
2148 );
2149 for index in recorded {
2150 self.paint_items.push(PaintItem::AppTexture(index));
2151 }
2152 }
2153 DrawOp::Vector {
2154 rect,
2155 scissor,
2156 asset,
2157 render_mode,
2158 ..
2159 } => {
2160 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2161 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2162 timings.paint_culled_ops += 1;
2163 continue;
2164 }
2165 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2166 timings.paint_culled_ops += 1;
2167 continue;
2168 }
2169 close_run(
2170 &mut self.runs,
2171 &mut self.paint_items,
2172 current,
2173 run_first,
2174 self.quad_scratch.len() as u32,
2175 );
2176 current = None;
2177 run_first = self.quad_scratch.len() as u32;
2178
2179 let recorded =
2180 text.record_vector(*rect, phys, asset, *render_mode, scale_factor);
2181 for index in recorded {
2182 self.paint_items.push(PaintItem::Vector(index));
2183 }
2184 }
2185 DrawOp::BackdropSnapshot => {
2186 close_run(
2187 &mut self.runs,
2188 &mut self.paint_items,
2189 current,
2190 run_first,
2191 self.quad_scratch.len() as u32,
2192 );
2193 current = None;
2194 run_first = self.quad_scratch.len() as u32;
2195 if !snapshot_emitted {
2198 self.paint_items.push(PaintItem::BackdropSnapshot);
2199 snapshot_emitted = true;
2200 }
2201 }
2202 }
2203 }
2204 close_run(
2205 &mut self.runs,
2206 &mut self.paint_items,
2207 current,
2208 run_first,
2209 self.quad_scratch.len() as u32,
2210 );
2211 timings.paint = Instant::now() - t0;
2212 }
2213
2214 pub fn snapshot(&mut self, root: &El, timings: &mut PrepareTimings) {
2219 crate::profile_span!("prepare::snapshot");
2220 let t0 = Instant::now();
2221 self.last_tree = Some(root.clone());
2222 timings.snapshot = Instant::now() - t0;
2223 }
2224}
2225
2226fn paint_rect_visible(
2227 rect: Rect,
2228 scissor: Option<Rect>,
2229 viewport_px: (u32, u32),
2230 scale_factor: f32,
2231) -> bool {
2232 if rect.w <= 0.0 || rect.h <= 0.0 {
2233 return false;
2234 }
2235 let scale = scale_factor.max(f32::EPSILON);
2236 let viewport = Rect::new(
2237 0.0,
2238 0.0,
2239 viewport_px.0 as f32 / scale,
2240 viewport_px.1 as f32 / scale,
2241 );
2242 let Some(clip) = scissor.map_or(Some(viewport), |s| s.intersect(viewport)) else {
2243 return false;
2244 };
2245 rect.intersect(clip).is_some()
2246}
2247
2248fn op_is_continuous<F>(op: &DrawOp, samples_time: &F) -> bool
2255where
2256 F: Fn(&ShaderHandle) -> bool,
2257{
2258 match op.shader() {
2259 Some(handle @ ShaderHandle::Stock(s)) => s.is_continuous() || samples_time(handle),
2260 Some(handle @ ShaderHandle::Custom(_)) => samples_time(handle),
2261 None => false,
2262 }
2263}
2264
2265fn aggregate_redraw_within(
2271 node: &El,
2272 viewport: Rect,
2273 rects: &rustc_hash::FxHashMap<String, Rect>,
2274) -> Option<std::time::Duration> {
2275 let mut acc: Option<std::time::Duration> = None;
2276 visit_redraw_within(node, viewport, rects, VisibilityClip::Unclipped, &mut acc);
2277 acc
2278}
2279
2280#[derive(Clone, Copy)]
2281enum VisibilityClip {
2282 Unclipped,
2283 Clipped(Rect),
2284 Empty,
2285}
2286
2287impl VisibilityClip {
2288 fn intersect(self, rect: Rect) -> Self {
2289 if rect.w <= 0.0 || rect.h <= 0.0 {
2290 return Self::Empty;
2291 }
2292 match self {
2293 Self::Unclipped => Self::Clipped(rect),
2294 Self::Clipped(prev) => prev
2295 .intersect(rect)
2296 .map(Self::Clipped)
2297 .unwrap_or(Self::Empty),
2298 Self::Empty => Self::Empty,
2299 }
2300 }
2301
2302 fn permits(self, rect: Rect) -> bool {
2303 if rect.w <= 0.0 || rect.h <= 0.0 {
2304 return false;
2305 }
2306 match self {
2307 Self::Unclipped => true,
2308 Self::Clipped(clip) => rect.intersect(clip).is_some(),
2309 Self::Empty => false,
2310 }
2311 }
2312}
2313
2314fn visit_redraw_within(
2315 node: &El,
2316 viewport: Rect,
2317 rects: &rustc_hash::FxHashMap<String, Rect>,
2318 inherited_clip: VisibilityClip,
2319 acc: &mut Option<std::time::Duration>,
2320) {
2321 let rect = rects.get(&node.computed_id).copied();
2322 if let Some(d) = node.redraw_within {
2323 if let Some(rect) = rect
2324 && rect.w > 0.0
2325 && rect.h > 0.0
2326 && rect.intersect(viewport).is_some()
2327 && inherited_clip.permits(rect)
2328 {
2329 *acc = Some(match *acc {
2330 Some(prev) => prev.min(d),
2331 None => d,
2332 });
2333 }
2334 }
2335 let child_clip = if node.clip {
2336 rect.map(|r| inherited_clip.intersect(r))
2337 .unwrap_or(VisibilityClip::Empty)
2338 } else {
2339 inherited_clip
2340 };
2341 for child in &node.children {
2342 visit_redraw_within(child, viewport, rects, child_clip, acc);
2343 }
2344}
2345
2346pub(crate) fn find_capture_keys(node: &El, id: &str) -> Option<bool> {
2351 if node.computed_id == id {
2352 return Some(node.capture_keys);
2353 }
2354 node.children.iter().find_map(|c| find_capture_keys(c, id))
2355}
2356
2357fn find_consumes_touch_drag(node: &El, id: &str, ancestor_consumes: bool) -> Option<bool> {
2367 let consumes = ancestor_consumes || node.consumes_touch_drag;
2368 if node.computed_id == id {
2369 return Some(consumes);
2370 }
2371 node.children
2372 .iter()
2373 .find_map(|c| find_consumes_touch_drag(c, id, consumes))
2374}
2375
2376fn selection_event(
2378 new_sel: crate::selection::Selection,
2379 modifiers: KeyModifiers,
2380 pointer: Option<(f32, f32)>,
2381 pointer_kind: Option<PointerKind>,
2382) -> UiEvent {
2383 UiEvent {
2384 kind: UiEventKind::SelectionChanged,
2385 key: None,
2386 target: None,
2387 pointer,
2388 key_press: None,
2389 text: None,
2390 selection: Some(new_sel),
2391 modifiers,
2392 click_count: 0,
2393 path: None,
2394 pointer_kind,
2395 }
2396}
2397
2398fn head_for_drag(
2410 root: &El,
2411 ui_state: &UiState,
2412 point: (f32, f32),
2413) -> Option<crate::selection::SelectionPoint> {
2414 if let Some(p) = hit_test::selection_point_at(root, ui_state, point) {
2415 return Some(p);
2416 }
2417
2418 let order = &ui_state.selection.order;
2419 if order.is_empty() {
2420 return None;
2421 }
2422 let target = order
2427 .iter()
2428 .find(|t| point.1 >= t.rect.y && point.1 < t.rect.y + t.rect.h)
2429 .or_else(|| {
2430 order.iter().min_by(|a, b| {
2431 let da = y_distance(a.rect, point.1);
2432 let db = y_distance(b.rect, point.1);
2433 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
2434 })
2435 })?;
2436 let target_rect = target.rect;
2437 let cy = point
2438 .1
2439 .clamp(target_rect.y, target_rect.y + target_rect.h - 1.0);
2440 if let Some(p) = hit_test::selection_point_at(root, ui_state, (point.0, cy)) {
2441 return Some(p);
2442 }
2443 let leaf_len = find_text_len(root, &target.node_id).unwrap_or(0);
2446 let byte = if point.0 < target_rect.x { 0 } else { leaf_len };
2447 Some(crate::selection::SelectionPoint {
2448 key: target.key.clone(),
2449 byte,
2450 })
2451}
2452
2453fn selection_range_for_drag(
2454 root: &El,
2455 ui_state: &UiState,
2456 drag: &crate::state::SelectionDrag,
2457 raw_head: crate::selection::SelectionPoint,
2458) -> (
2459 crate::selection::SelectionPoint,
2460 crate::selection::SelectionPoint,
2461) {
2462 match drag.granularity {
2463 SelectionDragGranularity::Character => (drag.anchor.clone(), raw_head),
2464 SelectionDragGranularity::Word => {
2465 let text = crate::selection::find_keyed_text(root, &raw_head.key).unwrap_or_default();
2466 let (lo, hi) = crate::selection::word_range_at(&text, raw_head.byte);
2467 if point_cmp(ui_state, &raw_head, &drag.anchor) == Ordering::Less {
2468 (
2469 drag.head.clone(),
2470 crate::selection::SelectionPoint::new(raw_head.key, lo),
2471 )
2472 } else {
2473 (
2474 drag.anchor.clone(),
2475 crate::selection::SelectionPoint::new(raw_head.key, hi),
2476 )
2477 }
2478 }
2479 SelectionDragGranularity::Leaf => {
2480 let len = crate::selection::find_keyed_text(root, &raw_head.key)
2481 .map(|text| text.len())
2482 .unwrap_or(raw_head.byte);
2483 if point_cmp(ui_state, &raw_head, &drag.anchor) == Ordering::Less {
2484 (
2485 drag.head.clone(),
2486 crate::selection::SelectionPoint::new(raw_head.key, 0),
2487 )
2488 } else {
2489 (
2490 drag.anchor.clone(),
2491 crate::selection::SelectionPoint::new(raw_head.key, len),
2492 )
2493 }
2494 }
2495 }
2496}
2497
2498fn point_cmp(
2499 ui_state: &UiState,
2500 a: &crate::selection::SelectionPoint,
2501 b: &crate::selection::SelectionPoint,
2502) -> Ordering {
2503 let order_index = |key: &str| {
2504 ui_state
2505 .selection
2506 .order
2507 .iter()
2508 .position(|target| target.key == key)
2509 .unwrap_or(usize::MAX)
2510 };
2511 order_index(&a.key)
2512 .cmp(&order_index(&b.key))
2513 .then_with(|| a.byte.cmp(&b.byte))
2514}
2515
2516fn y_distance(rect: Rect, y: f32) -> f32 {
2517 if y < rect.y {
2518 rect.y - y
2519 } else if y > rect.y + rect.h {
2520 y - (rect.y + rect.h)
2521 } else {
2522 0.0
2523 }
2524}
2525
2526fn find_text_len(node: &El, id: &str) -> Option<usize> {
2527 if node.computed_id == id {
2528 if let Some(source) = &node.selection_source {
2529 return Some(source.visible_len());
2530 }
2531 return node.text.as_ref().map(|t| t.len());
2532 }
2533 node.children.iter().find_map(|c| find_text_len(c, id))
2534}
2535
2536pub enum RecordedPaint {
2539 Text(Range<usize>),
2540 Icon(Range<usize>),
2541}
2542
2543pub trait TextRecorder {
2547 #[allow(clippy::too_many_arguments)]
2555 fn record(
2556 &mut self,
2557 rect: Rect,
2558 scissor: Option<PhysicalScissor>,
2559 style: &RunStyle,
2560 text: &str,
2561 size: f32,
2562 line_height: f32,
2563 wrap: TextWrap,
2564 anchor: TextAnchor,
2565 scale_factor: f32,
2566 ) -> Range<usize>;
2567
2568 #[allow(clippy::too_many_arguments)]
2573 fn record_runs(
2574 &mut self,
2575 rect: Rect,
2576 scissor: Option<PhysicalScissor>,
2577 runs: &[(String, RunStyle)],
2578 size: f32,
2579 line_height: f32,
2580 wrap: TextWrap,
2581 anchor: TextAnchor,
2582 scale_factor: f32,
2583 ) -> Range<usize>;
2584
2585 #[allow(clippy::too_many_arguments)]
2591 fn record_icon(
2592 &mut self,
2593 rect: Rect,
2594 scissor: Option<PhysicalScissor>,
2595 source: &crate::icons::svg::IconSource,
2596 color: Color,
2597 size: f32,
2598 _stroke_width: f32,
2599 scale_factor: f32,
2600 ) -> RecordedPaint {
2601 let glyph = match source {
2602 crate::icons::svg::IconSource::Builtin(name) => name.fallback_glyph(),
2603 crate::icons::svg::IconSource::Custom(_) => "?",
2604 };
2605 RecordedPaint::Text(self.record(
2606 rect,
2607 scissor,
2608 &RunStyle::new(FontWeight::Regular, color),
2609 glyph,
2610 size,
2611 crate::text::metrics::line_height(size),
2612 TextWrap::NoWrap,
2613 TextAnchor::Middle,
2614 scale_factor,
2615 ))
2616 }
2617
2618 #[allow(clippy::too_many_arguments)]
2625 fn record_image(
2626 &mut self,
2627 _rect: Rect,
2628 _scissor: Option<PhysicalScissor>,
2629 _image: &crate::image::Image,
2630 _tint: Option<Color>,
2631 _radius: crate::tree::Corners,
2632 _fit: crate::image::ImageFit,
2633 _scale_factor: f32,
2634 ) -> Range<usize> {
2635 0..0
2636 }
2637
2638 fn record_app_texture(
2644 &mut self,
2645 _rect: Rect,
2646 _scissor: Option<PhysicalScissor>,
2647 _texture: &crate::surface::AppTexture,
2648 _alpha: crate::surface::SurfaceAlpha,
2649 _transform: crate::affine::Affine2,
2650 _scale_factor: f32,
2651 ) -> Range<usize> {
2652 0..0
2653 }
2654
2655 fn record_vector(
2661 &mut self,
2662 _rect: Rect,
2663 _scissor: Option<PhysicalScissor>,
2664 _asset: &crate::vector::VectorAsset,
2665 _render_mode: crate::vector::VectorRenderMode,
2666 _scale_factor: f32,
2667 ) -> Range<usize> {
2668 0..0
2669 }
2670}
2671
2672#[cfg(test)]
2673mod tests {
2674 use super::*;
2675 use crate::event::PointerId;
2676 use crate::shader::{ShaderHandle, StockShader, UniformBlock};
2677
2678 struct NoText;
2680 impl TextRecorder for NoText {
2681 fn record(
2682 &mut self,
2683 _rect: Rect,
2684 _scissor: Option<PhysicalScissor>,
2685 _style: &RunStyle,
2686 _text: &str,
2687 _size: f32,
2688 _line_height: f32,
2689 _wrap: TextWrap,
2690 _anchor: TextAnchor,
2691 _scale_factor: f32,
2692 ) -> Range<usize> {
2693 0..0
2694 }
2695 fn record_runs(
2696 &mut self,
2697 _rect: Rect,
2698 _scissor: Option<PhysicalScissor>,
2699 _runs: &[(String, RunStyle)],
2700 _size: f32,
2701 _line_height: f32,
2702 _wrap: TextWrap,
2703 _anchor: TextAnchor,
2704 _scale_factor: f32,
2705 ) -> Range<usize> {
2706 0..0
2707 }
2708 }
2709
2710 #[derive(Default)]
2711 struct CountingText {
2712 records: usize,
2713 }
2714
2715 impl TextRecorder for CountingText {
2716 fn record(
2717 &mut self,
2718 _rect: Rect,
2719 _scissor: Option<PhysicalScissor>,
2720 _style: &RunStyle,
2721 _text: &str,
2722 _size: f32,
2723 _line_height: f32,
2724 _wrap: TextWrap,
2725 _anchor: TextAnchor,
2726 _scale_factor: f32,
2727 ) -> Range<usize> {
2728 self.records += 1;
2729 0..0
2730 }
2731
2732 fn record_runs(
2733 &mut self,
2734 _rect: Rect,
2735 _scissor: Option<PhysicalScissor>,
2736 _runs: &[(String, RunStyle)],
2737 _size: f32,
2738 _line_height: f32,
2739 _wrap: TextWrap,
2740 _anchor: TextAnchor,
2741 _scale_factor: f32,
2742 ) -> Range<usize> {
2743 self.records += 1;
2744 0..0
2745 }
2746 }
2747
2748 fn empty_text_layout(line_height: f32) -> crate::text::metrics::TextLayout {
2749 crate::text::metrics::TextLayout {
2750 lines: Vec::new(),
2751 width: 0.0,
2752 height: 0.0,
2753 line_height,
2754 }
2755 }
2756
2757 fn lay_out_input_tree(capture: bool) -> RunnerCore {
2764 use crate::tree::*;
2765 let ti = if capture {
2766 crate::widgets::text::text("input").key("ti").capture_keys()
2767 } else {
2768 crate::widgets::text::text("noop").key("ti").focusable()
2769 };
2770 let mut tree =
2771 crate::column([crate::widgets::button::button("Btn").key("btn"), ti]).padding(10.0);
2772 let mut core = RunnerCore::new();
2773 crate::layout::layout(
2774 &mut tree,
2775 &mut core.ui_state,
2776 Rect::new(0.0, 0.0, 200.0, 200.0),
2777 );
2778 core.ui_state.sync_focus_order(&tree);
2779 let mut t = PrepareTimings::default();
2780 core.snapshot(&tree, &mut t);
2781 core
2782 }
2783
2784 #[test]
2785 fn pointer_up_emits_pointer_up_then_click() {
2786 let mut core = lay_out_input_tree(false);
2787 let btn_rect = core.rect_of_key("btn").expect("btn rect");
2788 let cx = btn_rect.x + btn_rect.w * 0.5;
2789 let cy = btn_rect.y + btn_rect.h * 0.5;
2790 core.pointer_moved(Pointer::moving(cx, cy));
2791 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
2792 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
2793 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
2794 assert_eq!(kinds, vec![UiEventKind::PointerUp, UiEventKind::Click]);
2795 }
2796
2797 fn lay_out_link_tree() -> (RunnerCore, Rect, &'static str) {
2803 use crate::tree::*;
2804 const URL: &str = "https://github.com/computer-whisperer/aetna";
2805 let mut tree = crate::column([crate::text_runs([
2806 crate::text("Visit "),
2807 crate::text("github.com/computer-whisperer/aetna").link(URL),
2808 crate::text("."),
2809 ])])
2810 .padding(10.0);
2811 let mut core = RunnerCore::new();
2812 crate::layout::layout(
2813 &mut tree,
2814 &mut core.ui_state,
2815 Rect::new(0.0, 0.0, 600.0, 200.0),
2816 );
2817 core.ui_state.sync_focus_order(&tree);
2818 let mut t = PrepareTimings::default();
2819 core.snapshot(&tree, &mut t);
2820 let para = core
2821 .last_tree
2822 .as_ref()
2823 .and_then(|t| t.children.first())
2824 .map(|p| core.ui_state.rect(&p.computed_id))
2825 .expect("paragraph rect");
2826 (core, para, URL)
2827 }
2828
2829 #[test]
2830 fn pointer_up_on_link_emits_link_activated_with_url() {
2831 let (mut core, para, url) = lay_out_link_tree();
2832 let cx = para.x + 100.0;
2836 let cy = para.y + para.h * 0.5;
2837 core.pointer_moved(Pointer::moving(cx, cy));
2838 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
2839 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
2840 let link = events
2841 .iter()
2842 .find(|e| e.kind == UiEventKind::LinkActivated)
2843 .expect("LinkActivated event");
2844 assert_eq!(link.key.as_deref(), Some(url));
2845 }
2846
2847 #[test]
2848 fn pointer_up_after_drag_off_link_does_not_activate() {
2849 let (mut core, para, _url) = lay_out_link_tree();
2850 let press_x = para.x + 100.0;
2851 let cy = para.y + para.h * 0.5;
2852 core.pointer_moved(Pointer::moving(press_x, cy));
2853 core.pointer_down(Pointer::mouse(press_x, cy, PointerButton::Primary));
2854 let events = core.pointer_up(Pointer::mouse(press_x, 180.0, PointerButton::Primary));
2858 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
2859 assert!(
2860 !kinds.contains(&UiEventKind::LinkActivated),
2861 "drag-off-link should cancel the link activation; got {kinds:?}",
2862 );
2863 }
2864
2865 #[test]
2866 fn pointer_moved_over_link_resolves_cursor_to_pointer_and_requests_redraw() {
2867 use crate::cursor::Cursor;
2868 let (mut core, para, _url) = lay_out_link_tree();
2869 let cx = para.x + 100.0;
2870 let cy = para.y + para.h * 0.5;
2871 let initial = core.pointer_moved(Pointer::moving(para.x - 50.0, cy));
2873 assert!(
2874 !initial.needs_redraw,
2875 "moving in empty space shouldn't request a redraw"
2876 );
2877 let tree = core.last_tree.as_ref().expect("tree").clone();
2878 assert_eq!(
2879 core.ui_state.cursor(&tree),
2880 Cursor::Default,
2881 "no link under pointer → default cursor"
2882 );
2883 let onto = core.pointer_moved(Pointer::moving(cx, cy));
2886 assert!(
2887 onto.needs_redraw,
2888 "entering a link region should flag a redraw so the cursor refresh isn't stale"
2889 );
2890 assert_eq!(
2891 core.ui_state.cursor(&tree),
2892 Cursor::Pointer,
2893 "pointer over a link → Pointer cursor"
2894 );
2895 let off = core.pointer_moved(Pointer::moving(para.x - 50.0, cy));
2898 assert!(
2899 off.needs_redraw,
2900 "leaving a link region should flag a redraw"
2901 );
2902 assert_eq!(core.ui_state.cursor(&tree), Cursor::Default);
2903 }
2904
2905 #[test]
2906 fn pointer_up_on_unlinked_text_does_not_emit_link_activated() {
2907 let (mut core, para, _url) = lay_out_link_tree();
2908 let cx = para.x + 1.0;
2911 let cy = para.y + para.h * 0.5;
2912 core.pointer_moved(Pointer::moving(cx, cy));
2913 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
2914 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
2915 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
2916 assert!(
2917 !kinds.contains(&UiEventKind::LinkActivated),
2918 "click on the unlinked prefix should not surface a link event; got {kinds:?}",
2919 );
2920 }
2921
2922 #[test]
2923 fn pointer_up_off_target_emits_only_pointer_up() {
2924 let mut core = lay_out_input_tree(false);
2925 let btn_rect = core.rect_of_key("btn").expect("btn rect");
2926 let cx = btn_rect.x + btn_rect.w * 0.5;
2927 let cy = btn_rect.y + btn_rect.h * 0.5;
2928 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
2929 let events = core.pointer_up(Pointer::mouse(180.0, 180.0, PointerButton::Primary));
2931 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
2932 assert_eq!(
2933 kinds,
2934 vec![UiEventKind::PointerUp],
2935 "drag-off-target should still surface PointerUp so widgets see drag-end"
2936 );
2937 }
2938
2939 #[test]
2940 fn pointer_moved_while_pressed_emits_drag() {
2941 let mut core = lay_out_input_tree(false);
2942 let btn_rect = core.rect_of_key("btn").expect("btn rect");
2943 let cx = btn_rect.x + btn_rect.w * 0.5;
2944 let cy = btn_rect.y + btn_rect.h * 0.5;
2945 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
2946 let drag = core
2947 .pointer_moved(Pointer::moving(cx + 30.0, cy))
2948 .events
2949 .into_iter()
2950 .find(|e| e.kind == UiEventKind::Drag)
2951 .expect("drag while pressed");
2952 assert_eq!(drag.target.as_ref().map(|t| t.key.as_str()), Some("btn"));
2953 assert_eq!(drag.pointer, Some((cx + 30.0, cy)));
2954 }
2955
2956 #[test]
2957 fn toast_dismiss_click_removes_toast_and_suppresses_click_event() {
2958 use crate::toast::ToastSpec;
2959 use crate::tree::Size;
2960 let mut core = RunnerCore::new();
2964 core.ui_state
2965 .push_toast(ToastSpec::success("hi"), Instant::now());
2966 let toast_id = core.ui_state.toasts()[0].id;
2967
2968 let mut tree: El = crate::stack(std::iter::empty::<El>())
2972 .width(Size::Fill(1.0))
2973 .height(Size::Fill(1.0));
2974 crate::layout::assign_ids(&mut tree);
2975 let _ = crate::toast::synthesize_toasts(&mut tree, &mut core.ui_state, Instant::now());
2976 crate::layout::layout(
2977 &mut tree,
2978 &mut core.ui_state,
2979 Rect::new(0.0, 0.0, 800.0, 600.0),
2980 );
2981 core.ui_state.sync_focus_order(&tree);
2982 let mut t = PrepareTimings::default();
2983 core.snapshot(&tree, &mut t);
2984
2985 let dismiss_key = format!("toast-dismiss-{toast_id}");
2986 let dismiss_rect = core.rect_of_key(&dismiss_key).expect("dismiss button");
2987 let cx = dismiss_rect.x + dismiss_rect.w * 0.5;
2988 let cy = dismiss_rect.y + dismiss_rect.h * 0.5;
2989
2990 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
2991 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
2992 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
2993 assert!(
2997 !kinds.contains(&UiEventKind::Click),
2998 "Click on toast-dismiss should not be surfaced: {kinds:?}",
2999 );
3000 assert!(
3001 core.ui_state.toasts().iter().all(|t| t.id != toast_id),
3002 "toast {toast_id} should be dropped after dismiss-click",
3003 );
3004 }
3005
3006 #[test]
3007 fn pointer_moved_without_press_emits_no_drag() {
3008 let mut core = lay_out_input_tree(false);
3009 let events = core.pointer_moved(Pointer::moving(50.0, 50.0)).events;
3010 assert!(!events.iter().any(|e| e.kind == UiEventKind::Drag));
3014 }
3015
3016 #[test]
3017 fn spinner_in_tree_keeps_needs_redraw_set() {
3018 use crate::widgets::spinner::spinner;
3023 let mut tree = crate::column([spinner()]);
3024 let mut core = RunnerCore::new();
3025 let mut t = PrepareTimings::default();
3026 let LayoutPrepared { needs_redraw, .. } = core.prepare_layout(
3027 &mut tree,
3028 Rect::new(0.0, 0.0, 200.0, 200.0),
3029 1.0,
3030 &mut t,
3031 RunnerCore::no_time_shaders,
3032 );
3033 assert!(
3034 needs_redraw,
3035 "tree with a spinner must request continuous redraw",
3036 );
3037
3038 let mut bare = crate::column([crate::widgets::text::text("idle")]);
3042 let mut core2 = RunnerCore::new();
3043 let mut t2 = PrepareTimings::default();
3044 let LayoutPrepared {
3045 needs_redraw: needs_redraw2,
3046 ..
3047 } = core2.prepare_layout(
3048 &mut bare,
3049 Rect::new(0.0, 0.0, 200.0, 200.0),
3050 1.0,
3051 &mut t2,
3052 RunnerCore::no_time_shaders,
3053 );
3054 assert!(
3055 !needs_redraw2,
3056 "tree without time-driven shaders should idle: got needs_redraw={needs_redraw2}",
3057 );
3058 }
3059
3060 #[test]
3061 fn custom_samples_time_shader_keeps_needs_redraw_set() {
3062 let mut tree = crate::column([crate::tree::El::new(crate::tree::Kind::Custom("anim"))
3066 .shader(crate::shader::ShaderBinding::custom("my_animated_glow"))
3067 .width(crate::tree::Size::Fixed(32.0))
3068 .height(crate::tree::Size::Fixed(32.0))]);
3069 let mut core = RunnerCore::new();
3070 let mut t = PrepareTimings::default();
3071
3072 let LayoutPrepared {
3073 needs_redraw: idle, ..
3074 } = core.prepare_layout(
3075 &mut tree,
3076 Rect::new(0.0, 0.0, 200.0, 200.0),
3077 1.0,
3078 &mut t,
3079 RunnerCore::no_time_shaders,
3080 );
3081 assert!(
3082 !idle,
3083 "without a samples_time registration the host should idle",
3084 );
3085
3086 let mut t2 = PrepareTimings::default();
3087 let LayoutPrepared {
3088 needs_redraw: animated,
3089 ..
3090 } = core.prepare_layout(
3091 &mut tree,
3092 Rect::new(0.0, 0.0, 200.0, 200.0),
3093 1.0,
3094 &mut t2,
3095 |handle| matches!(handle, ShaderHandle::Custom("my_animated_glow")),
3096 );
3097 assert!(
3098 animated,
3099 "custom shader registered as samples_time=true must request continuous redraw",
3100 );
3101 }
3102
3103 #[test]
3104 fn redraw_within_aggregates_to_minimum_visible_deadline() {
3105 use std::time::Duration;
3106 let mut tree = crate::column([
3107 crate::widgets::text::text("a")
3109 .redraw_within(Duration::from_millis(16))
3110 .width(crate::tree::Size::Fixed(20.0))
3111 .height(crate::tree::Size::Fixed(20.0)),
3112 crate::widgets::text::text("b")
3114 .redraw_within(Duration::from_millis(50))
3115 .width(crate::tree::Size::Fixed(20.0))
3116 .height(crate::tree::Size::Fixed(20.0)),
3117 ]);
3118 let mut core = RunnerCore::new();
3119 let mut t = PrepareTimings::default();
3120 let LayoutPrepared {
3121 needs_redraw,
3122 next_layout_redraw_in,
3123 ..
3124 } = core.prepare_layout(
3125 &mut tree,
3126 Rect::new(0.0, 0.0, 200.0, 200.0),
3127 1.0,
3128 &mut t,
3129 RunnerCore::no_time_shaders,
3130 );
3131 assert!(needs_redraw, "redraw_within must lift the legacy bool");
3132 assert_eq!(
3133 next_layout_redraw_in,
3134 Some(Duration::from_millis(16)),
3135 "tightest visible deadline wins, on the layout lane",
3136 );
3137 }
3138
3139 #[test]
3140 fn redraw_within_off_screen_widget_is_ignored() {
3141 use std::time::Duration;
3142 let mut tree = crate::column([
3148 crate::tree::spacer().height(crate::tree::Size::Fixed(150.0)),
3149 crate::widgets::text::text("offscreen")
3150 .redraw_within(Duration::from_millis(16))
3151 .width(crate::tree::Size::Fixed(10.0))
3152 .height(crate::tree::Size::Fixed(10.0)),
3153 ]);
3154 let mut core = RunnerCore::new();
3155 let mut t = PrepareTimings::default();
3156 let LayoutPrepared {
3157 next_layout_redraw_in,
3158 ..
3159 } = core.prepare_layout(
3160 &mut tree,
3161 Rect::new(0.0, 0.0, 100.0, 100.0),
3162 1.0,
3163 &mut t,
3164 RunnerCore::no_time_shaders,
3165 );
3166 assert_eq!(
3167 next_layout_redraw_in, None,
3168 "off-screen redraw_within must not contribute to the aggregate",
3169 );
3170 }
3171
3172 #[test]
3173 fn redraw_within_clipped_out_widget_is_ignored() {
3174 use std::time::Duration;
3175
3176 let clipped = crate::column([crate::widgets::text::text("clipped")
3177 .redraw_within(Duration::from_millis(16))
3178 .width(crate::tree::Size::Fixed(10.0))
3179 .height(crate::tree::Size::Fixed(10.0))])
3180 .clip()
3181 .width(crate::tree::Size::Fixed(100.0))
3182 .height(crate::tree::Size::Fixed(20.0))
3183 .layout(|ctx| {
3184 vec![Rect::new(
3185 ctx.container.x,
3186 ctx.container.y + 30.0,
3187 10.0,
3188 10.0,
3189 )]
3190 });
3191 let mut tree = crate::column([clipped]);
3192
3193 let mut core = RunnerCore::new();
3194 let mut t = PrepareTimings::default();
3195 let LayoutPrepared {
3196 next_layout_redraw_in,
3197 ..
3198 } = core.prepare_layout(
3199 &mut tree,
3200 Rect::new(0.0, 0.0, 100.0, 100.0),
3201 1.0,
3202 &mut t,
3203 RunnerCore::no_time_shaders,
3204 );
3205 assert_eq!(
3206 next_layout_redraw_in, None,
3207 "redraw_within inside an inherited clip but outside the clip rect must not contribute",
3208 );
3209 }
3210
3211 #[test]
3212 fn pointer_moved_within_same_hovered_node_does_not_request_redraw() {
3213 let mut core = lay_out_input_tree(false);
3219 let btn = core.rect_of_key("btn").expect("btn rect");
3220 let (cx, cy) = (btn.x + btn.w * 0.5, btn.y + btn.h * 0.5);
3221
3222 let first = core.pointer_moved(Pointer::moving(cx, cy));
3226 assert_eq!(first.events.len(), 1);
3227 assert_eq!(first.events[0].kind, UiEventKind::PointerEnter);
3228 assert_eq!(first.events[0].key.as_deref(), Some("btn"));
3229 assert!(
3230 first.needs_redraw,
3231 "entering a focusable should warrant a redraw",
3232 );
3233
3234 let second = core.pointer_moved(Pointer::moving(cx + 1.0, cy));
3238 assert!(second.events.is_empty());
3239 assert!(
3240 !second.needs_redraw,
3241 "identical hover, no drag → host should idle",
3242 );
3243
3244 let off = core.pointer_moved(Pointer::moving(0.0, 0.0));
3248 assert_eq!(off.events.len(), 1);
3249 assert_eq!(off.events[0].kind, UiEventKind::PointerLeave);
3250 assert_eq!(off.events[0].key.as_deref(), Some("btn"));
3251 assert!(
3252 off.needs_redraw,
3253 "leaving a hovered node still warrants a redraw",
3254 );
3255 }
3256
3257 #[test]
3258 fn pointer_moved_between_keyed_targets_emits_leave_then_enter() {
3259 let mut core = lay_out_input_tree(false);
3266 let btn = core.rect_of_key("btn").expect("btn rect");
3267 let ti = core.rect_of_key("ti").expect("ti rect");
3268
3269 let _ = core.pointer_moved(Pointer::moving(btn.x + 4.0, btn.y + 4.0));
3271
3272 let cross = core.pointer_moved(Pointer::moving(ti.x + 4.0, ti.y + 4.0));
3274 let kinds: Vec<UiEventKind> = cross.events.iter().map(|e| e.kind).collect();
3275 assert_eq!(
3276 kinds,
3277 vec![UiEventKind::PointerLeave, UiEventKind::PointerEnter],
3278 "paired Leave-then-Enter on cross-target hover transition",
3279 );
3280 assert_eq!(cross.events[0].key.as_deref(), Some("btn"));
3281 assert_eq!(cross.events[1].key.as_deref(), Some("ti"));
3282 assert!(cross.needs_redraw);
3283 }
3284
3285 #[test]
3286 fn touch_pointer_down_emits_pointer_enter_then_pointer_down() {
3287 let mut core = lay_out_input_tree(false);
3293 let btn = core.rect_of_key("btn").expect("btn rect");
3294 let cx = btn.x + btn.w * 0.5;
3295 let cy = btn.y + btn.h * 0.5;
3296 let events = core.pointer_down(Pointer::touch(
3297 cx,
3298 cy,
3299 PointerButton::Primary,
3300 PointerId::PRIMARY,
3301 ));
3302 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3303 assert_eq!(
3304 kinds,
3305 vec![UiEventKind::PointerEnter, UiEventKind::PointerDown],
3306 );
3307 for e in &events {
3308 assert_eq!(e.pointer_kind, Some(PointerKind::Touch));
3309 }
3310 assert_eq!(core.ui_state().hovered_key(), Some("btn"));
3311 }
3312
3313 #[test]
3314 fn touch_pointer_up_emits_pointer_leave_after_click() {
3315 let mut core = lay_out_input_tree(false);
3320 let btn = core.rect_of_key("btn").expect("btn rect");
3321 let cx = btn.x + btn.w * 0.5;
3322 let cy = btn.y + btn.h * 0.5;
3323 let _ = core.pointer_down(Pointer::touch(
3324 cx,
3325 cy,
3326 PointerButton::Primary,
3327 PointerId::PRIMARY,
3328 ));
3329 let events = core.pointer_up(Pointer::touch(
3330 cx,
3331 cy,
3332 PointerButton::Primary,
3333 PointerId::PRIMARY,
3334 ));
3335 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3336 assert_eq!(
3337 kinds,
3338 vec![
3339 UiEventKind::PointerUp,
3340 UiEventKind::Click,
3341 UiEventKind::PointerLeave,
3342 ],
3343 );
3344 assert_eq!(core.ui_state().hovered_key(), None);
3345 }
3346
3347 #[test]
3348 fn touch_pointer_moved_without_press_does_not_emit_hover_transitions() {
3349 let mut core = lay_out_input_tree(false);
3357 let btn = core.rect_of_key("btn").expect("btn rect");
3358 let mut p = Pointer::moving(btn.x + 4.0, btn.y + 4.0);
3359 p.kind = PointerKind::Touch;
3360 let moved = core.pointer_moved(p);
3361 assert!(
3362 moved.events.is_empty(),
3363 "touch move without press should not emit hover events, got {:?}",
3364 moved.events.iter().map(|e| e.kind).collect::<Vec<_>>(),
3365 );
3366 }
3367
3368 #[test]
3369 fn touch_drag_between_targets_still_emits_hover_transitions() {
3370 use crate::tree::*;
3381 let mut tree = crate::column([
3382 crate::widgets::button::button("Btn")
3383 .key("btn")
3384 .consumes_touch_drag(),
3385 crate::widgets::button::button("Other").key("other"),
3386 ])
3387 .padding(10.0);
3388 let mut core = RunnerCore::new();
3389 crate::layout::layout(
3390 &mut tree,
3391 &mut core.ui_state,
3392 Rect::new(0.0, 0.0, 200.0, 200.0),
3393 );
3394 core.ui_state.sync_focus_order(&tree);
3395 let mut t = PrepareTimings::default();
3396 core.snapshot(&tree, &mut t);
3397
3398 let btn = core.rect_of_key("btn").expect("btn rect");
3399 let other = core.rect_of_key("other").expect("other rect");
3400 let _ = core.pointer_down(Pointer::touch(
3401 btn.x + 4.0,
3402 btn.y + 4.0,
3403 PointerButton::Primary,
3404 PointerId::PRIMARY,
3405 ));
3406 let mut move_p = Pointer::moving(other.x + 4.0, other.y + 4.0);
3407 move_p.kind = PointerKind::Touch;
3408 let cross = core.pointer_moved(move_p);
3409 let kinds: Vec<UiEventKind> = cross.events.iter().map(|e| e.kind).collect();
3410 assert!(
3411 kinds.contains(&UiEventKind::PointerLeave)
3412 && kinds.contains(&UiEventKind::PointerEnter),
3413 "touch drag across targets should emit Leave + Enter, got {kinds:?}",
3414 );
3415 assert!(kinds.contains(&UiEventKind::Drag));
3419 }
3420
3421 #[test]
3422 fn would_press_focus_text_input_distinguishes_capture_keys() {
3423 let core = lay_out_input_tree(true);
3428 let ti = core.rect_of_key("ti").expect("ti rect");
3429 let btn = core.rect_of_key("btn").expect("btn rect");
3430
3431 assert!(
3432 core.would_press_focus_text_input(ti.center_x(), ti.center_y()),
3433 "press on capture_keys widget should report true",
3434 );
3435 assert!(
3436 !core.would_press_focus_text_input(btn.center_x(), btn.center_y()),
3437 "press on plain focusable should report false",
3438 );
3439 assert!(!core.would_press_focus_text_input(0.0, 0.0));
3441 }
3442
3443 #[test]
3444 fn touch_jiggle_below_threshold_still_taps() {
3445 let mut core = lay_out_input_tree(false);
3451 let btn = core.rect_of_key("btn").expect("btn rect");
3452 let cx = btn.x + btn.w * 0.5;
3453 let cy = btn.y + btn.h * 0.5;
3454 let _ = core.pointer_down(Pointer::touch(
3455 cx,
3456 cy,
3457 PointerButton::Primary,
3458 PointerId::PRIMARY,
3459 ));
3460 let mut jiggle = Pointer::moving(cx + 3.0, cy + 2.0);
3462 jiggle.kind = PointerKind::Touch;
3463 let _ = core.pointer_moved(jiggle);
3464 let events = core.pointer_up(Pointer::touch(
3465 cx + 3.0,
3466 cy + 2.0,
3467 PointerButton::Primary,
3468 PointerId::PRIMARY,
3469 ));
3470 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3471 assert!(
3472 kinds.contains(&UiEventKind::Click),
3473 "small jiggle should not commit to scroll, expected Click in {kinds:?}",
3474 );
3475 }
3476
3477 #[test]
3478 fn touch_drag_on_consuming_widget_emits_drag_not_cancel() {
3479 use crate::tree::*;
3484 let mut tree = crate::column([crate::widgets::button::button("Drag me")
3485 .key("draggable")
3486 .consumes_touch_drag()])
3487 .padding(10.0);
3488 let mut core = RunnerCore::new();
3489 crate::layout::layout(
3490 &mut tree,
3491 &mut core.ui_state,
3492 Rect::new(0.0, 0.0, 200.0, 200.0),
3493 );
3494 core.ui_state.sync_focus_order(&tree);
3495 let mut t = PrepareTimings::default();
3496 core.snapshot(&tree, &mut t);
3497
3498 let r = core.rect_of_key("draggable").expect("rect");
3499 let cx = r.x + r.w * 0.5;
3500 let cy = r.y + r.h * 0.5;
3501 let _ = core.pointer_down(Pointer::touch(
3502 cx,
3503 cy,
3504 PointerButton::Primary,
3505 PointerId::PRIMARY,
3506 ));
3507 let mut over = Pointer::moving(cx + 30.0, cy);
3510 over.kind = PointerKind::Touch;
3511 let moved = core.pointer_moved(over);
3512 let kinds: Vec<UiEventKind> = moved.events.iter().map(|e| e.kind).collect();
3513 assert!(
3514 kinds.contains(&UiEventKind::Drag),
3515 "drag-consuming widget should receive Drag past threshold, got {kinds:?}",
3516 );
3517 assert!(
3518 !kinds.contains(&UiEventKind::PointerCancel),
3519 "drag-consuming widget should not see PointerCancel, got {kinds:?}",
3520 );
3521 }
3522
3523 #[test]
3524 fn touch_drag_in_scrollable_cancels_press_and_scrolls() {
3525 use crate::tree::*;
3532 let mut tree = crate::scroll([
3533 crate::widgets::button::button("row 0")
3534 .key("row0")
3535 .height(Size::Fixed(50.0)),
3536 crate::widgets::button::button("row 1")
3537 .key("row1")
3538 .height(Size::Fixed(50.0)),
3539 crate::widgets::button::button("row 2")
3540 .key("row2")
3541 .height(Size::Fixed(50.0)),
3542 crate::widgets::button::button("row 3")
3543 .key("row3")
3544 .height(Size::Fixed(50.0)),
3545 crate::widgets::button::button("row 4")
3546 .key("row4")
3547 .height(Size::Fixed(50.0)),
3548 ])
3549 .key("list")
3550 .height(Size::Fixed(120.0));
3551 let mut core = RunnerCore::new();
3552 crate::layout::layout(
3553 &mut tree,
3554 &mut core.ui_state,
3555 Rect::new(0.0, 0.0, 200.0, 120.0),
3556 );
3557 core.ui_state.sync_focus_order(&tree);
3558 let mut t = PrepareTimings::default();
3559 core.snapshot(&tree, &mut t);
3560 let scroll_id = core
3561 .last_tree
3562 .as_ref()
3563 .map(|t| t.computed_id.clone())
3564 .expect("scroll id");
3565
3566 let row1 = core.rect_of_key("row1").expect("row1");
3571 let cx = row1.x + row1.w * 0.5;
3572 let cy = row1.y + row1.h * 0.5;
3573
3574 let down_events = core.pointer_down(Pointer::touch(
3576 cx,
3577 cy,
3578 PointerButton::Primary,
3579 PointerId::PRIMARY,
3580 ));
3581 assert!(
3583 down_events
3584 .iter()
3585 .any(|e| matches!(e.kind, UiEventKind::PointerDown)),
3586 "expected PointerDown on press",
3587 );
3588
3589 let mut up_finger = Pointer::moving(cx, cy - 40.0);
3593 up_finger.kind = PointerKind::Touch;
3594 let move_events = core.pointer_moved(up_finger);
3595 let kinds: Vec<UiEventKind> = move_events.events.iter().map(|e| e.kind).collect();
3596 assert!(
3597 kinds.contains(&UiEventKind::PointerCancel),
3598 "scroll commit should fire PointerCancel, got {kinds:?}",
3599 );
3600 assert!(
3601 !kinds.contains(&UiEventKind::Drag),
3602 "scroll commit should NOT emit Drag, got {kinds:?}",
3603 );
3604
3605 let offset = core.ui_state().scroll_offset(&scroll_id);
3607 assert!(
3608 offset > 30.0 && offset <= 50.0,
3609 "scroll offset should advance ~40px after a 40px finger drag, got {offset}",
3610 );
3611
3612 let up_events = core.pointer_up(Pointer::touch(
3615 cx,
3616 cy - 40.0,
3617 PointerButton::Primary,
3618 PointerId::PRIMARY,
3619 ));
3620 let up_kinds: Vec<UiEventKind> = up_events.iter().map(|e| e.kind).collect();
3621 assert!(
3622 !up_kinds.contains(&UiEventKind::Click),
3623 "scroll-committed gesture must not fire Click on release, got {up_kinds:?}",
3624 );
3625 }
3626
3627 #[test]
3628 fn pointer_left_emits_leave_for_prior_hover() {
3629 let mut core = lay_out_input_tree(false);
3630 let btn = core.rect_of_key("btn").expect("btn rect");
3631 let _ = core.pointer_moved(Pointer::moving(btn.x + 4.0, btn.y + 4.0));
3632
3633 let events = core.pointer_left();
3634 assert_eq!(events.len(), 1);
3635 assert_eq!(events[0].kind, UiEventKind::PointerLeave);
3636 assert_eq!(events[0].key.as_deref(), Some("btn"));
3637 }
3638
3639 #[test]
3640 fn pointer_left_with_no_prior_hover_emits_nothing() {
3641 let mut core = lay_out_input_tree(false);
3642 let events = core.pointer_left();
3645 assert!(events.is_empty());
3646 }
3647
3648 #[test]
3649 fn poll_input_before_long_press_delay_emits_nothing() {
3650 let mut core = lay_out_input_tree(false);
3653 let btn = core.rect_of_key("btn").expect("btn rect");
3654 let cx = btn.x + btn.w * 0.5;
3655 let cy = btn.y + btn.h * 0.5;
3656 let _ = core.pointer_down(Pointer::touch(
3657 cx,
3658 cy,
3659 PointerButton::Primary,
3660 PointerId::PRIMARY,
3661 ));
3662 let polled = core.poll_input(Instant::now() + Duration::from_millis(100));
3664 assert!(polled.is_empty(), "should not fire before delay");
3665 }
3666
3667 #[test]
3668 fn poll_input_after_long_press_delay_fires_cancel_then_long_press() {
3669 let mut core = lay_out_input_tree(false);
3673 let btn = core.rect_of_key("btn").expect("btn rect");
3674 let cx = btn.x + btn.w * 0.5;
3675 let cy = btn.y + btn.h * 0.5;
3676 let _ = core.pointer_down(Pointer::touch(
3677 cx,
3678 cy,
3679 PointerButton::Primary,
3680 PointerId::PRIMARY,
3681 ));
3682 let polled = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
3683 let kinds: Vec<UiEventKind> = polled.iter().map(|e| e.kind).collect();
3684 assert!(
3685 kinds.contains(&UiEventKind::PointerCancel),
3686 "expected PointerCancel before LongPress, got {kinds:?}",
3687 );
3688 let long_press = polled
3689 .iter()
3690 .find(|e| matches!(e.kind, UiEventKind::LongPress))
3691 .expect("LongPress event missing");
3692 assert_eq!(
3693 long_press.key.as_deref(),
3694 Some("btn"),
3695 "LongPress should target the originally pressed node",
3696 );
3697 assert_eq!(
3698 long_press.pointer_kind,
3699 Some(PointerKind::Touch),
3700 "LongPress is touch-only",
3701 );
3702 }
3703
3704 #[test]
3705 fn pointer_up_after_long_press_emits_no_click() {
3706 let mut core = lay_out_input_tree(false);
3710 let btn = core.rect_of_key("btn").expect("btn rect");
3711 let cx = btn.x + btn.w * 0.5;
3712 let cy = btn.y + btn.h * 0.5;
3713 let _ = core.pointer_down(Pointer::touch(
3714 cx,
3715 cy,
3716 PointerButton::Primary,
3717 PointerId::PRIMARY,
3718 ));
3719 let _ = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
3720 let up_events = core.pointer_up(Pointer::touch(
3721 cx,
3722 cy,
3723 PointerButton::Primary,
3724 PointerId::PRIMARY,
3725 ));
3726 assert!(
3727 up_events.is_empty(),
3728 "lift after long-press emits nothing, got {:?}",
3729 up_events.iter().map(|e| e.kind).collect::<Vec<_>>(),
3730 );
3731 }
3732
3733 #[test]
3734 fn moving_past_threshold_before_long_press_cancels_the_timer() {
3735 let mut core = lay_out_input_tree(false);
3740 let btn = core.rect_of_key("btn").expect("btn rect");
3741 let cx = btn.x + btn.w * 0.5;
3742 let cy = btn.y + btn.h * 0.5;
3743 let _ = core.pointer_down(Pointer::touch(
3744 cx,
3745 cy,
3746 PointerButton::Primary,
3747 PointerId::PRIMARY,
3748 ));
3749 let mut over = Pointer::moving(cx + 30.0, cy);
3751 over.kind = PointerKind::Touch;
3752 let _ = core.pointer_moved(over);
3753 let polled = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
3755 assert!(
3756 polled.is_empty(),
3757 "long-press should not fire after gesture committed",
3758 );
3759 }
3760
3761 #[test]
3762 fn ui_state_hovered_key_returns_leaf_key() {
3763 let mut core = lay_out_input_tree(false);
3764 assert_eq!(core.ui_state().hovered_key(), None);
3765
3766 let btn = core.rect_of_key("btn").expect("btn rect");
3767 core.pointer_moved(Pointer::moving(btn.x + 4.0, btn.y + 4.0));
3768 assert_eq!(core.ui_state().hovered_key(), Some("btn"));
3769
3770 core.pointer_moved(Pointer::moving(0.0, 0.0));
3772 assert_eq!(core.ui_state().hovered_key(), None);
3773 }
3774
3775 #[test]
3776 fn ui_state_is_hovering_within_walks_subtree() {
3777 use crate::tree::*;
3781 let mut tree = crate::column([crate::stack([
3782 crate::widgets::button::button("Inner").key("inner_btn")
3783 ])
3784 .key("card")
3785 .focusable()
3786 .width(Size::Fixed(120.0))
3787 .height(Size::Fixed(60.0))])
3788 .padding(20.0);
3789 let mut core = RunnerCore::new();
3790 crate::layout::layout(
3791 &mut tree,
3792 &mut core.ui_state,
3793 Rect::new(0.0, 0.0, 400.0, 200.0),
3794 );
3795 core.ui_state.sync_focus_order(&tree);
3796 let mut t = PrepareTimings::default();
3797 core.snapshot(&tree, &mut t);
3798
3799 assert!(!core.ui_state().is_hovering_within("card"));
3801 assert!(!core.ui_state().is_hovering_within("inner_btn"));
3802
3803 let inner = core.rect_of_key("inner_btn").expect("inner rect");
3806 core.pointer_moved(Pointer::moving(inner.x + 4.0, inner.y + 4.0));
3807 assert!(core.ui_state().is_hovering_within("card"));
3808 assert!(core.ui_state().is_hovering_within("inner_btn"));
3809
3810 assert!(!core.ui_state().is_hovering_within("not_a_key"));
3812
3813 core.pointer_moved(Pointer::moving(0.0, 0.0));
3815 assert!(!core.ui_state().is_hovering_within("card"));
3816 assert!(!core.ui_state().is_hovering_within("inner_btn"));
3817 }
3818
3819 #[test]
3820 fn hover_driven_scale_via_is_hovering_within_plus_animate() {
3821 use crate::Theme;
3828 use crate::anim::Timing;
3829 use crate::tree::*;
3830
3831 let build_card = |hovering: bool| -> El {
3834 let scale = if hovering { 1.05 } else { 1.0 };
3835 crate::column([crate::stack(
3836 [crate::widgets::button::button("Inner").key("inner_btn")],
3837 )
3838 .key("card")
3839 .focusable()
3840 .scale(scale)
3841 .animate(Timing::SPRING_QUICK)
3842 .width(Size::Fixed(120.0))
3843 .height(Size::Fixed(60.0))])
3844 .padding(20.0)
3845 };
3846
3847 let mut core = RunnerCore::new();
3848 core.ui_state
3851 .set_animation_mode(crate::state::AnimationMode::Settled);
3852
3853 let theme = Theme::default();
3855 let cx_pre = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
3856 assert!(!cx_pre.is_hovering_within("card"));
3857 let mut tree = build_card(cx_pre.is_hovering_within("card"));
3858 crate::layout::layout(
3859 &mut tree,
3860 &mut core.ui_state,
3861 Rect::new(0.0, 0.0, 400.0, 200.0),
3862 );
3863 core.ui_state.sync_focus_order(&tree);
3864 let mut t = PrepareTimings::default();
3865 core.snapshot(&tree, &mut t);
3866 core.ui_state
3867 .tick_visual_animations(&mut tree, web_time::Instant::now());
3868 let card_at_rest = tree.children[0].clone();
3869 assert!((card_at_rest.scale - 1.0).abs() < 1e-3);
3870
3871 let card_rect = core.rect_of_key("card").expect("card rect");
3873 core.pointer_moved(Pointer::moving(card_rect.x + 4.0, card_rect.y + 4.0));
3874
3875 let cx_hot = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
3878 assert!(cx_hot.is_hovering_within("card"));
3879 let mut tree = build_card(cx_hot.is_hovering_within("card"));
3880 crate::layout::layout(
3881 &mut tree,
3882 &mut core.ui_state,
3883 Rect::new(0.0, 0.0, 400.0, 200.0),
3884 );
3885 core.ui_state.sync_focus_order(&tree);
3886 core.snapshot(&tree, &mut t);
3887 core.ui_state
3888 .tick_visual_animations(&mut tree, web_time::Instant::now());
3889 let card_hot = tree.children[0].clone();
3890 assert!(
3891 (card_hot.scale - 1.05).abs() < 1e-3,
3892 "hover should drive card scale to 1.05 via animate; got {}",
3893 card_hot.scale,
3894 );
3895
3896 core.pointer_moved(Pointer::moving(0.0, 0.0));
3898 let cx_cold = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
3899 assert!(!cx_cold.is_hovering_within("card"));
3900 let mut tree = build_card(cx_cold.is_hovering_within("card"));
3901 crate::layout::layout(
3902 &mut tree,
3903 &mut core.ui_state,
3904 Rect::new(0.0, 0.0, 400.0, 200.0),
3905 );
3906 core.ui_state.sync_focus_order(&tree);
3907 core.snapshot(&tree, &mut t);
3908 core.ui_state
3909 .tick_visual_animations(&mut tree, web_time::Instant::now());
3910 let card_after = tree.children[0].clone();
3911 assert!((card_after.scale - 1.0).abs() < 1e-3);
3912 }
3913
3914 #[test]
3915 fn file_dropped_routes_to_keyed_leaf_at_pointer() {
3916 let mut core = lay_out_input_tree(false);
3917 let btn = core.rect_of_key("btn").expect("btn rect");
3918 let path = std::path::PathBuf::from("/tmp/screenshot.png");
3919 let events = core.file_dropped(path.clone(), btn.x + 4.0, btn.y + 4.0);
3920 assert_eq!(events.len(), 1);
3921 let event = &events[0];
3922 assert_eq!(event.kind, UiEventKind::FileDropped);
3923 assert_eq!(event.key.as_deref(), Some("btn"));
3924 assert_eq!(event.path.as_deref(), Some(path.as_path()));
3925 assert_eq!(event.pointer, Some((btn.x + 4.0, btn.y + 4.0)));
3926 }
3927
3928 #[test]
3929 fn file_dropped_outside_keyed_surface_emits_window_level_event() {
3930 let mut core = lay_out_input_tree(false);
3931 let path = std::path::PathBuf::from("/tmp/screenshot.png");
3933 let events = core.file_dropped(path.clone(), 1.0, 1.0);
3934 assert_eq!(events.len(), 1);
3935 let event = &events[0];
3936 assert_eq!(event.kind, UiEventKind::FileDropped);
3937 assert!(
3938 event.target.is_none(),
3939 "drop outside any keyed surface routes window-level",
3940 );
3941 assert!(event.key.is_none());
3942 assert_eq!(event.path.as_deref(), Some(path.as_path()));
3944 }
3945
3946 #[test]
3947 fn file_hovered_then_cancelled_pair() {
3948 let mut core = lay_out_input_tree(false);
3949 let btn = core.rect_of_key("btn").expect("btn rect");
3950 let path = std::path::PathBuf::from("/tmp/a.png");
3951
3952 let hover = core.file_hovered(path.clone(), btn.x + 4.0, btn.y + 4.0);
3953 assert_eq!(hover.len(), 1);
3954 assert_eq!(hover[0].kind, UiEventKind::FileHovered);
3955 assert_eq!(hover[0].key.as_deref(), Some("btn"));
3956 assert_eq!(hover[0].path.as_deref(), Some(path.as_path()));
3957
3958 let cancel = core.file_hover_cancelled();
3959 assert_eq!(cancel.len(), 1);
3960 assert_eq!(cancel[0].kind, UiEventKind::FileHoverCancelled);
3961 assert!(cancel[0].target.is_none());
3962 assert!(cancel[0].path.is_none());
3963 }
3964
3965 #[test]
3966 fn build_cx_hover_accessors_default_off_without_state() {
3967 use crate::Theme;
3968 let theme = Theme::default();
3969 let cx = crate::BuildCx::new(&theme);
3970 assert_eq!(cx.hovered_key(), None);
3971 assert!(!cx.is_hovering_within("anything"));
3972 }
3973
3974 #[test]
3975 fn build_cx_hover_accessors_delegate_when_state_attached() {
3976 use crate::Theme;
3977 let mut core = lay_out_input_tree(false);
3978 let btn = core.rect_of_key("btn").expect("btn rect");
3979 core.pointer_moved(Pointer::moving(btn.x + 4.0, btn.y + 4.0));
3980
3981 let theme = Theme::default();
3982 let cx = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
3983 assert_eq!(cx.hovered_key(), Some("btn"));
3984 assert!(cx.is_hovering_within("btn"));
3985 assert!(!cx.is_hovering_within("ti"));
3986 }
3987
3988 fn lay_out_paragraph_tree() -> RunnerCore {
3989 use crate::tree::*;
3990 let mut tree = crate::column([
3991 crate::widgets::text::text("First paragraph of text.")
3992 .key("p1")
3993 .selectable(),
3994 crate::widgets::text::text("Second paragraph of text.")
3995 .key("p2")
3996 .selectable(),
3997 ])
3998 .padding(20.0);
3999 let mut core = RunnerCore::new();
4000 crate::layout::layout(
4001 &mut tree,
4002 &mut core.ui_state,
4003 Rect::new(0.0, 0.0, 400.0, 300.0),
4004 );
4005 core.ui_state.sync_focus_order(&tree);
4006 core.ui_state.sync_selection_order(&tree);
4007 let mut t = PrepareTimings::default();
4008 core.snapshot(&tree, &mut t);
4009 core
4010 }
4011
4012 #[test]
4013 fn pointer_down_on_selectable_text_emits_selection_changed() {
4014 let mut core = lay_out_paragraph_tree();
4015 let p1 = core.rect_of_key("p1").expect("p1 rect");
4016 let cx = p1.x + 4.0;
4017 let cy = p1.y + p1.h * 0.5;
4018 let events = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4019 let sel_event = events
4020 .iter()
4021 .find(|e| e.kind == UiEventKind::SelectionChanged)
4022 .expect("SelectionChanged emitted");
4023 let new_sel = sel_event
4024 .selection
4025 .as_ref()
4026 .expect("SelectionChanged carries a selection");
4027 let range = new_sel.range.as_ref().expect("collapsed selection at hit");
4028 assert_eq!(range.anchor.key, "p1");
4029 assert_eq!(range.head.key, "p1");
4030 assert_eq!(range.anchor.byte, range.head.byte);
4031 assert!(core.ui_state.selection.drag.is_some());
4032 }
4033
4034 #[test]
4035 fn pointer_drag_on_selectable_text_extends_head() {
4036 let mut core = lay_out_paragraph_tree();
4037 let p1 = core.rect_of_key("p1").expect("p1 rect");
4038 let cx = p1.x + 4.0;
4039 let cy = p1.y + p1.h * 0.5;
4040 core.pointer_moved(Pointer::moving(cx, cy));
4041 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4042
4043 let events = core
4045 .pointer_moved(Pointer::moving(p1.x + p1.w - 10.0, cy))
4046 .events;
4047 let sel_event = events
4048 .iter()
4049 .find(|e| e.kind == UiEventKind::SelectionChanged)
4050 .expect("Drag emits SelectionChanged");
4051 let new_sel = sel_event.selection.as_ref().unwrap();
4052 let range = new_sel.range.as_ref().unwrap();
4053 assert_eq!(range.anchor.key, "p1");
4054 assert_eq!(range.head.key, "p1");
4055 assert!(
4056 range.head.byte > range.anchor.byte,
4057 "head should advance past anchor (anchor={}, head={})",
4058 range.anchor.byte,
4059 range.head.byte
4060 );
4061 }
4062
4063 #[test]
4064 fn double_click_hold_drag_inside_selectable_word_keeps_word_selected() {
4065 let mut core = lay_out_paragraph_tree();
4066 let p1 = core.rect_of_key("p1").expect("p1 rect");
4067 let cx = p1.x + 4.0;
4068 let cy = p1.y + p1.h * 0.5;
4069
4070 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4071 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
4072 let down = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4073 let sel = down
4074 .iter()
4075 .find(|e| e.kind == UiEventKind::SelectionChanged)
4076 .and_then(|e| e.selection.as_ref())
4077 .and_then(|s| s.range.as_ref())
4078 .expect("double-click selects word");
4079 assert_eq!(sel.anchor.byte, 0);
4080 assert_eq!(sel.head.byte, 5);
4081
4082 let events = core.pointer_moved(Pointer::moving(cx + 1.0, cy)).events;
4083 assert!(
4084 !events
4085 .iter()
4086 .any(|e| e.kind == UiEventKind::SelectionChanged),
4087 "drag jitter within the double-clicked word should not collapse the selection"
4088 );
4089 let range = core
4090 .ui_state
4091 .current_selection
4092 .range
4093 .as_ref()
4094 .expect("selection persists");
4095 assert_eq!(range.anchor.byte, 0);
4096 assert_eq!(range.head.byte, 5);
4097 }
4098
4099 #[test]
4100 fn pointer_up_clears_drag_but_keeps_selection() {
4101 let mut core = lay_out_paragraph_tree();
4102 let p1 = core.rect_of_key("p1").expect("p1 rect");
4103 let cx = p1.x + 4.0;
4104 let cy = p1.y + p1.h * 0.5;
4105 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4106 core.pointer_moved(Pointer::moving(p1.x + p1.w - 10.0, cy));
4107 let _ = core.pointer_up(Pointer::mouse(
4108 p1.x + p1.w - 10.0,
4109 cy,
4110 PointerButton::Primary,
4111 ));
4112 assert!(
4113 core.ui_state.selection.drag.is_none(),
4114 "drag flag should clear on pointer_up"
4115 );
4116 assert!(
4117 !core.ui_state.current_selection.is_empty(),
4118 "selection itself should persist after pointer_up"
4119 );
4120 }
4121
4122 #[test]
4123 fn drag_past_a_leaf_bottom_keeps_head_in_that_leaf_not_anchor() {
4124 let mut core = lay_out_paragraph_tree();
4130 let p1 = core.rect_of_key("p1").expect("p1 rect");
4131 let p2 = core.rect_of_key("p2").expect("p2 rect");
4132 core.pointer_down(Pointer::mouse(
4134 p1.x + 4.0,
4135 p1.y + p1.h * 0.5,
4136 PointerButton::Primary,
4137 ));
4138 core.pointer_moved(Pointer::moving(p2.x + 8.0, p2.y + p2.h * 0.5));
4140 let events = core
4143 .pointer_moved(Pointer::moving(p2.x + 8.0, p2.y + p2.h + 200.0))
4144 .events;
4145 let sel = events
4146 .iter()
4147 .find(|e| e.kind == UiEventKind::SelectionChanged)
4148 .map(|e| e.selection.as_ref().unwrap().clone())
4149 .unwrap_or_else(|| core.ui_state.current_selection.clone());
4152 let r = sel.range.as_ref().expect("selection still active");
4153 assert_eq!(r.anchor.key, "p1", "anchor unchanged");
4154 assert_eq!(
4155 r.head.key, "p2",
4156 "head must stay in p2 even when pointer is below p2's rect"
4157 );
4158 }
4159
4160 #[test]
4161 fn drag_into_a_sibling_selectable_extends_head_into_that_leaf() {
4162 let mut core = lay_out_paragraph_tree();
4163 let p1 = core.rect_of_key("p1").expect("p1 rect");
4164 let p2 = core.rect_of_key("p2").expect("p2 rect");
4165 core.pointer_down(Pointer::mouse(
4167 p1.x + 4.0,
4168 p1.y + p1.h * 0.5,
4169 PointerButton::Primary,
4170 ));
4171 let events = core
4173 .pointer_moved(Pointer::moving(p2.x + 8.0, p2.y + p2.h * 0.5))
4174 .events;
4175 let sel_event = events
4176 .iter()
4177 .find(|e| e.kind == UiEventKind::SelectionChanged)
4178 .expect("Drag emits SelectionChanged");
4179 let new_sel = sel_event.selection.as_ref().unwrap();
4180 let range = new_sel.range.as_ref().unwrap();
4181 assert_eq!(range.anchor.key, "p1", "anchor stays in p1");
4182 assert_eq!(range.head.key, "p2", "head migrates into p2");
4183 }
4184
4185 #[test]
4186 fn pointer_down_on_focusable_owning_selection_does_not_clear_it() {
4187 let mut core = lay_out_input_tree(true);
4195 core.set_selection(crate::selection::Selection::caret("ti", 3));
4198 let ti = core.rect_of_key("ti").expect("ti rect");
4199 let cx = ti.x + ti.w * 0.5;
4200 let cy = ti.y + ti.h * 0.5;
4201
4202 let events = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4203 let cleared = events.iter().find(|e| {
4204 e.kind == UiEventKind::SelectionChanged
4205 && e.selection.as_ref().map(|s| s.is_empty()).unwrap_or(false)
4206 });
4207 assert!(
4208 cleared.is_none(),
4209 "click on the selection-owning input must not emit a clearing SelectionChanged"
4210 );
4211 assert_eq!(
4212 core.ui_state.current_selection,
4213 crate::selection::Selection::caret("ti", 3),
4214 "runtime mirror is preserved when the click owns the selection"
4215 );
4216 }
4217
4218 #[test]
4219 fn pointer_down_into_a_different_capture_keys_widget_does_not_clear_first() {
4220 let mut core = lay_out_input_tree(true);
4230 core.set_selection(crate::selection::Selection::caret("other", 4));
4232 let ti = core.rect_of_key("ti").expect("ti rect");
4233 let cx = ti.x + ti.w * 0.5;
4234 let cy = ti.y + ti.h * 0.5;
4235
4236 let events = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4237 let cleared = events.iter().any(|e| {
4238 e.kind == UiEventKind::SelectionChanged
4239 && e.selection.as_ref().map(|s| s.is_empty()).unwrap_or(false)
4240 });
4241 assert!(
4242 !cleared,
4243 "click on a different capture_keys widget must not race-clear the selection"
4244 );
4245 }
4246
4247 #[test]
4248 fn pointer_down_on_non_selectable_clears_existing_selection() {
4249 let mut core = lay_out_paragraph_tree();
4250 let p1 = core.rect_of_key("p1").expect("p1 rect");
4251 let cy = p1.y + p1.h * 0.5;
4252 core.pointer_down(Pointer::mouse(p1.x + 4.0, cy, PointerButton::Primary));
4254 core.pointer_up(Pointer::mouse(p1.x + 4.0, cy, PointerButton::Primary));
4255 assert!(!core.ui_state.current_selection.is_empty());
4256
4257 let events = core.pointer_down(Pointer::mouse(2.0, 2.0, PointerButton::Primary));
4259 let cleared = events
4260 .iter()
4261 .find(|e| e.kind == UiEventKind::SelectionChanged)
4262 .expect("clearing emits SelectionChanged");
4263 let new_sel = cleared.selection.as_ref().unwrap();
4264 assert!(new_sel.is_empty(), "new selection should be empty");
4265 assert!(core.ui_state.current_selection.is_empty());
4266 }
4267
4268 #[test]
4269 fn pointer_down_in_dead_space_clears_focus() {
4270 let mut core = lay_out_input_tree(false);
4271 let btn = core.rect_of_key("btn").expect("btn rect");
4272 let cx = btn.x + btn.w * 0.5;
4273 let cy = btn.y + btn.h * 0.5;
4274 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4275 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
4276 assert_eq!(
4277 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4278 Some("btn")
4279 );
4280
4281 core.pointer_down(Pointer::mouse(2.0, 2.0, PointerButton::Primary));
4282
4283 assert_eq!(core.ui_state.focused.as_ref().map(|t| t.key.as_str()), None);
4284 }
4285
4286 #[test]
4287 fn key_down_bumps_caret_activity_when_focused_widget_captures_keys() {
4288 let mut core = lay_out_input_tree(true);
4293 let target = core
4294 .ui_state
4295 .focus
4296 .order
4297 .iter()
4298 .find(|t| t.key == "ti")
4299 .cloned();
4300 core.ui_state.set_focus(target); let after_focus = core.ui_state.caret.activity_at.expect("focus bump");
4302
4303 std::thread::sleep(std::time::Duration::from_millis(2));
4304 let _ = core.key_down(UiKey::ArrowRight, KeyModifiers::default(), false);
4305 let after_arrow = core
4306 .ui_state
4307 .caret
4308 .activity_at
4309 .expect("arrow key bumps even without app-side selection");
4310 assert!(
4311 after_arrow > after_focus,
4312 "ArrowRight to a capture_keys focused widget bumps caret activity"
4313 );
4314 }
4315
4316 #[test]
4317 fn text_input_bumps_caret_activity_when_focused() {
4318 let mut core = lay_out_input_tree(true);
4319 let target = core
4320 .ui_state
4321 .focus
4322 .order
4323 .iter()
4324 .find(|t| t.key == "ti")
4325 .cloned();
4326 core.ui_state.set_focus(target);
4327 let after_focus = core.ui_state.caret.activity_at.unwrap();
4328
4329 std::thread::sleep(std::time::Duration::from_millis(2));
4330 let _ = core.text_input("a".into());
4331 let after_text = core.ui_state.caret.activity_at.unwrap();
4332 assert!(
4333 after_text > after_focus,
4334 "TextInput to focused widget bumps caret activity"
4335 );
4336 }
4337
4338 #[test]
4339 fn pointer_down_inside_focused_input_bumps_caret_activity() {
4340 let mut core = lay_out_input_tree(true);
4345 let ti = core.rect_of_key("ti").expect("ti rect");
4346 let cx = ti.x + ti.w * 0.5;
4347 let cy = ti.y + ti.h * 0.5;
4348
4349 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4351 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
4352 let after_first = core.ui_state.caret.activity_at.unwrap();
4353
4354 std::thread::sleep(std::time::Duration::from_millis(2));
4357 core.pointer_down(Pointer::mouse(cx + 1.0, cy, PointerButton::Primary));
4358 let after_second = core
4359 .ui_state
4360 .caret
4361 .activity_at
4362 .expect("second click bumps too");
4363 assert!(
4364 after_second > after_first,
4365 "click within already-focused capture_keys widget still bumps"
4366 );
4367 }
4368
4369 #[test]
4370 fn arrow_key_through_apply_event_mutates_selection_and_bumps_on_set() {
4371 use crate::widgets::text_input;
4377 let mut sel = crate::selection::Selection::caret("ti", 2);
4378 let mut value = String::from("hello");
4379
4380 let mut core = RunnerCore::new();
4381 core.set_selection(sel.clone());
4384 let baseline = core.ui_state.caret.activity_at;
4385
4386 let arrow_right = UiEvent {
4388 key: Some("ti".into()),
4389 target: None,
4390 pointer: None,
4391 key_press: Some(crate::event::KeyPress {
4392 key: UiKey::ArrowRight,
4393 modifiers: KeyModifiers::default(),
4394 repeat: false,
4395 }),
4396 text: None,
4397 selection: None,
4398 modifiers: KeyModifiers::default(),
4399 click_count: 0,
4400 path: None,
4401 pointer_kind: None,
4402 kind: UiEventKind::KeyDown,
4403 };
4404
4405 let mutated = text_input::apply_event(&mut value, &mut sel, "ti", &arrow_right);
4407 assert!(mutated, "ArrowRight should mutate selection");
4408 assert_eq!(
4409 sel.within("ti").unwrap().head,
4410 3,
4411 "head moved one char right (h-e-l-l-o, byte 2 → 3)"
4412 );
4413
4414 std::thread::sleep(std::time::Duration::from_millis(2));
4416 core.set_selection(sel);
4417 let after = core.ui_state.caret.activity_at.unwrap();
4418 if let Some(b) = baseline {
4422 assert!(after > b, "arrow-key flow should bump activity");
4423 }
4424 }
4425
4426 #[test]
4427 fn set_selection_bumps_caret_activity_only_when_value_changes() {
4428 let mut core = lay_out_paragraph_tree();
4429 core.set_selection(crate::selection::Selection::default());
4432 assert!(
4433 core.ui_state.caret.activity_at.is_none(),
4434 "no-op set_selection should not bump activity"
4435 );
4436
4437 let sel_a = crate::selection::Selection::caret("p1", 3);
4439 core.set_selection(sel_a.clone());
4440 let bumped_at = core
4441 .ui_state
4442 .caret
4443 .activity_at
4444 .expect("first real selection bumps");
4445
4446 core.set_selection(sel_a.clone());
4449 assert_eq!(
4450 core.ui_state.caret.activity_at,
4451 Some(bumped_at),
4452 "set_selection with same value is a no-op"
4453 );
4454
4455 std::thread::sleep(std::time::Duration::from_millis(2));
4458 let sel_b = crate::selection::Selection::caret("p1", 7);
4459 core.set_selection(sel_b);
4460 let new_bump = core.ui_state.caret.activity_at.expect("second bump");
4461 assert!(
4462 new_bump > bumped_at,
4463 "moving the caret bumps activity again",
4464 );
4465 }
4466
4467 #[test]
4468 fn escape_clears_active_selection_and_emits_selection_changed() {
4469 let mut core = lay_out_paragraph_tree();
4470 let p1 = core.rect_of_key("p1").expect("p1 rect");
4471 let cy = p1.y + p1.h * 0.5;
4472 core.pointer_down(Pointer::mouse(p1.x + 4.0, cy, PointerButton::Primary));
4474 core.pointer_moved(Pointer::moving(p1.x + p1.w - 10.0, cy));
4475 core.pointer_up(Pointer::mouse(
4476 p1.x + p1.w - 10.0,
4477 cy,
4478 PointerButton::Primary,
4479 ));
4480 assert!(!core.ui_state.current_selection.is_empty());
4481
4482 let events = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
4483 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
4484 assert_eq!(
4485 kinds,
4486 vec![UiEventKind::Escape, UiEventKind::SelectionChanged],
4487 "Esc emits Escape (for popover dismiss) AND SelectionChanged"
4488 );
4489 let cleared = events
4490 .iter()
4491 .find(|e| e.kind == UiEventKind::SelectionChanged)
4492 .unwrap();
4493 assert!(cleared.selection.as_ref().unwrap().is_empty());
4494 assert!(core.ui_state.current_selection.is_empty());
4495 }
4496
4497 #[test]
4498 fn consecutive_clicks_on_same_target_extend_count() {
4499 let mut core = lay_out_input_tree(false);
4500 let btn = core.rect_of_key("btn").expect("btn rect");
4501 let cx = btn.x + btn.w * 0.5;
4502 let cy = btn.y + btn.h * 0.5;
4503
4504 let down1 = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4506 let pd1 = down1
4507 .iter()
4508 .find(|e| e.kind == UiEventKind::PointerDown)
4509 .expect("PointerDown emitted");
4510 assert_eq!(pd1.click_count, 1, "first press starts the sequence");
4511 let up1 = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
4512 let click1 = up1
4513 .iter()
4514 .find(|e| e.kind == UiEventKind::Click)
4515 .expect("Click emitted");
4516 assert_eq!(
4517 click1.click_count, 1,
4518 "Click carries the same count as its PointerDown"
4519 );
4520
4521 let down2 = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4523 let pd2 = down2
4524 .iter()
4525 .find(|e| e.kind == UiEventKind::PointerDown)
4526 .unwrap();
4527 assert_eq!(pd2.click_count, 2, "second press extends the sequence");
4528 let up2 = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
4529 assert_eq!(
4530 up2.iter()
4531 .find(|e| e.kind == UiEventKind::Click)
4532 .unwrap()
4533 .click_count,
4534 2
4535 );
4536
4537 let down3 = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4539 let pd3 = down3
4540 .iter()
4541 .find(|e| e.kind == UiEventKind::PointerDown)
4542 .unwrap();
4543 assert_eq!(pd3.click_count, 3, "third press → triple-click");
4544 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
4545 }
4546
4547 #[test]
4548 fn click_count_resets_when_target_changes() {
4549 let mut core = lay_out_input_tree(false);
4550 let btn = core.rect_of_key("btn").expect("btn rect");
4551 let ti = core.rect_of_key("ti").expect("ti rect");
4552
4553 let down1 = core.pointer_down(Pointer::mouse(
4555 btn.x + btn.w * 0.5,
4556 btn.y + btn.h * 0.5,
4557 PointerButton::Primary,
4558 ));
4559 assert_eq!(
4560 down1
4561 .iter()
4562 .find(|e| e.kind == UiEventKind::PointerDown)
4563 .unwrap()
4564 .click_count,
4565 1
4566 );
4567 let _ = core.pointer_up(Pointer::mouse(
4568 btn.x + btn.w * 0.5,
4569 btn.y + btn.h * 0.5,
4570 PointerButton::Primary,
4571 ));
4572
4573 let down2 = core.pointer_down(Pointer::mouse(
4575 ti.x + ti.w * 0.5,
4576 ti.y + ti.h * 0.5,
4577 PointerButton::Primary,
4578 ));
4579 let pd2 = down2
4580 .iter()
4581 .find(|e| e.kind == UiEventKind::PointerDown)
4582 .unwrap();
4583 assert_eq!(
4584 pd2.click_count, 1,
4585 "press on a new target resets the multi-click sequence"
4586 );
4587 }
4588
4589 #[test]
4590 fn double_click_on_selectable_text_selects_word_at_hit() {
4591 let mut core = lay_out_paragraph_tree();
4592 let p1 = core.rect_of_key("p1").expect("p1 rect");
4593 let cy = p1.y + p1.h * 0.5;
4594 let cx = p1.x + 4.0;
4597 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4598 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
4599 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4600 let sel = &core.ui_state.current_selection;
4602 let r = sel.range.as_ref().expect("selection set");
4603 assert_eq!(r.anchor.key, "p1");
4604 assert_eq!(r.head.key, "p1");
4605 assert_eq!(r.anchor.byte.min(r.head.byte), 0);
4607 assert_eq!(r.anchor.byte.max(r.head.byte), 5);
4608 }
4609
4610 #[test]
4611 fn triple_click_on_selectable_text_selects_whole_leaf() {
4612 let mut core = lay_out_paragraph_tree();
4613 let p1 = core.rect_of_key("p1").expect("p1 rect");
4614 let cy = p1.y + p1.h * 0.5;
4615 let cx = p1.x + 4.0;
4616 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4617 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
4618 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4619 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
4620 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4621 let sel = &core.ui_state.current_selection;
4622 let r = sel.range.as_ref().expect("selection set");
4623 assert_eq!(r.anchor.byte, 0);
4624 assert_eq!(r.head.byte, 24);
4626 }
4627
4628 #[test]
4629 fn click_count_resets_when_press_drifts_outside_distance_window() {
4630 let mut core = lay_out_input_tree(false);
4631 let btn = core.rect_of_key("btn").expect("btn rect");
4632 let cx = btn.x + btn.w * 0.5;
4633 let cy = btn.y + btn.h * 0.5;
4634
4635 let _ = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4636 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
4637
4638 let down2 = core.pointer_down(Pointer::mouse(cx + 10.0, cy, PointerButton::Primary));
4641 let pd2 = down2
4642 .iter()
4643 .find(|e| e.kind == UiEventKind::PointerDown)
4644 .unwrap();
4645 assert_eq!(pd2.click_count, 1);
4646 }
4647
4648 #[test]
4649 fn escape_with_no_selection_emits_only_escape() {
4650 let mut core = lay_out_paragraph_tree();
4651 assert!(core.ui_state.current_selection.is_empty());
4652 let events = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
4653 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
4654 assert_eq!(
4655 kinds,
4656 vec![UiEventKind::Escape],
4657 "no selection → no SelectionChanged side-effect"
4658 );
4659 }
4660
4661 fn lay_out_scroll_tree() -> (RunnerCore, String) {
4664 use crate::tree::*;
4665 let mut tree = crate::scroll(
4666 (0..6)
4667 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
4668 )
4669 .gap(12.0)
4670 .height(Size::Fixed(200.0));
4671 let mut core = RunnerCore::new();
4672 crate::layout::layout(
4673 &mut tree,
4674 &mut core.ui_state,
4675 Rect::new(0.0, 0.0, 300.0, 200.0),
4676 );
4677 let scroll_id = tree.computed_id.clone();
4678 let mut t = PrepareTimings::default();
4679 core.snapshot(&tree, &mut t);
4680 (core, scroll_id)
4681 }
4682
4683 #[test]
4684 fn thumb_pointer_down_captures_drag_and_suppresses_events() {
4685 let (mut core, scroll_id) = lay_out_scroll_tree();
4686 let thumb = core
4687 .ui_state
4688 .scroll
4689 .thumb_rects
4690 .get(&scroll_id)
4691 .copied()
4692 .expect("scrollable should have a thumb");
4693 let event = core.pointer_down(Pointer::mouse(
4694 thumb.x + thumb.w * 0.5,
4695 thumb.y + thumb.h * 0.5,
4696 PointerButton::Primary,
4697 ));
4698 assert!(
4699 event.is_empty(),
4700 "thumb press should not emit PointerDown to the app"
4701 );
4702 let drag = core
4703 .ui_state
4704 .scroll
4705 .thumb_drag
4706 .as_ref()
4707 .expect("scroll.thumb_drag should be set after pointer_down on thumb");
4708 assert_eq!(drag.scroll_id, scroll_id);
4709 }
4710
4711 #[test]
4712 fn track_click_above_thumb_pages_up_below_pages_down() {
4713 let (mut core, scroll_id) = lay_out_scroll_tree();
4714 let track = core
4715 .ui_state
4716 .scroll
4717 .thumb_tracks
4718 .get(&scroll_id)
4719 .copied()
4720 .expect("scrollable should have a track");
4721 let thumb = core
4722 .ui_state
4723 .scroll
4724 .thumb_rects
4725 .get(&scroll_id)
4726 .copied()
4727 .unwrap();
4728 let metrics = core
4729 .ui_state
4730 .scroll
4731 .metrics
4732 .get(&scroll_id)
4733 .copied()
4734 .unwrap();
4735
4736 let evt = core.pointer_down(Pointer::mouse(
4738 track.x + track.w * 0.5,
4739 thumb.y + thumb.h + 10.0,
4740 PointerButton::Primary,
4741 ));
4742 assert!(evt.is_empty(), "track press should not surface PointerDown");
4743 assert!(
4744 core.ui_state.scroll.thumb_drag.is_none(),
4745 "track click outside the thumb should not start a drag",
4746 );
4747 let after_down = core.ui_state.scroll_offset(&scroll_id);
4748 let expected_page = (metrics.viewport_h - SCROLL_PAGE_OVERLAP).max(0.0);
4749 assert!(
4750 (after_down - expected_page.min(metrics.max_offset)).abs() < 0.5,
4751 "page-down offset = {after_down} (expected ~{expected_page})",
4752 );
4753 let _ = core.pointer_up(Pointer::mouse(0.0, 0.0, PointerButton::Primary));
4755
4756 let mut tree = lay_out_scroll_tree_only();
4759 crate::layout::layout(
4760 &mut tree,
4761 &mut core.ui_state,
4762 Rect::new(0.0, 0.0, 300.0, 200.0),
4763 );
4764 let mut t = PrepareTimings::default();
4765 core.snapshot(&tree, &mut t);
4766 let track = core
4767 .ui_state
4768 .scroll
4769 .thumb_tracks
4770 .get(&tree.computed_id)
4771 .copied()
4772 .unwrap();
4773 let thumb = core
4774 .ui_state
4775 .scroll
4776 .thumb_rects
4777 .get(&tree.computed_id)
4778 .copied()
4779 .unwrap();
4780
4781 core.pointer_down(Pointer::mouse(
4782 track.x + track.w * 0.5,
4783 thumb.y - 4.0,
4784 PointerButton::Primary,
4785 ));
4786 let after_up = core.ui_state.scroll_offset(&tree.computed_id);
4787 assert!(
4788 after_up < after_down,
4789 "page-up should reduce offset: before={after_down} after={after_up}",
4790 );
4791 }
4792
4793 fn lay_out_scroll_tree_only() -> El {
4798 use crate::tree::*;
4799 crate::scroll(
4800 (0..6)
4801 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
4802 )
4803 .gap(12.0)
4804 .height(Size::Fixed(200.0))
4805 }
4806
4807 #[test]
4808 fn thumb_drag_translates_pointer_delta_into_scroll_offset() {
4809 let (mut core, scroll_id) = lay_out_scroll_tree();
4810 let thumb = core
4811 .ui_state
4812 .scroll
4813 .thumb_rects
4814 .get(&scroll_id)
4815 .copied()
4816 .unwrap();
4817 let metrics = core
4818 .ui_state
4819 .scroll
4820 .metrics
4821 .get(&scroll_id)
4822 .copied()
4823 .unwrap();
4824 let track_remaining = (metrics.viewport_h - thumb.h).max(0.0);
4825
4826 let press_y = thumb.y + thumb.h * 0.5;
4827 core.pointer_down(Pointer::mouse(
4828 thumb.x + thumb.w * 0.5,
4829 press_y,
4830 PointerButton::Primary,
4831 ));
4832 let evt = core.pointer_moved(Pointer::moving(thumb.x + thumb.w * 0.5, press_y + 20.0));
4834 assert!(
4835 evt.events.is_empty(),
4836 "thumb-drag move should suppress Drag event",
4837 );
4838 let offset = core.ui_state.scroll_offset(&scroll_id);
4839 let expected = 20.0 * (metrics.max_offset / track_remaining);
4840 assert!(
4841 (offset - expected).abs() < 0.5,
4842 "offset {offset} (expected {expected})",
4843 );
4844 core.pointer_moved(Pointer::moving(thumb.x + thumb.w * 0.5, press_y + 9999.0));
4846 let offset = core.ui_state.scroll_offset(&scroll_id);
4847 assert!(
4848 (offset - metrics.max_offset).abs() < 0.5,
4849 "overshoot offset {offset} (expected {})",
4850 metrics.max_offset
4851 );
4852 let events = core.pointer_up(Pointer::mouse(thumb.x, press_y, PointerButton::Primary));
4854 assert!(events.is_empty(), "thumb release shouldn't emit events");
4855 assert!(core.ui_state.scroll.thumb_drag.is_none());
4856 }
4857
4858 #[test]
4859 fn secondary_click_does_not_steal_focus_or_press() {
4860 let mut core = lay_out_input_tree(false);
4861 let btn_rect = core.rect_of_key("btn").expect("btn rect");
4862 let cx = btn_rect.x + btn_rect.w * 0.5;
4863 let cy = btn_rect.y + btn_rect.h * 0.5;
4864 let ti_rect = core.rect_of_key("ti").expect("ti rect");
4866 let tx = ti_rect.x + ti_rect.w * 0.5;
4867 let ty = ti_rect.y + ti_rect.h * 0.5;
4868 core.pointer_down(Pointer::mouse(tx, ty, PointerButton::Primary));
4869 let _ = core.pointer_up(Pointer::mouse(tx, ty, PointerButton::Primary));
4870 let focused_before = core.ui_state.focused.as_ref().map(|t| t.key.clone());
4871 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Secondary));
4873 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Secondary));
4874 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
4875 assert_eq!(kinds, vec![UiEventKind::SecondaryClick]);
4876 let focused_after = core.ui_state.focused.as_ref().map(|t| t.key.clone());
4877 assert_eq!(
4878 focused_before, focused_after,
4879 "right-click must not steal focus"
4880 );
4881 assert!(
4882 core.ui_state.pressed.is_none(),
4883 "right-click must not set primary press"
4884 );
4885 }
4886
4887 #[test]
4888 fn text_input_routes_to_focused_only() {
4889 let mut core = lay_out_input_tree(false);
4890 assert!(core.text_input("a".into()).is_none());
4892 let btn_rect = core.rect_of_key("btn").expect("btn rect");
4894 let cx = btn_rect.x + btn_rect.w * 0.5;
4895 let cy = btn_rect.y + btn_rect.h * 0.5;
4896 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4897 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
4898 let event = core.text_input("hi".into()).expect("focused → event");
4899 assert_eq!(event.kind, UiEventKind::TextInput);
4900 assert_eq!(event.text.as_deref(), Some("hi"));
4901 assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("btn"));
4902 assert!(core.text_input(String::new()).is_none());
4904 }
4905
4906 #[test]
4907 fn capture_keys_bypasses_tab_traversal_for_focused_node() {
4908 let mut core = lay_out_input_tree(true);
4911 let ti_rect = core.rect_of_key("ti").expect("ti rect");
4912 let tx = ti_rect.x + ti_rect.w * 0.5;
4913 let ty = ti_rect.y + ti_rect.h * 0.5;
4914 core.pointer_down(Pointer::mouse(tx, ty, PointerButton::Primary));
4915 let _ = core.pointer_up(Pointer::mouse(tx, ty, PointerButton::Primary));
4916 assert_eq!(
4917 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4918 Some("ti"),
4919 "primary click on capture_keys node still focuses it"
4920 );
4921
4922 let events = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
4923 assert_eq!(events.len(), 1, "Tab → exactly one KeyDown");
4924 let event = &events[0];
4925 assert_eq!(event.kind, UiEventKind::KeyDown);
4926 assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("ti"));
4927 assert_eq!(
4928 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4929 Some("ti"),
4930 "Tab inside capture_keys must NOT move focus"
4931 );
4932 }
4933
4934 #[test]
4935 fn escape_blurs_capture_keys_after_delivering_raw_keydown() {
4936 let mut core = lay_out_input_tree(true);
4937 let ti_rect = core.rect_of_key("ti").expect("ti rect");
4938 let tx = ti_rect.x + ti_rect.w * 0.5;
4939 let ty = ti_rect.y + ti_rect.h * 0.5;
4940 core.pointer_down(Pointer::mouse(tx, ty, PointerButton::Primary));
4941 let _ = core.pointer_up(Pointer::mouse(tx, ty, PointerButton::Primary));
4942 assert_eq!(
4943 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4944 Some("ti")
4945 );
4946
4947 let events = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
4948
4949 assert_eq!(events.len(), 1);
4950 let event = &events[0];
4951 assert_eq!(event.kind, UiEventKind::KeyDown);
4952 assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("ti"));
4953 assert!(matches!(
4954 event.key_press.as_ref().map(|p| &p.key),
4955 Some(UiKey::Escape)
4956 ));
4957 assert_eq!(core.ui_state.focused.as_ref().map(|t| t.key.as_str()), None);
4958 }
4959
4960 #[test]
4961 fn pointer_down_focus_does_not_raise_focus_visible() {
4962 let mut core = lay_out_input_tree(false);
4965 let btn_rect = core.rect_of_key("btn").expect("btn rect");
4966 let cx = btn_rect.x + btn_rect.w * 0.5;
4967 let cy = btn_rect.y + btn_rect.h * 0.5;
4968 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4969 assert_eq!(
4970 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4971 Some("btn"),
4972 "primary click focuses the button",
4973 );
4974 assert!(
4975 !core.ui_state.focus_visible,
4976 "click focus must not raise focus_visible — ring stays off",
4977 );
4978 }
4979
4980 #[test]
4981 fn tab_key_raises_focus_visible_so_ring_appears() {
4982 let mut core = lay_out_input_tree(false);
4983 let btn_rect = core.rect_of_key("btn").expect("btn rect");
4985 let cx = btn_rect.x + btn_rect.w * 0.5;
4986 let cy = btn_rect.y + btn_rect.h * 0.5;
4987 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4988 assert!(!core.ui_state.focus_visible);
4989 let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
4991 assert!(
4992 core.ui_state.focus_visible,
4993 "Tab must raise focus_visible so the ring paints on the new target",
4994 );
4995 }
4996
4997 #[test]
4998 fn click_after_tab_clears_focus_visible_again() {
4999 let mut core = lay_out_input_tree(false);
5002 let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
5003 assert!(core.ui_state.focus_visible, "Tab raises ring");
5004 let btn_rect = core.rect_of_key("btn").expect("btn rect");
5005 let cx = btn_rect.x + btn_rect.w * 0.5;
5006 let cy = btn_rect.y + btn_rect.h * 0.5;
5007 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5008 assert!(
5009 !core.ui_state.focus_visible,
5010 "pointer-down clears focus_visible — ring fades back out",
5011 );
5012 }
5013
5014 #[test]
5015 fn keypress_on_focused_widget_raises_focus_visible_after_click() {
5016 let mut core = lay_out_input_tree(false);
5020 let btn_rect = core.rect_of_key("btn").expect("btn rect");
5021 let cx = btn_rect.x + btn_rect.w * 0.5;
5022 let cy = btn_rect.y + btn_rect.h * 0.5;
5023 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5024 assert!(!core.ui_state.focus_visible);
5025 let _ = core.key_down(UiKey::ArrowRight, KeyModifiers::default(), false);
5026 assert!(
5027 core.ui_state.focus_visible,
5028 "non-Tab key on focused widget raises focus_visible",
5029 );
5030 }
5031
5032 #[test]
5033 fn selected_text_resolves_a_selection_inside_a_virtual_list() {
5034 use crate::selection::{Selection, SelectionPoint, SelectionRange};
5041 use crate::tree::*;
5042
5043 let mut tree = virtual_list_dyn(
5047 20,
5048 50.0,
5049 |i| format!("row-{i}"),
5050 |i| {
5051 crate::widgets::text::text(format!("row {i} text"))
5052 .key(format!("row-{i}"))
5053 .selectable()
5054 .height(Size::Fixed(50.0))
5055 },
5056 );
5057 let mut core = RunnerCore::new();
5058 crate::layout::layout(
5059 &mut tree,
5060 &mut core.ui_state,
5061 Rect::new(0.0, 0.0, 200.0, 200.0),
5062 );
5063 let mut t = PrepareTimings::default();
5064 core.snapshot(&tree, &mut t);
5065
5066 let selection = Selection {
5068 range: Some(SelectionRange {
5069 anchor: SelectionPoint::new("row-1", 0),
5070 head: SelectionPoint::new("row-1", 9),
5071 }),
5072 };
5073 core.set_selection(selection);
5074
5075 assert_eq!(
5076 core.selected_text().as_deref(),
5077 Some("row 1 tex"),
5078 "runtime.selected_text() must walk last_tree (realized rows) — \
5079 a build-only path would miss virtual_list children entirely",
5080 );
5081 }
5082
5083 #[test]
5084 fn shortcut_chord_does_not_raise_focus_visible() {
5085 let mut core = lay_out_input_tree(false);
5092 let btn_rect = core.rect_of_key("btn").expect("btn rect");
5093 let cx = btn_rect.x + btn_rect.w * 0.5;
5094 let cy = btn_rect.y + btn_rect.h * 0.5;
5095 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5096 assert!(!core.ui_state.focus_visible);
5097
5098 let ctrl = KeyModifiers {
5099 ctrl: true,
5100 ..Default::default()
5101 };
5102 let _ = core.key_down(UiKey::Other("Control".into()), ctrl, false);
5103 assert!(
5104 !core.ui_state.focus_visible,
5105 "bare Ctrl press must not raise focus_visible on a pointer-focused widget",
5106 );
5107 let _ = core.key_down(UiKey::Character("c".into()), ctrl, false);
5108 assert!(
5109 !core.ui_state.focus_visible,
5110 "Ctrl+C is a shortcut, not interaction with the focused widget",
5111 );
5112
5113 let _ = core.key_down(UiKey::Other("Shift".into()), KeyModifiers::default(), false);
5114 assert!(
5115 !core.ui_state.focus_visible,
5116 "bare Shift press must not raise focus_visible",
5117 );
5118 let _ = core.key_down(UiKey::Character("a".into()), KeyModifiers::default(), false);
5119 assert!(
5120 !core.ui_state.focus_visible,
5121 "bare character keys are typing/activation guesses, not navigation",
5122 );
5123 let _ = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
5124 assert!(
5125 !core.ui_state.focus_visible,
5126 "Escape is dismissal, not navigation — no ring",
5127 );
5128 }
5129
5130 #[test]
5131 fn arrow_nav_in_sibling_group_raises_focus_visible() {
5132 let mut core = lay_out_arrow_nav_tree();
5133 core.ui_state.set_focus_visible(false);
5136 let _ = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
5137 assert!(
5138 core.ui_state.focus_visible,
5139 "arrow-nav within an arrow_nav_siblings group is keyboard navigation",
5140 );
5141 }
5142
5143 #[test]
5144 fn capture_keys_falls_back_to_default_when_focus_off_capturing_node() {
5145 let mut core = lay_out_input_tree(true);
5149 let btn_rect = core.rect_of_key("btn").expect("btn rect");
5150 let cx = btn_rect.x + btn_rect.w * 0.5;
5151 let cy = btn_rect.y + btn_rect.h * 0.5;
5152 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5153 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5154 assert_eq!(
5155 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5156 Some("btn"),
5157 "primary click focuses button"
5158 );
5159 let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
5161 assert_eq!(
5162 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5163 Some("ti"),
5164 "Tab from non-capturing focused does library-default traversal"
5165 );
5166 }
5167
5168 fn lay_out_arrow_nav_tree() -> RunnerCore {
5173 use crate::tree::*;
5174 let mut tree = crate::column([
5175 crate::widgets::button::button("Red").key("opt-red"),
5176 crate::widgets::button::button("Green").key("opt-green"),
5177 crate::widgets::button::button("Blue").key("opt-blue"),
5178 ])
5179 .arrow_nav_siblings()
5180 .padding(10.0);
5181 let mut core = RunnerCore::new();
5182 crate::layout::layout(
5183 &mut tree,
5184 &mut core.ui_state,
5185 Rect::new(0.0, 0.0, 200.0, 300.0),
5186 );
5187 core.ui_state.sync_focus_order(&tree);
5188 let mut t = PrepareTimings::default();
5189 core.snapshot(&tree, &mut t);
5190 let target = core
5193 .ui_state
5194 .focus
5195 .order
5196 .iter()
5197 .find(|t| t.key == "opt-green")
5198 .cloned();
5199 core.ui_state.set_focus(target);
5200 core
5201 }
5202
5203 #[test]
5204 fn arrow_nav_moves_focus_among_siblings() {
5205 let mut core = lay_out_arrow_nav_tree();
5206
5207 let down = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
5210 assert!(down.is_empty(), "arrow-nav consumes the key event");
5211 assert_eq!(
5212 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5213 Some("opt-blue"),
5214 );
5215
5216 core.key_down(UiKey::ArrowUp, KeyModifiers::default(), false);
5218 assert_eq!(
5219 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5220 Some("opt-green"),
5221 );
5222
5223 core.key_down(UiKey::Home, KeyModifiers::default(), false);
5225 assert_eq!(
5226 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5227 Some("opt-red"),
5228 );
5229
5230 core.key_down(UiKey::End, KeyModifiers::default(), false);
5232 assert_eq!(
5233 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5234 Some("opt-blue"),
5235 );
5236 }
5237
5238 #[test]
5239 fn arrow_nav_saturates_at_ends() {
5240 let mut core = lay_out_arrow_nav_tree();
5241 core.key_down(UiKey::Home, KeyModifiers::default(), false);
5243 core.key_down(UiKey::ArrowUp, KeyModifiers::default(), false);
5244 assert_eq!(
5245 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5246 Some("opt-red"),
5247 "ArrowUp at top stays at top — no wrap",
5248 );
5249 core.key_down(UiKey::End, KeyModifiers::default(), false);
5251 core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
5252 assert_eq!(
5253 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5254 Some("opt-blue"),
5255 "ArrowDown at bottom stays at bottom — no wrap",
5256 );
5257 }
5258
5259 fn build_popover_tree(open: bool) -> El {
5263 use crate::widgets::button::button;
5264 use crate::widgets::overlay::overlay;
5265 use crate::widgets::popover::{dropdown, menu_item};
5266 let mut layers: Vec<El> = vec![button("Trigger").key("trigger")];
5267 if open {
5268 layers.push(dropdown(
5269 "menu",
5270 "trigger",
5271 [
5272 menu_item("A").key("item-a"),
5273 menu_item("B").key("item-b"),
5274 menu_item("C").key("item-c"),
5275 ],
5276 ));
5277 }
5278 overlay(layers).padding(20.0)
5279 }
5280
5281 fn run_frame(core: &mut RunnerCore, tree: &mut El) {
5285 let mut t = PrepareTimings::default();
5286 core.prepare_layout(
5287 tree,
5288 Rect::new(0.0, 0.0, 400.0, 300.0),
5289 1.0,
5290 &mut t,
5291 RunnerCore::no_time_shaders,
5292 );
5293 core.snapshot(tree, &mut t);
5294 }
5295
5296 #[test]
5297 fn popover_open_pushes_focus_and_auto_focuses_first_item() {
5298 let mut core = RunnerCore::new();
5299 let mut closed = build_popover_tree(false);
5300 run_frame(&mut core, &mut closed);
5301 let trigger = core
5304 .ui_state
5305 .focus
5306 .order
5307 .iter()
5308 .find(|t| t.key == "trigger")
5309 .cloned();
5310 core.ui_state.set_focus(trigger);
5311 assert_eq!(
5312 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5313 Some("trigger"),
5314 );
5315
5316 let mut open = build_popover_tree(true);
5319 run_frame(&mut core, &mut open);
5320 assert_eq!(
5321 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5322 Some("item-a"),
5323 "popover open should auto-focus the first menu item",
5324 );
5325 assert_eq!(
5326 core.ui_state.popover_focus.focus_stack.len(),
5327 1,
5328 "trigger should be saved on the focus stack",
5329 );
5330 assert_eq!(
5331 core.ui_state.popover_focus.focus_stack[0].key.as_str(),
5332 "trigger",
5333 "saved focus should be the pre-open target",
5334 );
5335 }
5336
5337 #[test]
5338 fn popover_close_restores_focus_to_trigger() {
5339 let mut core = RunnerCore::new();
5340 let mut closed = build_popover_tree(false);
5341 run_frame(&mut core, &mut closed);
5342 let trigger = core
5343 .ui_state
5344 .focus
5345 .order
5346 .iter()
5347 .find(|t| t.key == "trigger")
5348 .cloned();
5349 core.ui_state.set_focus(trigger);
5350
5351 let mut open = build_popover_tree(true);
5353 run_frame(&mut core, &mut open);
5354 assert_eq!(
5355 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5356 Some("item-a"),
5357 );
5358
5359 let mut closed_again = build_popover_tree(false);
5361 run_frame(&mut core, &mut closed_again);
5362 assert_eq!(
5363 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5364 Some("trigger"),
5365 "closing the popover should pop the saved focus",
5366 );
5367 assert!(
5368 core.ui_state.popover_focus.focus_stack.is_empty(),
5369 "focus stack should be drained after restore",
5370 );
5371 }
5372
5373 #[test]
5374 fn popover_close_does_not_override_intentional_focus_move() {
5375 let mut core = RunnerCore::new();
5376 let build = |open: bool| -> El {
5379 use crate::widgets::button::button;
5380 use crate::widgets::overlay::overlay;
5381 use crate::widgets::popover::{dropdown, menu_item};
5382 let main = crate::row([
5383 button("Trigger").key("trigger"),
5384 button("Other").key("other"),
5385 ]);
5386 let mut layers: Vec<El> = vec![main];
5387 if open {
5388 layers.push(dropdown("menu", "trigger", [menu_item("A").key("item-a")]));
5389 }
5390 overlay(layers).padding(20.0)
5391 };
5392
5393 let mut closed = build(false);
5394 run_frame(&mut core, &mut closed);
5395 let trigger = core
5396 .ui_state
5397 .focus
5398 .order
5399 .iter()
5400 .find(|t| t.key == "trigger")
5401 .cloned();
5402 core.ui_state.set_focus(trigger);
5403
5404 let mut open = build(true);
5405 run_frame(&mut core, &mut open);
5406 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
5407
5408 let other = core
5413 .ui_state
5414 .focus
5415 .order
5416 .iter()
5417 .find(|t| t.key == "other")
5418 .cloned();
5419 core.ui_state.set_focus(other);
5420
5421 let mut closed_again = build(false);
5422 run_frame(&mut core, &mut closed_again);
5423 assert_eq!(
5424 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5425 Some("other"),
5426 "focus moved before close should not be overridden by restore",
5427 );
5428 assert!(core.ui_state.popover_focus.focus_stack.is_empty());
5429 }
5430
5431 #[test]
5432 fn nested_popovers_stack_and_unwind_focus_correctly() {
5433 let mut core = RunnerCore::new();
5434 let build = |outer: bool, inner: bool| -> El {
5439 use crate::widgets::button::button;
5440 use crate::widgets::overlay::overlay;
5441 use crate::widgets::popover::{Anchor, popover, popover_panel};
5442 let main = button("Trigger").key("trigger");
5443 let mut layers: Vec<El> = vec![main];
5444 if outer {
5445 layers.push(popover(
5446 "outer",
5447 Anchor::below_key("trigger"),
5448 popover_panel([button("Open inner").key("inner-trigger")]),
5449 ));
5450 }
5451 if inner {
5452 layers.push(popover(
5453 "inner",
5454 Anchor::below_key("inner-trigger"),
5455 popover_panel([button("X").key("inner-a"), button("Y").key("inner-b")]),
5456 ));
5457 }
5458 overlay(layers).padding(20.0)
5459 };
5460
5461 let mut closed = build(false, false);
5463 run_frame(&mut core, &mut closed);
5464 let trigger = core
5465 .ui_state
5466 .focus
5467 .order
5468 .iter()
5469 .find(|t| t.key == "trigger")
5470 .cloned();
5471 core.ui_state.set_focus(trigger);
5472
5473 let mut outer = build(true, false);
5475 run_frame(&mut core, &mut outer);
5476 assert_eq!(
5477 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5478 Some("inner-trigger"),
5479 );
5480 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
5481
5482 let mut both = build(true, true);
5484 run_frame(&mut core, &mut both);
5485 assert_eq!(
5486 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5487 Some("inner-a"),
5488 );
5489 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 2);
5490
5491 let mut outer_only = build(true, false);
5493 run_frame(&mut core, &mut outer_only);
5494 assert_eq!(
5495 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5496 Some("inner-trigger"),
5497 );
5498 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
5499
5500 let mut none = build(false, false);
5502 run_frame(&mut core, &mut none);
5503 assert_eq!(
5504 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5505 Some("trigger"),
5506 );
5507 assert!(core.ui_state.popover_focus.focus_stack.is_empty());
5508 }
5509
5510 #[test]
5511 fn arrow_nav_does_not_intercept_outside_navigable_groups() {
5512 let mut core = lay_out_input_tree(false);
5516 let target = core
5517 .ui_state
5518 .focus
5519 .order
5520 .iter()
5521 .find(|t| t.key == "btn")
5522 .cloned();
5523 core.ui_state.set_focus(target);
5524 let events = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
5525 assert_eq!(
5526 events.len(),
5527 1,
5528 "ArrowDown without navigable parent → event"
5529 );
5530 assert_eq!(events[0].kind, UiEventKind::KeyDown);
5531 }
5532
5533 fn quad(shader: ShaderHandle) -> DrawOp {
5534 DrawOp::Quad {
5535 id: "q".into(),
5536 rect: Rect::new(0.0, 0.0, 10.0, 10.0),
5537 scissor: None,
5538 shader,
5539 uniforms: UniformBlock::new(),
5540 }
5541 }
5542
5543 #[test]
5544 fn prepare_paint_skips_ops_outside_viewport() {
5545 let mut core = RunnerCore::new();
5546 core.set_surface_size(100, 100);
5547 core.viewport_px = (100, 100);
5548 let ops = vec![
5549 DrawOp::Quad {
5550 id: "offscreen".into(),
5551 rect: Rect::new(0.0, 150.0, 10.0, 10.0),
5552 scissor: None,
5553 shader: ShaderHandle::Stock(StockShader::RoundedRect),
5554 uniforms: UniformBlock::new(),
5555 },
5556 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
5557 ];
5558 let mut timings = PrepareTimings::default();
5559 core.prepare_paint(&ops, |_| true, |_| false, &mut NoText, 1.0, &mut timings);
5560
5561 assert_eq!(timings.paint_culled_ops, 1);
5562 assert_eq!(
5563 core.runs.len(),
5564 1,
5565 "only the visible quad should become a paint run"
5566 );
5567 }
5568
5569 #[test]
5570 fn prepare_paint_does_not_shape_text_outside_clip() {
5571 let mut core = RunnerCore::new();
5572 core.set_surface_size(100, 100);
5573 core.viewport_px = (100, 100);
5574 let ops = vec![
5575 DrawOp::GlyphRun {
5576 id: "offscreen-text".into(),
5577 rect: Rect::new(0.0, 150.0, 80.0, 20.0),
5578 scissor: Some(Rect::new(0.0, 0.0, 100.0, 100.0)),
5579 shader: ShaderHandle::Stock(StockShader::Text),
5580 color: Color::rgba(255, 255, 255, 255),
5581 text: "offscreen".into(),
5582 size: 14.0,
5583 line_height: 20.0,
5584 family: Default::default(),
5585 mono_family: Default::default(),
5586 weight: FontWeight::Regular,
5587 mono: false,
5588 wrap: TextWrap::NoWrap,
5589 anchor: TextAnchor::Start,
5590 layout: empty_text_layout(20.0),
5591 underline: false,
5592 strikethrough: false,
5593 link: None,
5594 },
5595 DrawOp::GlyphRun {
5596 id: "visible-text".into(),
5597 rect: Rect::new(0.0, 10.0, 80.0, 20.0),
5598 scissor: Some(Rect::new(0.0, 0.0, 100.0, 100.0)),
5599 shader: ShaderHandle::Stock(StockShader::Text),
5600 color: Color::rgba(255, 255, 255, 255),
5601 text: "visible".into(),
5602 size: 14.0,
5603 line_height: 20.0,
5604 family: Default::default(),
5605 mono_family: Default::default(),
5606 weight: FontWeight::Regular,
5607 mono: false,
5608 wrap: TextWrap::NoWrap,
5609 anchor: TextAnchor::Start,
5610 layout: empty_text_layout(20.0),
5611 underline: false,
5612 strikethrough: false,
5613 link: None,
5614 },
5615 ];
5616 let mut text = CountingText::default();
5617 let mut timings = PrepareTimings::default();
5618 core.prepare_paint(&ops, |_| true, |_| false, &mut text, 1.0, &mut timings);
5619
5620 assert_eq!(timings.paint_culled_ops, 1);
5621 assert_eq!(text.records, 1, "offscreen text must not be shaped");
5622 }
5623
5624 #[test]
5625 fn samples_backdrop_inserts_snapshot_before_first_glass_quad() {
5626 let mut core = RunnerCore::new();
5627 core.set_surface_size(100, 100);
5628 let ops = vec![
5629 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
5630 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
5631 quad(ShaderHandle::Custom("liquid_glass")),
5632 quad(ShaderHandle::Custom("liquid_glass")),
5633 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
5634 ];
5635 let mut timings = PrepareTimings::default();
5636 core.prepare_paint(
5637 &ops,
5638 |_| true,
5639 |s| matches!(s, ShaderHandle::Custom(name) if *name == "liquid_glass"),
5640 &mut NoText,
5641 1.0,
5642 &mut timings,
5643 );
5644
5645 let kinds: Vec<&'static str> = core
5646 .paint_items
5647 .iter()
5648 .map(|p| match p {
5649 PaintItem::QuadRun(_) => "Q",
5650 PaintItem::IconRun(_) => "I",
5651 PaintItem::Text(_) => "T",
5652 PaintItem::Image(_) => "M",
5653 PaintItem::AppTexture(_) => "A",
5654 PaintItem::Vector(_) => "V",
5655 PaintItem::BackdropSnapshot => "S",
5656 })
5657 .collect();
5658 assert_eq!(
5659 kinds,
5660 vec!["Q", "S", "Q", "Q"],
5661 "expected one stock run, snapshot, then a glass run, then a foreground stock run"
5662 );
5663 }
5664
5665 #[test]
5666 fn no_snapshot_when_no_glass_drawn() {
5667 let mut core = RunnerCore::new();
5668 core.set_surface_size(100, 100);
5669 let ops = vec![
5670 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
5671 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
5672 ];
5673 let mut timings = PrepareTimings::default();
5674 core.prepare_paint(&ops, |_| true, |_| false, &mut NoText, 1.0, &mut timings);
5675 assert!(
5676 !core
5677 .paint_items
5678 .iter()
5679 .any(|p| matches!(p, PaintItem::BackdropSnapshot)),
5680 "no glass shader registered → no snapshot"
5681 );
5682 }
5683
5684 #[test]
5685 fn at_most_one_snapshot_per_frame() {
5686 let mut core = RunnerCore::new();
5687 core.set_surface_size(100, 100);
5688 let ops = vec![
5689 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
5690 quad(ShaderHandle::Custom("g")),
5691 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
5692 quad(ShaderHandle::Custom("g")),
5693 ];
5694 let mut timings = PrepareTimings::default();
5695 core.prepare_paint(
5696 &ops,
5697 |_| true,
5698 |s| matches!(s, ShaderHandle::Custom("g")),
5699 &mut NoText,
5700 1.0,
5701 &mut timings,
5702 );
5703 let snapshots = core
5704 .paint_items
5705 .iter()
5706 .filter(|p| matches!(p, PaintItem::BackdropSnapshot))
5707 .count();
5708 assert_eq!(snapshots, 1, "backdrop depth is capped at 1");
5709 }
5710}