1use std::cmp::Ordering;
51use std::ops::Range;
52use std::time::Duration;
53
54use web_time::Instant;
55
56use crate::color::ColorSpace;
57use crate::draw_ops::{self, DrawOpsStats};
58use crate::event::{
59 KeyChord, KeyModifiers, Pointer, PointerButton, PointerKind, UiEvent, UiEventKind, UiKey,
60 UiTarget,
61};
62use crate::focus;
63use crate::hit_test;
64use crate::ir::{DrawOp, TextAnchor};
65use crate::layout;
66use crate::paint::{
67 DEFAULT_WORKING_COLOR_SPACE, InstanceRun, PaintItem, PhysicalScissor, QuadInstance, close_run,
68 pack_instance_in, physical_scissor,
69};
70use crate::shader::ShaderHandle;
71use crate::state::{
72 AnimationMode, LONG_PRESS_DELAY, SelectionDragGranularity, TOUCH_DRAG_THRESHOLD,
73 TouchGestureState, UiState,
74};
75use crate::text::atlas::RunStyle;
76use crate::text::metrics::TextLayoutCacheStats;
77use crate::theme::Theme;
78use crate::toast;
79use crate::tooltip;
80use crate::tree::{Color, El, FontWeight, Rect, TextWrap};
81
82const SCROLL_PAGE_OVERLAP: f32 = 24.0;
88
89#[derive(Clone, Copy, Debug, Default)]
112pub struct PrepareResult {
113 pub needs_redraw: bool,
117 pub next_redraw_in: Option<std::time::Duration>,
121 pub next_layout_redraw_in: Option<std::time::Duration>,
125 pub next_paint_redraw_in: Option<std::time::Duration>,
130 pub timings: PrepareTimings,
131}
132
133#[derive(Debug, Default)]
145pub struct PointerMove {
146 pub events: Vec<UiEvent>,
149 pub needs_redraw: bool,
153}
154
155pub struct LayoutPrepared {
162 pub ops: Vec<DrawOp>,
163 pub needs_redraw: bool,
164 pub next_layout_redraw_in: Option<std::time::Duration>,
165 pub next_paint_redraw_in: Option<std::time::Duration>,
166}
167
168#[derive(Clone, Copy, Debug, Default)]
179pub struct PrepareTimings {
180 pub layout: Duration,
181 pub layout_intrinsic_cache: layout::LayoutIntrinsicCacheStats,
182 pub layout_prune: layout::LayoutPruneStats,
183 pub draw_ops: Duration,
184 pub draw_ops_culled_text_ops: u64,
185 pub paint: Duration,
186 pub paint_culled_ops: u64,
187 pub gpu_upload: Duration,
188 pub snapshot: Duration,
189 pub text_layout_cache: TextLayoutCacheStats,
190}
191
192pub struct RunnerCore {
200 pub ui_state: UiState,
201 pub last_tree: Option<El>,
205
206 pub quad_scratch: Vec<QuadInstance>,
209 pub runs: Vec<InstanceRun>,
210 pub paint_items: Vec<PaintItem>,
211
212 pub last_ops: Vec<DrawOp>,
219
220 pub viewport_px: (u32, u32),
224 pub surface_size_override: Option<(u32, u32)>,
230
231 pub theme: Theme,
233
234 pub working_color_space: ColorSpace,
247}
248
249impl Default for RunnerCore {
250 fn default() -> Self {
251 Self::new()
252 }
253}
254
255impl RunnerCore {
256 pub fn new() -> Self {
257 Self {
258 ui_state: UiState::default(),
259 last_tree: None,
260 quad_scratch: Vec::new(),
261 runs: Vec::new(),
262 paint_items: Vec::new(),
263 last_ops: Vec::new(),
264 viewport_px: (1, 1),
265 surface_size_override: None,
266 theme: Theme::default(),
267 working_color_space: DEFAULT_WORKING_COLOR_SPACE,
268 }
269 }
270
271 pub fn set_theme(&mut self, theme: Theme) {
272 self.theme = theme;
273 }
274
275 pub fn theme(&self) -> &Theme {
276 &self.theme
277 }
278
279 pub fn working_color_space(&self) -> ColorSpace {
283 self.working_color_space
284 }
285
286 pub fn set_working_color_space(&mut self, space: ColorSpace) {
291 self.working_color_space = space;
292 }
293
294 pub fn set_surface_size(&mut self, width: u32, height: u32) {
300 self.surface_size_override = Some((width.max(1), height.max(1)));
301 }
302
303 pub fn ui_state(&self) -> &UiState {
304 &self.ui_state
305 }
306
307 pub fn debug_summary(&self) -> String {
308 self.ui_state.debug_summary()
309 }
310
311 pub fn rect_of_key(&self, key: &str) -> Option<Rect> {
312 self.ui_state.rect_of_key(key)
313 }
314
315 pub fn would_press_focus_text_input(&self, x: f32, y: f32) -> bool {
332 let Some(tree) = self.last_tree.as_ref() else {
333 return false;
334 };
335 let Some(target) = hit_test::hit_test_target(tree, &self.ui_state, (x, y)) else {
336 return false;
337 };
338 find_capture_keys(tree, &target.node_id).unwrap_or(false)
339 }
340
341 pub fn pointer_moved(&mut self, p: Pointer) -> PointerMove {
355 let Pointer { x, y, kind, .. } = p;
356 let prev_pos = self.ui_state.pointer_pos;
360 self.ui_state.pointer_pos = Some((x, y));
361 self.ui_state.pointer_kind = kind;
362
363 if let Some(drag) = self.ui_state.scroll.thumb_drag.clone() {
369 let dy = y - drag.start_pointer_y;
370 let new_offset = if drag.track_remaining > 0.0 {
371 drag.start_offset + dy * (drag.max_offset / drag.track_remaining)
372 } else {
373 drag.start_offset
374 };
375 let clamped = new_offset.clamp(0.0, drag.max_offset);
376 let prev = self.ui_state.scroll.offsets.insert(drag.scroll_id, clamped);
377 let changed = prev.is_none_or(|old| (old - clamped).abs() > f32::EPSILON);
378 return PointerMove {
379 events: Vec::new(),
380 needs_redraw: changed,
381 };
382 }
383
384 if self.ui_state.camera_drag_active() {
388 let changed = self.ui_state.drag_camera_to(x, y);
389 return PointerMove {
390 events: Vec::new(),
391 needs_redraw: changed,
392 };
393 }
394
395 let hit = self
396 .last_tree
397 .as_ref()
398 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
399 let prev_hover = self.ui_state.hovered.clone();
403 let hover_changed = self.ui_state.set_hovered(hit, Instant::now());
404 let prev_hovered_link = self.ui_state.hovered_link.clone();
410 let new_hovered_link = self
411 .last_tree
412 .as_ref()
413 .and_then(|t| hit_test::link_at(t, &self.ui_state, (x, y)));
414 let link_hover_changed = new_hovered_link != prev_hovered_link;
415 self.ui_state.hovered_link = new_hovered_link;
416 let modifiers = self.ui_state.modifiers;
417
418 let mut out = Vec::new();
419
420 let touch_no_press = matches!(kind, PointerKind::Touch) && self.ui_state.pressed.is_none();
436 if hover_changed && !touch_no_press {
437 if let Some(prev) = prev_hover {
438 out.push(UiEvent {
439 key: Some(prev.key.clone()),
440 target: Some(prev),
441 pointer: Some((x, y)),
442 key_press: None,
443 text: None,
444 selection: None,
445 modifiers,
446 click_count: 0,
447 path: None,
448 pointer_kind: Some(kind),
449 wheel_delta: None,
450 kind: UiEventKind::PointerLeave,
451 });
452 }
453 if let Some(new) = self.ui_state.hovered.clone() {
454 out.push(UiEvent {
455 key: Some(new.key.clone()),
456 target: Some(new),
457 pointer: Some((x, y)),
458 key_press: None,
459 text: None,
460 selection: None,
461 modifiers,
462 click_count: 0,
463 path: None,
464 pointer_kind: Some(kind),
465 wheel_delta: None,
466 kind: UiEventKind::PointerEnter,
467 });
468 }
469 }
470
471 if matches!(kind, PointerKind::Touch) {
476 match self.ui_state.touch_gesture.clone() {
477 TouchGestureState::Pending {
478 initial,
479 consumes_drag,
480 started_at,
481 } => {
482 let dx = x - initial.0;
483 let dy = y - initial.1;
484 if (dx * dx + dy * dy).sqrt() < TOUCH_DRAG_THRESHOLD {
485 let needs_redraw = hover_changed || link_hover_changed || !out.is_empty();
490 return PointerMove {
491 events: out,
492 needs_redraw,
493 };
494 }
495 if consumes_drag {
496 self.ui_state.touch_gesture = TouchGestureState::None;
501 } else {
502 let now = Instant::now();
509 self.cancel_press_for_scroll(&mut out, x, y, kind, modifiers);
510 let scroll_dy = initial.1 - y;
516 let step = self.last_tree.as_ref().and_then(|tree| {
517 self.ui_state.scroll_by_pointer(tree, initial, scroll_dy)
518 });
519 let dt = now
520 .duration_since(started_at)
521 .as_secs_f32()
522 .max(1.0 / 120.0);
523 let velocity = step.as_ref().map(|s| s.applied_delta / dt).unwrap_or(0.0);
524 self.ui_state.touch_gesture = TouchGestureState::Scrolling {
525 last_pos: (x, y),
526 last_time: now,
527 velocity,
528 scroll_id: step.map(|s| s.scroll_id),
529 };
530 return PointerMove {
531 events: out,
532 needs_redraw: true,
533 };
534 }
535 }
536 TouchGestureState::Scrolling {
537 last_pos,
538 last_time,
539 velocity,
540 scroll_id,
541 } => {
542 let now = Instant::now();
543 let scroll_dy = last_pos.1 - y;
544 let step = scroll_id
545 .as_ref()
546 .and_then(|id| self.ui_state.scroll_by_id(id, scroll_dy))
547 .or_else(|| {
548 self.last_tree.as_ref().and_then(|tree| {
549 self.ui_state.scroll_by_pointer(tree, (x, y), scroll_dy)
550 })
551 });
552 let dt = now.duration_since(last_time).as_secs_f32().max(1.0 / 240.0);
553 let sample_velocity =
554 step.as_ref().map(|s| s.applied_delta / dt).unwrap_or(0.0);
555 let velocity = sample_velocity * 0.65 + velocity * 0.35;
556 self.ui_state.touch_gesture = TouchGestureState::Scrolling {
557 last_pos: (x, y),
558 last_time: now,
559 velocity,
560 scroll_id: step.map(|s| s.scroll_id).or(scroll_id),
561 };
562 return PointerMove {
563 events: out,
564 needs_redraw: true,
565 };
566 }
567 TouchGestureState::None => {
568 }
571 TouchGestureState::LongPressed => {
572 self.extend_selection_drag_at(x, y, kind, modifiers, &mut out);
577 if self.ui_state.pressed.is_none() {
578 let needs_redraw = hover_changed || link_hover_changed || !out.is_empty();
579 return PointerMove {
580 events: out,
581 needs_redraw,
582 };
583 }
584 }
585 }
586 }
587
588 self.extend_selection_drag_at(x, y, kind, modifiers, &mut out);
595
596 if let Some(p) = self.ui_state.pressed.clone() {
602 if self.focused_captures_keys() {
606 self.ui_state.bump_caret_activity(Instant::now());
607 }
608 out.push(UiEvent {
609 key: Some(p.key.clone()),
610 target: Some(p),
611 pointer: Some((x, y)),
612 key_press: None,
613 text: None,
614 selection: None,
615 modifiers,
616 click_count: self.ui_state.current_click_count(),
617 path: None,
618 pointer_kind: Some(kind),
619 wheel_delta: None,
620 kind: UiEventKind::Drag,
621 });
622 }
623
624 let over_hover_scene = self.ui_state.pointer_over_hover_scene(x, y)
628 || prev_pos.is_some_and(|(px, py)| self.ui_state.pointer_over_hover_scene(px, py));
629 let needs_redraw =
630 hover_changed || link_hover_changed || !out.is_empty() || over_hover_scene;
631 PointerMove {
632 events: out,
633 needs_redraw,
634 }
635 }
636
637 pub fn pointer_left(&mut self) -> Vec<UiEvent> {
645 let last_pos = self.ui_state.pointer_pos;
646 let prev_hover = self.ui_state.hovered.clone();
647 let modifiers = self.ui_state.modifiers;
648 let kind = self.ui_state.pointer_kind;
653 self.ui_state.pointer_pos = None;
654 self.ui_state.set_hovered(None, Instant::now());
655 self.ui_state.pressed = None;
656 self.ui_state.pressed_secondary = None;
657 self.ui_state.touch_gesture = TouchGestureState::None;
658 self.ui_state.cancel_scroll_momentum();
659 self.ui_state.hovered_link = None;
665 self.ui_state.pressed_link = None;
666
667 let mut out = Vec::new();
668 if let Some(prev) = prev_hover {
669 out.push(UiEvent {
670 key: Some(prev.key.clone()),
671 target: Some(prev),
672 pointer: last_pos,
673 key_press: None,
674 text: None,
675 selection: None,
676 modifiers,
677 click_count: 0,
678 path: None,
679 pointer_kind: Some(kind),
680 wheel_delta: None,
681 kind: UiEventKind::PointerLeave,
682 });
683 }
684 out
685 }
686
687 pub fn file_hovered(&mut self, path: std::path::PathBuf, x: f32, y: f32) -> Vec<UiEvent> {
701 self.ui_state.pointer_pos = Some((x, y));
702 let target = self
703 .last_tree
704 .as_ref()
705 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
706 let key = target.as_ref().map(|t| t.key.clone());
707 vec![UiEvent {
708 key,
709 target,
710 pointer: Some((x, y)),
711 key_press: None,
712 text: None,
713 selection: None,
714 modifiers: self.ui_state.modifiers,
715 click_count: 0,
716 path: Some(path),
717 pointer_kind: None,
718 wheel_delta: None,
719 kind: UiEventKind::FileHovered,
720 }]
721 }
722
723 pub fn file_hover_cancelled(&mut self) -> Vec<UiEvent> {
728 vec![UiEvent {
729 key: None,
730 target: None,
731 pointer: self.ui_state.pointer_pos,
732 key_press: None,
733 text: None,
734 selection: None,
735 modifiers: self.ui_state.modifiers,
736 click_count: 0,
737 path: None,
738 pointer_kind: None,
739 wheel_delta: None,
740 kind: UiEventKind::FileHoverCancelled,
741 }]
742 }
743
744 pub fn file_dropped(&mut self, path: std::path::PathBuf, x: f32, y: f32) -> Vec<UiEvent> {
749 self.ui_state.pointer_pos = Some((x, y));
750 let target = self
751 .last_tree
752 .as_ref()
753 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
754 let key = target.as_ref().map(|t| t.key.clone());
755 vec![UiEvent {
756 key,
757 target,
758 pointer: Some((x, y)),
759 key_press: None,
760 text: None,
761 selection: None,
762 modifiers: self.ui_state.modifiers,
763 click_count: 0,
764 path: Some(path),
765 pointer_kind: None,
766 wheel_delta: None,
767 kind: UiEventKind::FileDropped,
768 }]
769 }
770
771 pub fn pointer_down(&mut self, p: Pointer) -> Vec<UiEvent> {
784 let Pointer {
785 x, y, button, kind, ..
786 } = p;
787 self.ui_state.pointer_kind = kind;
788 self.ui_state.cancel_scroll_momentum();
789 if matches!(button, PointerButton::Primary)
798 && let Some((scroll_id, _track, thumb_rect)) = self
799 .ui_state
800 .thumb_at(x, y)
801 .filter(|(scroll_id, _, _)| self.scrollbar_can_capture(scroll_id, x, y))
802 {
803 let metrics = self
804 .ui_state
805 .scroll
806 .metrics
807 .get(&scroll_id)
808 .copied()
809 .unwrap_or_default();
810 let start_offset = self
811 .ui_state
812 .scroll
813 .offsets
814 .get(&scroll_id)
815 .copied()
816 .unwrap_or(0.0);
817
818 let grabbed = y >= thumb_rect.y && y <= thumb_rect.y + thumb_rect.h;
822 if grabbed {
823 let track_remaining = (metrics.viewport_h - thumb_rect.h).max(0.0);
824 self.ui_state.scroll.thumb_drag = Some(crate::state::ThumbDrag {
825 scroll_id,
826 start_pointer_y: y,
827 start_offset,
828 track_remaining,
829 max_offset: metrics.max_offset,
830 });
831 } else {
832 let page = (metrics.viewport_h - SCROLL_PAGE_OVERLAP).max(0.0);
838 let delta = if y < thumb_rect.y { -page } else { page };
839 let new_offset = (start_offset + delta).clamp(0.0, metrics.max_offset);
840 self.ui_state.scroll.offsets.insert(scroll_id, new_offset);
841 }
842 return Vec::new();
843 }
844
845 let hit = self
846 .last_tree
847 .as_ref()
848 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
849
850 if let Some(id) = self.ui_state.scene_at(x, y)
861 && hit.as_ref().is_none_or(|h| h.node_id == id)
862 && let Some(mode) = self
863 .ui_state
864 .scene_drag_mode(&id, button, self.ui_state.modifiers)
865 {
866 self.ui_state.begin_camera_drag(id, mode, x, y);
867 return Vec::new();
868 }
869
870 if !matches!(button, PointerButton::Primary) {
875 self.ui_state.pressed_secondary = hit.map(|h| (h, button));
878 return Vec::new();
879 }
880
881 self.ui_state.pressed_link = self
889 .last_tree
890 .as_ref()
891 .and_then(|t| hit_test::link_at(t, &self.ui_state, (x, y)));
892 self.ui_state.set_focus(hit.clone());
893 self.ui_state.set_focus_visible(false);
897 self.ui_state.pressed = hit.clone();
898 self.ui_state.tooltip.dismissed_for_hover = true;
901 let modifiers = self.ui_state.modifiers;
902
903 let now = Instant::now();
906 let click_count =
907 self.ui_state
908 .next_click_count(now, (x, y), hit.as_ref().map(|t| t.node_id.as_str()));
909
910 let mut out = Vec::new();
911
912 if matches!(kind, PointerKind::Touch) {
920 let prev_hover = self.ui_state.hovered.clone();
921 let hover_changed = self.ui_state.set_hovered(hit.clone(), now);
922 if hover_changed {
923 if let Some(prev) = prev_hover {
924 out.push(UiEvent {
925 key: Some(prev.key.clone()),
926 target: Some(prev),
927 pointer: Some((x, y)),
928 key_press: None,
929 text: None,
930 selection: None,
931 modifiers,
932 click_count: 0,
933 path: None,
934 pointer_kind: Some(kind),
935 wheel_delta: None,
936 kind: UiEventKind::PointerLeave,
937 });
938 }
939 if let Some(new) = hit.clone() {
940 out.push(UiEvent {
941 key: Some(new.key.clone()),
942 target: Some(new),
943 pointer: Some((x, y)),
944 key_press: None,
945 text: None,
946 selection: None,
947 modifiers,
948 click_count: 0,
949 path: None,
950 pointer_kind: Some(kind),
951 wheel_delta: None,
952 kind: UiEventKind::PointerEnter,
953 });
954 }
955 }
956 let consumes_drag = hit
964 .as_ref()
965 .and_then(|t| {
966 self.last_tree
967 .as_ref()
968 .and_then(|tree| find_consumes_touch_drag(tree, &t.node_id, false))
969 })
970 .unwrap_or(false);
971 self.ui_state.touch_gesture = TouchGestureState::Pending {
972 initial: (x, y),
973 consumes_drag,
974 started_at: now,
975 };
976 }
977
978 if let Some(p) = hit.clone() {
979 if self.focused_captures_keys() {
986 self.ui_state.bump_caret_activity(now);
987 }
988 out.push(UiEvent {
989 key: Some(p.key.clone()),
990 target: Some(p),
991 pointer: Some((x, y)),
992 key_press: None,
993 text: None,
994 selection: None,
995 modifiers,
996 click_count,
997 path: None,
998 pointer_kind: Some(kind),
999 wheel_delta: None,
1000 kind: UiEventKind::PointerDown,
1001 });
1002 }
1003
1004 if let Some(point) = self
1012 .last_tree
1013 .as_ref()
1014 .and_then(|t| hit_test::selection_point_at(t, &self.ui_state, (x, y)))
1015 {
1016 self.start_selection_drag(point, &mut out, modifiers, (x, y), click_count, kind);
1017 } else if !self.ui_state.current_selection.is_empty() {
1018 let click_handles_selection = match (&hit, &self.ui_state.current_selection.range) {
1040 (Some(h), Some(range)) => {
1041 h.key == range.anchor.key
1042 || h.key == range.head.key
1043 || self
1044 .last_tree
1045 .as_ref()
1046 .and_then(|t| find_capture_keys(t, &h.node_id))
1047 .unwrap_or(false)
1048 }
1049 _ => false,
1050 };
1051 if !click_handles_selection {
1052 out.push(selection_event(
1053 crate::selection::Selection::default(),
1054 modifiers,
1055 Some((x, y)),
1056 Some(kind),
1057 ));
1058 self.ui_state.current_selection = crate::selection::Selection::default();
1059 self.ui_state.selection.drag = None;
1060 }
1061 }
1062
1063 out
1064 }
1065
1066 fn start_selection_drag(
1074 &mut self,
1075 point: crate::selection::SelectionPoint,
1076 out: &mut Vec<UiEvent>,
1077 modifiers: KeyModifiers,
1078 pointer: (f32, f32),
1079 click_count: u8,
1080 kind: PointerKind,
1081 ) {
1082 let leaf_text = self
1083 .last_tree
1084 .as_ref()
1085 .and_then(|t| crate::selection::find_keyed_text(t, &point.key))
1086 .unwrap_or_default();
1087 let (anchor_byte, head_byte) = match click_count {
1088 2 => crate::selection::word_range_at(&leaf_text, point.byte),
1089 n if n >= 3 => (0, leaf_text.len()),
1090 _ => (point.byte, point.byte),
1091 };
1092 let granularity = match click_count {
1093 2 => SelectionDragGranularity::Word,
1094 n if n >= 3 => SelectionDragGranularity::Leaf,
1095 _ => SelectionDragGranularity::Character,
1096 };
1097 let anchor = crate::selection::SelectionPoint::new(point.key.clone(), anchor_byte);
1098 let head = crate::selection::SelectionPoint::new(point.key.clone(), head_byte);
1099 let new_sel = crate::selection::Selection {
1100 range: Some(crate::selection::SelectionRange {
1101 anchor: anchor.clone(),
1102 head: head.clone(),
1103 }),
1104 };
1105 self.ui_state.current_selection = new_sel.clone();
1106 self.ui_state.selection.drag = Some(crate::state::SelectionDrag {
1107 anchor,
1108 head,
1109 granularity,
1110 });
1111 out.push(selection_event(
1112 new_sel,
1113 modifiers,
1114 Some(pointer),
1115 Some(kind),
1116 ));
1117 }
1118
1119 fn extend_selection_drag_at(
1120 &mut self,
1121 x: f32,
1122 y: f32,
1123 kind: PointerKind,
1124 modifiers: KeyModifiers,
1125 out: &mut Vec<UiEvent>,
1126 ) {
1127 let Some(drag) = self.ui_state.selection.drag.clone() else {
1128 return;
1129 };
1130 let Some(tree) = self.last_tree.as_ref() else {
1131 return;
1132 };
1133 let raw_head =
1134 head_for_drag(tree, &self.ui_state, (x, y)).unwrap_or_else(|| drag.anchor.clone());
1135 let (anchor, head) = selection_range_for_drag(tree, &self.ui_state, &drag, raw_head);
1136 let new_sel = crate::selection::Selection {
1137 range: Some(crate::selection::SelectionRange { anchor, head }),
1138 };
1139 if new_sel != self.ui_state.current_selection {
1140 self.ui_state.current_selection = new_sel.clone();
1141 out.push(selection_event(
1142 new_sel,
1143 modifiers,
1144 Some((x, y)),
1145 Some(kind),
1146 ));
1147 }
1148 }
1149
1150 fn scrollbar_can_capture(&self, scroll_id: &str, x: f32, y: f32) -> bool {
1151 let Some(tree) = self.last_tree.as_ref() else {
1152 return false;
1153 };
1154 let target_allows_capture = hit_test::hit_test_target(tree, &self.ui_state, (x, y))
1155 .is_none_or(|target| target_id_in_subtree(scroll_id, &target.node_id));
1156 if !target_allows_capture {
1157 return false;
1158 }
1159 hit_test::scroll_targets_at(tree, &self.ui_state, (x, y))
1160 .iter()
1161 .any(|id| id == scroll_id)
1162 }
1163
1164 fn cancel_press_for_scroll(
1174 &mut self,
1175 out: &mut Vec<UiEvent>,
1176 x: f32,
1177 y: f32,
1178 kind: PointerKind,
1179 modifiers: KeyModifiers,
1180 ) {
1181 let pressed = self.ui_state.pressed.take();
1182 let hovered = self.ui_state.hovered.clone();
1183 self.ui_state.set_hovered(None, Instant::now());
1184 self.ui_state.pressed_secondary = None;
1185 self.ui_state.pressed_link = None;
1186 self.ui_state.selection.drag = None;
1187 if let Some(p) = pressed {
1188 out.push(UiEvent {
1189 key: Some(p.key.clone()),
1190 target: Some(p),
1191 pointer: Some((x, y)),
1192 key_press: None,
1193 text: None,
1194 selection: None,
1195 modifiers,
1196 click_count: 0,
1197 path: None,
1198 pointer_kind: Some(kind),
1199 wheel_delta: None,
1200 kind: UiEventKind::PointerCancel,
1201 });
1202 }
1203 if let Some(h) = hovered {
1204 out.push(UiEvent {
1205 key: Some(h.key.clone()),
1206 target: Some(h),
1207 pointer: Some((x, y)),
1208 key_press: None,
1209 text: None,
1210 selection: None,
1211 modifiers,
1212 click_count: 0,
1213 path: None,
1214 pointer_kind: Some(kind),
1215 wheel_delta: None,
1216 kind: UiEventKind::PointerLeave,
1217 });
1218 }
1219 }
1220
1221 pub fn pointer_up(&mut self, p: Pointer) -> Vec<UiEvent> {
1229 let Pointer {
1230 x, y, button, kind, ..
1231 } = p;
1232 self.ui_state.pointer_kind = kind;
1233 if matches!(button, PointerButton::Primary) && self.ui_state.scroll.thumb_drag.is_some() {
1238 self.ui_state.scroll.thumb_drag = None;
1239 self.ui_state.touch_gesture = TouchGestureState::None;
1240 return Vec::new();
1241 }
1242
1243 if self.ui_state.end_camera_drag() {
1247 self.ui_state.touch_gesture = TouchGestureState::None;
1248 return Vec::new();
1249 }
1250
1251 let was_long_pressed =
1257 matches!(self.ui_state.touch_gesture, TouchGestureState::LongPressed);
1258 let momentum = match &self.ui_state.touch_gesture {
1259 TouchGestureState::Scrolling {
1260 velocity,
1261 scroll_id,
1262 ..
1263 } if matches!(kind, PointerKind::Touch) => {
1264 Some((scroll_id.clone(), *velocity, Instant::now()))
1265 }
1266 _ => None,
1267 };
1268 let was_scrolling_or_long = matches!(
1269 self.ui_state.touch_gesture,
1270 TouchGestureState::Scrolling { .. } | TouchGestureState::LongPressed
1271 );
1272 self.ui_state.touch_gesture = TouchGestureState::None;
1273 if was_scrolling_or_long {
1274 if let Some((scroll_id, velocity, now)) = momentum {
1275 self.ui_state
1276 .start_scroll_momentum(scroll_id, velocity, now);
1277 }
1278 if was_long_pressed {
1279 self.ui_state.pressed = None;
1280 self.ui_state.pressed_secondary = None;
1281 self.ui_state.pressed_link = None;
1282 self.ui_state.selection.drag = None;
1283 self.ui_state.set_hovered(None, Instant::now());
1284 }
1285 return Vec::new();
1286 }
1287
1288 if matches!(button, PointerButton::Primary) {
1291 self.ui_state.selection.drag = None;
1292 }
1293
1294 let hit = self
1295 .last_tree
1296 .as_ref()
1297 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
1298 let modifiers = self.ui_state.modifiers;
1299 let mut out = Vec::new();
1300 match button {
1301 PointerButton::Primary => {
1302 let pressed = self.ui_state.pressed.take();
1303 let click_count = self.ui_state.current_click_count();
1304 if let Some(p) = pressed.clone() {
1305 out.push(UiEvent {
1306 key: Some(p.key.clone()),
1307 target: Some(p),
1308 pointer: Some((x, y)),
1309 key_press: None,
1310 text: None,
1311 selection: None,
1312 modifiers,
1313 click_count,
1314 path: None,
1315 pointer_kind: Some(kind),
1316 wheel_delta: None,
1317 kind: UiEventKind::PointerUp,
1318 });
1319 }
1320 if let (Some(p), Some(h)) = (pressed, hit)
1321 && p.node_id == h.node_id
1322 {
1323 if let Some(id) = toast::parse_dismiss_key(&p.key) {
1329 self.ui_state.dismiss_toast(id);
1330 } else {
1331 out.push(UiEvent {
1332 key: Some(p.key.clone()),
1333 target: Some(p),
1334 pointer: Some((x, y)),
1335 key_press: None,
1336 text: None,
1337 selection: None,
1338 modifiers,
1339 click_count,
1340 path: None,
1341 pointer_kind: Some(kind),
1342 wheel_delta: None,
1343 kind: UiEventKind::Click,
1344 });
1345 }
1346 }
1347 if let Some(pressed_url) = self.ui_state.pressed_link.take() {
1353 let up_link = self
1354 .last_tree
1355 .as_ref()
1356 .and_then(|t| hit_test::link_at(t, &self.ui_state, (x, y)));
1357 if up_link.as_ref() == Some(&pressed_url) {
1358 out.push(UiEvent {
1359 key: Some(pressed_url),
1360 target: None,
1361 pointer: Some((x, y)),
1362 key_press: None,
1363 text: None,
1364 selection: None,
1365 modifiers,
1366 click_count: 1,
1367 path: None,
1368 pointer_kind: Some(kind),
1369 wheel_delta: None,
1370 kind: UiEventKind::LinkActivated,
1371 });
1372 }
1373 }
1374 }
1375 PointerButton::Secondary | PointerButton::Middle => {
1376 let pressed = self.ui_state.pressed_secondary.take();
1377 if let (Some((p, b)), Some(h)) = (pressed, hit)
1378 && b == button
1379 && p.node_id == h.node_id
1380 {
1381 let event_kind = match button {
1382 PointerButton::Secondary => UiEventKind::SecondaryClick,
1383 PointerButton::Middle => UiEventKind::MiddleClick,
1384 PointerButton::Primary => unreachable!(),
1385 };
1386 out.push(UiEvent {
1387 key: Some(p.key.clone()),
1388 target: Some(p),
1389 pointer: Some((x, y)),
1390 key_press: None,
1391 text: None,
1392 selection: None,
1393 modifiers,
1394 click_count: 1,
1395 path: None,
1396 pointer_kind: Some(kind),
1397 wheel_delta: None,
1398 kind: event_kind,
1399 });
1400 }
1401 }
1402 }
1403
1404 if matches!(kind, PointerKind::Touch)
1410 && let Some(prev) = self.ui_state.hovered.clone()
1411 {
1412 self.ui_state.set_hovered(None, Instant::now());
1413 out.push(UiEvent {
1414 key: Some(prev.key.clone()),
1415 target: Some(prev),
1416 pointer: Some((x, y)),
1417 key_press: None,
1418 text: None,
1419 selection: None,
1420 modifiers,
1421 click_count: 0,
1422 path: None,
1423 pointer_kind: Some(kind),
1424 wheel_delta: None,
1425 kind: UiEventKind::PointerLeave,
1426 });
1427 }
1428
1429 out
1430 }
1431
1432 pub fn key_down(&mut self, key: UiKey, modifiers: KeyModifiers, repeat: bool) -> Vec<UiEvent> {
1433 if self.focused_captures_keys() {
1441 if let Some(event) = self.ui_state.try_hotkey(&key, modifiers, repeat) {
1442 return vec![event];
1443 }
1444 self.ui_state.bump_caret_activity(Instant::now());
1451 self.ui_state.set_focus_visible(true);
1452 let blur_after = matches!(key, UiKey::Escape);
1453 let out = self
1454 .ui_state
1455 .key_down_raw(key, modifiers, repeat)
1456 .into_iter()
1457 .collect();
1458 if blur_after {
1459 self.ui_state.set_focus(None);
1460 self.ui_state.set_focus_visible(false);
1461 }
1462 return out;
1463 }
1464
1465 if matches!(
1471 key,
1472 UiKey::ArrowUp | UiKey::ArrowDown | UiKey::Home | UiKey::End
1473 ) && let Some(siblings) = self.focused_arrow_nav_group()
1474 {
1475 if let Some(event) = self.ui_state.try_hotkey(&key, modifiers, repeat) {
1476 return vec![event];
1477 }
1478 self.move_focus_in_group(&key, &siblings);
1479 return Vec::new();
1480 }
1481
1482 let mut out: Vec<UiEvent> = self
1483 .ui_state
1484 .key_down(key, modifiers, repeat)
1485 .into_iter()
1486 .collect();
1487
1488 if matches!(out.first().map(|e| e.kind), Some(UiEventKind::Escape))
1496 && !self.ui_state.current_selection.is_empty()
1497 {
1498 self.ui_state.current_selection = crate::selection::Selection::default();
1499 self.ui_state.selection.drag = None;
1500 out.push(selection_event(
1501 crate::selection::Selection::default(),
1502 modifiers,
1503 None,
1504 None,
1505 ));
1506 }
1507
1508 out
1509 }
1510
1511 fn focused_arrow_nav_group(&self) -> Option<Vec<UiTarget>> {
1518 let focused = self.ui_state.focused.as_ref()?;
1519 let tree = self.last_tree.as_ref()?;
1520 focus::arrow_nav_group(tree, &self.ui_state, &focused.node_id)
1521 }
1522
1523 fn move_focus_in_group(&mut self, key: &UiKey, siblings: &[UiTarget]) {
1528 if siblings.is_empty() {
1529 return;
1530 }
1531 let focused_id = match self.ui_state.focused.as_ref() {
1532 Some(t) => t.node_id.clone(),
1533 None => return,
1534 };
1535 let idx = siblings.iter().position(|t| t.node_id == focused_id);
1536 let next_idx = match (key, idx) {
1537 (UiKey::ArrowUp, Some(i)) => i.saturating_sub(1),
1538 (UiKey::ArrowDown, Some(i)) => (i + 1).min(siblings.len() - 1),
1539 (UiKey::Home, _) => 0,
1540 (UiKey::End, _) => siblings.len() - 1,
1541 _ => return,
1542 };
1543 if Some(next_idx) != idx {
1544 self.ui_state.set_focus(Some(siblings[next_idx].clone()));
1545 self.ui_state.set_focus_visible(true);
1546 }
1547 }
1548
1549 pub fn focused_captures_keys(&self) -> bool {
1556 let Some(focused) = self.ui_state.focused.as_ref() else {
1557 return false;
1558 };
1559 let Some(tree) = self.last_tree.as_ref() else {
1560 return false;
1561 };
1562 find_capture_keys(tree, &focused.node_id).unwrap_or(false)
1563 }
1564
1565 pub fn text_input(&mut self, text: String) -> Option<UiEvent> {
1571 if text.is_empty() {
1572 return None;
1573 }
1574 let target = self.ui_state.focused.clone()?;
1575 let modifiers = self.ui_state.modifiers;
1576 self.ui_state.bump_caret_activity(Instant::now());
1579 Some(UiEvent {
1580 key: Some(target.key.clone()),
1581 target: Some(target),
1582 pointer: None,
1583 key_press: None,
1584 text: Some(text),
1585 selection: None,
1586 modifiers,
1587 click_count: 0,
1588 path: None,
1589 pointer_kind: None,
1590 wheel_delta: None,
1591 kind: UiEventKind::TextInput,
1592 })
1593 }
1594
1595 pub fn set_hotkeys(&mut self, hotkeys: Vec<(KeyChord, String)>) {
1596 self.ui_state.set_hotkeys(hotkeys);
1597 }
1598
1599 pub fn set_selection(&mut self, selection: crate::selection::Selection) {
1604 if self.ui_state.current_selection != selection {
1605 self.ui_state.bump_caret_activity(Instant::now());
1606 }
1607 self.ui_state.current_selection = selection;
1608 }
1609
1610 pub fn selected_text(&self) -> Option<String> {
1624 self.selected_text_for(&self.ui_state.current_selection)
1625 }
1626
1627 pub fn selected_text_for(&self, selection: &crate::selection::Selection) -> Option<String> {
1633 let tree = self.last_tree.as_ref()?;
1634 crate::selection::selected_text(tree, selection)
1635 }
1636
1637 pub fn push_toasts(&mut self, specs: Vec<crate::toast::ToastSpec>) {
1643 let now = Instant::now();
1644 for spec in specs {
1645 self.ui_state.push_toast(spec, now);
1646 }
1647 }
1648
1649 pub fn dismiss_toast(&mut self, id: u64) {
1653 self.ui_state.dismiss_toast(id);
1654 }
1655
1656 pub fn push_focus_requests(&mut self, keys: Vec<String>) {
1662 self.ui_state.push_focus_requests(keys);
1663 }
1664
1665 pub fn push_scroll_requests(&mut self, requests: Vec<crate::scroll::ScrollRequest>) {
1671 self.ui_state.push_scroll_requests(requests);
1672 }
1673
1674 pub fn set_animation_mode(&mut self, mode: AnimationMode) {
1675 self.ui_state.set_animation_mode(mode);
1676 }
1677
1678 pub fn pointer_wheel(&mut self, x: f32, y: f32, dy: f32) -> bool {
1679 let Some(tree) = self.last_tree.as_ref() else {
1680 return false;
1681 };
1682 self.ui_state.cancel_scroll_momentum();
1683 if self.ui_state.camera_wheel_zoom(x, y, dy) {
1686 return true;
1687 }
1688 self.ui_state.pointer_wheel(tree, (x, y), dy)
1689 }
1690
1691 pub fn pointer_wheel_event(&mut self, x: f32, y: f32, dx: f32, dy: f32) -> Option<UiEvent> {
1698 if dx.abs() <= f32::EPSILON && dy.abs() <= f32::EPSILON {
1699 return None;
1700 }
1701 let tree = self.last_tree.as_ref()?;
1702 let target = hit_test::hit_test_target(tree, &self.ui_state, (x, y))?;
1703 self.ui_state.cancel_scroll_momentum();
1704 Some(UiEvent {
1705 key: Some(target.key.clone()),
1706 target: Some(target),
1707 pointer: Some((x, y)),
1708 key_press: None,
1709 text: None,
1710 selection: None,
1711 modifiers: self.ui_state.modifiers,
1712 click_count: 0,
1713 path: None,
1714 pointer_kind: Some(self.ui_state.pointer_kind),
1715 wheel_delta: Some((dx, dy)),
1716 kind: UiEventKind::PointerWheel,
1717 })
1718 }
1719
1720 pub fn poll_input(&mut self, now: Instant) -> Vec<UiEvent> {
1735 let TouchGestureState::Pending {
1736 initial,
1737 started_at,
1738 ..
1739 } = self.ui_state.touch_gesture.clone()
1740 else {
1741 return Vec::new();
1742 };
1743 if now.duration_since(started_at) < LONG_PRESS_DELAY {
1744 return Vec::new();
1745 }
1746 let mut out = Vec::new();
1747 let modifiers = self.ui_state.modifiers;
1748 let kind = PointerKind::Touch;
1749 let (x, y) = initial;
1750 let press_target = self.ui_state.pressed.clone();
1751 let preserves_press_for_drag = press_target.as_ref().is_some_and(|t| {
1752 self.last_tree
1753 .as_ref()
1754 .and_then(|tree| find_capture_keys(tree, &t.node_id))
1755 .unwrap_or(false)
1756 });
1757 if preserves_press_for_drag {
1758 self.ui_state.pressed_secondary = None;
1759 self.ui_state.pressed_link = None;
1760 self.ui_state.selection.drag = None;
1761 } else {
1762 self.cancel_press_for_scroll(&mut out, x, y, kind, modifiers);
1769 }
1770 if let Some(t) = press_target {
1771 out.push(UiEvent {
1772 key: Some(t.key.clone()),
1773 target: Some(t),
1774 pointer: Some((x, y)),
1775 key_press: None,
1776 text: None,
1777 selection: None,
1778 modifiers,
1779 click_count: 0,
1780 path: None,
1781 pointer_kind: Some(kind),
1782 wheel_delta: None,
1783 kind: UiEventKind::LongPress,
1784 });
1785 } else {
1786 out.push(UiEvent {
1790 key: None,
1791 target: None,
1792 pointer: Some((x, y)),
1793 key_press: None,
1794 text: None,
1795 selection: None,
1796 modifiers,
1797 click_count: 0,
1798 path: None,
1799 pointer_kind: Some(kind),
1800 wheel_delta: None,
1801 kind: UiEventKind::LongPress,
1802 });
1803 }
1804 if !preserves_press_for_drag
1805 && let Some(point) = self
1806 .last_tree
1807 .as_ref()
1808 .and_then(|t| hit_test::selection_point_at(t, &self.ui_state, (x, y)))
1809 {
1810 self.start_selection_drag(point, &mut out, modifiers, (x, y), 2, kind);
1811 }
1812 self.ui_state.touch_gesture = TouchGestureState::LongPressed;
1813 out
1814 }
1815
1816 pub fn next_input_deadline(&self, now: Instant) -> Option<std::time::Duration> {
1826 if self.ui_state.has_scroll_momentum() {
1827 return Some(std::time::Duration::ZERO);
1828 }
1829 let TouchGestureState::Pending { started_at, .. } = self.ui_state.touch_gesture.clone()
1830 else {
1831 return None;
1832 };
1833 let elapsed = now.duration_since(started_at);
1834 Some(LONG_PRESS_DELAY.saturating_sub(elapsed))
1835 }
1836
1837 pub fn prepare_layout<F>(
1854 &mut self,
1855 root: &mut El,
1856 viewport: Rect,
1857 scale_factor: f32,
1858 timings: &mut PrepareTimings,
1859 samples_time: F,
1860 ) -> LayoutPrepared
1861 where
1862 F: Fn(&ShaderHandle) -> bool,
1863 {
1864 let t0 = Instant::now();
1865 let scroll_momentum_pending = self.ui_state.tick_scroll_momentum(t0);
1866 let mut needs_redraw = {
1873 crate::profile_span!("prepare::layout");
1874 {
1875 crate::profile_span!("prepare::layout::assign_ids");
1876 layout::assign_ids(root);
1877 }
1878 let tooltip_pending = {
1879 crate::profile_span!("prepare::layout::tooltip");
1880 tooltip::synthesize_tooltip(root, &self.ui_state, t0)
1881 };
1882 let toast_pending = {
1883 crate::profile_span!("prepare::layout::toast");
1884 toast::synthesize_toasts(root, &mut self.ui_state, t0)
1885 };
1886 {
1887 crate::profile_span!("prepare::layout::apply_metrics");
1888 self.theme.apply_metrics(root);
1889 }
1890 {
1891 crate::profile_span!("prepare::layout::layout");
1892 layout::layout_post_assign(root, &mut self.ui_state, viewport);
1899 self.ui_state.clear_pending_scroll_requests();
1904 }
1905 {
1906 crate::profile_span!("prepare::layout::sync_focus_order");
1907 self.ui_state.sync_focus_order(root);
1908 }
1909 {
1910 crate::profile_span!("prepare::layout::sync_selection_order");
1911 self.ui_state.sync_selection_order(root);
1912 }
1913 {
1914 crate::profile_span!("prepare::layout::sync_popover_focus");
1915 focus::sync_popover_focus(root, &mut self.ui_state);
1916 }
1917 {
1918 crate::profile_span!("prepare::layout::drain_focus_requests");
1923 self.ui_state.drain_focus_requests();
1924 }
1925 {
1926 crate::profile_span!("prepare::layout::apply_state");
1927 self.ui_state.apply_to_state();
1928 }
1929 self.viewport_px = self.surface_size_override.unwrap_or_else(|| {
1930 (
1931 (viewport.w * scale_factor).ceil().max(1.0) as u32,
1932 (viewport.h * scale_factor).ceil().max(1.0) as u32,
1933 )
1934 });
1935 let animations = {
1936 crate::profile_span!("prepare::layout::tick_animations");
1937 self.ui_state
1938 .tick_visual_animations(root, Instant::now(), self.theme.palette())
1939 };
1940 let cameras_animating = {
1945 crate::profile_span!("prepare::layout::tick_cameras");
1946 self.ui_state.tick_scene_cameras(root, Instant::now())
1947 };
1948 animations
1949 || cameras_animating
1950 || tooltip_pending
1951 || toast_pending
1952 || scroll_momentum_pending
1953 };
1954 let t_after_layout = Instant::now();
1955 timings.layout_intrinsic_cache = layout::take_intrinsic_cache_stats();
1956 timings.layout_prune = layout::take_prune_stats();
1957 let (ops, draw_ops_stats) = {
1958 crate::profile_span!("prepare::draw_ops");
1959 let mut stats = DrawOpsStats::default();
1960 let ops = draw_ops::draw_ops_with_theme_and_stats(
1961 root,
1962 &self.ui_state,
1963 &self.theme,
1964 &mut stats,
1965 );
1966 (ops, stats)
1967 };
1968 let t_after_draw_ops = Instant::now();
1969 timings.layout = t_after_layout - t0;
1970 timings.draw_ops = t_after_draw_ops - t_after_layout;
1971 timings.draw_ops_culled_text_ops = draw_ops_stats.culled_text_ops;
1972 self.ui_state
1975 .set_hovered_scene_point(draw_ops_stats.hovered_scene_point);
1976 timings.text_layout_cache = crate::text::metrics::take_shape_cache_stats();
1977
1978 let shader_needs_redraw = ops.iter().any(|op| op_is_continuous(op, &samples_time));
1995 let widget_redraw =
1996 aggregate_redraw_within(root, viewport, &self.ui_state.layout.computed_rects);
1997 let input_deadline = self.next_input_deadline(Instant::now());
2003 let widget_redraw = match (widget_redraw, input_deadline) {
2004 (Some(a), Some(b)) => Some(a.min(b)),
2005 (a, b) => a.or(b),
2006 };
2007
2008 let next_layout_redraw_in = match (needs_redraw, widget_redraw) {
2009 (true, Some(d)) => Some(d.min(std::time::Duration::ZERO)),
2010 (true, None) => Some(std::time::Duration::ZERO),
2011 (false, d) => d,
2012 };
2013 let next_paint_redraw_in = if shader_needs_redraw {
2014 Some(std::time::Duration::ZERO)
2015 } else {
2016 None
2017 };
2018 if next_layout_redraw_in.is_some() || next_paint_redraw_in.is_some() {
2019 needs_redraw = true;
2020 }
2021
2022 LayoutPrepared {
2027 ops,
2028 needs_redraw,
2029 next_layout_redraw_in,
2030 next_paint_redraw_in,
2031 }
2032 }
2033
2034 pub fn prepare_paint_cached<F1, F2>(
2047 &mut self,
2048 is_registered: F1,
2049 samples_backdrop: F2,
2050 text: &mut dyn TextRecorder,
2051 scale_factor: f32,
2052 timings: &mut PrepareTimings,
2053 ) where
2054 F1: Fn(&ShaderHandle) -> bool,
2055 F2: Fn(&ShaderHandle) -> bool,
2056 {
2057 let ops = std::mem::take(&mut self.last_ops);
2061 self.prepare_paint(
2062 &ops,
2063 is_registered,
2064 samples_backdrop,
2065 text,
2066 scale_factor,
2067 timings,
2068 );
2069 self.last_ops = ops;
2070 }
2071
2072 pub fn no_time_shaders(_shader: &ShaderHandle) -> bool {
2077 false
2078 }
2079
2080 pub fn scan_continuous_shaders<F>(&self, samples_time: F) -> Option<std::time::Duration>
2087 where
2088 F: Fn(&ShaderHandle) -> bool,
2089 {
2090 let any = self
2091 .last_ops
2092 .iter()
2093 .any(|op| op_is_continuous(op, &samples_time));
2094 if any {
2095 Some(std::time::Duration::ZERO)
2096 } else {
2097 None
2098 }
2099 }
2100
2101 pub fn prepare_paint<F1, F2>(
2112 &mut self,
2113 ops: &[DrawOp],
2114 is_registered: F1,
2115 samples_backdrop: F2,
2116 text: &mut dyn TextRecorder,
2117 scale_factor: f32,
2118 timings: &mut PrepareTimings,
2119 ) where
2120 F1: Fn(&ShaderHandle) -> bool,
2121 F2: Fn(&ShaderHandle) -> bool,
2122 {
2123 crate::profile_span!("prepare::paint");
2124 let t0 = Instant::now();
2125 self.quad_scratch.clear();
2126 self.runs.clear();
2127 self.paint_items.clear();
2128
2129 let mut current: Option<(ShaderHandle, Option<PhysicalScissor>)> = None;
2130 let mut run_first: u32 = 0;
2131 let mut snapshot_emitted = false;
2134
2135 for op in ops {
2136 match op {
2137 DrawOp::Quad {
2138 rect,
2139 scissor,
2140 shader,
2141 uniforms,
2142 ..
2143 } => {
2144 if !is_registered(shader) {
2145 continue;
2146 }
2147 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2148 timings.paint_culled_ops += 1;
2149 continue;
2150 }
2151 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2152 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2153 timings.paint_culled_ops += 1;
2154 continue;
2155 }
2156 if !snapshot_emitted && samples_backdrop(shader) {
2157 close_run(
2158 &mut self.runs,
2159 &mut self.paint_items,
2160 current,
2161 run_first,
2162 self.quad_scratch.len() as u32,
2163 );
2164 current = None;
2165 run_first = self.quad_scratch.len() as u32;
2166 self.paint_items.push(PaintItem::BackdropSnapshot);
2167 snapshot_emitted = true;
2168 }
2169 let inst = pack_instance_in(*rect, *shader, uniforms, self.working_color_space);
2170
2171 let key = (*shader, phys);
2172 if current != Some(key) {
2173 close_run(
2174 &mut self.runs,
2175 &mut self.paint_items,
2176 current,
2177 run_first,
2178 self.quad_scratch.len() as u32,
2179 );
2180 current = Some(key);
2181 run_first = self.quad_scratch.len() as u32;
2182 }
2183 self.quad_scratch.push(inst);
2184 }
2185 DrawOp::GlyphRun {
2186 rect,
2187 scissor,
2188 color,
2189 text: glyph_text,
2190 size,
2191 line_height,
2192 family,
2193 mono_family,
2194 weight,
2195 mono,
2196 wrap,
2197 anchor,
2198 underline,
2199 strikethrough,
2200 link,
2201 ..
2202 } => {
2203 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2204 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2205 timings.paint_culled_ops += 1;
2206 continue;
2207 }
2208 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2209 timings.paint_culled_ops += 1;
2210 continue;
2211 }
2212 close_run(
2213 &mut self.runs,
2214 &mut self.paint_items,
2215 current,
2216 run_first,
2217 self.quad_scratch.len() as u32,
2218 );
2219 current = None;
2220 run_first = self.quad_scratch.len() as u32;
2221
2222 let mut style = crate::text::atlas::RunStyle::new(*weight, *color)
2223 .family(*family)
2224 .mono_family(*mono_family);
2225 if *mono {
2226 style = style.mono();
2227 }
2228 if *underline {
2229 style = style.underline();
2230 }
2231 if *strikethrough {
2232 style = style.strikethrough();
2233 }
2234 if let Some(url) = link {
2235 style = style.with_link(url.clone());
2236 }
2237 let layers = text.record(
2238 *rect,
2239 phys,
2240 &style,
2241 glyph_text,
2242 *size,
2243 *line_height,
2244 *wrap,
2245 *anchor,
2246 scale_factor,
2247 );
2248 for index in layers {
2249 self.paint_items.push(PaintItem::Text(index));
2250 }
2251 }
2252 DrawOp::AttributedText {
2253 rect,
2254 scissor,
2255 runs,
2256 size,
2257 line_height,
2258 wrap,
2259 anchor,
2260 ..
2261 } => {
2262 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2263 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2264 timings.paint_culled_ops += 1;
2265 continue;
2266 }
2267 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2268 timings.paint_culled_ops += 1;
2269 continue;
2270 }
2271 close_run(
2272 &mut self.runs,
2273 &mut self.paint_items,
2274 current,
2275 run_first,
2276 self.quad_scratch.len() as u32,
2277 );
2278 current = None;
2279 run_first = self.quad_scratch.len() as u32;
2280
2281 let layers = text.record_runs(
2282 *rect,
2283 phys,
2284 runs,
2285 *size,
2286 *line_height,
2287 *wrap,
2288 *anchor,
2289 scale_factor,
2290 );
2291 for index in layers {
2292 self.paint_items.push(PaintItem::Text(index));
2293 }
2294 }
2295 DrawOp::Icon {
2296 rect,
2297 scissor,
2298 source,
2299 color,
2300 size,
2301 stroke_width,
2302 ..
2303 } => {
2304 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2305 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2306 timings.paint_culled_ops += 1;
2307 continue;
2308 }
2309 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2310 timings.paint_culled_ops += 1;
2311 continue;
2312 }
2313 close_run(
2314 &mut self.runs,
2315 &mut self.paint_items,
2316 current,
2317 run_first,
2318 self.quad_scratch.len() as u32,
2319 );
2320 current = None;
2321 run_first = self.quad_scratch.len() as u32;
2322
2323 let recorded = text.record_icon(
2324 *rect,
2325 phys,
2326 source,
2327 *color,
2328 *size,
2329 *stroke_width,
2330 scale_factor,
2331 );
2332 match recorded {
2333 RecordedPaint::Text(layers) => {
2334 for index in layers {
2335 self.paint_items.push(PaintItem::Text(index));
2336 }
2337 }
2338 RecordedPaint::Icon(runs) => {
2339 for index in runs {
2340 self.paint_items.push(PaintItem::IconRun(index));
2341 }
2342 }
2343 }
2344 }
2345 DrawOp::Image {
2346 rect,
2347 scissor,
2348 image,
2349 tint,
2350 radius,
2351 fit,
2352 range_limit,
2353 ..
2354 } => {
2355 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2356 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2357 timings.paint_culled_ops += 1;
2358 continue;
2359 }
2360 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2361 timings.paint_culled_ops += 1;
2362 continue;
2363 }
2364 close_run(
2365 &mut self.runs,
2366 &mut self.paint_items,
2367 current,
2368 run_first,
2369 self.quad_scratch.len() as u32,
2370 );
2371 current = None;
2372 run_first = self.quad_scratch.len() as u32;
2373
2374 let recorded = text.record_image(
2375 *rect,
2376 phys,
2377 image,
2378 *tint,
2379 *radius,
2380 *fit,
2381 *range_limit,
2382 scale_factor,
2383 );
2384 for index in recorded {
2385 self.paint_items.push(PaintItem::Image(index));
2386 }
2387 }
2388 DrawOp::AppTexture {
2389 rect,
2390 scissor,
2391 texture,
2392 alpha,
2393 transform,
2394 ..
2395 } => {
2396 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2397 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2398 timings.paint_culled_ops += 1;
2399 continue;
2400 }
2401 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2402 timings.paint_culled_ops += 1;
2403 continue;
2404 }
2405 close_run(
2406 &mut self.runs,
2407 &mut self.paint_items,
2408 current,
2409 run_first,
2410 self.quad_scratch.len() as u32,
2411 );
2412 current = None;
2413 run_first = self.quad_scratch.len() as u32;
2414
2415 let recorded = text.record_app_texture(
2416 *rect,
2417 phys,
2418 texture,
2419 *alpha,
2420 *transform,
2421 scale_factor,
2422 );
2423 for index in recorded {
2424 self.paint_items.push(PaintItem::AppTexture(index));
2425 }
2426 }
2427 DrawOp::Vector {
2428 rect,
2429 scissor,
2430 asset,
2431 render_mode,
2432 ..
2433 } => {
2434 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2435 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2436 timings.paint_culled_ops += 1;
2437 continue;
2438 }
2439 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2440 timings.paint_culled_ops += 1;
2441 continue;
2442 }
2443 close_run(
2444 &mut self.runs,
2445 &mut self.paint_items,
2446 current,
2447 run_first,
2448 self.quad_scratch.len() as u32,
2449 );
2450 current = None;
2451 run_first = self.quad_scratch.len() as u32;
2452
2453 let recorded =
2454 text.record_vector(*rect, phys, asset, *render_mode, scale_factor);
2455 for index in recorded {
2456 self.paint_items.push(PaintItem::Vector(index));
2457 }
2458 }
2459 DrawOp::Scene3D {
2460 id,
2461 rect,
2462 scissor,
2463 scene,
2464 } => {
2465 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2466 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2467 timings.paint_culled_ops += 1;
2468 continue;
2469 }
2470 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2471 timings.paint_culled_ops += 1;
2472 continue;
2473 }
2474 close_run(
2480 &mut self.runs,
2481 &mut self.paint_items,
2482 current,
2483 run_first,
2484 self.quad_scratch.len() as u32,
2485 );
2486 current = None;
2487 run_first = self.quad_scratch.len() as u32;
2488
2489 let recorded = text.record_scene3d(*rect, phys, id, scene, scale_factor);
2490 for index in recorded {
2491 self.paint_items.push(PaintItem::Scene3D(index));
2492 }
2493 }
2494 DrawOp::BackdropSnapshot => {
2495 close_run(
2496 &mut self.runs,
2497 &mut self.paint_items,
2498 current,
2499 run_first,
2500 self.quad_scratch.len() as u32,
2501 );
2502 current = None;
2503 run_first = self.quad_scratch.len() as u32;
2504 if !snapshot_emitted {
2507 self.paint_items.push(PaintItem::BackdropSnapshot);
2508 snapshot_emitted = true;
2509 }
2510 }
2511 }
2512 }
2513 close_run(
2514 &mut self.runs,
2515 &mut self.paint_items,
2516 current,
2517 run_first,
2518 self.quad_scratch.len() as u32,
2519 );
2520 timings.paint = Instant::now() - t0;
2521 }
2522
2523 pub fn snapshot(&mut self, root: &El, timings: &mut PrepareTimings) {
2528 crate::profile_span!("prepare::snapshot");
2529 let t0 = Instant::now();
2530 self.last_tree = Some(root.clone());
2531 timings.snapshot = Instant::now() - t0;
2532 }
2533}
2534
2535fn paint_rect_visible(
2536 rect: Rect,
2537 scissor: Option<Rect>,
2538 viewport_px: (u32, u32),
2539 scale_factor: f32,
2540) -> bool {
2541 if rect.w <= 0.0 || rect.h <= 0.0 {
2542 return false;
2543 }
2544 let scale = scale_factor.max(f32::EPSILON);
2545 let viewport = Rect::new(
2546 0.0,
2547 0.0,
2548 viewport_px.0 as f32 / scale,
2549 viewport_px.1 as f32 / scale,
2550 );
2551 let Some(clip) = scissor.map_or(Some(viewport), |s| s.intersect(viewport)) else {
2552 return false;
2553 };
2554 rect.intersect(clip).is_some()
2555}
2556
2557fn target_id_in_subtree(root_id: &str, target_id: &str) -> bool {
2558 target_id == root_id
2559 || target_id
2560 .strip_prefix(root_id)
2561 .is_some_and(|rest| rest.starts_with('.'))
2562}
2563
2564fn op_is_continuous<F>(op: &DrawOp, samples_time: &F) -> bool
2571where
2572 F: Fn(&ShaderHandle) -> bool,
2573{
2574 match op.shader() {
2575 Some(handle @ ShaderHandle::Stock(s)) => s.is_continuous() || samples_time(handle),
2576 Some(handle @ ShaderHandle::Custom(_)) => samples_time(handle),
2577 None => false,
2578 }
2579}
2580
2581fn aggregate_redraw_within(
2587 node: &El,
2588 viewport: Rect,
2589 rects: &rustc_hash::FxHashMap<String, Rect>,
2590) -> Option<std::time::Duration> {
2591 let mut acc: Option<std::time::Duration> = None;
2592 visit_redraw_within(node, viewport, rects, VisibilityClip::Unclipped, &mut acc);
2593 acc
2594}
2595
2596#[derive(Clone, Copy)]
2597enum VisibilityClip {
2598 Unclipped,
2599 Clipped(Rect),
2600 Empty,
2601}
2602
2603impl VisibilityClip {
2604 fn intersect(self, rect: Rect) -> Self {
2605 if rect.w <= 0.0 || rect.h <= 0.0 {
2606 return Self::Empty;
2607 }
2608 match self {
2609 Self::Unclipped => Self::Clipped(rect),
2610 Self::Clipped(prev) => prev
2611 .intersect(rect)
2612 .map(Self::Clipped)
2613 .unwrap_or(Self::Empty),
2614 Self::Empty => Self::Empty,
2615 }
2616 }
2617
2618 fn permits(self, rect: Rect) -> bool {
2619 if rect.w <= 0.0 || rect.h <= 0.0 {
2620 return false;
2621 }
2622 match self {
2623 Self::Unclipped => true,
2624 Self::Clipped(clip) => rect.intersect(clip).is_some(),
2625 Self::Empty => false,
2626 }
2627 }
2628}
2629
2630fn visit_redraw_within(
2631 node: &El,
2632 viewport: Rect,
2633 rects: &rustc_hash::FxHashMap<String, Rect>,
2634 inherited_clip: VisibilityClip,
2635 acc: &mut Option<std::time::Duration>,
2636) {
2637 let rect = rects.get(&node.computed_id).copied();
2638 if let Some(d) = node.redraw_within {
2639 if let Some(rect) = rect
2640 && rect.w > 0.0
2641 && rect.h > 0.0
2642 && rect.intersect(viewport).is_some()
2643 && inherited_clip.permits(rect)
2644 {
2645 *acc = Some(match *acc {
2646 Some(prev) => prev.min(d),
2647 None => d,
2648 });
2649 }
2650 }
2651 let child_clip = if node.clip {
2652 rect.map(|r| inherited_clip.intersect(r))
2653 .unwrap_or(VisibilityClip::Empty)
2654 } else {
2655 inherited_clip
2656 };
2657 for child in &node.children {
2658 visit_redraw_within(child, viewport, rects, child_clip, acc);
2659 }
2660}
2661
2662pub(crate) fn find_capture_keys(node: &El, id: &str) -> Option<bool> {
2667 if node.computed_id == id {
2668 return Some(node.capture_keys);
2669 }
2670 node.children.iter().find_map(|c| find_capture_keys(c, id))
2671}
2672
2673fn find_consumes_touch_drag(node: &El, id: &str, ancestor_consumes: bool) -> Option<bool> {
2683 let consumes = ancestor_consumes || node.consumes_touch_drag;
2684 if node.computed_id == id {
2685 return Some(consumes);
2686 }
2687 node.children
2688 .iter()
2689 .find_map(|c| find_consumes_touch_drag(c, id, consumes))
2690}
2691
2692fn selection_event(
2694 new_sel: crate::selection::Selection,
2695 modifiers: KeyModifiers,
2696 pointer: Option<(f32, f32)>,
2697 pointer_kind: Option<PointerKind>,
2698) -> UiEvent {
2699 UiEvent {
2700 kind: UiEventKind::SelectionChanged,
2701 key: None,
2702 target: None,
2703 pointer,
2704 key_press: None,
2705 text: None,
2706 selection: Some(new_sel),
2707 modifiers,
2708 click_count: 0,
2709 path: None,
2710 pointer_kind,
2711 wheel_delta: None,
2712 }
2713}
2714
2715fn head_for_drag(
2727 root: &El,
2728 ui_state: &UiState,
2729 point: (f32, f32),
2730) -> Option<crate::selection::SelectionPoint> {
2731 if let Some(p) = hit_test::selection_point_at(root, ui_state, point) {
2732 return Some(p);
2733 }
2734
2735 let order = &ui_state.selection.order;
2736 if order.is_empty() {
2737 return None;
2738 }
2739 let target = order
2744 .iter()
2745 .find(|t| point.1 >= t.rect.y && point.1 < t.rect.y + t.rect.h)
2746 .or_else(|| {
2747 order.iter().min_by(|a, b| {
2748 let da = y_distance(a.rect, point.1);
2749 let db = y_distance(b.rect, point.1);
2750 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
2751 })
2752 })?;
2753 let target_rect = target.rect;
2754 let cy = point
2755 .1
2756 .clamp(target_rect.y, target_rect.y + target_rect.h - 1.0);
2757 if let Some(p) = hit_test::selection_point_at(root, ui_state, (point.0, cy)) {
2758 return Some(p);
2759 }
2760 let leaf_len = find_text_len(root, &target.node_id).unwrap_or(0);
2763 let byte = if point.0 < target_rect.x { 0 } else { leaf_len };
2764 Some(crate::selection::SelectionPoint {
2765 key: target.key.clone(),
2766 byte,
2767 })
2768}
2769
2770fn selection_range_for_drag(
2771 root: &El,
2772 ui_state: &UiState,
2773 drag: &crate::state::SelectionDrag,
2774 raw_head: crate::selection::SelectionPoint,
2775) -> (
2776 crate::selection::SelectionPoint,
2777 crate::selection::SelectionPoint,
2778) {
2779 match drag.granularity {
2780 SelectionDragGranularity::Character => (drag.anchor.clone(), raw_head),
2781 SelectionDragGranularity::Word => {
2782 let text = crate::selection::find_keyed_text(root, &raw_head.key).unwrap_or_default();
2783 let (lo, hi) = crate::selection::word_range_at(&text, raw_head.byte);
2784 if point_cmp(ui_state, &raw_head, &drag.anchor) == Ordering::Less {
2785 (
2786 drag.head.clone(),
2787 crate::selection::SelectionPoint::new(raw_head.key, lo),
2788 )
2789 } else {
2790 (
2791 drag.anchor.clone(),
2792 crate::selection::SelectionPoint::new(raw_head.key, hi),
2793 )
2794 }
2795 }
2796 SelectionDragGranularity::Leaf => {
2797 let len = crate::selection::find_keyed_text(root, &raw_head.key)
2798 .map(|text| text.len())
2799 .unwrap_or(raw_head.byte);
2800 if point_cmp(ui_state, &raw_head, &drag.anchor) == Ordering::Less {
2801 (
2802 drag.head.clone(),
2803 crate::selection::SelectionPoint::new(raw_head.key, 0),
2804 )
2805 } else {
2806 (
2807 drag.anchor.clone(),
2808 crate::selection::SelectionPoint::new(raw_head.key, len),
2809 )
2810 }
2811 }
2812 }
2813}
2814
2815fn point_cmp(
2816 ui_state: &UiState,
2817 a: &crate::selection::SelectionPoint,
2818 b: &crate::selection::SelectionPoint,
2819) -> Ordering {
2820 let order_index = |key: &str| {
2821 ui_state
2822 .selection
2823 .order
2824 .iter()
2825 .position(|target| target.key == key)
2826 .unwrap_or(usize::MAX)
2827 };
2828 order_index(&a.key)
2829 .cmp(&order_index(&b.key))
2830 .then_with(|| a.byte.cmp(&b.byte))
2831}
2832
2833fn y_distance(rect: Rect, y: f32) -> f32 {
2834 if y < rect.y {
2835 rect.y - y
2836 } else if y > rect.y + rect.h {
2837 y - (rect.y + rect.h)
2838 } else {
2839 0.0
2840 }
2841}
2842
2843fn find_text_len(node: &El, id: &str) -> Option<usize> {
2844 if node.computed_id == id {
2845 if let Some(source) = &node.selection_source {
2846 return Some(source.visible_len());
2847 }
2848 return node.text.as_ref().map(|t| t.len());
2849 }
2850 node.children.iter().find_map(|c| find_text_len(c, id))
2851}
2852
2853pub enum RecordedPaint {
2856 Text(Range<usize>),
2857 Icon(Range<usize>),
2858}
2859
2860pub trait TextRecorder {
2864 #[allow(clippy::too_many_arguments)]
2872 fn record(
2873 &mut self,
2874 rect: Rect,
2875 scissor: Option<PhysicalScissor>,
2876 style: &RunStyle,
2877 text: &str,
2878 size: f32,
2879 line_height: f32,
2880 wrap: TextWrap,
2881 anchor: TextAnchor,
2882 scale_factor: f32,
2883 ) -> Range<usize>;
2884
2885 #[allow(clippy::too_many_arguments)]
2890 fn record_runs(
2891 &mut self,
2892 rect: Rect,
2893 scissor: Option<PhysicalScissor>,
2894 runs: &[(String, RunStyle)],
2895 size: f32,
2896 line_height: f32,
2897 wrap: TextWrap,
2898 anchor: TextAnchor,
2899 scale_factor: f32,
2900 ) -> Range<usize>;
2901
2902 #[allow(clippy::too_many_arguments)]
2908 fn record_icon(
2909 &mut self,
2910 rect: Rect,
2911 scissor: Option<PhysicalScissor>,
2912 source: &crate::icons::svg::IconSource,
2913 color: Color,
2914 size: f32,
2915 _stroke_width: f32,
2916 scale_factor: f32,
2917 ) -> RecordedPaint {
2918 let glyph = match source {
2919 crate::icons::svg::IconSource::Builtin(name) => name.fallback_glyph(),
2920 crate::icons::svg::IconSource::Custom(_) => "?",
2921 };
2922 RecordedPaint::Text(self.record(
2923 rect,
2924 scissor,
2925 &RunStyle::new(FontWeight::Regular, color),
2926 glyph,
2927 size,
2928 crate::text::metrics::line_height(size),
2929 TextWrap::NoWrap,
2930 TextAnchor::Middle,
2931 scale_factor,
2932 ))
2933 }
2934
2935 #[allow(clippy::too_many_arguments)]
2942 fn record_image(
2943 &mut self,
2944 _rect: Rect,
2945 _scissor: Option<PhysicalScissor>,
2946 _image: &crate::image::Image,
2947 _tint: Option<Color>,
2948 _radius: crate::tree::Corners,
2949 _fit: crate::image::ImageFit,
2950 _range_limit: crate::image::DynamicRangeLimit,
2951 _scale_factor: f32,
2952 ) -> Range<usize> {
2953 0..0
2954 }
2955
2956 fn record_app_texture(
2962 &mut self,
2963 _rect: Rect,
2964 _scissor: Option<PhysicalScissor>,
2965 _texture: &crate::surface::AppTexture,
2966 _alpha: crate::surface::SurfaceAlpha,
2967 _transform: crate::affine::Affine2,
2968 _scale_factor: f32,
2969 ) -> Range<usize> {
2970 0..0
2971 }
2972
2973 fn record_vector(
2979 &mut self,
2980 _rect: Rect,
2981 _scissor: Option<PhysicalScissor>,
2982 _asset: &crate::vector::VectorAsset,
2983 _render_mode: crate::vector::VectorRenderMode,
2984 _scale_factor: f32,
2985 ) -> Range<usize> {
2986 0..0
2987 }
2988
2989 fn record_scene3d(
3000 &mut self,
3001 _rect: Rect,
3002 _scissor: Option<PhysicalScissor>,
3003 _id: &str,
3004 _scene: &std::sync::Arc<crate::scene::Scene3DData>,
3005 _scale_factor: f32,
3006 ) -> Range<usize> {
3007 0..0
3008 }
3009}
3010
3011#[cfg(test)]
3012mod tests {
3013 use super::*;
3014 use crate::event::PointerId;
3015 use crate::shader::{ShaderHandle, StockShader, UniformBlock};
3016
3017 struct NoText;
3019 impl TextRecorder for NoText {
3020 fn record(
3021 &mut self,
3022 _rect: Rect,
3023 _scissor: Option<PhysicalScissor>,
3024 _style: &RunStyle,
3025 _text: &str,
3026 _size: f32,
3027 _line_height: f32,
3028 _wrap: TextWrap,
3029 _anchor: TextAnchor,
3030 _scale_factor: f32,
3031 ) -> Range<usize> {
3032 0..0
3033 }
3034 fn record_runs(
3035 &mut self,
3036 _rect: Rect,
3037 _scissor: Option<PhysicalScissor>,
3038 _runs: &[(String, RunStyle)],
3039 _size: f32,
3040 _line_height: f32,
3041 _wrap: TextWrap,
3042 _anchor: TextAnchor,
3043 _scale_factor: f32,
3044 ) -> Range<usize> {
3045 0..0
3046 }
3047 }
3048
3049 #[derive(Default)]
3050 struct CountingText {
3051 records: usize,
3052 }
3053
3054 impl TextRecorder for CountingText {
3055 fn record(
3056 &mut self,
3057 _rect: Rect,
3058 _scissor: Option<PhysicalScissor>,
3059 _style: &RunStyle,
3060 _text: &str,
3061 _size: f32,
3062 _line_height: f32,
3063 _wrap: TextWrap,
3064 _anchor: TextAnchor,
3065 _scale_factor: f32,
3066 ) -> Range<usize> {
3067 self.records += 1;
3068 0..0
3069 }
3070
3071 fn record_runs(
3072 &mut self,
3073 _rect: Rect,
3074 _scissor: Option<PhysicalScissor>,
3075 _runs: &[(String, RunStyle)],
3076 _size: f32,
3077 _line_height: f32,
3078 _wrap: TextWrap,
3079 _anchor: TextAnchor,
3080 _scale_factor: f32,
3081 ) -> Range<usize> {
3082 self.records += 1;
3083 0..0
3084 }
3085 }
3086
3087 fn empty_text_layout(line_height: f32) -> crate::text::metrics::TextLayout {
3088 crate::text::metrics::TextLayout {
3089 lines: Vec::new(),
3090 width: 0.0,
3091 height: 0.0,
3092 line_height,
3093 }
3094 }
3095
3096 fn lay_out_input_tree(capture: bool) -> RunnerCore {
3103 use crate::tree::*;
3104 let ti = if capture {
3105 crate::widgets::text::text("input").key("ti").capture_keys()
3106 } else {
3107 crate::widgets::text::text("noop").key("ti").focusable()
3108 };
3109 let mut tree =
3110 crate::column([crate::widgets::button::button("Btn").key("btn"), ti]).padding(10.0);
3111 let mut core = RunnerCore::new();
3112 crate::layout::layout(
3113 &mut tree,
3114 &mut core.ui_state,
3115 Rect::new(0.0, 0.0, 200.0, 200.0),
3116 );
3117 core.ui_state.sync_focus_order(&tree);
3118 let mut t = PrepareTimings::default();
3119 core.snapshot(&tree, &mut t);
3120 core
3121 }
3122
3123 #[test]
3124 fn pointer_up_emits_pointer_up_then_click() {
3125 let mut core = lay_out_input_tree(false);
3126 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3127 let cx = btn_rect.x + btn_rect.w * 0.5;
3128 let cy = btn_rect.y + btn_rect.h * 0.5;
3129 core.pointer_moved(Pointer::moving(cx, cy));
3130 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3131 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
3132 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3133 assert_eq!(kinds, vec![UiEventKind::PointerUp, UiEventKind::Click]);
3134 }
3135
3136 #[test]
3137 fn pointer_wheel_event_routes_to_keyed_target() {
3138 let mut core = lay_out_input_tree(false);
3139 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3140 let cx = btn_rect.center_x();
3141 let cy = btn_rect.center_y();
3142
3143 let event = core
3144 .pointer_wheel_event(cx, cy, 0.0, 40.0)
3145 .expect("wheel over keyed button");
3146
3147 assert_eq!(event.kind, UiEventKind::PointerWheel);
3148 assert_eq!(event.route(), Some("btn"));
3149 assert_eq!(event.pointer_pos(), Some((cx, cy)));
3150 assert_eq!(event.wheel_delta(), Some((0.0, 40.0)));
3151 assert_eq!(event.wheel_dy(), Some(40.0));
3152 assert_eq!(event.target_rect(), Some(btn_rect));
3153 assert_eq!(event.pointer_kind, Some(PointerKind::Mouse));
3154 }
3155
3156 fn lay_out_link_tree() -> (RunnerCore, Rect, &'static str) {
3162 use crate::tree::*;
3163 const URL: &str = "https://github.com/computer-whisperer/damascene";
3164 let mut tree = crate::column([crate::text_runs([
3165 crate::text("Visit "),
3166 crate::text("github.com/computer-whisperer/damascene").link(URL),
3167 crate::text("."),
3168 ])])
3169 .padding(10.0);
3170 let mut core = RunnerCore::new();
3171 crate::layout::layout(
3172 &mut tree,
3173 &mut core.ui_state,
3174 Rect::new(0.0, 0.0, 600.0, 200.0),
3175 );
3176 core.ui_state.sync_focus_order(&tree);
3177 let mut t = PrepareTimings::default();
3178 core.snapshot(&tree, &mut t);
3179 let para = core
3180 .last_tree
3181 .as_ref()
3182 .and_then(|t| t.children.first())
3183 .map(|p| core.ui_state.rect(&p.computed_id))
3184 .expect("paragraph rect");
3185 (core, para, URL)
3186 }
3187
3188 #[test]
3189 fn pointer_up_on_link_emits_link_activated_with_url() {
3190 let (mut core, para, url) = lay_out_link_tree();
3191 let cx = para.x + 100.0;
3195 let cy = para.y + para.h * 0.5;
3196 core.pointer_moved(Pointer::moving(cx, cy));
3197 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3198 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
3199 let link = events
3200 .iter()
3201 .find(|e| e.kind == UiEventKind::LinkActivated)
3202 .expect("LinkActivated event");
3203 assert_eq!(link.key.as_deref(), Some(url));
3204 }
3205
3206 #[test]
3207 fn pointer_up_after_drag_off_link_does_not_activate() {
3208 let (mut core, para, _url) = lay_out_link_tree();
3209 let press_x = para.x + 100.0;
3210 let cy = para.y + para.h * 0.5;
3211 core.pointer_moved(Pointer::moving(press_x, cy));
3212 core.pointer_down(Pointer::mouse(press_x, cy, PointerButton::Primary));
3213 let events = core.pointer_up(Pointer::mouse(press_x, 180.0, PointerButton::Primary));
3217 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3218 assert!(
3219 !kinds.contains(&UiEventKind::LinkActivated),
3220 "drag-off-link should cancel the link activation; got {kinds:?}",
3221 );
3222 }
3223
3224 #[test]
3225 fn pointer_moved_over_link_resolves_cursor_to_pointer_and_requests_redraw() {
3226 use crate::cursor::Cursor;
3227 let (mut core, para, _url) = lay_out_link_tree();
3228 let cx = para.x + 100.0;
3229 let cy = para.y + para.h * 0.5;
3230 let initial = core.pointer_moved(Pointer::moving(para.x - 50.0, cy));
3232 assert!(
3233 !initial.needs_redraw,
3234 "moving in empty space shouldn't request a redraw"
3235 );
3236 let tree = core.last_tree.as_ref().expect("tree").clone();
3237 assert_eq!(
3238 core.ui_state.cursor(&tree),
3239 Cursor::Default,
3240 "no link under pointer → default cursor"
3241 );
3242 let onto = core.pointer_moved(Pointer::moving(cx, cy));
3245 assert!(
3246 onto.needs_redraw,
3247 "entering a link region should flag a redraw so the cursor refresh isn't stale"
3248 );
3249 assert_eq!(
3250 core.ui_state.cursor(&tree),
3251 Cursor::Pointer,
3252 "pointer over a link → Pointer cursor"
3253 );
3254 let off = core.pointer_moved(Pointer::moving(para.x - 50.0, cy));
3257 assert!(
3258 off.needs_redraw,
3259 "leaving a link region should flag a redraw"
3260 );
3261 assert_eq!(core.ui_state.cursor(&tree), Cursor::Default);
3262 }
3263
3264 #[test]
3265 fn pointer_up_on_unlinked_text_does_not_emit_link_activated() {
3266 let (mut core, para, _url) = lay_out_link_tree();
3267 let cx = para.x + 1.0;
3270 let cy = para.y + para.h * 0.5;
3271 core.pointer_moved(Pointer::moving(cx, cy));
3272 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3273 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
3274 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3275 assert!(
3276 !kinds.contains(&UiEventKind::LinkActivated),
3277 "click on the unlinked prefix should not surface a link event; got {kinds:?}",
3278 );
3279 }
3280
3281 #[test]
3282 fn pointer_up_off_target_emits_only_pointer_up() {
3283 let mut core = lay_out_input_tree(false);
3284 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3285 let cx = btn_rect.x + btn_rect.w * 0.5;
3286 let cy = btn_rect.y + btn_rect.h * 0.5;
3287 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3288 let events = core.pointer_up(Pointer::mouse(180.0, 180.0, PointerButton::Primary));
3290 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3291 assert_eq!(
3292 kinds,
3293 vec![UiEventKind::PointerUp],
3294 "drag-off-target should still surface PointerUp so widgets see drag-end"
3295 );
3296 }
3297
3298 #[test]
3299 fn pointer_moved_while_pressed_emits_drag() {
3300 let mut core = lay_out_input_tree(false);
3301 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3302 let cx = btn_rect.x + btn_rect.w * 0.5;
3303 let cy = btn_rect.y + btn_rect.h * 0.5;
3304 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3305 let drag = core
3306 .pointer_moved(Pointer::moving(cx + 30.0, cy))
3307 .events
3308 .into_iter()
3309 .find(|e| e.kind == UiEventKind::Drag)
3310 .expect("drag while pressed");
3311 assert_eq!(drag.target.as_ref().map(|t| t.key.as_str()), Some("btn"));
3312 assert_eq!(drag.pointer, Some((cx + 30.0, cy)));
3313 }
3314
3315 #[test]
3316 fn toast_dismiss_click_removes_toast_and_suppresses_click_event() {
3317 use crate::toast::ToastSpec;
3318 use crate::tree::Size;
3319 let mut core = RunnerCore::new();
3323 core.ui_state
3324 .push_toast(ToastSpec::success("hi"), Instant::now());
3325 let toast_id = core.ui_state.toasts()[0].id;
3326
3327 let mut tree: El = crate::stack(std::iter::empty::<El>())
3331 .width(Size::Fill(1.0))
3332 .height(Size::Fill(1.0));
3333 crate::layout::assign_ids(&mut tree);
3334 let _ = crate::toast::synthesize_toasts(&mut tree, &mut core.ui_state, Instant::now());
3335 crate::layout::layout(
3336 &mut tree,
3337 &mut core.ui_state,
3338 Rect::new(0.0, 0.0, 800.0, 600.0),
3339 );
3340 core.ui_state.sync_focus_order(&tree);
3341 let mut t = PrepareTimings::default();
3342 core.snapshot(&tree, &mut t);
3343
3344 let dismiss_key = format!("toast-dismiss-{toast_id}");
3345 let dismiss_rect = core.rect_of_key(&dismiss_key).expect("dismiss button");
3346 let cx = dismiss_rect.x + dismiss_rect.w * 0.5;
3347 let cy = dismiss_rect.y + dismiss_rect.h * 0.5;
3348
3349 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3350 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
3351 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3352 assert!(
3356 !kinds.contains(&UiEventKind::Click),
3357 "Click on toast-dismiss should not be surfaced: {kinds:?}",
3358 );
3359 assert!(
3360 core.ui_state.toasts().iter().all(|t| t.id != toast_id),
3361 "toast {toast_id} should be dropped after dismiss-click",
3362 );
3363 }
3364
3365 #[test]
3366 fn pointer_moved_without_press_emits_no_drag() {
3367 let mut core = lay_out_input_tree(false);
3368 let events = core.pointer_moved(Pointer::moving(50.0, 50.0)).events;
3369 assert!(!events.iter().any(|e| e.kind == UiEventKind::Drag));
3373 }
3374
3375 #[test]
3376 fn spinner_in_tree_keeps_needs_redraw_set() {
3377 use crate::widgets::spinner::spinner;
3382 let mut tree = crate::column([spinner()]);
3383 let mut core = RunnerCore::new();
3384 let mut t = PrepareTimings::default();
3385 let LayoutPrepared { needs_redraw, .. } = core.prepare_layout(
3386 &mut tree,
3387 Rect::new(0.0, 0.0, 200.0, 200.0),
3388 1.0,
3389 &mut t,
3390 RunnerCore::no_time_shaders,
3391 );
3392 assert!(
3393 needs_redraw,
3394 "tree with a spinner must request continuous redraw",
3395 );
3396
3397 let mut bare = crate::column([crate::widgets::text::text("idle")]);
3401 let mut core2 = RunnerCore::new();
3402 let mut t2 = PrepareTimings::default();
3403 let LayoutPrepared {
3404 needs_redraw: needs_redraw2,
3405 ..
3406 } = core2.prepare_layout(
3407 &mut bare,
3408 Rect::new(0.0, 0.0, 200.0, 200.0),
3409 1.0,
3410 &mut t2,
3411 RunnerCore::no_time_shaders,
3412 );
3413 assert!(
3414 !needs_redraw2,
3415 "tree without time-driven shaders should idle: got needs_redraw={needs_redraw2}",
3416 );
3417 }
3418
3419 #[test]
3420 fn custom_samples_time_shader_keeps_needs_redraw_set() {
3421 let mut tree = crate::column([crate::tree::El::new(crate::tree::Kind::Custom("anim"))
3425 .shader(crate::shader::ShaderBinding::custom("my_animated_glow"))
3426 .width(crate::tree::Size::Fixed(32.0))
3427 .height(crate::tree::Size::Fixed(32.0))]);
3428 let mut core = RunnerCore::new();
3429 let mut t = PrepareTimings::default();
3430
3431 let LayoutPrepared {
3432 needs_redraw: idle, ..
3433 } = core.prepare_layout(
3434 &mut tree,
3435 Rect::new(0.0, 0.0, 200.0, 200.0),
3436 1.0,
3437 &mut t,
3438 RunnerCore::no_time_shaders,
3439 );
3440 assert!(
3441 !idle,
3442 "without a samples_time registration the host should idle",
3443 );
3444
3445 let mut t2 = PrepareTimings::default();
3446 let LayoutPrepared {
3447 needs_redraw: animated,
3448 ..
3449 } = core.prepare_layout(
3450 &mut tree,
3451 Rect::new(0.0, 0.0, 200.0, 200.0),
3452 1.0,
3453 &mut t2,
3454 |handle| matches!(handle, ShaderHandle::Custom("my_animated_glow")),
3455 );
3456 assert!(
3457 animated,
3458 "custom shader registered as samples_time=true must request continuous redraw",
3459 );
3460 }
3461
3462 #[test]
3463 fn redraw_within_aggregates_to_minimum_visible_deadline() {
3464 use std::time::Duration;
3465 let mut tree = crate::column([
3466 crate::widgets::text::text("a")
3468 .redraw_within(Duration::from_millis(16))
3469 .width(crate::tree::Size::Fixed(20.0))
3470 .height(crate::tree::Size::Fixed(20.0)),
3471 crate::widgets::text::text("b")
3473 .redraw_within(Duration::from_millis(50))
3474 .width(crate::tree::Size::Fixed(20.0))
3475 .height(crate::tree::Size::Fixed(20.0)),
3476 ]);
3477 let mut core = RunnerCore::new();
3478 let mut t = PrepareTimings::default();
3479 let LayoutPrepared {
3480 needs_redraw,
3481 next_layout_redraw_in,
3482 ..
3483 } = core.prepare_layout(
3484 &mut tree,
3485 Rect::new(0.0, 0.0, 200.0, 200.0),
3486 1.0,
3487 &mut t,
3488 RunnerCore::no_time_shaders,
3489 );
3490 assert!(needs_redraw, "redraw_within must lift the legacy bool");
3491 assert_eq!(
3492 next_layout_redraw_in,
3493 Some(Duration::from_millis(16)),
3494 "tightest visible deadline wins, on the layout lane",
3495 );
3496 }
3497
3498 #[test]
3499 fn redraw_within_off_screen_widget_is_ignored() {
3500 use std::time::Duration;
3501 let mut tree = crate::column([
3507 crate::tree::spacer().height(crate::tree::Size::Fixed(150.0)),
3508 crate::widgets::text::text("offscreen")
3509 .redraw_within(Duration::from_millis(16))
3510 .width(crate::tree::Size::Fixed(10.0))
3511 .height(crate::tree::Size::Fixed(10.0)),
3512 ]);
3513 let mut core = RunnerCore::new();
3514 let mut t = PrepareTimings::default();
3515 let LayoutPrepared {
3516 next_layout_redraw_in,
3517 ..
3518 } = core.prepare_layout(
3519 &mut tree,
3520 Rect::new(0.0, 0.0, 100.0, 100.0),
3521 1.0,
3522 &mut t,
3523 RunnerCore::no_time_shaders,
3524 );
3525 assert_eq!(
3526 next_layout_redraw_in, None,
3527 "off-screen redraw_within must not contribute to the aggregate",
3528 );
3529 }
3530
3531 #[test]
3532 fn redraw_within_clipped_out_widget_is_ignored() {
3533 use std::time::Duration;
3534
3535 let clipped = crate::column([crate::widgets::text::text("clipped")
3536 .redraw_within(Duration::from_millis(16))
3537 .width(crate::tree::Size::Fixed(10.0))
3538 .height(crate::tree::Size::Fixed(10.0))])
3539 .clip()
3540 .width(crate::tree::Size::Fixed(100.0))
3541 .height(crate::tree::Size::Fixed(20.0))
3542 .layout(|ctx| {
3543 vec![Rect::new(
3544 ctx.container.x,
3545 ctx.container.y + 30.0,
3546 10.0,
3547 10.0,
3548 )]
3549 });
3550 let mut tree = crate::column([clipped]);
3551
3552 let mut core = RunnerCore::new();
3553 let mut t = PrepareTimings::default();
3554 let LayoutPrepared {
3555 next_layout_redraw_in,
3556 ..
3557 } = core.prepare_layout(
3558 &mut tree,
3559 Rect::new(0.0, 0.0, 100.0, 100.0),
3560 1.0,
3561 &mut t,
3562 RunnerCore::no_time_shaders,
3563 );
3564 assert_eq!(
3565 next_layout_redraw_in, None,
3566 "redraw_within inside an inherited clip but outside the clip rect must not contribute",
3567 );
3568 }
3569
3570 #[test]
3571 fn pointer_moved_within_same_hovered_node_does_not_request_redraw() {
3572 let mut core = lay_out_input_tree(false);
3578 let btn = core.rect_of_key("btn").expect("btn rect");
3579 let (cx, cy) = (btn.x + btn.w * 0.5, btn.y + btn.h * 0.5);
3580
3581 let first = core.pointer_moved(Pointer::moving(cx, cy));
3585 assert_eq!(first.events.len(), 1);
3586 assert_eq!(first.events[0].kind, UiEventKind::PointerEnter);
3587 assert_eq!(first.events[0].key.as_deref(), Some("btn"));
3588 assert!(
3589 first.needs_redraw,
3590 "entering a focusable should warrant a redraw",
3591 );
3592
3593 let second = core.pointer_moved(Pointer::moving(cx + 1.0, cy));
3597 assert!(second.events.is_empty());
3598 assert!(
3599 !second.needs_redraw,
3600 "identical hover, no drag → host should idle",
3601 );
3602
3603 let off = core.pointer_moved(Pointer::moving(0.0, 0.0));
3607 assert_eq!(off.events.len(), 1);
3608 assert_eq!(off.events[0].kind, UiEventKind::PointerLeave);
3609 assert_eq!(off.events[0].key.as_deref(), Some("btn"));
3610 assert!(
3611 off.needs_redraw,
3612 "leaving a hovered node still warrants a redraw",
3613 );
3614 }
3615
3616 #[test]
3617 fn pointer_move_within_hover_label_scene_keeps_redrawing() {
3618 use crate::scene::glam::Vec3;
3623 use crate::scene::{
3624 PointData, PointLabels, PointStyle, PointsHandle, ScenePoint, SceneSpec,
3625 };
3626 let pts = PointsHandle::new(PointData {
3627 points: vec![ScenePoint {
3628 position: Vec3::ZERO,
3629 color: [1.0; 4],
3630 }],
3631 });
3632 let spec = SceneSpec::new().points_labeled(
3633 pts,
3634 PointStyle::default(),
3635 PointLabels::new(["a"]).on_hover(),
3636 );
3637 let mut tree = crate::tree::chart3d(spec).key("scene");
3638 let mut core = RunnerCore::new();
3639 crate::layout::layout(
3640 &mut tree,
3641 &mut core.ui_state,
3642 Rect::new(0.0, 0.0, 200.0, 200.0),
3643 );
3644 let mut t = PrepareTimings::default();
3645 core.snapshot(&tree, &mut t);
3646 core.ui_state.tick_scene_cameras(&tree, Instant::now());
3649
3650 let scene = core.rect_of_key("scene").expect("scene rect");
3651 let (cx, cy) = (scene.center_x(), scene.center_y());
3652
3653 let enter = core.pointer_moved(Pointer::moving(cx, cy));
3654 assert!(enter.needs_redraw, "entering a hover-label scene redraws");
3655 let within = core.pointer_moved(Pointer::moving(cx + 3.0, cy - 2.0));
3658 assert!(
3659 within.needs_redraw,
3660 "moving within a hover-label scene keeps redrawing the tooltip",
3661 );
3662 let leave = core.pointer_moved(Pointer::moving(scene.x + scene.w + 20.0, cy));
3664 assert!(leave.needs_redraw, "leaving clears the tooltip");
3665 }
3666
3667 #[test]
3668 fn pointer_moved_between_keyed_targets_emits_leave_then_enter() {
3669 let mut core = lay_out_input_tree(false);
3676 let btn = core.rect_of_key("btn").expect("btn rect");
3677 let ti = core.rect_of_key("ti").expect("ti rect");
3678
3679 let _ = core.pointer_moved(Pointer::moving(btn.x + 4.0, btn.y + 4.0));
3681
3682 let cross = core.pointer_moved(Pointer::moving(ti.x + 4.0, ti.y + 4.0));
3684 let kinds: Vec<UiEventKind> = cross.events.iter().map(|e| e.kind).collect();
3685 assert_eq!(
3686 kinds,
3687 vec![UiEventKind::PointerLeave, UiEventKind::PointerEnter],
3688 "paired Leave-then-Enter on cross-target hover transition",
3689 );
3690 assert_eq!(cross.events[0].key.as_deref(), Some("btn"));
3691 assert_eq!(cross.events[1].key.as_deref(), Some("ti"));
3692 assert!(cross.needs_redraw);
3693 }
3694
3695 #[test]
3696 fn touch_pointer_down_emits_pointer_enter_then_pointer_down() {
3697 let mut core = lay_out_input_tree(false);
3703 let btn = core.rect_of_key("btn").expect("btn rect");
3704 let cx = btn.x + btn.w * 0.5;
3705 let cy = btn.y + btn.h * 0.5;
3706 let events = core.pointer_down(Pointer::touch(
3707 cx,
3708 cy,
3709 PointerButton::Primary,
3710 PointerId::PRIMARY,
3711 ));
3712 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3713 assert_eq!(
3714 kinds,
3715 vec![UiEventKind::PointerEnter, UiEventKind::PointerDown],
3716 );
3717 for e in &events {
3718 assert_eq!(e.pointer_kind, Some(PointerKind::Touch));
3719 }
3720 assert_eq!(core.ui_state().hovered_key(), Some("btn"));
3721 }
3722
3723 #[test]
3724 fn touch_pointer_up_emits_pointer_leave_after_click() {
3725 let mut core = lay_out_input_tree(false);
3730 let btn = core.rect_of_key("btn").expect("btn rect");
3731 let cx = btn.x + btn.w * 0.5;
3732 let cy = btn.y + btn.h * 0.5;
3733 let _ = core.pointer_down(Pointer::touch(
3734 cx,
3735 cy,
3736 PointerButton::Primary,
3737 PointerId::PRIMARY,
3738 ));
3739 let events = core.pointer_up(Pointer::touch(
3740 cx,
3741 cy,
3742 PointerButton::Primary,
3743 PointerId::PRIMARY,
3744 ));
3745 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3746 assert_eq!(
3747 kinds,
3748 vec![
3749 UiEventKind::PointerUp,
3750 UiEventKind::Click,
3751 UiEventKind::PointerLeave,
3752 ],
3753 );
3754 assert_eq!(core.ui_state().hovered_key(), None);
3755 }
3756
3757 #[test]
3758 fn touch_pointer_moved_without_press_does_not_emit_hover_transitions() {
3759 let mut core = lay_out_input_tree(false);
3767 let btn = core.rect_of_key("btn").expect("btn rect");
3768 let mut p = Pointer::moving(btn.x + 4.0, btn.y + 4.0);
3769 p.kind = PointerKind::Touch;
3770 let moved = core.pointer_moved(p);
3771 assert!(
3772 moved.events.is_empty(),
3773 "touch move without press should not emit hover events, got {:?}",
3774 moved.events.iter().map(|e| e.kind).collect::<Vec<_>>(),
3775 );
3776 }
3777
3778 #[test]
3779 fn touch_drag_between_targets_still_emits_hover_transitions() {
3780 use crate::tree::*;
3791 let mut tree = crate::column([
3792 crate::widgets::button::button("Btn")
3793 .key("btn")
3794 .consumes_touch_drag(),
3795 crate::widgets::button::button("Other").key("other"),
3796 ])
3797 .padding(10.0);
3798 let mut core = RunnerCore::new();
3799 crate::layout::layout(
3800 &mut tree,
3801 &mut core.ui_state,
3802 Rect::new(0.0, 0.0, 200.0, 200.0),
3803 );
3804 core.ui_state.sync_focus_order(&tree);
3805 let mut t = PrepareTimings::default();
3806 core.snapshot(&tree, &mut t);
3807
3808 let btn = core.rect_of_key("btn").expect("btn rect");
3809 let other = core.rect_of_key("other").expect("other rect");
3810 let _ = core.pointer_down(Pointer::touch(
3811 btn.x + 4.0,
3812 btn.y + 4.0,
3813 PointerButton::Primary,
3814 PointerId::PRIMARY,
3815 ));
3816 let mut move_p = Pointer::moving(other.x + 4.0, other.y + 4.0);
3817 move_p.kind = PointerKind::Touch;
3818 let cross = core.pointer_moved(move_p);
3819 let kinds: Vec<UiEventKind> = cross.events.iter().map(|e| e.kind).collect();
3820 assert!(
3821 kinds.contains(&UiEventKind::PointerLeave)
3822 && kinds.contains(&UiEventKind::PointerEnter),
3823 "touch drag across targets should emit Leave + Enter, got {kinds:?}",
3824 );
3825 assert!(kinds.contains(&UiEventKind::Drag));
3829 }
3830
3831 #[test]
3832 fn would_press_focus_text_input_distinguishes_capture_keys() {
3833 let core = lay_out_input_tree(true);
3838 let ti = core.rect_of_key("ti").expect("ti rect");
3839 let btn = core.rect_of_key("btn").expect("btn rect");
3840
3841 assert!(
3842 core.would_press_focus_text_input(ti.center_x(), ti.center_y()),
3843 "press on capture_keys widget should report true",
3844 );
3845 assert!(
3846 !core.would_press_focus_text_input(btn.center_x(), btn.center_y()),
3847 "press on plain focusable should report false",
3848 );
3849 assert!(!core.would_press_focus_text_input(0.0, 0.0));
3851 }
3852
3853 #[test]
3854 fn touch_jiggle_below_threshold_still_taps() {
3855 let mut core = lay_out_input_tree(false);
3861 let btn = core.rect_of_key("btn").expect("btn rect");
3862 let cx = btn.x + btn.w * 0.5;
3863 let cy = btn.y + btn.h * 0.5;
3864 let _ = core.pointer_down(Pointer::touch(
3865 cx,
3866 cy,
3867 PointerButton::Primary,
3868 PointerId::PRIMARY,
3869 ));
3870 let mut jiggle = Pointer::moving(cx + 3.0, cy + 2.0);
3872 jiggle.kind = PointerKind::Touch;
3873 let _ = core.pointer_moved(jiggle);
3874 let events = core.pointer_up(Pointer::touch(
3875 cx + 3.0,
3876 cy + 2.0,
3877 PointerButton::Primary,
3878 PointerId::PRIMARY,
3879 ));
3880 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3881 assert!(
3882 kinds.contains(&UiEventKind::Click),
3883 "small jiggle should not commit to scroll, expected Click in {kinds:?}",
3884 );
3885 }
3886
3887 #[test]
3888 fn touch_drag_on_consuming_widget_emits_drag_not_cancel() {
3889 use crate::tree::*;
3894 let mut tree = crate::column([crate::widgets::button::button("Drag me")
3895 .key("draggable")
3896 .consumes_touch_drag()])
3897 .padding(10.0);
3898 let mut core = RunnerCore::new();
3899 crate::layout::layout(
3900 &mut tree,
3901 &mut core.ui_state,
3902 Rect::new(0.0, 0.0, 200.0, 200.0),
3903 );
3904 core.ui_state.sync_focus_order(&tree);
3905 let mut t = PrepareTimings::default();
3906 core.snapshot(&tree, &mut t);
3907
3908 let r = core.rect_of_key("draggable").expect("rect");
3909 let cx = r.x + r.w * 0.5;
3910 let cy = r.y + r.h * 0.5;
3911 let _ = core.pointer_down(Pointer::touch(
3912 cx,
3913 cy,
3914 PointerButton::Primary,
3915 PointerId::PRIMARY,
3916 ));
3917 let mut over = Pointer::moving(cx + 30.0, cy);
3920 over.kind = PointerKind::Touch;
3921 let moved = core.pointer_moved(over);
3922 let kinds: Vec<UiEventKind> = moved.events.iter().map(|e| e.kind).collect();
3923 assert!(
3924 kinds.contains(&UiEventKind::Drag),
3925 "drag-consuming widget should receive Drag past threshold, got {kinds:?}",
3926 );
3927 assert!(
3928 !kinds.contains(&UiEventKind::PointerCancel),
3929 "drag-consuming widget should not see PointerCancel, got {kinds:?}",
3930 );
3931 }
3932
3933 #[test]
3934 fn touch_drag_in_scrollable_cancels_press_and_scrolls() {
3935 use crate::tree::*;
3942 let mut tree = crate::scroll([
3943 crate::widgets::button::button("row 0")
3944 .key("row0")
3945 .height(Size::Fixed(50.0)),
3946 crate::widgets::button::button("row 1")
3947 .key("row1")
3948 .height(Size::Fixed(50.0)),
3949 crate::widgets::button::button("row 2")
3950 .key("row2")
3951 .height(Size::Fixed(50.0)),
3952 crate::widgets::button::button("row 3")
3953 .key("row3")
3954 .height(Size::Fixed(50.0)),
3955 crate::widgets::button::button("row 4")
3956 .key("row4")
3957 .height(Size::Fixed(50.0)),
3958 ])
3959 .key("list")
3960 .height(Size::Fixed(120.0));
3961 let mut core = RunnerCore::new();
3962 crate::layout::layout(
3963 &mut tree,
3964 &mut core.ui_state,
3965 Rect::new(0.0, 0.0, 200.0, 120.0),
3966 );
3967 core.ui_state.sync_focus_order(&tree);
3968 let mut t = PrepareTimings::default();
3969 core.snapshot(&tree, &mut t);
3970 let scroll_id = core
3971 .last_tree
3972 .as_ref()
3973 .map(|t| t.computed_id.clone())
3974 .expect("scroll id");
3975
3976 let row1 = core.rect_of_key("row1").expect("row1");
3981 let cx = row1.x + row1.w * 0.5;
3982 let cy = row1.y + row1.h * 0.5;
3983
3984 let down_events = core.pointer_down(Pointer::touch(
3986 cx,
3987 cy,
3988 PointerButton::Primary,
3989 PointerId::PRIMARY,
3990 ));
3991 assert!(
3993 down_events
3994 .iter()
3995 .any(|e| matches!(e.kind, UiEventKind::PointerDown)),
3996 "expected PointerDown on press",
3997 );
3998
3999 let mut up_finger = Pointer::moving(cx, cy - 40.0);
4003 up_finger.kind = PointerKind::Touch;
4004 let move_events = core.pointer_moved(up_finger);
4005 let kinds: Vec<UiEventKind> = move_events.events.iter().map(|e| e.kind).collect();
4006 assert!(
4007 kinds.contains(&UiEventKind::PointerCancel),
4008 "scroll commit should fire PointerCancel, got {kinds:?}",
4009 );
4010 assert!(
4011 !kinds.contains(&UiEventKind::Drag),
4012 "scroll commit should NOT emit Drag, got {kinds:?}",
4013 );
4014
4015 let offset = core.ui_state().scroll_offset(&scroll_id);
4017 assert!(
4018 offset > 30.0 && offset <= 50.0,
4019 "scroll offset should advance ~40px after a 40px finger drag, got {offset}",
4020 );
4021
4022 let up_events = core.pointer_up(Pointer::touch(
4025 cx,
4026 cy - 40.0,
4027 PointerButton::Primary,
4028 PointerId::PRIMARY,
4029 ));
4030 let up_kinds: Vec<UiEventKind> = up_events.iter().map(|e| e.kind).collect();
4031 assert!(
4032 !up_kinds.contains(&UiEventKind::Click),
4033 "scroll-committed gesture must not fire Click on release, got {up_kinds:?}",
4034 );
4035 }
4036
4037 #[test]
4038 fn touch_scroll_release_starts_momentum_after_fast_swipe_outside_viewport() {
4039 use crate::tree::*;
4040 let mut tree = crate::scroll((0..20).map(|i| {
4041 crate::widgets::button::button(format!("row {i}"))
4042 .key(format!("row{i}"))
4043 .height(Size::Fixed(50.0))
4044 }))
4045 .key("list")
4046 .height(Size::Fixed(120.0));
4047 let mut core = RunnerCore::new();
4048 crate::layout::layout(
4049 &mut tree,
4050 &mut core.ui_state,
4051 Rect::new(0.0, 0.0, 200.0, 120.0),
4052 );
4053 core.ui_state.sync_focus_order(&tree);
4054 let mut t = PrepareTimings::default();
4055 core.snapshot(&tree, &mut t);
4056 let scroll_id = core
4057 .last_tree
4058 .as_ref()
4059 .map(|t| t.computed_id.clone())
4060 .expect("scroll id");
4061
4062 let row1 = core.rect_of_key("row1").expect("row1");
4063 let cx = row1.x + row1.w * 0.5;
4064 let cy = row1.y + row1.h * 0.5;
4065
4066 core.pointer_down(Pointer::touch(
4067 cx,
4068 cy,
4069 PointerButton::Primary,
4070 PointerId::PRIMARY,
4071 ));
4072 let mut up_finger = Pointer::moving(cx, cy - 80.0);
4073 up_finger.kind = PointerKind::Touch;
4074 core.pointer_moved(up_finger);
4075 let before_release = core.ui_state().scroll_offset(&scroll_id);
4076 core.pointer_up(Pointer::touch(
4077 cx,
4078 cy - 80.0,
4079 PointerButton::Primary,
4080 PointerId::PRIMARY,
4081 ));
4082
4083 assert!(
4084 core.ui_state.has_scroll_momentum(),
4085 "quick touch scroll release should retain inertial velocity"
4086 );
4087 assert_eq!(
4088 core.next_input_deadline(Instant::now()),
4089 Some(Duration::ZERO),
4090 "active scroll momentum should request the next layout frame"
4091 );
4092
4093 let ticked = core
4094 .ui_state
4095 .tick_scroll_momentum(Instant::now() + Duration::from_millis(16));
4096 let after_tick = core.ui_state().scroll_offset(&scroll_id);
4097 assert!(ticked, "momentum tick should report visual work");
4098 assert!(
4099 after_tick > before_release,
4100 "momentum should continue in release direction: before={before_release}, after={after_tick}"
4101 );
4102 }
4103
4104 #[test]
4105 fn pointer_left_emits_leave_for_prior_hover() {
4106 let mut core = lay_out_input_tree(false);
4107 let btn = core.rect_of_key("btn").expect("btn rect");
4108 let _ = core.pointer_moved(Pointer::moving(btn.x + 4.0, btn.y + 4.0));
4109
4110 let events = core.pointer_left();
4111 assert_eq!(events.len(), 1);
4112 assert_eq!(events[0].kind, UiEventKind::PointerLeave);
4113 assert_eq!(events[0].key.as_deref(), Some("btn"));
4114 }
4115
4116 #[test]
4117 fn pointer_left_with_no_prior_hover_emits_nothing() {
4118 let mut core = lay_out_input_tree(false);
4119 let events = core.pointer_left();
4122 assert!(events.is_empty());
4123 }
4124
4125 #[test]
4126 fn poll_input_before_long_press_delay_emits_nothing() {
4127 let mut core = lay_out_input_tree(false);
4130 let btn = core.rect_of_key("btn").expect("btn rect");
4131 let cx = btn.x + btn.w * 0.5;
4132 let cy = btn.y + btn.h * 0.5;
4133 let _ = core.pointer_down(Pointer::touch(
4134 cx,
4135 cy,
4136 PointerButton::Primary,
4137 PointerId::PRIMARY,
4138 ));
4139 let polled = core.poll_input(Instant::now() + Duration::from_millis(100));
4141 assert!(polled.is_empty(), "should not fire before delay");
4142 }
4143
4144 #[test]
4145 fn poll_input_after_long_press_delay_fires_cancel_then_long_press() {
4146 let mut core = lay_out_input_tree(false);
4150 let btn = core.rect_of_key("btn").expect("btn rect");
4151 let cx = btn.x + btn.w * 0.5;
4152 let cy = btn.y + btn.h * 0.5;
4153 let _ = core.pointer_down(Pointer::touch(
4154 cx,
4155 cy,
4156 PointerButton::Primary,
4157 PointerId::PRIMARY,
4158 ));
4159 let polled = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
4160 let kinds: Vec<UiEventKind> = polled.iter().map(|e| e.kind).collect();
4161 assert!(
4162 kinds.contains(&UiEventKind::PointerCancel),
4163 "expected PointerCancel before LongPress, got {kinds:?}",
4164 );
4165 let long_press = polled
4166 .iter()
4167 .find(|e| matches!(e.kind, UiEventKind::LongPress))
4168 .expect("LongPress event missing");
4169 assert_eq!(
4170 long_press.key.as_deref(),
4171 Some("btn"),
4172 "LongPress should target the originally pressed node",
4173 );
4174 assert_eq!(
4175 long_press.pointer_kind,
4176 Some(PointerKind::Touch),
4177 "LongPress is touch-only",
4178 );
4179 }
4180
4181 #[test]
4182 fn touch_long_press_on_editable_preserves_drag_extension() {
4183 let mut core = lay_out_input_tree(true);
4184 let ti = core.rect_of_key("ti").expect("ti rect");
4185 let cx = ti.x + 4.0;
4186 let cy = ti.y + ti.h * 0.5;
4187 let _ = core.pointer_down(Pointer::touch(
4188 cx,
4189 cy,
4190 PointerButton::Primary,
4191 PointerId::PRIMARY,
4192 ));
4193
4194 let polled = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
4195 assert!(
4196 polled.iter().any(|e| e.kind == UiEventKind::LongPress),
4197 "editable long-press should emit LongPress"
4198 );
4199 assert!(
4200 !polled.iter().any(|e| e.kind == UiEventKind::PointerCancel),
4201 "editable long-press keeps the press captured so drag can extend selection"
4202 );
4203
4204 let mut moved = Pointer::moving(cx + 40.0, cy);
4205 moved.kind = PointerKind::Touch;
4206 let drag = core.pointer_moved(moved);
4207 assert!(
4208 drag.events.iter().any(|e| e.kind == UiEventKind::Drag),
4209 "held touch move after editable long-press should emit Drag"
4210 );
4211
4212 let up_events = core.pointer_up(Pointer::touch(
4213 cx + 40.0,
4214 cy,
4215 PointerButton::Primary,
4216 PointerId::PRIMARY,
4217 ));
4218 assert!(
4219 up_events.is_empty(),
4220 "long-press release should not synthesize click or pointer-up"
4221 );
4222 }
4223
4224 #[test]
4225 fn pointer_up_after_long_press_emits_no_click() {
4226 let mut core = lay_out_input_tree(false);
4230 let btn = core.rect_of_key("btn").expect("btn rect");
4231 let cx = btn.x + btn.w * 0.5;
4232 let cy = btn.y + btn.h * 0.5;
4233 let _ = core.pointer_down(Pointer::touch(
4234 cx,
4235 cy,
4236 PointerButton::Primary,
4237 PointerId::PRIMARY,
4238 ));
4239 let _ = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
4240 let up_events = core.pointer_up(Pointer::touch(
4241 cx,
4242 cy,
4243 PointerButton::Primary,
4244 PointerId::PRIMARY,
4245 ));
4246 assert!(
4247 up_events.is_empty(),
4248 "lift after long-press emits nothing, got {:?}",
4249 up_events.iter().map(|e| e.kind).collect::<Vec<_>>(),
4250 );
4251 }
4252
4253 #[test]
4254 fn moving_past_threshold_before_long_press_cancels_the_timer() {
4255 let mut core = lay_out_input_tree(false);
4260 let btn = core.rect_of_key("btn").expect("btn rect");
4261 let cx = btn.x + btn.w * 0.5;
4262 let cy = btn.y + btn.h * 0.5;
4263 let _ = core.pointer_down(Pointer::touch(
4264 cx,
4265 cy,
4266 PointerButton::Primary,
4267 PointerId::PRIMARY,
4268 ));
4269 let mut over = Pointer::moving(cx + 30.0, cy);
4271 over.kind = PointerKind::Touch;
4272 let _ = core.pointer_moved(over);
4273 let polled = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
4275 assert!(
4276 polled.is_empty(),
4277 "long-press should not fire after gesture committed",
4278 );
4279 }
4280
4281 #[test]
4282 fn ui_state_hovered_key_returns_leaf_key() {
4283 let mut core = lay_out_input_tree(false);
4284 assert_eq!(core.ui_state().hovered_key(), None);
4285
4286 let btn = core.rect_of_key("btn").expect("btn rect");
4287 core.pointer_moved(Pointer::moving(btn.x + 4.0, btn.y + 4.0));
4288 assert_eq!(core.ui_state().hovered_key(), Some("btn"));
4289
4290 core.pointer_moved(Pointer::moving(0.0, 0.0));
4292 assert_eq!(core.ui_state().hovered_key(), None);
4293 }
4294
4295 #[test]
4296 fn ui_state_is_hovering_within_walks_subtree() {
4297 use crate::tree::*;
4301 let mut tree = crate::column([crate::stack([
4302 crate::widgets::button::button("Inner").key("inner_btn")
4303 ])
4304 .key("card")
4305 .focusable()
4306 .width(Size::Fixed(120.0))
4307 .height(Size::Fixed(60.0))])
4308 .padding(20.0);
4309 let mut core = RunnerCore::new();
4310 crate::layout::layout(
4311 &mut tree,
4312 &mut core.ui_state,
4313 Rect::new(0.0, 0.0, 400.0, 200.0),
4314 );
4315 core.ui_state.sync_focus_order(&tree);
4316 let mut t = PrepareTimings::default();
4317 core.snapshot(&tree, &mut t);
4318
4319 assert!(!core.ui_state().is_hovering_within("card"));
4321 assert!(!core.ui_state().is_hovering_within("inner_btn"));
4322
4323 let inner = core.rect_of_key("inner_btn").expect("inner rect");
4326 core.pointer_moved(Pointer::moving(inner.x + 4.0, inner.y + 4.0));
4327 assert!(core.ui_state().is_hovering_within("card"));
4328 assert!(core.ui_state().is_hovering_within("inner_btn"));
4329
4330 assert!(!core.ui_state().is_hovering_within("not_a_key"));
4332
4333 core.pointer_moved(Pointer::moving(0.0, 0.0));
4335 assert!(!core.ui_state().is_hovering_within("card"));
4336 assert!(!core.ui_state().is_hovering_within("inner_btn"));
4337 }
4338
4339 #[test]
4340 fn hover_driven_scale_via_is_hovering_within_plus_animate() {
4341 use crate::Theme;
4348 use crate::anim::Timing;
4349 use crate::tree::*;
4350
4351 let build_card = |hovering: bool| -> El {
4354 let scale = if hovering { 1.05 } else { 1.0 };
4355 crate::column([crate::stack(
4356 [crate::widgets::button::button("Inner").key("inner_btn")],
4357 )
4358 .key("card")
4359 .focusable()
4360 .scale(scale)
4361 .animate(Timing::SPRING_QUICK)
4362 .width(Size::Fixed(120.0))
4363 .height(Size::Fixed(60.0))])
4364 .padding(20.0)
4365 };
4366
4367 let mut core = RunnerCore::new();
4368 core.ui_state
4371 .set_animation_mode(crate::state::AnimationMode::Settled);
4372
4373 let theme = Theme::default();
4375 let cx_pre = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
4376 assert!(!cx_pre.is_hovering_within("card"));
4377 let mut tree = build_card(cx_pre.is_hovering_within("card"));
4378 crate::layout::layout(
4379 &mut tree,
4380 &mut core.ui_state,
4381 Rect::new(0.0, 0.0, 400.0, 200.0),
4382 );
4383 core.ui_state.sync_focus_order(&tree);
4384 let mut t = PrepareTimings::default();
4385 core.snapshot(&tree, &mut t);
4386 core.ui_state
4387 .tick_visual_animations(&mut tree, web_time::Instant::now(), theme.palette());
4388 let card_at_rest = tree.children[0].clone();
4389 assert!((card_at_rest.scale - 1.0).abs() < 1e-3);
4390
4391 let card_rect = core.rect_of_key("card").expect("card rect");
4393 core.pointer_moved(Pointer::moving(card_rect.x + 4.0, card_rect.y + 4.0));
4394
4395 let cx_hot = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
4398 assert!(cx_hot.is_hovering_within("card"));
4399 let mut tree = build_card(cx_hot.is_hovering_within("card"));
4400 crate::layout::layout(
4401 &mut tree,
4402 &mut core.ui_state,
4403 Rect::new(0.0, 0.0, 400.0, 200.0),
4404 );
4405 core.ui_state.sync_focus_order(&tree);
4406 core.snapshot(&tree, &mut t);
4407 core.ui_state
4408 .tick_visual_animations(&mut tree, web_time::Instant::now(), theme.palette());
4409 let card_hot = tree.children[0].clone();
4410 assert!(
4411 (card_hot.scale - 1.05).abs() < 1e-3,
4412 "hover should drive card scale to 1.05 via animate; got {}",
4413 card_hot.scale,
4414 );
4415
4416 core.pointer_moved(Pointer::moving(0.0, 0.0));
4418 let cx_cold = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
4419 assert!(!cx_cold.is_hovering_within("card"));
4420 let mut tree = build_card(cx_cold.is_hovering_within("card"));
4421 crate::layout::layout(
4422 &mut tree,
4423 &mut core.ui_state,
4424 Rect::new(0.0, 0.0, 400.0, 200.0),
4425 );
4426 core.ui_state.sync_focus_order(&tree);
4427 core.snapshot(&tree, &mut t);
4428 core.ui_state
4429 .tick_visual_animations(&mut tree, web_time::Instant::now(), theme.palette());
4430 let card_after = tree.children[0].clone();
4431 assert!((card_after.scale - 1.0).abs() < 1e-3);
4432 }
4433
4434 #[test]
4435 fn file_dropped_routes_to_keyed_leaf_at_pointer() {
4436 let mut core = lay_out_input_tree(false);
4437 let btn = core.rect_of_key("btn").expect("btn rect");
4438 let path = std::path::PathBuf::from("/tmp/screenshot.png");
4439 let events = core.file_dropped(path.clone(), btn.x + 4.0, btn.y + 4.0);
4440 assert_eq!(events.len(), 1);
4441 let event = &events[0];
4442 assert_eq!(event.kind, UiEventKind::FileDropped);
4443 assert_eq!(event.key.as_deref(), Some("btn"));
4444 assert_eq!(event.path.as_deref(), Some(path.as_path()));
4445 assert_eq!(event.pointer, Some((btn.x + 4.0, btn.y + 4.0)));
4446 }
4447
4448 #[test]
4449 fn file_dropped_outside_keyed_surface_emits_window_level_event() {
4450 let mut core = lay_out_input_tree(false);
4451 let path = std::path::PathBuf::from("/tmp/screenshot.png");
4453 let events = core.file_dropped(path.clone(), 1.0, 1.0);
4454 assert_eq!(events.len(), 1);
4455 let event = &events[0];
4456 assert_eq!(event.kind, UiEventKind::FileDropped);
4457 assert!(
4458 event.target.is_none(),
4459 "drop outside any keyed surface routes window-level",
4460 );
4461 assert!(event.key.is_none());
4462 assert_eq!(event.path.as_deref(), Some(path.as_path()));
4464 }
4465
4466 #[test]
4467 fn file_hovered_then_cancelled_pair() {
4468 let mut core = lay_out_input_tree(false);
4469 let btn = core.rect_of_key("btn").expect("btn rect");
4470 let path = std::path::PathBuf::from("/tmp/a.png");
4471
4472 let hover = core.file_hovered(path.clone(), btn.x + 4.0, btn.y + 4.0);
4473 assert_eq!(hover.len(), 1);
4474 assert_eq!(hover[0].kind, UiEventKind::FileHovered);
4475 assert_eq!(hover[0].key.as_deref(), Some("btn"));
4476 assert_eq!(hover[0].path.as_deref(), Some(path.as_path()));
4477
4478 let cancel = core.file_hover_cancelled();
4479 assert_eq!(cancel.len(), 1);
4480 assert_eq!(cancel[0].kind, UiEventKind::FileHoverCancelled);
4481 assert!(cancel[0].target.is_none());
4482 assert!(cancel[0].path.is_none());
4483 }
4484
4485 #[test]
4486 fn build_cx_hover_accessors_default_off_without_state() {
4487 use crate::Theme;
4488 let theme = Theme::default();
4489 let cx = crate::BuildCx::new(&theme);
4490 assert_eq!(cx.hovered_key(), None);
4491 assert!(!cx.is_hovering_within("anything"));
4492 }
4493
4494 #[test]
4495 fn build_cx_hover_accessors_delegate_when_state_attached() {
4496 use crate::Theme;
4497 let mut core = lay_out_input_tree(false);
4498 let btn = core.rect_of_key("btn").expect("btn rect");
4499 core.pointer_moved(Pointer::moving(btn.x + 4.0, btn.y + 4.0));
4500
4501 let theme = Theme::default();
4502 let cx = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
4503 assert_eq!(cx.hovered_key(), Some("btn"));
4504 assert!(cx.is_hovering_within("btn"));
4505 assert!(!cx.is_hovering_within("ti"));
4506 }
4507
4508 #[test]
4509 fn build_cx_rect_of_key_reads_previous_layout() {
4510 use crate::Theme;
4511 let core = lay_out_input_tree(false);
4512 let theme = Theme::default();
4513
4514 let detached = crate::BuildCx::new(&theme);
4515 assert_eq!(detached.rect_of_key("btn"), None);
4516
4517 let cx = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
4518 assert_eq!(cx.rect_of_key("btn"), core.rect_of_key("btn"));
4519 assert_eq!(cx.rect_of_key("missing"), None);
4520 }
4521
4522 #[test]
4523 fn event_cx_rect_of_key_answers_from_laid_out_state() {
4524 let core = lay_out_input_tree(false);
4525
4526 let detached = crate::EventCx::new();
4527 assert_eq!(detached.rect_of_key("btn"), None);
4528
4529 let cx = crate::EventCx::new().with_ui_state(core.ui_state());
4530 let rect = cx.rect_of_key("btn").expect("btn rect via EventCx");
4531 assert_eq!(Some(rect), core.rect_of_key("btn"));
4532 assert_eq!(cx.rect_of_key("missing"), None);
4533 }
4534
4535 fn lay_out_paragraph_tree() -> RunnerCore {
4536 use crate::tree::*;
4537 let mut tree = crate::column([
4538 crate::widgets::text::text("First paragraph of text.")
4539 .key("p1")
4540 .selectable(),
4541 crate::widgets::text::text("Second paragraph of text.")
4542 .key("p2")
4543 .selectable(),
4544 ])
4545 .padding(20.0);
4546 let mut core = RunnerCore::new();
4547 crate::layout::layout(
4548 &mut tree,
4549 &mut core.ui_state,
4550 Rect::new(0.0, 0.0, 400.0, 300.0),
4551 );
4552 core.ui_state.sync_focus_order(&tree);
4553 core.ui_state.sync_selection_order(&tree);
4554 let mut t = PrepareTimings::default();
4555 core.snapshot(&tree, &mut t);
4556 core
4557 }
4558
4559 #[test]
4560 fn pointer_down_on_selectable_text_emits_selection_changed() {
4561 let mut core = lay_out_paragraph_tree();
4562 let p1 = core.rect_of_key("p1").expect("p1 rect");
4563 let cx = p1.x + 4.0;
4564 let cy = p1.y + p1.h * 0.5;
4565 let events = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4566 let sel_event = events
4567 .iter()
4568 .find(|e| e.kind == UiEventKind::SelectionChanged)
4569 .expect("SelectionChanged emitted");
4570 let new_sel = sel_event
4571 .selection
4572 .as_ref()
4573 .expect("SelectionChanged carries a selection");
4574 let range = new_sel.range.as_ref().expect("collapsed selection at hit");
4575 assert_eq!(range.anchor.key, "p1");
4576 assert_eq!(range.head.key, "p1");
4577 assert_eq!(range.anchor.byte, range.head.byte);
4578 assert!(core.ui_state.selection.drag.is_some());
4579 }
4580
4581 #[test]
4582 fn touch_long_press_on_selectable_text_selects_word_and_drags() {
4583 let mut core = lay_out_paragraph_tree();
4584 let p1 = core.rect_of_key("p1").expect("p1 rect");
4585 let p2 = core.rect_of_key("p2").expect("p2 rect");
4586 let x = p1.x + 4.0;
4587 let y = p1.y + p1.h * 0.5;
4588
4589 let _ = core.pointer_down(Pointer::touch(
4590 x,
4591 y,
4592 PointerButton::Primary,
4593 PointerId::PRIMARY,
4594 ));
4595 let polled = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
4596 let selection = polled
4597 .iter()
4598 .rev()
4599 .find(|e| e.kind == UiEventKind::SelectionChanged)
4600 .and_then(|e| e.selection.as_ref())
4601 .expect("touch long-press should select text");
4602 let range = selection.range.as_ref().expect("word selection");
4603 assert_eq!(range.anchor.key, "p1");
4604 assert_eq!(range.head.key, "p1");
4605 assert_eq!(range.anchor.byte, 0);
4606 assert_eq!(range.head.byte, 5);
4607 assert!(
4608 core.ui_state.selection.drag.is_some(),
4609 "long-pressed selectable text should stay ready for drag extension"
4610 );
4611
4612 let mut moved = Pointer::moving(p2.x + 8.0, p2.y + p2.h * 0.5);
4613 moved.kind = PointerKind::Touch;
4614 let events = core.pointer_moved(moved).events;
4615 let selection = events
4616 .iter()
4617 .find(|e| e.kind == UiEventKind::SelectionChanged)
4618 .and_then(|e| e.selection.as_ref())
4619 .unwrap_or(&core.ui_state.current_selection);
4620 let range = selection.range.as_ref().expect("extended selection");
4621 assert_eq!(range.anchor.key, "p1");
4622 assert_eq!(range.head.key, "p2");
4623
4624 let _ = core.pointer_up(Pointer::touch(
4625 p2.x + 8.0,
4626 p2.y + p2.h * 0.5,
4627 PointerButton::Primary,
4628 PointerId::PRIMARY,
4629 ));
4630 assert!(
4631 core.ui_state.selection.drag.is_none(),
4632 "selection drag should end on lift"
4633 );
4634 }
4635
4636 #[test]
4637 fn pointer_drag_on_selectable_text_extends_head() {
4638 let mut core = lay_out_paragraph_tree();
4639 let p1 = core.rect_of_key("p1").expect("p1 rect");
4640 let cx = p1.x + 4.0;
4641 let cy = p1.y + p1.h * 0.5;
4642 core.pointer_moved(Pointer::moving(cx, cy));
4643 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4644
4645 let events = core
4647 .pointer_moved(Pointer::moving(p1.x + p1.w - 10.0, cy))
4648 .events;
4649 let sel_event = events
4650 .iter()
4651 .find(|e| e.kind == UiEventKind::SelectionChanged)
4652 .expect("Drag emits SelectionChanged");
4653 let new_sel = sel_event.selection.as_ref().unwrap();
4654 let range = new_sel.range.as_ref().unwrap();
4655 assert_eq!(range.anchor.key, "p1");
4656 assert_eq!(range.head.key, "p1");
4657 assert!(
4658 range.head.byte > range.anchor.byte,
4659 "head should advance past anchor (anchor={}, head={})",
4660 range.anchor.byte,
4661 range.head.byte
4662 );
4663 }
4664
4665 #[test]
4666 fn double_click_hold_drag_inside_selectable_word_keeps_word_selected() {
4667 let mut core = lay_out_paragraph_tree();
4668 let p1 = core.rect_of_key("p1").expect("p1 rect");
4669 let cx = p1.x + 4.0;
4670 let cy = p1.y + p1.h * 0.5;
4671
4672 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4673 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
4674 let down = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4675 let sel = down
4676 .iter()
4677 .find(|e| e.kind == UiEventKind::SelectionChanged)
4678 .and_then(|e| e.selection.as_ref())
4679 .and_then(|s| s.range.as_ref())
4680 .expect("double-click selects word");
4681 assert_eq!(sel.anchor.byte, 0);
4682 assert_eq!(sel.head.byte, 5);
4683
4684 let events = core.pointer_moved(Pointer::moving(cx + 1.0, cy)).events;
4685 assert!(
4686 !events
4687 .iter()
4688 .any(|e| e.kind == UiEventKind::SelectionChanged),
4689 "drag jitter within the double-clicked word should not collapse the selection"
4690 );
4691 let range = core
4692 .ui_state
4693 .current_selection
4694 .range
4695 .as_ref()
4696 .expect("selection persists");
4697 assert_eq!(range.anchor.byte, 0);
4698 assert_eq!(range.head.byte, 5);
4699 }
4700
4701 #[test]
4702 fn pointer_up_clears_drag_but_keeps_selection() {
4703 let mut core = lay_out_paragraph_tree();
4704 let p1 = core.rect_of_key("p1").expect("p1 rect");
4705 let cx = p1.x + 4.0;
4706 let cy = p1.y + p1.h * 0.5;
4707 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4708 core.pointer_moved(Pointer::moving(p1.x + p1.w - 10.0, cy));
4709 let _ = core.pointer_up(Pointer::mouse(
4710 p1.x + p1.w - 10.0,
4711 cy,
4712 PointerButton::Primary,
4713 ));
4714 assert!(
4715 core.ui_state.selection.drag.is_none(),
4716 "drag flag should clear on pointer_up"
4717 );
4718 assert!(
4719 !core.ui_state.current_selection.is_empty(),
4720 "selection itself should persist after pointer_up"
4721 );
4722 }
4723
4724 #[test]
4725 fn drag_past_a_leaf_bottom_keeps_head_in_that_leaf_not_anchor() {
4726 let mut core = lay_out_paragraph_tree();
4732 let p1 = core.rect_of_key("p1").expect("p1 rect");
4733 let p2 = core.rect_of_key("p2").expect("p2 rect");
4734 core.pointer_down(Pointer::mouse(
4736 p1.x + 4.0,
4737 p1.y + p1.h * 0.5,
4738 PointerButton::Primary,
4739 ));
4740 core.pointer_moved(Pointer::moving(p2.x + 8.0, p2.y + p2.h * 0.5));
4742 let events = core
4745 .pointer_moved(Pointer::moving(p2.x + 8.0, p2.y + p2.h + 200.0))
4746 .events;
4747 let sel = events
4748 .iter()
4749 .find(|e| e.kind == UiEventKind::SelectionChanged)
4750 .map(|e| e.selection.as_ref().unwrap().clone())
4751 .unwrap_or_else(|| core.ui_state.current_selection.clone());
4754 let r = sel.range.as_ref().expect("selection still active");
4755 assert_eq!(r.anchor.key, "p1", "anchor unchanged");
4756 assert_eq!(
4757 r.head.key, "p2",
4758 "head must stay in p2 even when pointer is below p2's rect"
4759 );
4760 }
4761
4762 #[test]
4763 fn drag_into_a_sibling_selectable_extends_head_into_that_leaf() {
4764 let mut core = lay_out_paragraph_tree();
4765 let p1 = core.rect_of_key("p1").expect("p1 rect");
4766 let p2 = core.rect_of_key("p2").expect("p2 rect");
4767 core.pointer_down(Pointer::mouse(
4769 p1.x + 4.0,
4770 p1.y + p1.h * 0.5,
4771 PointerButton::Primary,
4772 ));
4773 let events = core
4775 .pointer_moved(Pointer::moving(p2.x + 8.0, p2.y + p2.h * 0.5))
4776 .events;
4777 let sel_event = events
4778 .iter()
4779 .find(|e| e.kind == UiEventKind::SelectionChanged)
4780 .expect("Drag emits SelectionChanged");
4781 let new_sel = sel_event.selection.as_ref().unwrap();
4782 let range = new_sel.range.as_ref().unwrap();
4783 assert_eq!(range.anchor.key, "p1", "anchor stays in p1");
4784 assert_eq!(range.head.key, "p2", "head migrates into p2");
4785 }
4786
4787 #[test]
4788 fn pointer_down_on_focusable_owning_selection_does_not_clear_it() {
4789 let mut core = lay_out_input_tree(true);
4797 core.set_selection(crate::selection::Selection::caret("ti", 3));
4800 let ti = core.rect_of_key("ti").expect("ti rect");
4801 let cx = ti.x + ti.w * 0.5;
4802 let cy = ti.y + ti.h * 0.5;
4803
4804 let events = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4805 let cleared = events.iter().find(|e| {
4806 e.kind == UiEventKind::SelectionChanged
4807 && e.selection.as_ref().map(|s| s.is_empty()).unwrap_or(false)
4808 });
4809 assert!(
4810 cleared.is_none(),
4811 "click on the selection-owning input must not emit a clearing SelectionChanged"
4812 );
4813 assert_eq!(
4814 core.ui_state.current_selection,
4815 crate::selection::Selection::caret("ti", 3),
4816 "runtime mirror is preserved when the click owns the selection"
4817 );
4818 }
4819
4820 #[test]
4821 fn pointer_down_into_a_different_capture_keys_widget_does_not_clear_first() {
4822 let mut core = lay_out_input_tree(true);
4832 core.set_selection(crate::selection::Selection::caret("other", 4));
4834 let ti = core.rect_of_key("ti").expect("ti rect");
4835 let cx = ti.x + ti.w * 0.5;
4836 let cy = ti.y + ti.h * 0.5;
4837
4838 let events = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4839 let cleared = events.iter().any(|e| {
4840 e.kind == UiEventKind::SelectionChanged
4841 && e.selection.as_ref().map(|s| s.is_empty()).unwrap_or(false)
4842 });
4843 assert!(
4844 !cleared,
4845 "click on a different capture_keys widget must not race-clear the selection"
4846 );
4847 }
4848
4849 #[test]
4850 fn pointer_down_on_non_selectable_clears_existing_selection() {
4851 let mut core = lay_out_paragraph_tree();
4852 let p1 = core.rect_of_key("p1").expect("p1 rect");
4853 let cy = p1.y + p1.h * 0.5;
4854 core.pointer_down(Pointer::mouse(p1.x + 4.0, cy, PointerButton::Primary));
4856 core.pointer_up(Pointer::mouse(p1.x + 4.0, cy, PointerButton::Primary));
4857 assert!(!core.ui_state.current_selection.is_empty());
4858
4859 let events = core.pointer_down(Pointer::mouse(2.0, 2.0, PointerButton::Primary));
4861 let cleared = events
4862 .iter()
4863 .find(|e| e.kind == UiEventKind::SelectionChanged)
4864 .expect("clearing emits SelectionChanged");
4865 let new_sel = cleared.selection.as_ref().unwrap();
4866 assert!(new_sel.is_empty(), "new selection should be empty");
4867 assert!(core.ui_state.current_selection.is_empty());
4868 }
4869
4870 #[test]
4871 fn pointer_down_in_dead_space_clears_focus() {
4872 let mut core = lay_out_input_tree(false);
4873 let btn = core.rect_of_key("btn").expect("btn rect");
4874 let cx = btn.x + btn.w * 0.5;
4875 let cy = btn.y + btn.h * 0.5;
4876 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4877 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
4878 assert_eq!(
4879 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4880 Some("btn")
4881 );
4882
4883 core.pointer_down(Pointer::mouse(2.0, 2.0, PointerButton::Primary));
4884
4885 assert_eq!(core.ui_state.focused.as_ref().map(|t| t.key.as_str()), None);
4886 }
4887
4888 #[test]
4889 fn key_down_bumps_caret_activity_when_focused_widget_captures_keys() {
4890 let mut core = lay_out_input_tree(true);
4895 let target = core
4896 .ui_state
4897 .focus
4898 .order
4899 .iter()
4900 .find(|t| t.key == "ti")
4901 .cloned();
4902 core.ui_state.set_focus(target); let after_focus = core.ui_state.caret.activity_at.expect("focus bump");
4904
4905 std::thread::sleep(std::time::Duration::from_millis(2));
4906 let _ = core.key_down(UiKey::ArrowRight, KeyModifiers::default(), false);
4907 let after_arrow = core
4908 .ui_state
4909 .caret
4910 .activity_at
4911 .expect("arrow key bumps even without app-side selection");
4912 assert!(
4913 after_arrow > after_focus,
4914 "ArrowRight to a capture_keys focused widget bumps caret activity"
4915 );
4916 }
4917
4918 #[test]
4919 fn text_input_bumps_caret_activity_when_focused() {
4920 let mut core = lay_out_input_tree(true);
4921 let target = core
4922 .ui_state
4923 .focus
4924 .order
4925 .iter()
4926 .find(|t| t.key == "ti")
4927 .cloned();
4928 core.ui_state.set_focus(target);
4929 let after_focus = core.ui_state.caret.activity_at.unwrap();
4930
4931 std::thread::sleep(std::time::Duration::from_millis(2));
4932 let _ = core.text_input("a".into());
4933 let after_text = core.ui_state.caret.activity_at.unwrap();
4934 assert!(
4935 after_text > after_focus,
4936 "TextInput to focused widget bumps caret activity"
4937 );
4938 }
4939
4940 #[test]
4941 fn pointer_down_inside_focused_input_bumps_caret_activity() {
4942 let mut core = lay_out_input_tree(true);
4947 let ti = core.rect_of_key("ti").expect("ti rect");
4948 let cx = ti.x + ti.w * 0.5;
4949 let cy = ti.y + ti.h * 0.5;
4950
4951 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4953 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
4954 let after_first = core.ui_state.caret.activity_at.unwrap();
4955
4956 std::thread::sleep(std::time::Duration::from_millis(2));
4959 core.pointer_down(Pointer::mouse(cx + 1.0, cy, PointerButton::Primary));
4960 let after_second = core
4961 .ui_state
4962 .caret
4963 .activity_at
4964 .expect("second click bumps too");
4965 assert!(
4966 after_second > after_first,
4967 "click within already-focused capture_keys widget still bumps"
4968 );
4969 }
4970
4971 #[test]
4972 fn arrow_key_through_apply_event_mutates_selection_and_bumps_on_set() {
4973 use crate::widgets::text_input;
4979 let mut sel = crate::selection::Selection::caret("ti", 2);
4980 let mut value = String::from("hello");
4981
4982 let mut core = RunnerCore::new();
4983 core.set_selection(sel.clone());
4986 let baseline = core.ui_state.caret.activity_at;
4987
4988 let arrow_right = UiEvent {
4990 key: Some("ti".into()),
4991 target: None,
4992 pointer: None,
4993 key_press: Some(crate::event::KeyPress {
4994 key: UiKey::ArrowRight,
4995 modifiers: KeyModifiers::default(),
4996 repeat: false,
4997 }),
4998 text: None,
4999 selection: None,
5000 modifiers: KeyModifiers::default(),
5001 click_count: 0,
5002 path: None,
5003 pointer_kind: None,
5004 wheel_delta: None,
5005 kind: UiEventKind::KeyDown,
5006 };
5007
5008 let mutated = text_input::apply_event(&mut value, &mut sel, "ti", &arrow_right);
5010 assert!(mutated, "ArrowRight should mutate selection");
5011 assert_eq!(
5012 sel.within("ti").unwrap().head,
5013 3,
5014 "head moved one char right (h-e-l-l-o, byte 2 → 3)"
5015 );
5016
5017 std::thread::sleep(std::time::Duration::from_millis(2));
5019 core.set_selection(sel);
5020 let after = core.ui_state.caret.activity_at.unwrap();
5021 if let Some(b) = baseline {
5025 assert!(after > b, "arrow-key flow should bump activity");
5026 }
5027 }
5028
5029 #[test]
5030 fn set_selection_bumps_caret_activity_only_when_value_changes() {
5031 let mut core = lay_out_paragraph_tree();
5032 core.set_selection(crate::selection::Selection::default());
5035 assert!(
5036 core.ui_state.caret.activity_at.is_none(),
5037 "no-op set_selection should not bump activity"
5038 );
5039
5040 let sel_a = crate::selection::Selection::caret("p1", 3);
5042 core.set_selection(sel_a.clone());
5043 let bumped_at = core
5044 .ui_state
5045 .caret
5046 .activity_at
5047 .expect("first real selection bumps");
5048
5049 core.set_selection(sel_a.clone());
5052 assert_eq!(
5053 core.ui_state.caret.activity_at,
5054 Some(bumped_at),
5055 "set_selection with same value is a no-op"
5056 );
5057
5058 std::thread::sleep(std::time::Duration::from_millis(2));
5061 let sel_b = crate::selection::Selection::caret("p1", 7);
5062 core.set_selection(sel_b);
5063 let new_bump = core.ui_state.caret.activity_at.expect("second bump");
5064 assert!(
5065 new_bump > bumped_at,
5066 "moving the caret bumps activity again",
5067 );
5068 }
5069
5070 #[test]
5071 fn escape_clears_active_selection_and_emits_selection_changed() {
5072 let mut core = lay_out_paragraph_tree();
5073 let p1 = core.rect_of_key("p1").expect("p1 rect");
5074 let cy = p1.y + p1.h * 0.5;
5075 core.pointer_down(Pointer::mouse(p1.x + 4.0, cy, PointerButton::Primary));
5077 core.pointer_moved(Pointer::moving(p1.x + p1.w - 10.0, cy));
5078 core.pointer_up(Pointer::mouse(
5079 p1.x + p1.w - 10.0,
5080 cy,
5081 PointerButton::Primary,
5082 ));
5083 assert!(!core.ui_state.current_selection.is_empty());
5084
5085 let events = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
5086 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
5087 assert_eq!(
5088 kinds,
5089 vec![UiEventKind::Escape, UiEventKind::SelectionChanged],
5090 "Esc emits Escape (for popover dismiss) AND SelectionChanged"
5091 );
5092 let cleared = events
5093 .iter()
5094 .find(|e| e.kind == UiEventKind::SelectionChanged)
5095 .unwrap();
5096 assert!(cleared.selection.as_ref().unwrap().is_empty());
5097 assert!(core.ui_state.current_selection.is_empty());
5098 }
5099
5100 #[test]
5101 fn consecutive_clicks_on_same_target_extend_count() {
5102 let mut core = lay_out_input_tree(false);
5103 let btn = core.rect_of_key("btn").expect("btn rect");
5104 let cx = btn.x + btn.w * 0.5;
5105 let cy = btn.y + btn.h * 0.5;
5106
5107 let down1 = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5109 let pd1 = down1
5110 .iter()
5111 .find(|e| e.kind == UiEventKind::PointerDown)
5112 .expect("PointerDown emitted");
5113 assert_eq!(pd1.click_count, 1, "first press starts the sequence");
5114 let up1 = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5115 let click1 = up1
5116 .iter()
5117 .find(|e| e.kind == UiEventKind::Click)
5118 .expect("Click emitted");
5119 assert_eq!(
5120 click1.click_count, 1,
5121 "Click carries the same count as its PointerDown"
5122 );
5123
5124 let down2 = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5126 let pd2 = down2
5127 .iter()
5128 .find(|e| e.kind == UiEventKind::PointerDown)
5129 .unwrap();
5130 assert_eq!(pd2.click_count, 2, "second press extends the sequence");
5131 let up2 = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5132 assert_eq!(
5133 up2.iter()
5134 .find(|e| e.kind == UiEventKind::Click)
5135 .unwrap()
5136 .click_count,
5137 2
5138 );
5139
5140 let down3 = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5142 let pd3 = down3
5143 .iter()
5144 .find(|e| e.kind == UiEventKind::PointerDown)
5145 .unwrap();
5146 assert_eq!(pd3.click_count, 3, "third press → triple-click");
5147 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5148 }
5149
5150 #[test]
5151 fn click_count_resets_when_target_changes() {
5152 let mut core = lay_out_input_tree(false);
5153 let btn = core.rect_of_key("btn").expect("btn rect");
5154 let ti = core.rect_of_key("ti").expect("ti rect");
5155
5156 let down1 = core.pointer_down(Pointer::mouse(
5158 btn.x + btn.w * 0.5,
5159 btn.y + btn.h * 0.5,
5160 PointerButton::Primary,
5161 ));
5162 assert_eq!(
5163 down1
5164 .iter()
5165 .find(|e| e.kind == UiEventKind::PointerDown)
5166 .unwrap()
5167 .click_count,
5168 1
5169 );
5170 let _ = core.pointer_up(Pointer::mouse(
5171 btn.x + btn.w * 0.5,
5172 btn.y + btn.h * 0.5,
5173 PointerButton::Primary,
5174 ));
5175
5176 let down2 = core.pointer_down(Pointer::mouse(
5178 ti.x + ti.w * 0.5,
5179 ti.y + ti.h * 0.5,
5180 PointerButton::Primary,
5181 ));
5182 let pd2 = down2
5183 .iter()
5184 .find(|e| e.kind == UiEventKind::PointerDown)
5185 .unwrap();
5186 assert_eq!(
5187 pd2.click_count, 1,
5188 "press on a new target resets the multi-click sequence"
5189 );
5190 }
5191
5192 #[test]
5193 fn double_click_on_selectable_text_selects_word_at_hit() {
5194 let mut core = lay_out_paragraph_tree();
5195 let p1 = core.rect_of_key("p1").expect("p1 rect");
5196 let cy = p1.y + p1.h * 0.5;
5197 let cx = p1.x + 4.0;
5200 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5201 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5202 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5203 let sel = &core.ui_state.current_selection;
5205 let r = sel.range.as_ref().expect("selection set");
5206 assert_eq!(r.anchor.key, "p1");
5207 assert_eq!(r.head.key, "p1");
5208 assert_eq!(r.anchor.byte.min(r.head.byte), 0);
5210 assert_eq!(r.anchor.byte.max(r.head.byte), 5);
5211 }
5212
5213 #[test]
5214 fn triple_click_on_selectable_text_selects_whole_leaf() {
5215 let mut core = lay_out_paragraph_tree();
5216 let p1 = core.rect_of_key("p1").expect("p1 rect");
5217 let cy = p1.y + p1.h * 0.5;
5218 let cx = p1.x + 4.0;
5219 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5220 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5221 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5222 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5223 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5224 let sel = &core.ui_state.current_selection;
5225 let r = sel.range.as_ref().expect("selection set");
5226 assert_eq!(r.anchor.byte, 0);
5227 assert_eq!(r.head.byte, 24);
5229 }
5230
5231 #[test]
5232 fn click_count_resets_when_press_drifts_outside_distance_window() {
5233 let mut core = lay_out_input_tree(false);
5234 let btn = core.rect_of_key("btn").expect("btn rect");
5235 let cx = btn.x + btn.w * 0.5;
5236 let cy = btn.y + btn.h * 0.5;
5237
5238 let _ = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5239 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5240
5241 let down2 = core.pointer_down(Pointer::mouse(cx + 10.0, cy, PointerButton::Primary));
5244 let pd2 = down2
5245 .iter()
5246 .find(|e| e.kind == UiEventKind::PointerDown)
5247 .unwrap();
5248 assert_eq!(pd2.click_count, 1);
5249 }
5250
5251 #[test]
5252 fn escape_with_no_selection_emits_only_escape() {
5253 let mut core = lay_out_paragraph_tree();
5254 assert!(core.ui_state.current_selection.is_empty());
5255 let events = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
5256 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
5257 assert_eq!(
5258 kinds,
5259 vec![UiEventKind::Escape],
5260 "no selection → no SelectionChanged side-effect"
5261 );
5262 }
5263
5264 fn lay_out_scroll_tree() -> (RunnerCore, String) {
5267 use crate::tree::*;
5268 let mut tree = crate::scroll(
5269 (0..6)
5270 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
5271 )
5272 .gap(12.0)
5273 .height(Size::Fixed(200.0));
5274 let mut core = RunnerCore::new();
5275 crate::layout::layout(
5276 &mut tree,
5277 &mut core.ui_state,
5278 Rect::new(0.0, 0.0, 300.0, 200.0),
5279 );
5280 let scroll_id = tree.computed_id.clone();
5281 let mut t = PrepareTimings::default();
5282 core.snapshot(&tree, &mut t);
5283 (core, scroll_id)
5284 }
5285
5286 #[test]
5287 fn thumb_pointer_down_captures_drag_and_suppresses_events() {
5288 let (mut core, scroll_id) = lay_out_scroll_tree();
5289 let thumb = core
5290 .ui_state
5291 .scroll
5292 .thumb_rects
5293 .get(&scroll_id)
5294 .copied()
5295 .expect("scrollable should have a thumb");
5296 let event = core.pointer_down(Pointer::mouse(
5297 thumb.x + thumb.w * 0.5,
5298 thumb.y + thumb.h * 0.5,
5299 PointerButton::Primary,
5300 ));
5301 assert!(
5302 event.is_empty(),
5303 "thumb press should not emit PointerDown to the app"
5304 );
5305 let drag = core
5306 .ui_state
5307 .scroll
5308 .thumb_drag
5309 .as_ref()
5310 .expect("scroll.thumb_drag should be set after pointer_down on thumb");
5311 assert_eq!(drag.scroll_id, scroll_id);
5312 }
5313
5314 #[test]
5315 fn track_click_above_thumb_pages_up_below_pages_down() {
5316 let (mut core, scroll_id) = lay_out_scroll_tree();
5317 let track = core
5318 .ui_state
5319 .scroll
5320 .thumb_tracks
5321 .get(&scroll_id)
5322 .copied()
5323 .expect("scrollable should have a track");
5324 let thumb = core
5325 .ui_state
5326 .scroll
5327 .thumb_rects
5328 .get(&scroll_id)
5329 .copied()
5330 .unwrap();
5331 let metrics = core
5332 .ui_state
5333 .scroll
5334 .metrics
5335 .get(&scroll_id)
5336 .copied()
5337 .unwrap();
5338
5339 let evt = core.pointer_down(Pointer::mouse(
5341 track.x + track.w * 0.5,
5342 thumb.y + thumb.h + 10.0,
5343 PointerButton::Primary,
5344 ));
5345 assert!(evt.is_empty(), "track press should not surface PointerDown");
5346 assert!(
5347 core.ui_state.scroll.thumb_drag.is_none(),
5348 "track click outside the thumb should not start a drag",
5349 );
5350 let after_down = core.ui_state.scroll_offset(&scroll_id);
5351 let expected_page = (metrics.viewport_h - SCROLL_PAGE_OVERLAP).max(0.0);
5352 assert!(
5353 (after_down - expected_page.min(metrics.max_offset)).abs() < 0.5,
5354 "page-down offset = {after_down} (expected ~{expected_page})",
5355 );
5356 let _ = core.pointer_up(Pointer::mouse(0.0, 0.0, PointerButton::Primary));
5358
5359 let mut tree = lay_out_scroll_tree_only();
5362 crate::layout::layout(
5363 &mut tree,
5364 &mut core.ui_state,
5365 Rect::new(0.0, 0.0, 300.0, 200.0),
5366 );
5367 let mut t = PrepareTimings::default();
5368 core.snapshot(&tree, &mut t);
5369 let track = core
5370 .ui_state
5371 .scroll
5372 .thumb_tracks
5373 .get(&tree.computed_id)
5374 .copied()
5375 .unwrap();
5376 let thumb = core
5377 .ui_state
5378 .scroll
5379 .thumb_rects
5380 .get(&tree.computed_id)
5381 .copied()
5382 .unwrap();
5383
5384 core.pointer_down(Pointer::mouse(
5385 track.x + track.w * 0.5,
5386 thumb.y - 4.0,
5387 PointerButton::Primary,
5388 ));
5389 let after_up = core.ui_state.scroll_offset(&tree.computed_id);
5390 assert!(
5391 after_up < after_down,
5392 "page-up should reduce offset: before={after_down} after={after_up}",
5393 );
5394 }
5395
5396 #[test]
5397 fn scrollbar_press_does_not_bypass_covering_scrim() {
5398 let (mut core, scroll_id) =
5399 lay_out_scroll_tree_with_layer(crate::widgets::overlay::scrim("modal:dismiss"));
5400 let thumb = core
5401 .ui_state
5402 .scroll
5403 .thumb_rects
5404 .get(&scroll_id)
5405 .copied()
5406 .expect("scrollable should have a thumb");
5407
5408 let events = core.pointer_down(Pointer::mouse(
5409 thumb.x + thumb.w * 0.5,
5410 thumb.y + thumb.h * 0.5,
5411 PointerButton::Primary,
5412 ));
5413
5414 assert_eq!(events.len(), 1);
5415 assert_eq!(events[0].kind, UiEventKind::PointerDown);
5416 assert_eq!(events[0].route(), Some("modal:dismiss"));
5417 assert!(
5418 core.ui_state.scroll.thumb_drag.is_none(),
5419 "covered scrollbar thumb must not capture drag",
5420 );
5421 }
5422
5423 #[test]
5424 fn scrollbar_press_does_not_bypass_block_pointer_layer() {
5425 use crate::tree::*;
5426
5427 let (mut core, scroll_id) =
5428 lay_out_scroll_tree_with_layer(El::new(Kind::Group).fill_size().block_pointer());
5429 let thumb = core
5430 .ui_state
5431 .scroll
5432 .thumb_rects
5433 .get(&scroll_id)
5434 .copied()
5435 .expect("scrollable should have a thumb");
5436
5437 let events = core.pointer_down(Pointer::mouse(
5438 thumb.x + thumb.w * 0.5,
5439 thumb.y + thumb.h * 0.5,
5440 PointerButton::Primary,
5441 ));
5442
5443 assert!(
5444 events.is_empty(),
5445 "block_pointer layer should swallow the press without scrolling",
5446 );
5447 assert!(
5448 core.ui_state.scroll.thumb_drag.is_none(),
5449 "covered scrollbar thumb must not capture drag",
5450 );
5451 }
5452
5453 fn lay_out_scroll_tree_only() -> El {
5458 use crate::tree::*;
5459 crate::scroll(
5460 (0..6)
5461 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
5462 )
5463 .gap(12.0)
5464 .height(Size::Fixed(200.0))
5465 }
5466
5467 fn lay_out_scroll_tree_with_layer(layer: El) -> (RunnerCore, String) {
5468 use crate::tree::*;
5469
5470 let scroll = lay_out_scroll_tree_only();
5471 let mut tree = crate::stack([scroll, layer]).fill_size();
5472 let mut core = RunnerCore::new();
5473 crate::layout::layout(
5474 &mut tree,
5475 &mut core.ui_state,
5476 Rect::new(0.0, 0.0, 300.0, 200.0),
5477 );
5478 let scroll_id = tree.children[0].computed_id.clone();
5479 let mut t = PrepareTimings::default();
5480 core.snapshot(&tree, &mut t);
5481 (core, scroll_id)
5482 }
5483
5484 #[test]
5485 fn thumb_drag_translates_pointer_delta_into_scroll_offset() {
5486 let (mut core, scroll_id) = lay_out_scroll_tree();
5487 let thumb = core
5488 .ui_state
5489 .scroll
5490 .thumb_rects
5491 .get(&scroll_id)
5492 .copied()
5493 .unwrap();
5494 let metrics = core
5495 .ui_state
5496 .scroll
5497 .metrics
5498 .get(&scroll_id)
5499 .copied()
5500 .unwrap();
5501 let track_remaining = (metrics.viewport_h - thumb.h).max(0.0);
5502
5503 let press_y = thumb.y + thumb.h * 0.5;
5504 core.pointer_down(Pointer::mouse(
5505 thumb.x + thumb.w * 0.5,
5506 press_y,
5507 PointerButton::Primary,
5508 ));
5509 let evt = core.pointer_moved(Pointer::moving(thumb.x + thumb.w * 0.5, press_y + 20.0));
5511 assert!(
5512 evt.events.is_empty(),
5513 "thumb-drag move should suppress Drag event",
5514 );
5515 let offset = core.ui_state.scroll_offset(&scroll_id);
5516 let expected = 20.0 * (metrics.max_offset / track_remaining);
5517 assert!(
5518 (offset - expected).abs() < 0.5,
5519 "offset {offset} (expected {expected})",
5520 );
5521 core.pointer_moved(Pointer::moving(thumb.x + thumb.w * 0.5, press_y + 9999.0));
5523 let offset = core.ui_state.scroll_offset(&scroll_id);
5524 assert!(
5525 (offset - metrics.max_offset).abs() < 0.5,
5526 "overshoot offset {offset} (expected {})",
5527 metrics.max_offset
5528 );
5529 let events = core.pointer_up(Pointer::mouse(thumb.x, press_y, PointerButton::Primary));
5531 assert!(events.is_empty(), "thumb release shouldn't emit events");
5532 assert!(core.ui_state.scroll.thumb_drag.is_none());
5533 }
5534
5535 #[test]
5536 fn secondary_click_does_not_steal_focus_or_press() {
5537 let mut core = lay_out_input_tree(false);
5538 let btn_rect = core.rect_of_key("btn").expect("btn rect");
5539 let cx = btn_rect.x + btn_rect.w * 0.5;
5540 let cy = btn_rect.y + btn_rect.h * 0.5;
5541 let ti_rect = core.rect_of_key("ti").expect("ti rect");
5543 let tx = ti_rect.x + ti_rect.w * 0.5;
5544 let ty = ti_rect.y + ti_rect.h * 0.5;
5545 core.pointer_down(Pointer::mouse(tx, ty, PointerButton::Primary));
5546 let _ = core.pointer_up(Pointer::mouse(tx, ty, PointerButton::Primary));
5547 let focused_before = core.ui_state.focused.as_ref().map(|t| t.key.clone());
5548 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Secondary));
5550 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Secondary));
5551 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
5552 assert_eq!(kinds, vec![UiEventKind::SecondaryClick]);
5553 let focused_after = core.ui_state.focused.as_ref().map(|t| t.key.clone());
5554 assert_eq!(
5555 focused_before, focused_after,
5556 "right-click must not steal focus"
5557 );
5558 assert!(
5559 core.ui_state.pressed.is_none(),
5560 "right-click must not set primary press"
5561 );
5562 }
5563
5564 #[test]
5565 fn text_input_routes_to_focused_only() {
5566 let mut core = lay_out_input_tree(false);
5567 assert!(core.text_input("a".into()).is_none());
5569 let btn_rect = core.rect_of_key("btn").expect("btn rect");
5571 let cx = btn_rect.x + btn_rect.w * 0.5;
5572 let cy = btn_rect.y + btn_rect.h * 0.5;
5573 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5574 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5575 let event = core.text_input("hi".into()).expect("focused → event");
5576 assert_eq!(event.kind, UiEventKind::TextInput);
5577 assert_eq!(event.text.as_deref(), Some("hi"));
5578 assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("btn"));
5579 assert!(core.text_input(String::new()).is_none());
5581 }
5582
5583 #[test]
5584 fn capture_keys_bypasses_tab_traversal_for_focused_node() {
5585 let mut core = lay_out_input_tree(true);
5588 let ti_rect = core.rect_of_key("ti").expect("ti rect");
5589 let tx = ti_rect.x + ti_rect.w * 0.5;
5590 let ty = ti_rect.y + ti_rect.h * 0.5;
5591 core.pointer_down(Pointer::mouse(tx, ty, PointerButton::Primary));
5592 let _ = core.pointer_up(Pointer::mouse(tx, ty, PointerButton::Primary));
5593 assert_eq!(
5594 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5595 Some("ti"),
5596 "primary click on capture_keys node still focuses it"
5597 );
5598
5599 let events = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
5600 assert_eq!(events.len(), 1, "Tab → exactly one KeyDown");
5601 let event = &events[0];
5602 assert_eq!(event.kind, UiEventKind::KeyDown);
5603 assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("ti"));
5604 assert_eq!(
5605 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5606 Some("ti"),
5607 "Tab inside capture_keys must NOT move focus"
5608 );
5609 }
5610
5611 #[test]
5612 fn escape_blurs_capture_keys_after_delivering_raw_keydown() {
5613 let mut core = lay_out_input_tree(true);
5614 let ti_rect = core.rect_of_key("ti").expect("ti rect");
5615 let tx = ti_rect.x + ti_rect.w * 0.5;
5616 let ty = ti_rect.y + ti_rect.h * 0.5;
5617 core.pointer_down(Pointer::mouse(tx, ty, PointerButton::Primary));
5618 let _ = core.pointer_up(Pointer::mouse(tx, ty, PointerButton::Primary));
5619 assert_eq!(
5620 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5621 Some("ti")
5622 );
5623
5624 let events = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
5625
5626 assert_eq!(events.len(), 1);
5627 let event = &events[0];
5628 assert_eq!(event.kind, UiEventKind::KeyDown);
5629 assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("ti"));
5630 assert!(matches!(
5631 event.key_press.as_ref().map(|p| &p.key),
5632 Some(UiKey::Escape)
5633 ));
5634 assert_eq!(core.ui_state.focused.as_ref().map(|t| t.key.as_str()), None);
5635 }
5636
5637 #[test]
5638 fn pointer_down_focus_does_not_raise_focus_visible() {
5639 let mut core = lay_out_input_tree(false);
5642 let btn_rect = core.rect_of_key("btn").expect("btn rect");
5643 let cx = btn_rect.x + btn_rect.w * 0.5;
5644 let cy = btn_rect.y + btn_rect.h * 0.5;
5645 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5646 assert_eq!(
5647 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5648 Some("btn"),
5649 "primary click focuses the button",
5650 );
5651 assert!(
5652 !core.ui_state.focus_visible,
5653 "click focus must not raise focus_visible — ring stays off",
5654 );
5655 }
5656
5657 #[test]
5658 fn tab_key_raises_focus_visible_so_ring_appears() {
5659 let mut core = lay_out_input_tree(false);
5660 let btn_rect = core.rect_of_key("btn").expect("btn rect");
5662 let cx = btn_rect.x + btn_rect.w * 0.5;
5663 let cy = btn_rect.y + btn_rect.h * 0.5;
5664 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5665 assert!(!core.ui_state.focus_visible);
5666 let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
5668 assert!(
5669 core.ui_state.focus_visible,
5670 "Tab must raise focus_visible so the ring paints on the new target",
5671 );
5672 }
5673
5674 #[test]
5675 fn click_after_tab_clears_focus_visible_again() {
5676 let mut core = lay_out_input_tree(false);
5679 let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
5680 assert!(core.ui_state.focus_visible, "Tab raises ring");
5681 let btn_rect = core.rect_of_key("btn").expect("btn rect");
5682 let cx = btn_rect.x + btn_rect.w * 0.5;
5683 let cy = btn_rect.y + btn_rect.h * 0.5;
5684 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5685 assert!(
5686 !core.ui_state.focus_visible,
5687 "pointer-down clears focus_visible — ring fades back out",
5688 );
5689 }
5690
5691 #[test]
5692 fn keypress_on_focused_widget_raises_focus_visible_after_click() {
5693 let mut core = lay_out_input_tree(false);
5697 let btn_rect = core.rect_of_key("btn").expect("btn rect");
5698 let cx = btn_rect.x + btn_rect.w * 0.5;
5699 let cy = btn_rect.y + btn_rect.h * 0.5;
5700 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5701 assert!(!core.ui_state.focus_visible);
5702 let _ = core.key_down(UiKey::ArrowRight, KeyModifiers::default(), false);
5703 assert!(
5704 core.ui_state.focus_visible,
5705 "non-Tab key on focused widget raises focus_visible",
5706 );
5707 }
5708
5709 #[test]
5710 fn selected_text_resolves_a_selection_inside_a_virtual_list() {
5711 use crate::selection::{Selection, SelectionPoint, SelectionRange};
5718 use crate::tree::*;
5719
5720 let mut tree = virtual_list_dyn(
5724 20,
5725 50.0,
5726 |i| format!("row-{i}"),
5727 |i| {
5728 crate::widgets::text::text(format!("row {i} text"))
5729 .key(format!("row-{i}"))
5730 .selectable()
5731 .height(Size::Fixed(50.0))
5732 },
5733 );
5734 let mut core = RunnerCore::new();
5735 crate::layout::layout(
5736 &mut tree,
5737 &mut core.ui_state,
5738 Rect::new(0.0, 0.0, 200.0, 200.0),
5739 );
5740 let mut t = PrepareTimings::default();
5741 core.snapshot(&tree, &mut t);
5742
5743 let selection = Selection {
5745 range: Some(SelectionRange {
5746 anchor: SelectionPoint::new("row-1", 0),
5747 head: SelectionPoint::new("row-1", 9),
5748 }),
5749 };
5750 core.set_selection(selection);
5751
5752 assert_eq!(
5753 core.selected_text().as_deref(),
5754 Some("row 1 tex"),
5755 "runtime.selected_text() must walk last_tree (realized rows) — \
5756 a build-only path would miss virtual_list children entirely",
5757 );
5758 }
5759
5760 #[test]
5761 fn shortcut_chord_does_not_raise_focus_visible() {
5762 let mut core = lay_out_input_tree(false);
5769 let btn_rect = core.rect_of_key("btn").expect("btn rect");
5770 let cx = btn_rect.x + btn_rect.w * 0.5;
5771 let cy = btn_rect.y + btn_rect.h * 0.5;
5772 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5773 assert!(!core.ui_state.focus_visible);
5774
5775 let ctrl = KeyModifiers {
5776 ctrl: true,
5777 ..Default::default()
5778 };
5779 let _ = core.key_down(UiKey::Other("Control".into()), ctrl, false);
5780 assert!(
5781 !core.ui_state.focus_visible,
5782 "bare Ctrl press must not raise focus_visible on a pointer-focused widget",
5783 );
5784 let _ = core.key_down(UiKey::Character("c".into()), ctrl, false);
5785 assert!(
5786 !core.ui_state.focus_visible,
5787 "Ctrl+C is a shortcut, not interaction with the focused widget",
5788 );
5789
5790 let _ = core.key_down(UiKey::Other("Shift".into()), KeyModifiers::default(), false);
5791 assert!(
5792 !core.ui_state.focus_visible,
5793 "bare Shift press must not raise focus_visible",
5794 );
5795 let _ = core.key_down(UiKey::Character("a".into()), KeyModifiers::default(), false);
5796 assert!(
5797 !core.ui_state.focus_visible,
5798 "bare character keys are typing/activation guesses, not navigation",
5799 );
5800 let _ = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
5801 assert!(
5802 !core.ui_state.focus_visible,
5803 "Escape is dismissal, not navigation — no ring",
5804 );
5805 }
5806
5807 #[test]
5808 fn arrow_nav_in_sibling_group_raises_focus_visible() {
5809 let mut core = lay_out_arrow_nav_tree();
5810 core.ui_state.set_focus_visible(false);
5813 let _ = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
5814 assert!(
5815 core.ui_state.focus_visible,
5816 "arrow-nav within an arrow_nav_siblings group is keyboard navigation",
5817 );
5818 }
5819
5820 #[test]
5821 fn capture_keys_falls_back_to_default_when_focus_off_capturing_node() {
5822 let mut core = lay_out_input_tree(true);
5826 let btn_rect = core.rect_of_key("btn").expect("btn rect");
5827 let cx = btn_rect.x + btn_rect.w * 0.5;
5828 let cy = btn_rect.y + btn_rect.h * 0.5;
5829 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5830 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5831 assert_eq!(
5832 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5833 Some("btn"),
5834 "primary click focuses button"
5835 );
5836 let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
5838 assert_eq!(
5839 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5840 Some("ti"),
5841 "Tab from non-capturing focused does library-default traversal"
5842 );
5843 }
5844
5845 fn lay_out_arrow_nav_tree() -> RunnerCore {
5850 use crate::tree::*;
5851 let mut tree = crate::column([
5852 crate::widgets::button::button("Red").key("opt-red"),
5853 crate::widgets::button::button("Green").key("opt-green"),
5854 crate::widgets::button::button("Blue").key("opt-blue"),
5855 ])
5856 .arrow_nav_siblings()
5857 .padding(10.0);
5858 let mut core = RunnerCore::new();
5859 crate::layout::layout(
5860 &mut tree,
5861 &mut core.ui_state,
5862 Rect::new(0.0, 0.0, 200.0, 300.0),
5863 );
5864 core.ui_state.sync_focus_order(&tree);
5865 let mut t = PrepareTimings::default();
5866 core.snapshot(&tree, &mut t);
5867 let target = core
5870 .ui_state
5871 .focus
5872 .order
5873 .iter()
5874 .find(|t| t.key == "opt-green")
5875 .cloned();
5876 core.ui_state.set_focus(target);
5877 core
5878 }
5879
5880 #[test]
5881 fn arrow_nav_moves_focus_among_siblings() {
5882 let mut core = lay_out_arrow_nav_tree();
5883
5884 let down = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
5887 assert!(down.is_empty(), "arrow-nav consumes the key event");
5888 assert_eq!(
5889 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5890 Some("opt-blue"),
5891 );
5892
5893 core.key_down(UiKey::ArrowUp, KeyModifiers::default(), false);
5895 assert_eq!(
5896 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5897 Some("opt-green"),
5898 );
5899
5900 core.key_down(UiKey::Home, KeyModifiers::default(), false);
5902 assert_eq!(
5903 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5904 Some("opt-red"),
5905 );
5906
5907 core.key_down(UiKey::End, KeyModifiers::default(), false);
5909 assert_eq!(
5910 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5911 Some("opt-blue"),
5912 );
5913 }
5914
5915 #[test]
5916 fn arrow_nav_saturates_at_ends() {
5917 let mut core = lay_out_arrow_nav_tree();
5918 core.key_down(UiKey::Home, KeyModifiers::default(), false);
5920 core.key_down(UiKey::ArrowUp, KeyModifiers::default(), false);
5921 assert_eq!(
5922 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5923 Some("opt-red"),
5924 "ArrowUp at top stays at top — no wrap",
5925 );
5926 core.key_down(UiKey::End, KeyModifiers::default(), false);
5928 core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
5929 assert_eq!(
5930 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5931 Some("opt-blue"),
5932 "ArrowDown at bottom stays at bottom — no wrap",
5933 );
5934 }
5935
5936 fn build_popover_tree(open: bool) -> El {
5940 use crate::widgets::button::button;
5941 use crate::widgets::overlay::overlay;
5942 use crate::widgets::popover::{dropdown, menu_item};
5943 let mut layers: Vec<El> = vec![button("Trigger").key("trigger")];
5944 if open {
5945 layers.push(dropdown(
5946 "menu",
5947 "trigger",
5948 [
5949 menu_item("A").key("item-a"),
5950 menu_item("B").key("item-b"),
5951 menu_item("C").key("item-c"),
5952 ],
5953 ));
5954 }
5955 overlay(layers).padding(20.0)
5956 }
5957
5958 fn run_frame(core: &mut RunnerCore, tree: &mut El) {
5962 let mut t = PrepareTimings::default();
5963 core.prepare_layout(
5964 tree,
5965 Rect::new(0.0, 0.0, 400.0, 300.0),
5966 1.0,
5967 &mut t,
5968 RunnerCore::no_time_shaders,
5969 );
5970 core.snapshot(tree, &mut t);
5971 }
5972
5973 #[test]
5974 fn popover_open_pushes_focus_and_auto_focuses_first_item() {
5975 let mut core = RunnerCore::new();
5976 let mut closed = build_popover_tree(false);
5977 run_frame(&mut core, &mut closed);
5978 let trigger = core
5981 .ui_state
5982 .focus
5983 .order
5984 .iter()
5985 .find(|t| t.key == "trigger")
5986 .cloned();
5987 core.ui_state.set_focus(trigger);
5988 assert_eq!(
5989 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5990 Some("trigger"),
5991 );
5992
5993 let mut open = build_popover_tree(true);
5996 run_frame(&mut core, &mut open);
5997 assert_eq!(
5998 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5999 Some("item-a"),
6000 "popover open should auto-focus the first menu item",
6001 );
6002 assert_eq!(
6003 core.ui_state.popover_focus.focus_stack.len(),
6004 1,
6005 "trigger should be saved on the focus stack",
6006 );
6007 assert_eq!(
6008 core.ui_state.popover_focus.focus_stack[0].key.as_str(),
6009 "trigger",
6010 "saved focus should be the pre-open target",
6011 );
6012 }
6013
6014 #[test]
6015 fn popover_close_restores_focus_to_trigger() {
6016 let mut core = RunnerCore::new();
6017 let mut closed = build_popover_tree(false);
6018 run_frame(&mut core, &mut closed);
6019 let trigger = core
6020 .ui_state
6021 .focus
6022 .order
6023 .iter()
6024 .find(|t| t.key == "trigger")
6025 .cloned();
6026 core.ui_state.set_focus(trigger);
6027
6028 let mut open = build_popover_tree(true);
6030 run_frame(&mut core, &mut open);
6031 assert_eq!(
6032 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6033 Some("item-a"),
6034 );
6035
6036 let mut closed_again = build_popover_tree(false);
6038 run_frame(&mut core, &mut closed_again);
6039 assert_eq!(
6040 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6041 Some("trigger"),
6042 "closing the popover should pop the saved focus",
6043 );
6044 assert!(
6045 core.ui_state.popover_focus.focus_stack.is_empty(),
6046 "focus stack should be drained after restore",
6047 );
6048 }
6049
6050 #[test]
6051 fn popover_close_does_not_override_intentional_focus_move() {
6052 let mut core = RunnerCore::new();
6053 let build = |open: bool| -> El {
6056 use crate::widgets::button::button;
6057 use crate::widgets::overlay::overlay;
6058 use crate::widgets::popover::{dropdown, menu_item};
6059 let main = crate::row([
6060 button("Trigger").key("trigger"),
6061 button("Other").key("other"),
6062 ]);
6063 let mut layers: Vec<El> = vec![main];
6064 if open {
6065 layers.push(dropdown("menu", "trigger", [menu_item("A").key("item-a")]));
6066 }
6067 overlay(layers).padding(20.0)
6068 };
6069
6070 let mut closed = build(false);
6071 run_frame(&mut core, &mut closed);
6072 let trigger = core
6073 .ui_state
6074 .focus
6075 .order
6076 .iter()
6077 .find(|t| t.key == "trigger")
6078 .cloned();
6079 core.ui_state.set_focus(trigger);
6080
6081 let mut open = build(true);
6082 run_frame(&mut core, &mut open);
6083 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
6084
6085 let other = core
6090 .ui_state
6091 .focus
6092 .order
6093 .iter()
6094 .find(|t| t.key == "other")
6095 .cloned();
6096 core.ui_state.set_focus(other);
6097
6098 let mut closed_again = build(false);
6099 run_frame(&mut core, &mut closed_again);
6100 assert_eq!(
6101 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6102 Some("other"),
6103 "focus moved before close should not be overridden by restore",
6104 );
6105 assert!(core.ui_state.popover_focus.focus_stack.is_empty());
6106 }
6107
6108 #[test]
6109 fn nested_popovers_stack_and_unwind_focus_correctly() {
6110 let mut core = RunnerCore::new();
6111 let build = |outer: bool, inner: bool| -> El {
6116 use crate::widgets::button::button;
6117 use crate::widgets::overlay::overlay;
6118 use crate::widgets::popover::{Anchor, popover, popover_panel};
6119 let main = button("Trigger").key("trigger");
6120 let mut layers: Vec<El> = vec![main];
6121 if outer {
6122 layers.push(popover(
6123 "outer",
6124 Anchor::below_key("trigger"),
6125 popover_panel([button("Open inner").key("inner-trigger")]),
6126 ));
6127 }
6128 if inner {
6129 layers.push(popover(
6130 "inner",
6131 Anchor::below_key("inner-trigger"),
6132 popover_panel([button("X").key("inner-a"), button("Y").key("inner-b")]),
6133 ));
6134 }
6135 overlay(layers).padding(20.0)
6136 };
6137
6138 let mut closed = build(false, false);
6140 run_frame(&mut core, &mut closed);
6141 let trigger = core
6142 .ui_state
6143 .focus
6144 .order
6145 .iter()
6146 .find(|t| t.key == "trigger")
6147 .cloned();
6148 core.ui_state.set_focus(trigger);
6149
6150 let mut outer = build(true, false);
6152 run_frame(&mut core, &mut outer);
6153 assert_eq!(
6154 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6155 Some("inner-trigger"),
6156 );
6157 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
6158
6159 let mut both = build(true, true);
6161 run_frame(&mut core, &mut both);
6162 assert_eq!(
6163 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6164 Some("inner-a"),
6165 );
6166 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 2);
6167
6168 let mut outer_only = build(true, false);
6170 run_frame(&mut core, &mut outer_only);
6171 assert_eq!(
6172 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6173 Some("inner-trigger"),
6174 );
6175 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
6176
6177 let mut none = build(false, false);
6179 run_frame(&mut core, &mut none);
6180 assert_eq!(
6181 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6182 Some("trigger"),
6183 );
6184 assert!(core.ui_state.popover_focus.focus_stack.is_empty());
6185 }
6186
6187 #[test]
6188 fn arrow_nav_does_not_intercept_outside_navigable_groups() {
6189 let mut core = lay_out_input_tree(false);
6193 let target = core
6194 .ui_state
6195 .focus
6196 .order
6197 .iter()
6198 .find(|t| t.key == "btn")
6199 .cloned();
6200 core.ui_state.set_focus(target);
6201 let events = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
6202 assert_eq!(
6203 events.len(),
6204 1,
6205 "ArrowDown without navigable parent → event"
6206 );
6207 assert_eq!(events[0].kind, UiEventKind::KeyDown);
6208 }
6209
6210 fn quad(shader: ShaderHandle) -> DrawOp {
6211 DrawOp::Quad {
6212 id: "q".into(),
6213 rect: Rect::new(0.0, 0.0, 10.0, 10.0),
6214 scissor: None,
6215 shader,
6216 uniforms: UniformBlock::new(),
6217 }
6218 }
6219
6220 #[test]
6221 fn prepare_paint_skips_ops_outside_viewport() {
6222 let mut core = RunnerCore::new();
6223 core.set_surface_size(100, 100);
6224 core.viewport_px = (100, 100);
6225 let ops = vec![
6226 DrawOp::Quad {
6227 id: "offscreen".into(),
6228 rect: Rect::new(0.0, 150.0, 10.0, 10.0),
6229 scissor: None,
6230 shader: ShaderHandle::Stock(StockShader::RoundedRect),
6231 uniforms: UniformBlock::new(),
6232 },
6233 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
6234 ];
6235 let mut timings = PrepareTimings::default();
6236 core.prepare_paint(&ops, |_| true, |_| false, &mut NoText, 1.0, &mut timings);
6237
6238 assert_eq!(timings.paint_culled_ops, 1);
6239 assert_eq!(
6240 core.runs.len(),
6241 1,
6242 "only the visible quad should become a paint run"
6243 );
6244 }
6245
6246 #[test]
6247 fn prepare_paint_does_not_shape_text_outside_clip() {
6248 let mut core = RunnerCore::new();
6249 core.set_surface_size(100, 100);
6250 core.viewport_px = (100, 100);
6251 let ops = vec![
6252 DrawOp::GlyphRun {
6253 id: "offscreen-text".into(),
6254 rect: Rect::new(0.0, 150.0, 80.0, 20.0),
6255 scissor: Some(Rect::new(0.0, 0.0, 100.0, 100.0)),
6256 shader: ShaderHandle::Stock(StockShader::Text),
6257 color: Color::srgb_u8a(255, 255, 255, 255),
6258 text: "offscreen".into(),
6259 size: 14.0,
6260 line_height: 20.0,
6261 family: Default::default(),
6262 mono_family: Default::default(),
6263 weight: FontWeight::Regular,
6264 mono: false,
6265 wrap: TextWrap::NoWrap,
6266 anchor: TextAnchor::Start,
6267 layout: empty_text_layout(20.0),
6268 underline: false,
6269 strikethrough: false,
6270 link: None,
6271 },
6272 DrawOp::GlyphRun {
6273 id: "visible-text".into(),
6274 rect: Rect::new(0.0, 10.0, 80.0, 20.0),
6275 scissor: Some(Rect::new(0.0, 0.0, 100.0, 100.0)),
6276 shader: ShaderHandle::Stock(StockShader::Text),
6277 color: Color::srgb_u8a(255, 255, 255, 255),
6278 text: "visible".into(),
6279 size: 14.0,
6280 line_height: 20.0,
6281 family: Default::default(),
6282 mono_family: Default::default(),
6283 weight: FontWeight::Regular,
6284 mono: false,
6285 wrap: TextWrap::NoWrap,
6286 anchor: TextAnchor::Start,
6287 layout: empty_text_layout(20.0),
6288 underline: false,
6289 strikethrough: false,
6290 link: None,
6291 },
6292 ];
6293 let mut text = CountingText::default();
6294 let mut timings = PrepareTimings::default();
6295 core.prepare_paint(&ops, |_| true, |_| false, &mut text, 1.0, &mut timings);
6296
6297 assert_eq!(timings.paint_culled_ops, 1);
6298 assert_eq!(text.records, 1, "offscreen text must not be shaped");
6299 }
6300
6301 #[test]
6302 fn samples_backdrop_inserts_snapshot_before_first_glass_quad() {
6303 let mut core = RunnerCore::new();
6304 core.set_surface_size(100, 100);
6305 let ops = vec![
6306 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
6307 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
6308 quad(ShaderHandle::Custom("liquid_glass")),
6309 quad(ShaderHandle::Custom("liquid_glass")),
6310 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
6311 ];
6312 let mut timings = PrepareTimings::default();
6313 core.prepare_paint(
6314 &ops,
6315 |_| true,
6316 |s| matches!(s, ShaderHandle::Custom(name) if *name == "liquid_glass"),
6317 &mut NoText,
6318 1.0,
6319 &mut timings,
6320 );
6321
6322 let kinds: Vec<&'static str> = core
6323 .paint_items
6324 .iter()
6325 .map(|p| match p {
6326 PaintItem::QuadRun(_) => "Q",
6327 PaintItem::IconRun(_) => "I",
6328 PaintItem::Text(_) => "T",
6329 PaintItem::Image(_) => "M",
6330 PaintItem::AppTexture(_) => "A",
6331 PaintItem::Vector(_) => "V",
6332 PaintItem::Scene3D(_) => "3",
6333 PaintItem::BackdropSnapshot => "S",
6334 })
6335 .collect();
6336 assert_eq!(
6337 kinds,
6338 vec!["Q", "S", "Q", "Q"],
6339 "expected one stock run, snapshot, then a glass run, then a foreground stock run"
6340 );
6341 }
6342
6343 #[test]
6344 fn no_snapshot_when_no_glass_drawn() {
6345 let mut core = RunnerCore::new();
6346 core.set_surface_size(100, 100);
6347 let ops = vec![
6348 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
6349 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
6350 ];
6351 let mut timings = PrepareTimings::default();
6352 core.prepare_paint(&ops, |_| true, |_| false, &mut NoText, 1.0, &mut timings);
6353 assert!(
6354 !core
6355 .paint_items
6356 .iter()
6357 .any(|p| matches!(p, PaintItem::BackdropSnapshot)),
6358 "no glass shader registered → no snapshot"
6359 );
6360 }
6361
6362 #[test]
6367 fn scene3d_op_emits_paint_item_only_when_recorder_records() {
6368 use crate::scene::{Aabb, CameraState, LightRig, Scene3DData, SceneStyle};
6369
6370 fn scene_op() -> DrawOp {
6371 let scene = Scene3DData {
6372 meshes: Vec::new(),
6373 points: Vec::new(),
6374 lines: Vec::new(),
6375 camera: CameraState::default().resolve(Aabb::EMPTY),
6376 lights: LightRig::default(),
6377 style: SceneStyle::default(),
6378 capture_depth: false,
6379 };
6380 DrawOp::Scene3D {
6381 id: "scene".into(),
6382 rect: Rect::new(0.0, 0.0, 40.0, 40.0),
6383 scissor: None,
6384 scene: std::sync::Arc::new(scene),
6385 }
6386 }
6387
6388 let mut core = RunnerCore::new();
6390 core.set_surface_size(100, 100);
6391 let mut timings = PrepareTimings::default();
6392 core.prepare_paint(
6393 &[scene_op()],
6394 |_| true,
6395 |_| false,
6396 &mut NoText,
6397 1.0,
6398 &mut timings,
6399 );
6400 assert!(
6401 !core
6402 .paint_items
6403 .iter()
6404 .any(|p| matches!(p, PaintItem::Scene3D(_))),
6405 "default no-op recorder must not emit a Scene3D paint item",
6406 );
6407
6408 struct SceneRecorder {
6410 calls: usize,
6411 }
6412 impl TextRecorder for SceneRecorder {
6413 fn record(
6414 &mut self,
6415 _: Rect,
6416 _: Option<PhysicalScissor>,
6417 _: &RunStyle,
6418 _: &str,
6419 _: f32,
6420 _: f32,
6421 _: TextWrap,
6422 _: TextAnchor,
6423 _: f32,
6424 ) -> Range<usize> {
6425 0..0
6426 }
6427 fn record_runs(
6428 &mut self,
6429 _: Rect,
6430 _: Option<PhysicalScissor>,
6431 _: &[(String, RunStyle)],
6432 _: f32,
6433 _: f32,
6434 _: TextWrap,
6435 _: TextAnchor,
6436 _: f32,
6437 ) -> Range<usize> {
6438 0..0
6439 }
6440 fn record_scene3d(
6441 &mut self,
6442 _: Rect,
6443 _: Option<PhysicalScissor>,
6444 id: &str,
6445 _: &std::sync::Arc<Scene3DData>,
6446 _: f32,
6447 ) -> Range<usize> {
6448 assert_eq!(id, "scene", "node id threads through to the recorder");
6449 let start = self.calls;
6450 self.calls += 1;
6451 start..self.calls
6452 }
6453 }
6454
6455 let mut core = RunnerCore::new();
6456 core.set_surface_size(100, 100);
6457 let mut rec = SceneRecorder { calls: 0 };
6458 let mut timings = PrepareTimings::default();
6459 core.prepare_paint(
6460 &[scene_op()],
6461 |_| true,
6462 |_| false,
6463 &mut rec,
6464 1.0,
6465 &mut timings,
6466 );
6467 let scenes = core
6468 .paint_items
6469 .iter()
6470 .filter(|p| matches!(p, PaintItem::Scene3D(_)))
6471 .count();
6472 assert_eq!(
6473 scenes, 1,
6474 "recorded scene must emit exactly one Scene3D item"
6475 );
6476 }
6477
6478 #[test]
6485 fn keyed_scene_still_begins_camera_drag() {
6486 use crate::scene::glam::Vec3;
6487 use crate::scene::{PointData, PointsHandle, ScenePoint, SceneSpec};
6488 use crate::tree::chart3d;
6489
6490 let spec = || {
6491 SceneSpec::new().points(PointsHandle::new(PointData {
6492 points: vec![
6493 ScenePoint {
6494 position: Vec3::splat(-1.0),
6495 color: [1.0; 4],
6496 },
6497 ScenePoint {
6498 position: Vec3::splat(1.0),
6499 color: [1.0; 4],
6500 },
6501 ],
6502 }))
6503 };
6504
6505 let drag_active_after_press = |mut tree: crate::tree::El| {
6508 let mut core = RunnerCore::new();
6509 crate::layout::layout(
6510 &mut tree,
6511 &mut core.ui_state,
6512 Rect::new(0.0, 0.0, 200.0, 200.0),
6513 );
6514 core.ui_state.tick_scene_cameras(&tree, Instant::now());
6515 let mut t = PrepareTimings::default();
6516 core.snapshot(&tree, &mut t);
6517 core.pointer_down(Pointer::mouse(100.0, 100.0, PointerButton::Primary));
6518 core.ui_state.camera_drag_active()
6519 };
6520
6521 assert!(
6523 drag_active_after_press(chart3d(spec())),
6524 "unkeyed scene should begin a camera drag"
6525 );
6526 assert!(
6528 drag_active_after_press(chart3d(spec()).key("scene")),
6529 "keyed scene must still begin a camera drag (its own node hit must not suppress it)"
6530 );
6531 }
6532
6533 #[test]
6534 fn at_most_one_snapshot_per_frame() {
6535 let mut core = RunnerCore::new();
6536 core.set_surface_size(100, 100);
6537 let ops = vec![
6538 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
6539 quad(ShaderHandle::Custom("g")),
6540 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
6541 quad(ShaderHandle::Custom("g")),
6542 ];
6543 let mut timings = PrepareTimings::default();
6544 core.prepare_paint(
6545 &ops,
6546 |_| true,
6547 |s| matches!(s, ShaderHandle::Custom("g")),
6548 &mut NoText,
6549 1.0,
6550 &mut timings,
6551 );
6552 let snapshots = core
6553 .paint_items
6554 .iter()
6555 .filter(|p| matches!(p, PaintItem::BackdropSnapshot))
6556 .count();
6557 assert_eq!(snapshots, 1, "backdrop depth is capped at 1");
6558 }
6559}