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