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 last_tree_has_links: bool,
210
211 pub quad_scratch: Vec<QuadInstance>,
214 pub runs: Vec<InstanceRun>,
215 pub paint_items: Vec<PaintItem>,
216
217 pub last_ops: Vec<DrawOp>,
224
225 pub viewport_px: (u32, u32),
229 pub surface_size_override: Option<(u32, u32)>,
235
236 pub theme: Theme,
238
239 pub working_color_space: ColorSpace,
252
253 shader_slot_maps: rustc_hash::FxHashMap<&'static str, crate::paint::slots::ShaderSlotMap>,
259 warned_shader_uniforms: rustc_hash::FxHashSet<(&'static str, &'static str)>,
262}
263
264impl Default for RunnerCore {
265 fn default() -> Self {
266 Self::new()
267 }
268}
269
270impl RunnerCore {
271 pub fn new() -> Self {
272 Self {
273 ui_state: UiState::default(),
274 last_tree: None,
275 last_tree_has_links: false,
276 quad_scratch: Vec::new(),
277 runs: Vec::new(),
278 paint_items: Vec::new(),
279 last_ops: Vec::new(),
280 viewport_px: (1, 1),
281 surface_size_override: None,
282 theme: Theme::default(),
283 working_color_space: DEFAULT_WORKING_COLOR_SPACE,
284 shader_slot_maps: Default::default(),
285 warned_shader_uniforms: Default::default(),
286 }
287 }
288
289 pub fn register_shader_slots(
295 &mut self,
296 name: &'static str,
297 map: crate::paint::slots::ShaderSlotMap,
298 ) {
299 self.shader_slot_maps.insert(name, map);
300 }
301
302 pub fn set_theme(&mut self, theme: Theme) {
303 self.theme = theme;
304 }
305
306 pub fn theme(&self) -> &Theme {
307 &self.theme
308 }
309
310 pub fn working_color_space(&self) -> ColorSpace {
314 self.working_color_space
315 }
316
317 pub fn set_working_color_space(&mut self, space: ColorSpace) {
322 self.working_color_space = space;
323 }
324
325 pub fn set_surface_size(&mut self, width: u32, height: u32) {
331 self.surface_size_override = Some((width.max(1), height.max(1)));
332 }
333
334 pub fn ui_state(&self) -> &UiState {
335 &self.ui_state
336 }
337
338 pub fn debug_summary(&self) -> String {
339 self.ui_state.debug_summary()
340 }
341
342 pub fn rect_of_key(&self, key: &str) -> Option<Rect> {
343 self.ui_state.rect_of_key(key)
344 }
345
346 pub fn would_press_focus_text_input(&self, x: f32, y: f32) -> bool {
363 let Some(tree) = self.last_tree.as_ref() else {
364 return false;
365 };
366 let Some(target) = hit_test::hit_test_target(tree, &self.ui_state, (x, y)) else {
367 return false;
368 };
369 find_capture_keys(tree, &target.node_id).unwrap_or(false)
370 }
371
372 pub fn pointer_moved(&mut self, p: Pointer) -> PointerMove {
386 let Pointer { x, y, kind, .. } = p;
387 let prev_pos = self.ui_state.pointer_pos;
391 self.ui_state.pointer_pos = Some((x, y));
392 self.ui_state.pointer_kind = kind;
393
394 if let Some(drag) = self.ui_state.scroll.thumb_drag.clone() {
400 let dy = y - drag.start_pointer_y;
401 let new_offset = if drag.track_remaining > 0.0 {
402 drag.start_offset + dy * (drag.max_offset / drag.track_remaining)
403 } else {
404 drag.start_offset
405 };
406 let clamped = new_offset.clamp(0.0, drag.max_offset);
407 let prev = self.ui_state.scroll.offsets.insert(drag.scroll_id, clamped);
408 let changed = prev.is_none_or(|old| (old - clamped).abs() > f32::EPSILON);
409 return PointerMove {
410 events: Vec::new(),
411 needs_redraw: changed,
412 };
413 }
414
415 if let Some(drag) = self.ui_state.resize.drag.clone() {
421 let pos = match drag.axis {
422 crate::tree::Axis::Column => y,
423 _ => x,
424 };
425 let next = (drag.initial + (pos - drag.anchor) * drag.sign).clamp(drag.min, drag.max);
426 let prev = self.ui_state.resize.overrides.insert(drag.id, next);
427 let changed = prev.is_none_or(|old| (old - next).abs() > f32::EPSILON);
428 return PointerMove {
429 events: Vec::new(),
430 needs_redraw: changed,
431 };
432 }
433
434 if self.ui_state.camera_drag_active() {
438 let changed = self.ui_state.drag_camera_to(x, y);
439 return PointerMove {
440 events: Vec::new(),
441 needs_redraw: changed,
442 };
443 }
444
445 let hit = self
446 .last_tree
447 .as_ref()
448 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
449 let prev_hover = self.ui_state.hovered.clone();
453 let hover_changed = self.ui_state.set_hovered(hit, Instant::now());
454 let prev_hovered_link = self.ui_state.hovered_link.clone();
460 let new_hovered_link = self
461 .linkable_tree()
462 .and_then(|t| hit_test::link_at(t, &self.ui_state, (x, y)));
463 let link_hover_changed = new_hovered_link != prev_hovered_link;
464 self.ui_state.hovered_link = new_hovered_link;
465 let prev_hovered_band = self.ui_state.resize.hovered_band.clone();
471 let new_hovered_band = self.ui_state.resize_band_at(x, y).map(|b| b.id.clone());
472 let band_hover_changed = new_hovered_band != prev_hovered_band;
473 self.ui_state.resize.hovered_band = new_hovered_band;
474 let modifiers = self.ui_state.modifiers;
475
476 let mut out = Vec::new();
477
478 let touch_no_press = matches!(kind, PointerKind::Touch) && self.ui_state.pressed.is_none();
494 if hover_changed && !touch_no_press {
495 if let Some(prev) = prev_hover {
496 out.push(UiEvent {
497 key: Some(prev.key.clone()),
498 target: Some(prev),
499 pointer: Some((x, y)),
500 key_press: None,
501 text: None,
502 selection: None,
503 modifiers,
504 click_count: 0,
505 path: None,
506 pointer_kind: Some(kind),
507 wheel_delta: None,
508 kind: UiEventKind::PointerLeave,
509 });
510 }
511 if let Some(new) = self.ui_state.hovered.clone() {
512 out.push(UiEvent {
513 key: Some(new.key.clone()),
514 target: Some(new),
515 pointer: Some((x, y)),
516 key_press: None,
517 text: None,
518 selection: None,
519 modifiers,
520 click_count: 0,
521 path: None,
522 pointer_kind: Some(kind),
523 wheel_delta: None,
524 kind: UiEventKind::PointerEnter,
525 });
526 }
527 }
528
529 if matches!(kind, PointerKind::Touch) {
534 match self.ui_state.touch_gesture.clone() {
535 TouchGestureState::Pending {
536 initial,
537 consumes_drag,
538 started_at,
539 } => {
540 let dx = x - initial.0;
541 let dy = y - initial.1;
542 if (dx * dx + dy * dy).sqrt() < TOUCH_DRAG_THRESHOLD {
543 let needs_redraw = hover_changed
548 || link_hover_changed
549 || band_hover_changed
550 || !out.is_empty();
551 return PointerMove {
552 events: out,
553 needs_redraw,
554 };
555 }
556 if consumes_drag {
557 self.ui_state.touch_gesture = TouchGestureState::None;
562 } else {
563 let now = Instant::now();
570 self.cancel_press_for_scroll(&mut out, x, y, kind, modifiers);
571 let scroll_dy = initial.1 - y;
577 let step = self.last_tree.as_ref().and_then(|tree| {
578 self.ui_state.scroll_by_pointer(tree, initial, scroll_dy)
579 });
580 let dt = now
581 .duration_since(started_at)
582 .as_secs_f32()
583 .max(1.0 / 120.0);
584 let velocity = step.as_ref().map(|s| s.applied_delta / dt).unwrap_or(0.0);
585 self.ui_state.touch_gesture = TouchGestureState::Scrolling {
586 last_pos: (x, y),
587 last_time: now,
588 velocity,
589 scroll_id: step.map(|s| s.scroll_id),
590 };
591 return PointerMove {
592 events: out,
593 needs_redraw: true,
594 };
595 }
596 }
597 TouchGestureState::Scrolling {
598 last_pos,
599 last_time,
600 velocity,
601 scroll_id,
602 } => {
603 let now = Instant::now();
604 let scroll_dy = last_pos.1 - y;
605 let step = scroll_id
606 .as_ref()
607 .and_then(|id| self.ui_state.scroll_by_id(id, scroll_dy))
608 .or_else(|| {
609 self.last_tree.as_ref().and_then(|tree| {
610 self.ui_state.scroll_by_pointer(tree, (x, y), scroll_dy)
611 })
612 });
613 let dt = now.duration_since(last_time).as_secs_f32().max(1.0 / 240.0);
614 let sample_velocity =
615 step.as_ref().map(|s| s.applied_delta / dt).unwrap_or(0.0);
616 let velocity = sample_velocity * 0.65 + velocity * 0.35;
617 self.ui_state.touch_gesture = TouchGestureState::Scrolling {
618 last_pos: (x, y),
619 last_time: now,
620 velocity,
621 scroll_id: step.map(|s| s.scroll_id).or(scroll_id),
622 };
623 return PointerMove {
624 events: out,
625 needs_redraw: true,
626 };
627 }
628 TouchGestureState::None => {
629 }
632 TouchGestureState::LongPressed => {
633 self.extend_selection_drag_at(x, y, kind, modifiers, &mut out);
638 if self.ui_state.pressed.is_none() {
639 let needs_redraw = hover_changed
640 || link_hover_changed
641 || band_hover_changed
642 || !out.is_empty();
643 return PointerMove {
644 events: out,
645 needs_redraw,
646 };
647 }
648 }
649 }
650 }
651
652 self.extend_selection_drag_at(x, y, kind, modifiers, &mut out);
659
660 if let Some(p) = self.ui_state.pressed.clone() {
666 if self.focused_captures_keys() {
670 self.ui_state.bump_caret_activity(Instant::now());
671 }
672 out.push(UiEvent {
673 key: Some(p.key.clone()),
674 target: Some(p),
675 pointer: Some((x, y)),
676 key_press: None,
677 text: None,
678 selection: None,
679 modifiers,
680 click_count: self.ui_state.current_click_count(),
681 path: None,
682 pointer_kind: Some(kind),
683 wheel_delta: None,
684 kind: UiEventKind::Drag,
685 });
686 }
687
688 let over_hover_scene = self.ui_state.pointer_over_hover_scene(x, y)
692 || prev_pos.is_some_and(|(px, py)| self.ui_state.pointer_over_hover_scene(px, py));
693 let needs_redraw = hover_changed
694 || link_hover_changed
695 || band_hover_changed
696 || !out.is_empty()
697 || over_hover_scene;
698 PointerMove {
699 events: out,
700 needs_redraw,
701 }
702 }
703
704 pub fn pointer_left(&mut self) -> Vec<UiEvent> {
712 let last_pos = self.ui_state.pointer_pos;
713 let prev_hover = self.ui_state.hovered.clone();
714 let modifiers = self.ui_state.modifiers;
715 let kind = self.ui_state.pointer_kind;
720 self.ui_state.pointer_pos = None;
721 self.ui_state.set_hovered(None, Instant::now());
722 self.ui_state.pressed = None;
723 self.ui_state.pressed_secondary = None;
724 self.ui_state.touch_gesture = TouchGestureState::None;
725 self.ui_state.cancel_scroll_momentum();
726 self.ui_state.hovered_link = None;
732 self.ui_state.pressed_link = None;
733 self.ui_state.resize.hovered_band = None;
737 self.ui_state.resize.drag = None;
738
739 let mut out = Vec::new();
740 if let Some(prev) = prev_hover {
741 out.push(UiEvent {
742 key: Some(prev.key.clone()),
743 target: Some(prev),
744 pointer: last_pos,
745 key_press: None,
746 text: None,
747 selection: None,
748 modifiers,
749 click_count: 0,
750 path: None,
751 pointer_kind: Some(kind),
752 wheel_delta: None,
753 kind: UiEventKind::PointerLeave,
754 });
755 }
756 out
757 }
758
759 pub fn file_hovered(&mut self, path: std::path::PathBuf, x: f32, y: f32) -> Vec<UiEvent> {
773 self.ui_state.pointer_pos = Some((x, y));
774 let target = self
775 .last_tree
776 .as_ref()
777 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
778 let key = target.as_ref().map(|t| t.key.clone());
779 vec![UiEvent {
780 key,
781 target,
782 pointer: Some((x, y)),
783 key_press: None,
784 text: None,
785 selection: None,
786 modifiers: self.ui_state.modifiers,
787 click_count: 0,
788 path: Some(path),
789 pointer_kind: None,
790 wheel_delta: None,
791 kind: UiEventKind::FileHovered,
792 }]
793 }
794
795 pub fn file_hover_cancelled(&mut self) -> Vec<UiEvent> {
800 vec![UiEvent {
801 key: None,
802 target: None,
803 pointer: self.ui_state.pointer_pos,
804 key_press: None,
805 text: None,
806 selection: None,
807 modifiers: self.ui_state.modifiers,
808 click_count: 0,
809 path: None,
810 pointer_kind: None,
811 wheel_delta: None,
812 kind: UiEventKind::FileHoverCancelled,
813 }]
814 }
815
816 pub fn file_dropped(&mut self, path: std::path::PathBuf, x: f32, y: f32) -> Vec<UiEvent> {
821 self.ui_state.pointer_pos = Some((x, y));
822 let target = self
823 .last_tree
824 .as_ref()
825 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
826 let key = target.as_ref().map(|t| t.key.clone());
827 vec![UiEvent {
828 key,
829 target,
830 pointer: Some((x, y)),
831 key_press: None,
832 text: None,
833 selection: None,
834 modifiers: self.ui_state.modifiers,
835 click_count: 0,
836 path: Some(path),
837 pointer_kind: None,
838 wheel_delta: None,
839 kind: UiEventKind::FileDropped,
840 }]
841 }
842
843 pub fn pointer_down(&mut self, p: Pointer) -> Vec<UiEvent> {
856 let Pointer {
857 x, y, button, kind, ..
858 } = p;
859 self.ui_state.pointer_kind = kind;
860 self.ui_state.cancel_scroll_momentum();
861 if matches!(button, PointerButton::Primary)
870 && let Some((scroll_id, _track, thumb_rect)) = self
871 .ui_state
872 .thumb_at(x, y)
873 .filter(|(scroll_id, _, _)| self.scrollbar_can_capture(scroll_id, x, y))
874 {
875 let metrics = self
876 .ui_state
877 .scroll
878 .metrics
879 .get(&scroll_id)
880 .copied()
881 .unwrap_or_default();
882 let start_offset = self
883 .ui_state
884 .scroll
885 .offsets
886 .get(&scroll_id)
887 .copied()
888 .unwrap_or(0.0);
889
890 let grabbed = y >= thumb_rect.y && y <= thumb_rect.y + thumb_rect.h;
894 if grabbed {
895 let track_remaining = (metrics.viewport_h - thumb_rect.h).max(0.0);
896 self.ui_state.scroll.thumb_drag = Some(crate::state::ThumbDrag {
897 scroll_id,
898 start_pointer_y: y,
899 start_offset,
900 track_remaining,
901 max_offset: metrics.max_offset,
902 });
903 } else {
904 let page = (metrics.viewport_h - SCROLL_PAGE_OVERLAP).max(0.0);
910 let delta = if y < thumb_rect.y { -page } else { page };
911 let new_offset = (start_offset + delta).clamp(0.0, metrics.max_offset);
912 self.ui_state.scroll.offsets.insert(scroll_id, new_offset);
913 }
914 return Vec::new();
915 }
916
917 if matches!(button, PointerButton::Primary)
924 && let Some(band) = self
925 .ui_state
926 .resize_band_at(x, y)
927 .filter(|b| self.resize_band_can_capture(b, x, y))
928 .cloned()
929 {
930 let anchor = match band.axis {
931 crate::tree::Axis::Column => y,
932 _ => x,
933 };
934 self.ui_state.resize.drag = Some(crate::state::resize::EdgeResizeDrag {
935 id: band.id,
936 key: band.key,
937 axis: band.axis,
938 sign: band.sign,
939 anchor,
940 initial: band.current,
941 min: band.min,
942 max: band.max,
943 });
944 return Vec::new();
945 }
946
947 let hit = self
948 .last_tree
949 .as_ref()
950 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
951
952 if let Some(id) = self.ui_state.scene_at(x, y)
963 && hit.as_ref().is_none_or(|h| h.node_id == id)
964 && let Some(mode) = self
965 .ui_state
966 .scene_drag_mode(&id, button, self.ui_state.modifiers)
967 {
968 self.ui_state.begin_camera_drag(id, mode, x, y);
969 return Vec::new();
970 }
971
972 if !matches!(button, PointerButton::Primary) {
977 self.ui_state.pressed_secondary = hit.map(|h| (h, button));
980 return Vec::new();
981 }
982
983 self.ui_state.pressed_link = self
991 .linkable_tree()
992 .and_then(|t| hit_test::link_at(t, &self.ui_state, (x, y)));
993 self.ui_state.set_focus_from_pointer(hit.clone());
994 self.ui_state.set_focus_visible(false);
998 self.ui_state.pressed = hit.clone();
999 self.ui_state.tooltip.dismissed_for_hover = true;
1002 let modifiers = self.ui_state.modifiers;
1003
1004 let now = Instant::now();
1007 let click_count =
1008 self.ui_state
1009 .next_click_count(now, (x, y), hit.as_ref().map(|t| t.node_id.as_str()));
1010
1011 let mut out = Vec::new();
1012
1013 if matches!(kind, PointerKind::Touch) {
1021 let prev_hover = self.ui_state.hovered.clone();
1022 let hover_changed = self.ui_state.set_hovered(hit.clone(), now);
1023 if hover_changed {
1024 if let Some(prev) = prev_hover {
1025 out.push(UiEvent {
1026 key: Some(prev.key.clone()),
1027 target: Some(prev),
1028 pointer: Some((x, y)),
1029 key_press: None,
1030 text: None,
1031 selection: None,
1032 modifiers,
1033 click_count: 0,
1034 path: None,
1035 pointer_kind: Some(kind),
1036 wheel_delta: None,
1037 kind: UiEventKind::PointerLeave,
1038 });
1039 }
1040 if let Some(new) = hit.clone() {
1041 out.push(UiEvent {
1042 key: Some(new.key.clone()),
1043 target: Some(new),
1044 pointer: Some((x, y)),
1045 key_press: None,
1046 text: None,
1047 selection: None,
1048 modifiers,
1049 click_count: 0,
1050 path: None,
1051 pointer_kind: Some(kind),
1052 wheel_delta: None,
1053 kind: UiEventKind::PointerEnter,
1054 });
1055 }
1056 }
1057 let consumes_drag = hit
1065 .as_ref()
1066 .and_then(|t| {
1067 self.last_tree
1068 .as_ref()
1069 .and_then(|tree| find_consumes_touch_drag(tree, &t.node_id, false))
1070 })
1071 .unwrap_or(false);
1072 self.ui_state.touch_gesture = TouchGestureState::Pending {
1073 initial: (x, y),
1074 consumes_drag,
1075 started_at: now,
1076 };
1077 }
1078
1079 if let Some(p) = hit.clone() {
1080 if self.focused_captures_keys() {
1087 self.ui_state.bump_caret_activity(now);
1088 }
1089 out.push(UiEvent {
1090 key: Some(p.key.clone()),
1091 target: Some(p),
1092 pointer: Some((x, y)),
1093 key_press: None,
1094 text: None,
1095 selection: None,
1096 modifiers,
1097 click_count,
1098 path: None,
1099 pointer_kind: Some(kind),
1100 wheel_delta: None,
1101 kind: UiEventKind::PointerDown,
1102 });
1103 }
1104
1105 if let Some(point) = self
1113 .last_tree
1114 .as_ref()
1115 .and_then(|t| hit_test::selection_point_at(t, &self.ui_state, (x, y)))
1116 {
1117 self.start_selection_drag(point, &mut out, modifiers, (x, y), click_count, kind);
1118 } else if !self.ui_state.current_selection.is_empty() {
1119 let click_handles_selection = match (&hit, &self.ui_state.current_selection.range) {
1141 (Some(h), Some(range)) => {
1142 h.key == range.anchor.key
1143 || h.key == range.head.key
1144 || self
1145 .last_tree
1146 .as_ref()
1147 .and_then(|t| find_capture_keys(t, &h.node_id))
1148 .unwrap_or(false)
1149 }
1150 _ => false,
1151 };
1152 if !click_handles_selection {
1153 out.push(selection_event(
1154 crate::selection::Selection::default(),
1155 modifiers,
1156 Some((x, y)),
1157 Some(kind),
1158 ));
1159 self.ui_state.current_selection = crate::selection::Selection::default();
1160 self.ui_state.selection.drag = None;
1161 }
1162 }
1163
1164 out
1165 }
1166
1167 fn start_selection_drag(
1175 &mut self,
1176 point: crate::selection::SelectionPoint,
1177 out: &mut Vec<UiEvent>,
1178 modifiers: KeyModifiers,
1179 pointer: (f32, f32),
1180 click_count: u8,
1181 kind: PointerKind,
1182 ) {
1183 let leaf_text = self
1184 .last_tree
1185 .as_ref()
1186 .and_then(|t| crate::selection::find_keyed_text(t, &point.key))
1187 .unwrap_or_default();
1188 let (anchor_byte, head_byte) = match click_count {
1189 2 => crate::selection::word_range_at(&leaf_text, point.byte),
1190 n if n >= 3 => (0, leaf_text.len()),
1191 _ => (point.byte, point.byte),
1192 };
1193 let granularity = match click_count {
1194 2 => SelectionDragGranularity::Word,
1195 n if n >= 3 => SelectionDragGranularity::Leaf,
1196 _ => SelectionDragGranularity::Character,
1197 };
1198 let anchor = crate::selection::SelectionPoint::new(point.key.clone(), anchor_byte);
1199 let head = crate::selection::SelectionPoint::new(point.key.clone(), head_byte);
1200 let new_sel = crate::selection::Selection {
1201 range: Some(crate::selection::SelectionRange {
1202 anchor: anchor.clone(),
1203 head: head.clone(),
1204 }),
1205 };
1206 self.ui_state.current_selection = new_sel.clone();
1207 self.ui_state.selection.drag = Some(crate::state::SelectionDrag {
1208 anchor,
1209 head,
1210 granularity,
1211 });
1212 out.push(selection_event(
1213 new_sel,
1214 modifiers,
1215 Some(pointer),
1216 Some(kind),
1217 ));
1218 }
1219
1220 fn extend_selection_drag_at(
1221 &mut self,
1222 x: f32,
1223 y: f32,
1224 kind: PointerKind,
1225 modifiers: KeyModifiers,
1226 out: &mut Vec<UiEvent>,
1227 ) {
1228 let Some(drag) = self.ui_state.selection.drag.clone() else {
1229 return;
1230 };
1231 let Some(tree) = self.last_tree.as_ref() else {
1232 return;
1233 };
1234 let raw_head =
1235 head_for_drag(tree, &self.ui_state, (x, y)).unwrap_or_else(|| drag.anchor.clone());
1236 let (anchor, head) = selection_range_for_drag(tree, &self.ui_state, &drag, raw_head);
1237 let new_sel = crate::selection::Selection {
1238 range: Some(crate::selection::SelectionRange { anchor, head }),
1239 };
1240 if new_sel != self.ui_state.current_selection {
1241 self.ui_state.current_selection = new_sel.clone();
1242 out.push(selection_event(
1243 new_sel,
1244 modifiers,
1245 Some((x, y)),
1246 Some(kind),
1247 ));
1248 }
1249 }
1250
1251 fn resize_band_can_capture(
1259 &self,
1260 band: &crate::state::resize::ResizeBand,
1261 x: f32,
1262 y: f32,
1263 ) -> bool {
1264 let Some(tree) = self.last_tree.as_ref() else {
1265 return false;
1266 };
1267 hit_test::hit_test_target(tree, &self.ui_state, (x, y))
1268 .is_none_or(|target| target_id_in_subtree(&band.container_id, &target.node_id))
1269 }
1270
1271 fn scrollbar_can_capture(&self, scroll_id: &str, x: f32, y: f32) -> bool {
1272 let Some(tree) = self.last_tree.as_ref() else {
1273 return false;
1274 };
1275 let target_allows_capture = hit_test::hit_test_target(tree, &self.ui_state, (x, y))
1276 .is_none_or(|target| target_id_in_subtree(scroll_id, &target.node_id));
1277 if !target_allows_capture {
1278 return false;
1279 }
1280 hit_test::scroll_targets_at(tree, &self.ui_state, (x, y))
1281 .iter()
1282 .any(|id| id == scroll_id)
1283 }
1284
1285 fn cancel_press_for_scroll(
1295 &mut self,
1296 out: &mut Vec<UiEvent>,
1297 x: f32,
1298 y: f32,
1299 kind: PointerKind,
1300 modifiers: KeyModifiers,
1301 ) {
1302 let pressed = self.ui_state.pressed.take();
1303 let hovered = self.ui_state.hovered.clone();
1304 self.ui_state.set_hovered(None, Instant::now());
1305 self.ui_state.pressed_secondary = None;
1306 self.ui_state.pressed_link = None;
1307 self.ui_state.selection.drag = None;
1308 if let Some(p) = pressed {
1309 out.push(UiEvent {
1310 key: Some(p.key.clone()),
1311 target: Some(p),
1312 pointer: Some((x, y)),
1313 key_press: None,
1314 text: None,
1315 selection: None,
1316 modifiers,
1317 click_count: 0,
1318 path: None,
1319 pointer_kind: Some(kind),
1320 wheel_delta: None,
1321 kind: UiEventKind::PointerCancel,
1322 });
1323 }
1324 if let Some(h) = hovered {
1325 out.push(UiEvent {
1326 key: Some(h.key.clone()),
1327 target: Some(h),
1328 pointer: Some((x, y)),
1329 key_press: None,
1330 text: None,
1331 selection: None,
1332 modifiers,
1333 click_count: 0,
1334 path: None,
1335 pointer_kind: Some(kind),
1336 wheel_delta: None,
1337 kind: UiEventKind::PointerLeave,
1338 });
1339 }
1340 }
1341
1342 pub fn pointer_up(&mut self, p: Pointer) -> Vec<UiEvent> {
1350 let Pointer {
1351 x, y, button, kind, ..
1352 } = p;
1353 self.ui_state.pointer_kind = kind;
1354 if matches!(button, PointerButton::Primary) && self.ui_state.scroll.thumb_drag.is_some() {
1359 self.ui_state.scroll.thumb_drag = None;
1360 self.ui_state.touch_gesture = TouchGestureState::None;
1361 return Vec::new();
1362 }
1363
1364 if matches!(button, PointerButton::Primary)
1370 && let Some(drag) = self.ui_state.resize.drag.take()
1371 {
1372 self.ui_state.touch_gesture = TouchGestureState::None;
1373 let Some(key) = drag.key else {
1374 return Vec::new();
1375 };
1376 return vec![UiEvent {
1377 key: Some(key),
1378 target: None,
1379 pointer: Some((x, y)),
1380 key_press: None,
1381 text: None,
1382 selection: None,
1383 modifiers: self.ui_state.modifiers,
1384 click_count: 0,
1385 path: None,
1386 pointer_kind: Some(kind),
1387 wheel_delta: None,
1388 kind: UiEventKind::Resized,
1389 }];
1390 }
1391
1392 if self.ui_state.end_camera_drag() {
1396 self.ui_state.touch_gesture = TouchGestureState::None;
1397 return Vec::new();
1398 }
1399
1400 let was_long_pressed =
1406 matches!(self.ui_state.touch_gesture, TouchGestureState::LongPressed);
1407 let momentum = match &self.ui_state.touch_gesture {
1408 TouchGestureState::Scrolling {
1409 velocity,
1410 scroll_id,
1411 ..
1412 } if matches!(kind, PointerKind::Touch) => {
1413 Some((scroll_id.clone(), *velocity, Instant::now()))
1414 }
1415 _ => None,
1416 };
1417 let was_scrolling_or_long = matches!(
1418 self.ui_state.touch_gesture,
1419 TouchGestureState::Scrolling { .. } | TouchGestureState::LongPressed
1420 );
1421 self.ui_state.touch_gesture = TouchGestureState::None;
1422 if was_scrolling_or_long {
1423 if let Some((scroll_id, velocity, now)) = momentum {
1424 self.ui_state
1425 .start_scroll_momentum(scroll_id, velocity, now);
1426 }
1427 if was_long_pressed {
1428 self.ui_state.pressed = None;
1429 self.ui_state.pressed_secondary = None;
1430 self.ui_state.pressed_link = None;
1431 self.ui_state.selection.drag = None;
1432 self.ui_state.set_hovered(None, Instant::now());
1433 }
1434 return Vec::new();
1435 }
1436
1437 if matches!(button, PointerButton::Primary) {
1440 self.ui_state.selection.drag = None;
1441 }
1442
1443 let hit = self
1444 .last_tree
1445 .as_ref()
1446 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
1447 let modifiers = self.ui_state.modifiers;
1448 let mut out = Vec::new();
1449 match button {
1450 PointerButton::Primary => {
1451 let pressed = self.ui_state.pressed.take();
1452 let click_count = self.ui_state.current_click_count();
1453 if let Some(p) = pressed.clone() {
1454 out.push(UiEvent {
1455 key: Some(p.key.clone()),
1456 target: Some(p),
1457 pointer: Some((x, y)),
1458 key_press: None,
1459 text: None,
1460 selection: None,
1461 modifiers,
1462 click_count,
1463 path: None,
1464 pointer_kind: Some(kind),
1465 wheel_delta: None,
1466 kind: UiEventKind::PointerUp,
1467 });
1468 }
1469 if let (Some(p), Some(h)) = (pressed, hit)
1470 && p.node_id == h.node_id
1471 {
1472 if let Some(id) = toast::parse_dismiss_key(&p.key) {
1478 self.ui_state.dismiss_toast(id);
1479 } else {
1480 out.push(UiEvent {
1481 key: Some(p.key.clone()),
1482 target: Some(p),
1483 pointer: Some((x, y)),
1484 key_press: None,
1485 text: None,
1486 selection: None,
1487 modifiers,
1488 click_count,
1489 path: None,
1490 pointer_kind: Some(kind),
1491 wheel_delta: None,
1492 kind: UiEventKind::Click,
1493 });
1494 }
1495 }
1496 if let Some(pressed_url) = self.ui_state.pressed_link.take() {
1502 let up_link = self
1503 .linkable_tree()
1504 .and_then(|t| hit_test::link_at(t, &self.ui_state, (x, y)));
1505 if up_link.as_ref() == Some(&pressed_url) {
1506 out.push(UiEvent {
1507 key: Some(pressed_url),
1508 target: None,
1509 pointer: Some((x, y)),
1510 key_press: None,
1511 text: None,
1512 selection: None,
1513 modifiers,
1514 click_count: 1,
1515 path: None,
1516 pointer_kind: Some(kind),
1517 wheel_delta: None,
1518 kind: UiEventKind::LinkActivated,
1519 });
1520 }
1521 }
1522 }
1523 PointerButton::Secondary | PointerButton::Middle => {
1524 let pressed = self.ui_state.pressed_secondary.take();
1525 if let (Some((p, b)), Some(h)) = (pressed, hit)
1526 && b == button
1527 && p.node_id == h.node_id
1528 {
1529 let event_kind = match button {
1530 PointerButton::Secondary => UiEventKind::SecondaryClick,
1531 PointerButton::Middle => UiEventKind::MiddleClick,
1532 PointerButton::Primary => unreachable!(),
1533 };
1534 out.push(UiEvent {
1535 key: Some(p.key.clone()),
1536 target: Some(p),
1537 pointer: Some((x, y)),
1538 key_press: None,
1539 text: None,
1540 selection: None,
1541 modifiers,
1542 click_count: 1,
1543 path: None,
1544 pointer_kind: Some(kind),
1545 wheel_delta: None,
1546 kind: event_kind,
1547 });
1548 }
1549 }
1550 }
1551
1552 if matches!(kind, PointerKind::Touch)
1558 && let Some(prev) = self.ui_state.hovered.clone()
1559 {
1560 self.ui_state.set_hovered(None, Instant::now());
1561 out.push(UiEvent {
1562 key: Some(prev.key.clone()),
1563 target: Some(prev),
1564 pointer: Some((x, y)),
1565 key_press: None,
1566 text: None,
1567 selection: None,
1568 modifiers,
1569 click_count: 0,
1570 path: None,
1571 pointer_kind: Some(kind),
1572 wheel_delta: None,
1573 kind: UiEventKind::PointerLeave,
1574 });
1575 }
1576
1577 out
1578 }
1579
1580 pub fn key_down(&mut self, key: UiKey, modifiers: KeyModifiers, repeat: bool) -> Vec<UiEvent> {
1581 if self.focused_captures_keys() {
1589 if let Some(event) = self.ui_state.try_hotkey(&key, modifiers, repeat) {
1590 return vec![event];
1591 }
1592 self.ui_state.bump_caret_activity(Instant::now());
1599 self.ui_state.set_focus_visible(true);
1600 let blur_after = matches!(key, UiKey::Escape);
1601 let out = self
1602 .ui_state
1603 .key_down_raw(key, modifiers, repeat)
1604 .into_iter()
1605 .collect();
1606 if blur_after {
1607 self.ui_state.set_focus(None);
1608 self.ui_state.set_focus_visible(false);
1609 }
1610 return out;
1611 }
1612
1613 if matches!(
1623 key,
1624 UiKey::ArrowUp
1625 | UiKey::ArrowDown
1626 | UiKey::ArrowLeft
1627 | UiKey::ArrowRight
1628 | UiKey::Home
1629 | UiKey::End
1630 ) && let Some((mode, members)) = self.focused_arrow_nav_group()
1631 && mode.handles(&key)
1632 {
1633 if let Some(event) = self.ui_state.try_hotkey(&key, modifiers, repeat) {
1634 return vec![event];
1635 }
1636 self.move_focus_in_group(&key, mode, &members);
1637 return Vec::new();
1638 }
1639
1640 let mut out: Vec<UiEvent> = self
1641 .ui_state
1642 .key_down(key, modifiers, repeat)
1643 .into_iter()
1644 .collect();
1645
1646 if matches!(out.first().map(|e| e.kind), Some(UiEventKind::Escape))
1654 && !self.ui_state.current_selection.is_empty()
1655 {
1656 self.ui_state.current_selection = crate::selection::Selection::default();
1657 self.ui_state.selection.drag = None;
1658 out.push(selection_event(
1659 crate::selection::Selection::default(),
1660 modifiers,
1661 None,
1662 None,
1663 ));
1664 }
1665
1666 out
1667 }
1668
1669 fn focused_arrow_nav_group(&self) -> Option<(crate::tree::ArrowNav, Vec<UiTarget>)> {
1675 let focused = self.ui_state.focused.as_ref()?;
1676 let tree = self.last_tree.as_ref()?;
1677 focus::arrow_nav_group(tree, &self.ui_state, &focused.node_id)
1678 }
1679
1680 fn move_focus_in_group(
1689 &mut self,
1690 key: &UiKey,
1691 mode: crate::tree::ArrowNav,
1692 members: &[UiTarget],
1693 ) {
1694 if members.is_empty() {
1695 return;
1696 }
1697 let focused_id = match self.ui_state.focused.as_ref() {
1698 Some(t) => t.node_id.clone(),
1699 None => return,
1700 };
1701 let idx = members.iter().position(|t| t.node_id == focused_id);
1702 let grid = mode == crate::tree::ArrowNav::Grid;
1703 let next_idx = match (key, idx) {
1704 (UiKey::ArrowUp, Some(i)) if grid => match grid_vertical_step(members, i, -1.0) {
1705 Some(j) => j,
1706 None => return,
1707 },
1708 (UiKey::ArrowDown, Some(i)) if grid => match grid_vertical_step(members, i, 1.0) {
1709 Some(j) => j,
1710 None => return,
1711 },
1712 (UiKey::ArrowUp | UiKey::ArrowLeft, Some(i)) => i.saturating_sub(1),
1713 (UiKey::ArrowDown | UiKey::ArrowRight, Some(i)) => (i + 1).min(members.len() - 1),
1714 (UiKey::Home, _) => 0,
1715 (UiKey::End, _) => members.len() - 1,
1716 _ => return,
1717 };
1718 if Some(next_idx) != idx {
1719 self.ui_state.set_focus(Some(members[next_idx].clone()));
1720 self.ui_state.set_focus_visible(true);
1721 }
1722 }
1723
1724 pub fn focused_captures_keys(&self) -> bool {
1731 let Some(focused) = self.ui_state.focused.as_ref() else {
1732 return false;
1733 };
1734 let Some(tree) = self.last_tree.as_ref() else {
1735 return false;
1736 };
1737 find_capture_keys(tree, &focused.node_id).unwrap_or(false)
1738 }
1739
1740 pub fn text_input(&mut self, text: String) -> Option<UiEvent> {
1746 if text.is_empty() {
1747 return None;
1748 }
1749 let target = self.ui_state.focused.clone()?;
1750 let modifiers = self.ui_state.modifiers;
1751 self.ui_state.bump_caret_activity(Instant::now());
1754 Some(UiEvent {
1755 key: Some(target.key.clone()),
1756 target: Some(target),
1757 pointer: None,
1758 key_press: None,
1759 text: Some(text),
1760 selection: None,
1761 modifiers,
1762 click_count: 0,
1763 path: None,
1764 pointer_kind: None,
1765 wheel_delta: None,
1766 kind: UiEventKind::TextInput,
1767 })
1768 }
1769
1770 pub fn set_hotkeys(&mut self, hotkeys: Vec<(KeyChord, String)>) {
1771 self.ui_state.set_hotkeys(hotkeys);
1772 }
1773
1774 pub fn set_selection(&mut self, selection: crate::selection::Selection) {
1779 if self.ui_state.current_selection != selection {
1780 self.ui_state.bump_caret_activity(Instant::now());
1781 }
1782 self.ui_state.current_selection = selection;
1783 }
1784
1785 pub fn selected_text(&self) -> Option<String> {
1799 self.selected_text_for(&self.ui_state.current_selection)
1800 }
1801
1802 pub fn selected_text_for(&self, selection: &crate::selection::Selection) -> Option<String> {
1808 let tree = self.last_tree.as_ref()?;
1809 crate::selection::selected_text(tree, selection)
1810 }
1811
1812 pub fn push_toasts(&mut self, specs: Vec<crate::toast::ToastSpec>) {
1818 let now = Instant::now();
1819 for spec in specs {
1820 self.ui_state.push_toast(spec, now);
1821 }
1822 }
1823
1824 pub fn dismiss_toast(&mut self, id: u64) {
1828 self.ui_state.dismiss_toast(id);
1829 }
1830
1831 pub fn push_focus_requests(&mut self, keys: Vec<String>) {
1837 self.ui_state.push_focus_requests(keys);
1838 }
1839
1840 pub fn push_scroll_requests(&mut self, requests: Vec<crate::scroll::ScrollRequest>) {
1846 self.ui_state.push_scroll_requests(requests);
1847 }
1848
1849 pub fn set_animation_mode(&mut self, mode: AnimationMode) {
1850 self.ui_state.set_animation_mode(mode);
1851 }
1852
1853 pub fn pointer_wheel(&mut self, x: f32, y: f32, dy: f32) -> bool {
1854 let Some(tree) = self.last_tree.as_ref() else {
1855 return false;
1856 };
1857 self.ui_state.cancel_scroll_momentum();
1858 if self.ui_state.camera_wheel_zoom(x, y, dy) {
1861 return true;
1862 }
1863 self.ui_state.pointer_wheel(tree, (x, y), dy)
1864 }
1865
1866 pub fn pointer_wheel_event(&mut self, x: f32, y: f32, dx: f32, dy: f32) -> Option<UiEvent> {
1873 if dx.abs() <= f32::EPSILON && dy.abs() <= f32::EPSILON {
1874 return None;
1875 }
1876 let tree = self.last_tree.as_ref()?;
1877 let target = hit_test::hit_test_target(tree, &self.ui_state, (x, y))?;
1878 self.ui_state.cancel_scroll_momentum();
1879 Some(UiEvent {
1880 key: Some(target.key.clone()),
1881 target: Some(target),
1882 pointer: Some((x, y)),
1883 key_press: None,
1884 text: None,
1885 selection: None,
1886 modifiers: self.ui_state.modifiers,
1887 click_count: 0,
1888 path: None,
1889 pointer_kind: Some(self.ui_state.pointer_kind),
1890 wheel_delta: Some((dx, dy)),
1891 kind: UiEventKind::PointerWheel,
1892 })
1893 }
1894
1895 pub fn poll_input(&mut self, now: Instant) -> Vec<UiEvent> {
1910 let TouchGestureState::Pending {
1911 initial,
1912 started_at,
1913 ..
1914 } = self.ui_state.touch_gesture.clone()
1915 else {
1916 return Vec::new();
1917 };
1918 if now.duration_since(started_at) < LONG_PRESS_DELAY {
1919 return Vec::new();
1920 }
1921 let mut out = Vec::new();
1922 let modifiers = self.ui_state.modifiers;
1923 let kind = PointerKind::Touch;
1924 let (x, y) = initial;
1925 let press_target = self.ui_state.pressed.clone();
1926 let preserves_press_for_drag = press_target.as_ref().is_some_and(|t| {
1927 self.last_tree
1928 .as_ref()
1929 .and_then(|tree| find_capture_keys(tree, &t.node_id))
1930 .unwrap_or(false)
1931 });
1932 if preserves_press_for_drag {
1933 self.ui_state.pressed_secondary = None;
1934 self.ui_state.pressed_link = None;
1935 self.ui_state.selection.drag = None;
1936 } else {
1937 self.cancel_press_for_scroll(&mut out, x, y, kind, modifiers);
1944 }
1945 if let Some(t) = press_target {
1946 out.push(UiEvent {
1947 key: Some(t.key.clone()),
1948 target: Some(t),
1949 pointer: Some((x, y)),
1950 key_press: None,
1951 text: None,
1952 selection: None,
1953 modifiers,
1954 click_count: 0,
1955 path: None,
1956 pointer_kind: Some(kind),
1957 wheel_delta: None,
1958 kind: UiEventKind::LongPress,
1959 });
1960 } else {
1961 out.push(UiEvent {
1965 key: None,
1966 target: None,
1967 pointer: Some((x, y)),
1968 key_press: None,
1969 text: None,
1970 selection: None,
1971 modifiers,
1972 click_count: 0,
1973 path: None,
1974 pointer_kind: Some(kind),
1975 wheel_delta: None,
1976 kind: UiEventKind::LongPress,
1977 });
1978 }
1979 if !preserves_press_for_drag
1980 && let Some(point) = self
1981 .last_tree
1982 .as_ref()
1983 .and_then(|t| hit_test::selection_point_at(t, &self.ui_state, (x, y)))
1984 {
1985 self.start_selection_drag(point, &mut out, modifiers, (x, y), 2, kind);
1986 }
1987 self.ui_state.touch_gesture = TouchGestureState::LongPressed;
1988 out
1989 }
1990
1991 pub fn next_input_deadline(&self, now: Instant) -> Option<std::time::Duration> {
2001 if self.ui_state.has_scroll_momentum() {
2002 return Some(std::time::Duration::ZERO);
2003 }
2004 let TouchGestureState::Pending { started_at, .. } = self.ui_state.touch_gesture.clone()
2005 else {
2006 return None;
2007 };
2008 let elapsed = now.duration_since(started_at);
2009 Some(LONG_PRESS_DELAY.saturating_sub(elapsed))
2010 }
2011
2012 pub fn prepare_layout<F>(
2029 &mut self,
2030 root: &mut El,
2031 viewport: Rect,
2032 scale_factor: f32,
2033 timings: &mut PrepareTimings,
2034 samples_time: F,
2035 ) -> LayoutPrepared
2036 where
2037 F: Fn(&ShaderHandle) -> bool,
2038 {
2039 let t0 = Instant::now();
2040 let scroll_momentum_pending = self.ui_state.tick_scroll_momentum(t0);
2041 let mut needs_redraw = {
2048 crate::profile_span!("prepare::layout");
2049 {
2050 crate::profile_span!("prepare::layout::assign_ids");
2051 layout::assign_ids(root);
2052 }
2053 let tooltip_pending = {
2054 crate::profile_span!("prepare::layout::tooltip");
2055 tooltip::synthesize_tooltip(root, &self.ui_state, t0)
2056 };
2057 let toast_pending = {
2058 crate::profile_span!("prepare::layout::toast");
2059 toast::synthesize_toasts(root, &mut self.ui_state, t0)
2060 };
2061 {
2062 crate::profile_span!("prepare::layout::apply_metrics");
2063 self.theme.apply_metrics(root);
2064 }
2065 {
2066 crate::profile_span!("prepare::layout::layout");
2067 layout::layout_post_assign(root, &mut self.ui_state, viewport);
2074 self.ui_state.clear_pending_scroll_requests();
2079 self.ui_state.gc_scroll_state(root);
2082 }
2083 {
2084 crate::profile_span!("prepare::layout::sync_focus_order");
2085 self.ui_state.sync_focus_order(root);
2086 }
2087 {
2088 crate::profile_span!("prepare::layout::sync_selection_order");
2089 self.ui_state.sync_selection_order(root);
2090 }
2091 {
2092 crate::profile_span!("prepare::layout::sync_popover_focus");
2093 focus::sync_popover_focus(root, &mut self.ui_state);
2094 }
2095 {
2096 crate::profile_span!("prepare::layout::drain_focus_requests");
2101 self.ui_state.drain_focus_requests();
2102 }
2103 {
2104 crate::profile_span!("prepare::layout::apply_state");
2105 self.ui_state.apply_to_state();
2106 }
2107 self.viewport_px = self.surface_size_override.unwrap_or_else(|| {
2108 (
2109 (viewport.w * scale_factor).ceil().max(1.0) as u32,
2110 (viewport.h * scale_factor).ceil().max(1.0) as u32,
2111 )
2112 });
2113 let animations = {
2114 crate::profile_span!("prepare::layout::tick_animations");
2115 self.ui_state
2116 .tick_visual_animations(root, Instant::now(), self.theme.palette())
2117 };
2118 let cameras_animating = {
2123 crate::profile_span!("prepare::layout::tick_cameras");
2124 self.ui_state.tick_scene_cameras(root, Instant::now())
2125 };
2126 animations
2127 || cameras_animating
2128 || tooltip_pending
2129 || toast_pending
2130 || scroll_momentum_pending
2131 };
2132 let t_after_layout = Instant::now();
2133 timings.layout_intrinsic_cache = layout::take_intrinsic_cache_stats();
2134 timings.layout_prune = layout::take_prune_stats();
2135 let (ops, draw_ops_stats) = {
2136 crate::profile_span!("prepare::draw_ops");
2137 let mut stats = DrawOpsStats::default();
2138 let ops = draw_ops::draw_ops_with_theme_and_stats(
2139 root,
2140 &self.ui_state,
2141 &self.theme,
2142 &mut stats,
2143 );
2144 (ops, stats)
2145 };
2146 let t_after_draw_ops = Instant::now();
2147 timings.layout = t_after_layout - t0;
2148 timings.draw_ops = t_after_draw_ops - t_after_layout;
2149 timings.draw_ops_culled_text_ops = draw_ops_stats.culled_text_ops;
2150 self.ui_state
2153 .set_hovered_scene_point(draw_ops_stats.hovered_scene_point);
2154 timings.text_layout_cache = crate::text::metrics::take_shape_cache_stats();
2155
2156 let shader_needs_redraw = ops.iter().any(|op| op_is_continuous(op, &samples_time));
2173 let widget_redraw =
2174 aggregate_redraw_within(root, viewport, &self.ui_state.layout.computed_rects);
2175 let input_deadline = self.next_input_deadline(Instant::now());
2181 let widget_redraw = match (widget_redraw, input_deadline) {
2182 (Some(a), Some(b)) => Some(a.min(b)),
2183 (a, b) => a.or(b),
2184 };
2185
2186 let next_layout_redraw_in = match (needs_redraw, widget_redraw) {
2187 (true, _) => Some(std::time::Duration::ZERO),
2190 (false, d) => d,
2191 };
2192 let next_paint_redraw_in = if shader_needs_redraw {
2193 Some(std::time::Duration::ZERO)
2194 } else {
2195 None
2196 };
2197 if next_layout_redraw_in.is_some() || next_paint_redraw_in.is_some() {
2198 needs_redraw = true;
2199 }
2200
2201 LayoutPrepared {
2206 ops,
2207 needs_redraw,
2208 next_layout_redraw_in,
2209 next_paint_redraw_in,
2210 }
2211 }
2212
2213 pub fn prepare_paint_cached<F1, F2>(
2226 &mut self,
2227 is_registered: F1,
2228 samples_backdrop: F2,
2229 text: &mut dyn TextRecorder,
2230 scale_factor: f32,
2231 timings: &mut PrepareTimings,
2232 ) where
2233 F1: Fn(&ShaderHandle) -> bool,
2234 F2: Fn(&ShaderHandle) -> bool,
2235 {
2236 let ops = std::mem::take(&mut self.last_ops);
2240 self.prepare_paint(
2241 &ops,
2242 is_registered,
2243 samples_backdrop,
2244 text,
2245 scale_factor,
2246 timings,
2247 );
2248 self.last_ops = ops;
2249 }
2250
2251 pub fn no_time_shaders(_shader: &ShaderHandle) -> bool {
2256 false
2257 }
2258
2259 pub fn scan_continuous_shaders<F>(&self, samples_time: F) -> Option<std::time::Duration>
2266 where
2267 F: Fn(&ShaderHandle) -> bool,
2268 {
2269 let any = self
2270 .last_ops
2271 .iter()
2272 .any(|op| op_is_continuous(op, &samples_time));
2273 if any {
2274 Some(std::time::Duration::ZERO)
2275 } else {
2276 None
2277 }
2278 }
2279
2280 pub fn prepare_paint<F1, F2>(
2291 &mut self,
2292 ops: &[DrawOp],
2293 is_registered: F1,
2294 samples_backdrop: F2,
2295 text: &mut dyn TextRecorder,
2296 scale_factor: f32,
2297 timings: &mut PrepareTimings,
2298 ) where
2299 F1: Fn(&ShaderHandle) -> bool,
2300 F2: Fn(&ShaderHandle) -> bool,
2301 {
2302 crate::profile_span!("prepare::paint");
2303 let t0 = Instant::now();
2304 self.quad_scratch.clear();
2305 self.runs.clear();
2306 self.paint_items.clear();
2307
2308 let mut current: Option<(ShaderHandle, Option<PhysicalScissor>)> = None;
2309 let mut run_first: u32 = 0;
2310 let mut snapshot_emitted = false;
2313
2314 for op in ops {
2315 match op {
2316 DrawOp::Quad {
2317 rect,
2318 scissor,
2319 shader,
2320 uniforms,
2321 ..
2322 } => {
2323 if !is_registered(shader) {
2324 continue;
2325 }
2326 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2327 timings.paint_culled_ops += 1;
2328 continue;
2329 }
2330 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2331 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2332 timings.paint_culled_ops += 1;
2333 continue;
2334 }
2335 if !snapshot_emitted && samples_backdrop(shader) {
2336 close_run(
2337 &mut self.runs,
2338 &mut self.paint_items,
2339 current,
2340 run_first,
2341 self.quad_scratch.len() as u32,
2342 );
2343 current = None;
2344 run_first = self.quad_scratch.len() as u32;
2345 self.paint_items.push(PaintItem::BackdropSnapshot);
2346 snapshot_emitted = true;
2347 }
2348 let inst = match shader {
2353 ShaderHandle::Custom(name) => match self.shader_slot_maps.get(name) {
2354 Some(slot_map) => {
2355 let mut unknown = Vec::new();
2356 let inst = crate::paint::pack_instance_named(
2357 *rect,
2358 uniforms,
2359 self.working_color_space,
2360 slot_map,
2361 &mut unknown,
2362 );
2363 for key in unknown {
2364 if self.warned_shader_uniforms.insert((name, key)) {
2365 let declared: Vec<&str> =
2366 slot_map.declared().map(|(n, _)| n).collect();
2367 log::warn!(
2368 "damascene: shader `{name}` declares no \
2369 instance attribute named `{key}` — the \
2370 value lands nowhere. Declared names: \
2371 {declared:?} (plus the positional aliases \
2372 vec_a..vec_e). Rename one side so the Rust \
2373 packing and WGSL unpacking agree."
2374 );
2375 }
2376 }
2377 inst
2378 }
2379 None => {
2380 pack_instance_in(*rect, *shader, uniforms, self.working_color_space)
2381 }
2382 },
2383 _ => pack_instance_in(*rect, *shader, uniforms, self.working_color_space),
2384 };
2385
2386 let key = (*shader, phys);
2387 if current != Some(key) {
2388 close_run(
2389 &mut self.runs,
2390 &mut self.paint_items,
2391 current,
2392 run_first,
2393 self.quad_scratch.len() as u32,
2394 );
2395 current = Some(key);
2396 run_first = self.quad_scratch.len() as u32;
2397 }
2398 self.quad_scratch.push(inst);
2399 }
2400 DrawOp::GlyphRun {
2401 rect,
2402 scissor,
2403 color,
2404 text: glyph_text,
2405 size,
2406 line_height,
2407 family,
2408 mono_family,
2409 weight,
2410 mono,
2411 wrap,
2412 anchor,
2413 underline,
2414 strikethrough,
2415 link,
2416 tabular_numerals,
2417 ..
2418 } => {
2419 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2420 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2421 timings.paint_culled_ops += 1;
2422 continue;
2423 }
2424 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2425 timings.paint_culled_ops += 1;
2426 continue;
2427 }
2428 close_run(
2429 &mut self.runs,
2430 &mut self.paint_items,
2431 current,
2432 run_first,
2433 self.quad_scratch.len() as u32,
2434 );
2435 current = None;
2436 run_first = self.quad_scratch.len() as u32;
2437
2438 let mut style = crate::text::atlas::RunStyle::new(*weight, *color)
2439 .family(*family)
2440 .mono_family(*mono_family);
2441 if *mono {
2442 style = style.mono();
2443 }
2444 if *tabular_numerals {
2445 style = style.tabular_numerals();
2446 }
2447 if *underline {
2448 style = style.underline();
2449 }
2450 if *strikethrough {
2451 style = style.strikethrough();
2452 }
2453 if let Some(url) = link {
2454 style = style.with_link(url.clone());
2455 }
2456 let layers = text.record(
2457 *rect,
2458 phys,
2459 &style,
2460 glyph_text,
2461 *size,
2462 *line_height,
2463 *wrap,
2464 *anchor,
2465 scale_factor,
2466 );
2467 for index in layers {
2468 self.paint_items.push(PaintItem::Text(index));
2469 }
2470 }
2471 DrawOp::AttributedText {
2472 rect,
2473 scissor,
2474 runs,
2475 size,
2476 line_height,
2477 wrap,
2478 anchor,
2479 ..
2480 } => {
2481 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2482 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2483 timings.paint_culled_ops += 1;
2484 continue;
2485 }
2486 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2487 timings.paint_culled_ops += 1;
2488 continue;
2489 }
2490 close_run(
2491 &mut self.runs,
2492 &mut self.paint_items,
2493 current,
2494 run_first,
2495 self.quad_scratch.len() as u32,
2496 );
2497 current = None;
2498 run_first = self.quad_scratch.len() as u32;
2499
2500 let layers = text.record_runs(
2501 *rect,
2502 phys,
2503 runs,
2504 *size,
2505 *line_height,
2506 *wrap,
2507 *anchor,
2508 scale_factor,
2509 );
2510 for index in layers {
2511 self.paint_items.push(PaintItem::Text(index));
2512 }
2513 }
2514 DrawOp::Icon {
2515 rect,
2516 scissor,
2517 source,
2518 color,
2519 size,
2520 stroke_width,
2521 ..
2522 } => {
2523 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2524 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2525 timings.paint_culled_ops += 1;
2526 continue;
2527 }
2528 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2529 timings.paint_culled_ops += 1;
2530 continue;
2531 }
2532 close_run(
2533 &mut self.runs,
2534 &mut self.paint_items,
2535 current,
2536 run_first,
2537 self.quad_scratch.len() as u32,
2538 );
2539 current = None;
2540 run_first = self.quad_scratch.len() as u32;
2541
2542 let recorded = text.record_icon(
2543 *rect,
2544 phys,
2545 source,
2546 *color,
2547 *size,
2548 *stroke_width,
2549 scale_factor,
2550 );
2551 match recorded {
2552 RecordedPaint::Text(layers) => {
2553 for index in layers {
2554 self.paint_items.push(PaintItem::Text(index));
2555 }
2556 }
2557 RecordedPaint::Icon(runs) => {
2558 for index in runs {
2559 self.paint_items.push(PaintItem::IconRun(index));
2560 }
2561 }
2562 }
2563 }
2564 DrawOp::Image {
2565 rect,
2566 scissor,
2567 image,
2568 tint,
2569 radius,
2570 fit,
2571 range_limit,
2572 ..
2573 } => {
2574 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2575 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2576 timings.paint_culled_ops += 1;
2577 continue;
2578 }
2579 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2580 timings.paint_culled_ops += 1;
2581 continue;
2582 }
2583 close_run(
2584 &mut self.runs,
2585 &mut self.paint_items,
2586 current,
2587 run_first,
2588 self.quad_scratch.len() as u32,
2589 );
2590 current = None;
2591 run_first = self.quad_scratch.len() as u32;
2592
2593 let recorded = text.record_image(
2594 *rect,
2595 phys,
2596 image,
2597 *tint,
2598 *radius,
2599 *fit,
2600 *range_limit,
2601 scale_factor,
2602 );
2603 for index in recorded {
2604 self.paint_items.push(PaintItem::Image(index));
2605 }
2606 }
2607 DrawOp::AppTexture {
2608 rect,
2609 scissor,
2610 texture,
2611 alpha,
2612 transform,
2613 ..
2614 } => {
2615 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2616 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2617 timings.paint_culled_ops += 1;
2618 continue;
2619 }
2620 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2621 timings.paint_culled_ops += 1;
2622 continue;
2623 }
2624 close_run(
2625 &mut self.runs,
2626 &mut self.paint_items,
2627 current,
2628 run_first,
2629 self.quad_scratch.len() as u32,
2630 );
2631 current = None;
2632 run_first = self.quad_scratch.len() as u32;
2633
2634 let recorded = text.record_app_texture(
2635 *rect,
2636 phys,
2637 texture,
2638 *alpha,
2639 *transform,
2640 scale_factor,
2641 );
2642 for index in recorded {
2643 self.paint_items.push(PaintItem::AppTexture(index));
2644 }
2645 }
2646 DrawOp::Vector {
2647 rect,
2648 scissor,
2649 asset,
2650 render_mode,
2651 ..
2652 } => {
2653 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2654 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2655 timings.paint_culled_ops += 1;
2656 continue;
2657 }
2658 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2659 timings.paint_culled_ops += 1;
2660 continue;
2661 }
2662 close_run(
2663 &mut self.runs,
2664 &mut self.paint_items,
2665 current,
2666 run_first,
2667 self.quad_scratch.len() as u32,
2668 );
2669 current = None;
2670 run_first = self.quad_scratch.len() as u32;
2671
2672 let recorded =
2673 text.record_vector(*rect, phys, asset, *render_mode, scale_factor);
2674 for index in recorded {
2675 self.paint_items.push(PaintItem::Vector(index));
2676 }
2677 }
2678 DrawOp::Scene3D {
2679 id,
2680 rect,
2681 scissor,
2682 scene,
2683 } => {
2684 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2685 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2686 timings.paint_culled_ops += 1;
2687 continue;
2688 }
2689 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2690 timings.paint_culled_ops += 1;
2691 continue;
2692 }
2693 close_run(
2699 &mut self.runs,
2700 &mut self.paint_items,
2701 current,
2702 run_first,
2703 self.quad_scratch.len() as u32,
2704 );
2705 current = None;
2706 run_first = self.quad_scratch.len() as u32;
2707
2708 let recorded = text.record_scene3d(*rect, phys, id, scene, scale_factor);
2709 for index in recorded {
2710 self.paint_items.push(PaintItem::Scene3D(index));
2711 }
2712 }
2713 DrawOp::BackdropSnapshot => {
2714 close_run(
2715 &mut self.runs,
2716 &mut self.paint_items,
2717 current,
2718 run_first,
2719 self.quad_scratch.len() as u32,
2720 );
2721 current = None;
2722 run_first = self.quad_scratch.len() as u32;
2723 if !snapshot_emitted {
2726 self.paint_items.push(PaintItem::BackdropSnapshot);
2727 snapshot_emitted = true;
2728 }
2729 }
2730 }
2731 }
2732 close_run(
2733 &mut self.runs,
2734 &mut self.paint_items,
2735 current,
2736 run_first,
2737 self.quad_scratch.len() as u32,
2738 );
2739 timings.paint = Instant::now() - t0;
2740 }
2741
2742 pub fn snapshot(&mut self, root: &El, timings: &mut PrepareTimings) {
2747 crate::profile_span!("prepare::snapshot");
2748 let t0 = Instant::now();
2749 self.last_tree_has_links = tree_has_links(root);
2750 self.last_tree = Some(root.clone());
2751 timings.snapshot = Instant::now() - t0;
2752 }
2753
2754 fn linkable_tree(&self) -> Option<&El> {
2759 self.last_tree.as_ref().filter(|_| self.last_tree_has_links)
2760 }
2761}
2762
2763fn paint_rect_visible(
2764 rect: Rect,
2765 scissor: Option<Rect>,
2766 viewport_px: (u32, u32),
2767 scale_factor: f32,
2768) -> bool {
2769 if rect.w <= 0.0 || rect.h <= 0.0 {
2770 return false;
2771 }
2772 let scale = scale_factor.max(f32::EPSILON);
2773 let viewport = Rect::new(
2774 0.0,
2775 0.0,
2776 viewport_px.0 as f32 / scale,
2777 viewport_px.1 as f32 / scale,
2778 );
2779 let Some(clip) = scissor.map_or(Some(viewport), |s| s.intersect(viewport)) else {
2780 return false;
2781 };
2782 rect.intersect(clip).is_some()
2783}
2784
2785fn target_id_in_subtree(root_id: &str, target_id: &str) -> bool {
2786 target_id == root_id
2787 || target_id
2788 .strip_prefix(root_id)
2789 .is_some_and(|rest| rest.starts_with('.'))
2790}
2791
2792fn op_is_continuous<F>(op: &DrawOp, samples_time: &F) -> bool
2799where
2800 F: Fn(&ShaderHandle) -> bool,
2801{
2802 match op.shader() {
2803 Some(handle @ ShaderHandle::Stock(s)) => s.is_continuous() || samples_time(handle),
2804 Some(handle @ ShaderHandle::Custom(_)) => samples_time(handle),
2805 None => false,
2806 }
2807}
2808
2809fn aggregate_redraw_within(
2815 node: &El,
2816 viewport: Rect,
2817 rects: &rustc_hash::FxHashMap<String, Rect>,
2818) -> Option<std::time::Duration> {
2819 let mut acc: Option<std::time::Duration> = None;
2820 visit_redraw_within(node, viewport, rects, VisibilityClip::Unclipped, &mut acc);
2821 acc
2822}
2823
2824#[derive(Clone, Copy)]
2825enum VisibilityClip {
2826 Unclipped,
2827 Clipped(Rect),
2828 Empty,
2829}
2830
2831impl VisibilityClip {
2832 fn intersect(self, rect: Rect) -> Self {
2833 if rect.w <= 0.0 || rect.h <= 0.0 {
2834 return Self::Empty;
2835 }
2836 match self {
2837 Self::Unclipped => Self::Clipped(rect),
2838 Self::Clipped(prev) => prev
2839 .intersect(rect)
2840 .map(Self::Clipped)
2841 .unwrap_or(Self::Empty),
2842 Self::Empty => Self::Empty,
2843 }
2844 }
2845
2846 fn permits(self, rect: Rect) -> bool {
2847 if rect.w <= 0.0 || rect.h <= 0.0 {
2848 return false;
2849 }
2850 match self {
2851 Self::Unclipped => true,
2852 Self::Clipped(clip) => rect.intersect(clip).is_some(),
2853 Self::Empty => false,
2854 }
2855 }
2856}
2857
2858fn visit_redraw_within(
2859 node: &El,
2860 viewport: Rect,
2861 rects: &rustc_hash::FxHashMap<String, Rect>,
2862 inherited_clip: VisibilityClip,
2863 acc: &mut Option<std::time::Duration>,
2864) {
2865 let rect = rects.get(&node.computed_id).copied();
2866 if let Some(d) = node.redraw_within {
2867 if let Some(rect) = rect
2868 && rect.w > 0.0
2869 && rect.h > 0.0
2870 && rect.intersect(viewport).is_some()
2871 && inherited_clip.permits(rect)
2872 {
2873 *acc = Some(match *acc {
2874 Some(prev) => prev.min(d),
2875 None => d,
2876 });
2877 }
2878 }
2879 let child_clip = if node.clip {
2880 rect.map(|r| inherited_clip.intersect(r))
2881 .unwrap_or(VisibilityClip::Empty)
2882 } else {
2883 inherited_clip
2884 };
2885 for child in &node.children {
2886 visit_redraw_within(child, viewport, rects, child_clip, acc);
2887 }
2888}
2889
2890fn grid_vertical_step(members: &[UiTarget], from: usize, dir: f32) -> Option<usize> {
2900 let cur = &members[from].rect;
2901 let (cx, cy) = (cur.x + cur.w / 2.0, cur.y + cur.h / 2.0);
2902 members
2903 .iter()
2904 .enumerate()
2905 .filter(|(i, t)| {
2906 let ty = t.rect.y + t.rect.h / 2.0;
2907 *i != from && (ty - cy) * dir > cur.h / 2.0
2911 })
2912 .min_by(|(_, a), (_, b)| {
2913 let key = |t: &UiTarget| {
2914 let tx = t.rect.x + t.rect.w / 2.0;
2915 let ty = t.rect.y + t.rect.h / 2.0;
2916 ((ty - cy).abs(), (tx - cx).abs())
2917 };
2918 key(a)
2919 .partial_cmp(&key(b))
2920 .unwrap_or(std::cmp::Ordering::Equal)
2921 })
2922 .map(|(i, _)| i)
2923}
2924
2925fn tree_has_links(node: &El) -> bool {
2933 node.text_link.is_some() || node.children.iter().any(tree_has_links)
2934}
2935
2936pub(crate) fn find_capture_keys(node: &El, id: &str) -> Option<bool> {
2937 if node.computed_id == id {
2938 return Some(node.capture_keys);
2939 }
2940 node.children.iter().find_map(|c| find_capture_keys(c, id))
2941}
2942
2943fn find_consumes_touch_drag(node: &El, id: &str, ancestor_consumes: bool) -> Option<bool> {
2953 let consumes = ancestor_consumes || node.consumes_touch_drag;
2954 if node.computed_id == id {
2955 return Some(consumes);
2956 }
2957 node.children
2958 .iter()
2959 .find_map(|c| find_consumes_touch_drag(c, id, consumes))
2960}
2961
2962fn selection_event(
2964 new_sel: crate::selection::Selection,
2965 modifiers: KeyModifiers,
2966 pointer: Option<(f32, f32)>,
2967 pointer_kind: Option<PointerKind>,
2968) -> UiEvent {
2969 UiEvent {
2970 kind: UiEventKind::SelectionChanged,
2971 key: None,
2972 target: None,
2973 pointer,
2974 key_press: None,
2975 text: None,
2976 selection: Some(new_sel),
2977 modifiers,
2978 click_count: 0,
2979 path: None,
2980 pointer_kind,
2981 wheel_delta: None,
2982 }
2983}
2984
2985fn head_for_drag(
2997 root: &El,
2998 ui_state: &UiState,
2999 point: (f32, f32),
3000) -> Option<crate::selection::SelectionPoint> {
3001 if let Some(p) = hit_test::selection_point_at(root, ui_state, point) {
3002 return Some(p);
3003 }
3004
3005 let order = &ui_state.selection.order;
3006 if order.is_empty() {
3007 return None;
3008 }
3009 let target = order
3014 .iter()
3015 .find(|t| point.1 >= t.rect.y && point.1 < t.rect.y + t.rect.h)
3016 .or_else(|| {
3017 order.iter().min_by(|a, b| {
3018 let da = y_distance(a.rect, point.1);
3019 let db = y_distance(b.rect, point.1);
3020 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
3021 })
3022 })?;
3023 let target_rect = target.rect;
3024 let cy = point
3025 .1
3026 .clamp(target_rect.y, target_rect.y + target_rect.h - 1.0);
3027 if let Some(p) = hit_test::selection_point_at(root, ui_state, (point.0, cy)) {
3028 return Some(p);
3029 }
3030 let leaf_len = find_text_len(root, &target.node_id).unwrap_or(0);
3033 let byte = if point.0 < target_rect.x { 0 } else { leaf_len };
3034 Some(crate::selection::SelectionPoint {
3035 key: target.key.clone(),
3036 byte,
3037 })
3038}
3039
3040fn selection_range_for_drag(
3041 root: &El,
3042 ui_state: &UiState,
3043 drag: &crate::state::SelectionDrag,
3044 raw_head: crate::selection::SelectionPoint,
3045) -> (
3046 crate::selection::SelectionPoint,
3047 crate::selection::SelectionPoint,
3048) {
3049 match drag.granularity {
3050 SelectionDragGranularity::Character => (drag.anchor.clone(), raw_head),
3051 SelectionDragGranularity::Word => {
3052 let text = crate::selection::find_keyed_text(root, &raw_head.key).unwrap_or_default();
3053 let (lo, hi) = crate::selection::word_range_at(&text, raw_head.byte);
3054 if point_cmp(ui_state, &raw_head, &drag.anchor) == Ordering::Less {
3055 (
3056 drag.head.clone(),
3057 crate::selection::SelectionPoint::new(raw_head.key, lo),
3058 )
3059 } else {
3060 (
3061 drag.anchor.clone(),
3062 crate::selection::SelectionPoint::new(raw_head.key, hi),
3063 )
3064 }
3065 }
3066 SelectionDragGranularity::Leaf => {
3067 let len = crate::selection::find_keyed_text(root, &raw_head.key)
3068 .map(|text| text.len())
3069 .unwrap_or(raw_head.byte);
3070 if point_cmp(ui_state, &raw_head, &drag.anchor) == Ordering::Less {
3071 (
3072 drag.head.clone(),
3073 crate::selection::SelectionPoint::new(raw_head.key, 0),
3074 )
3075 } else {
3076 (
3077 drag.anchor.clone(),
3078 crate::selection::SelectionPoint::new(raw_head.key, len),
3079 )
3080 }
3081 }
3082 }
3083}
3084
3085fn point_cmp(
3086 ui_state: &UiState,
3087 a: &crate::selection::SelectionPoint,
3088 b: &crate::selection::SelectionPoint,
3089) -> Ordering {
3090 let order_index = |key: &str| {
3091 ui_state
3092 .selection
3093 .order
3094 .iter()
3095 .position(|target| target.key == key)
3096 .unwrap_or(usize::MAX)
3097 };
3098 order_index(&a.key)
3099 .cmp(&order_index(&b.key))
3100 .then_with(|| a.byte.cmp(&b.byte))
3101}
3102
3103fn y_distance(rect: Rect, y: f32) -> f32 {
3104 if y < rect.y {
3105 rect.y - y
3106 } else if y > rect.y + rect.h {
3107 y - (rect.y + rect.h)
3108 } else {
3109 0.0
3110 }
3111}
3112
3113fn find_text_len(node: &El, id: &str) -> Option<usize> {
3114 if node.computed_id == id {
3115 if let Some(source) = &node.selection_source {
3116 return Some(source.visible_len());
3117 }
3118 return node.text.as_ref().map(|t| t.len());
3119 }
3120 node.children.iter().find_map(|c| find_text_len(c, id))
3121}
3122
3123pub enum RecordedPaint {
3126 Text(Range<usize>),
3127 Icon(Range<usize>),
3128}
3129
3130pub trait TextRecorder {
3134 #[allow(clippy::too_many_arguments)]
3142 fn record(
3143 &mut self,
3144 rect: Rect,
3145 scissor: Option<PhysicalScissor>,
3146 style: &RunStyle,
3147 text: &str,
3148 size: f32,
3149 line_height: f32,
3150 wrap: TextWrap,
3151 anchor: TextAnchor,
3152 scale_factor: f32,
3153 ) -> Range<usize>;
3154
3155 #[allow(clippy::too_many_arguments)]
3160 fn record_runs(
3161 &mut self,
3162 rect: Rect,
3163 scissor: Option<PhysicalScissor>,
3164 runs: &[(String, RunStyle)],
3165 size: f32,
3166 line_height: f32,
3167 wrap: TextWrap,
3168 anchor: TextAnchor,
3169 scale_factor: f32,
3170 ) -> Range<usize>;
3171
3172 #[allow(clippy::too_many_arguments)]
3178 fn record_icon(
3179 &mut self,
3180 rect: Rect,
3181 scissor: Option<PhysicalScissor>,
3182 source: &crate::icons::svg::IconSource,
3183 color: Color,
3184 size: f32,
3185 _stroke_width: f32,
3186 scale_factor: f32,
3187 ) -> RecordedPaint {
3188 let glyph = match source {
3189 crate::icons::svg::IconSource::Builtin(name) => name.fallback_glyph(),
3190 crate::icons::svg::IconSource::Custom(_) => "?",
3191 };
3192 RecordedPaint::Text(self.record(
3193 rect,
3194 scissor,
3195 &RunStyle::new(FontWeight::Regular, color),
3196 glyph,
3197 size,
3198 crate::text::metrics::line_height(size),
3199 TextWrap::NoWrap,
3200 TextAnchor::Middle,
3201 scale_factor,
3202 ))
3203 }
3204
3205 #[allow(clippy::too_many_arguments)]
3212 fn record_image(
3213 &mut self,
3214 _rect: Rect,
3215 _scissor: Option<PhysicalScissor>,
3216 _image: &crate::image::Image,
3217 _tint: Option<Color>,
3218 _radius: crate::tree::Corners,
3219 _fit: crate::image::ImageFit,
3220 _range_limit: crate::image::DynamicRangeLimit,
3221 _scale_factor: f32,
3222 ) -> Range<usize> {
3223 0..0
3224 }
3225
3226 fn record_app_texture(
3232 &mut self,
3233 _rect: Rect,
3234 _scissor: Option<PhysicalScissor>,
3235 _texture: &crate::surface::AppTexture,
3236 _alpha: crate::surface::SurfaceAlpha,
3237 _transform: crate::affine::Affine2,
3238 _scale_factor: f32,
3239 ) -> Range<usize> {
3240 0..0
3241 }
3242
3243 fn record_vector(
3249 &mut self,
3250 _rect: Rect,
3251 _scissor: Option<PhysicalScissor>,
3252 _asset: &crate::vector::VectorAsset,
3253 _render_mode: crate::vector::VectorRenderMode,
3254 _scale_factor: f32,
3255 ) -> Range<usize> {
3256 0..0
3257 }
3258
3259 fn record_scene3d(
3270 &mut self,
3271 _rect: Rect,
3272 _scissor: Option<PhysicalScissor>,
3273 _id: &str,
3274 _scene: &std::sync::Arc<crate::scene::Scene3DData>,
3275 _scale_factor: f32,
3276 ) -> Range<usize> {
3277 0..0
3278 }
3279}
3280
3281#[cfg(test)]
3282mod tests {
3283 use super::*;
3284 use crate::event::PointerId;
3285 use crate::shader::{ShaderHandle, StockShader, UniformBlock};
3286
3287 struct NoText;
3289 impl TextRecorder for NoText {
3290 fn record(
3291 &mut self,
3292 _rect: Rect,
3293 _scissor: Option<PhysicalScissor>,
3294 _style: &RunStyle,
3295 _text: &str,
3296 _size: f32,
3297 _line_height: f32,
3298 _wrap: TextWrap,
3299 _anchor: TextAnchor,
3300 _scale_factor: f32,
3301 ) -> Range<usize> {
3302 0..0
3303 }
3304 fn record_runs(
3305 &mut self,
3306 _rect: Rect,
3307 _scissor: Option<PhysicalScissor>,
3308 _runs: &[(String, RunStyle)],
3309 _size: f32,
3310 _line_height: f32,
3311 _wrap: TextWrap,
3312 _anchor: TextAnchor,
3313 _scale_factor: f32,
3314 ) -> Range<usize> {
3315 0..0
3316 }
3317 }
3318
3319 #[derive(Default)]
3320 struct CountingText {
3321 records: usize,
3322 }
3323
3324 impl TextRecorder for CountingText {
3325 fn record(
3326 &mut self,
3327 _rect: Rect,
3328 _scissor: Option<PhysicalScissor>,
3329 _style: &RunStyle,
3330 _text: &str,
3331 _size: f32,
3332 _line_height: f32,
3333 _wrap: TextWrap,
3334 _anchor: TextAnchor,
3335 _scale_factor: f32,
3336 ) -> Range<usize> {
3337 self.records += 1;
3338 0..0
3339 }
3340
3341 fn record_runs(
3342 &mut self,
3343 _rect: Rect,
3344 _scissor: Option<PhysicalScissor>,
3345 _runs: &[(String, RunStyle)],
3346 _size: f32,
3347 _line_height: f32,
3348 _wrap: TextWrap,
3349 _anchor: TextAnchor,
3350 _scale_factor: f32,
3351 ) -> Range<usize> {
3352 self.records += 1;
3353 0..0
3354 }
3355 }
3356
3357 fn empty_text_layout(line_height: f32) -> crate::text::metrics::TextLayout {
3358 crate::text::metrics::TextLayout {
3359 lines: Vec::new(),
3360 width: 0.0,
3361 height: 0.0,
3362 line_height,
3363 }
3364 }
3365
3366 fn lay_out_input_tree(capture: bool) -> RunnerCore {
3373 use crate::tree::*;
3374 let ti = if capture {
3375 crate::widgets::text::text("input").key("ti").capture_keys()
3376 } else {
3377 crate::widgets::text::text("noop").key("ti").focusable()
3378 };
3379 let mut tree =
3380 crate::column([crate::widgets::button::button("Btn").key("btn"), ti]).padding(10.0);
3381 let mut core = RunnerCore::new();
3382 crate::layout::layout(
3383 &mut tree,
3384 &mut core.ui_state,
3385 Rect::new(0.0, 0.0, 200.0, 200.0),
3386 );
3387 core.ui_state.sync_focus_order(&tree);
3388 let mut t = PrepareTimings::default();
3389 core.snapshot(&tree, &mut t);
3390 core
3391 }
3392
3393 #[test]
3394 fn pointer_up_emits_pointer_up_then_click() {
3395 let mut core = lay_out_input_tree(false);
3396 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3397 let cx = btn_rect.x + btn_rect.w * 0.5;
3398 let cy = btn_rect.y + btn_rect.h * 0.5;
3399 core.pointer_moved(Pointer::moving(cx, cy));
3400 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3401 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
3402 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3403 assert_eq!(kinds, vec![UiEventKind::PointerUp, UiEventKind::Click]);
3404 }
3405
3406 #[test]
3407 fn pointer_wheel_event_routes_to_keyed_target() {
3408 let mut core = lay_out_input_tree(false);
3409 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3410 let cx = btn_rect.center_x();
3411 let cy = btn_rect.center_y();
3412
3413 let event = core
3414 .pointer_wheel_event(cx, cy, 0.0, 40.0)
3415 .expect("wheel over keyed button");
3416
3417 assert_eq!(event.kind, UiEventKind::PointerWheel);
3418 assert_eq!(event.route(), Some("btn"));
3419 assert_eq!(event.pointer_pos(), Some((cx, cy)));
3420 assert_eq!(event.wheel_delta(), Some((0.0, 40.0)));
3421 assert_eq!(event.wheel_dy(), Some(40.0));
3422 assert_eq!(event.target_rect(), Some(btn_rect));
3423 assert_eq!(event.pointer_kind, Some(PointerKind::Mouse));
3424 }
3425
3426 fn lay_out_link_tree() -> (RunnerCore, Rect, &'static str) {
3432 use crate::tree::*;
3433 const URL: &str = "https://github.com/computer-whisperer/damascene";
3434 let mut tree = crate::column([crate::text_runs([
3435 crate::text("Visit "),
3436 crate::text("github.com/computer-whisperer/damascene").link(URL),
3437 crate::text("."),
3438 ])])
3439 .padding(10.0);
3440 let mut core = RunnerCore::new();
3441 crate::layout::layout(
3442 &mut tree,
3443 &mut core.ui_state,
3444 Rect::new(0.0, 0.0, 600.0, 200.0),
3445 );
3446 core.ui_state.sync_focus_order(&tree);
3447 let mut t = PrepareTimings::default();
3448 core.snapshot(&tree, &mut t);
3449 let para = core
3450 .last_tree
3451 .as_ref()
3452 .and_then(|t| t.children.first())
3453 .map(|p| core.ui_state.rect(&p.computed_id))
3454 .expect("paragraph rect");
3455 (core, para, URL)
3456 }
3457
3458 #[test]
3459 fn snapshot_computes_the_links_flag_that_gates_link_walks() {
3460 let core = lay_out_input_tree(false);
3463 assert!(
3464 !core.last_tree_has_links,
3465 "input tree has no links; the flag must stay off"
3466 );
3467 let (core, ..) = lay_out_link_tree();
3468 assert!(
3469 core.last_tree_has_links,
3470 "linked paragraph must flip the flag on"
3471 );
3472 }
3473
3474 #[test]
3475 fn pointer_up_on_link_emits_link_activated_with_url() {
3476 let (mut core, para, url) = lay_out_link_tree();
3477 let cx = para.x + 100.0;
3481 let cy = para.y + para.h * 0.5;
3482 core.pointer_moved(Pointer::moving(cx, cy));
3483 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3484 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
3485 let link = events
3486 .iter()
3487 .find(|e| e.kind == UiEventKind::LinkActivated)
3488 .expect("LinkActivated event");
3489 assert_eq!(link.key.as_deref(), Some(url));
3490 }
3491
3492 #[test]
3493 fn pointer_up_after_drag_off_link_does_not_activate() {
3494 let (mut core, para, _url) = lay_out_link_tree();
3495 let press_x = para.x + 100.0;
3496 let cy = para.y + para.h * 0.5;
3497 core.pointer_moved(Pointer::moving(press_x, cy));
3498 core.pointer_down(Pointer::mouse(press_x, cy, PointerButton::Primary));
3499 let events = core.pointer_up(Pointer::mouse(press_x, 180.0, PointerButton::Primary));
3503 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3504 assert!(
3505 !kinds.contains(&UiEventKind::LinkActivated),
3506 "drag-off-link should cancel the link activation; got {kinds:?}",
3507 );
3508 }
3509
3510 #[test]
3511 fn disabled_control_resolves_not_allowed_cursor_on_hover() {
3512 use crate::cursor::Cursor;
3517 let mut tree = crate::widgets::button::button("Save")
3518 .key("save")
3519 .disabled();
3520 let mut core = RunnerCore::new();
3521 crate::layout::layout(
3522 &mut tree,
3523 &mut core.ui_state,
3524 Rect::new(0.0, 0.0, 200.0, 100.0),
3525 );
3526 let rect = core.ui_state.rect(&tree.computed_id);
3527 let mut t = PrepareTimings::default();
3528 core.snapshot(&tree, &mut t);
3529 core.pointer_moved(Pointer::moving(
3530 rect.x + rect.w * 0.5,
3531 rect.y + rect.h * 0.5,
3532 ));
3533 let snap = core.last_tree.as_ref().expect("tree").clone();
3534 assert_eq!(core.ui_state.cursor(&snap), Cursor::NotAllowed);
3535 }
3536
3537 #[test]
3538 fn pointer_moved_over_link_resolves_cursor_to_pointer_and_requests_redraw() {
3539 use crate::cursor::Cursor;
3540 let (mut core, para, _url) = lay_out_link_tree();
3541 let cx = para.x + 100.0;
3542 let cy = para.y + para.h * 0.5;
3543 let initial = core.pointer_moved(Pointer::moving(para.x - 50.0, cy));
3545 assert!(
3546 !initial.needs_redraw,
3547 "moving in empty space shouldn't request a redraw"
3548 );
3549 let tree = core.last_tree.as_ref().expect("tree").clone();
3550 assert_eq!(
3551 core.ui_state.cursor(&tree),
3552 Cursor::Default,
3553 "no link under pointer → default cursor"
3554 );
3555 let onto = core.pointer_moved(Pointer::moving(cx, cy));
3558 assert!(
3559 onto.needs_redraw,
3560 "entering a link region should flag a redraw so the cursor refresh isn't stale"
3561 );
3562 assert_eq!(
3563 core.ui_state.cursor(&tree),
3564 Cursor::Pointer,
3565 "pointer over a link → Pointer cursor"
3566 );
3567 let off = core.pointer_moved(Pointer::moving(para.x - 50.0, cy));
3570 assert!(
3571 off.needs_redraw,
3572 "leaving a link region should flag a redraw"
3573 );
3574 assert_eq!(core.ui_state.cursor(&tree), Cursor::Default);
3575 }
3576
3577 #[test]
3578 fn pointer_up_on_unlinked_text_does_not_emit_link_activated() {
3579 let (mut core, para, _url) = lay_out_link_tree();
3580 let cx = para.x + 1.0;
3583 let cy = para.y + para.h * 0.5;
3584 core.pointer_moved(Pointer::moving(cx, cy));
3585 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3586 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
3587 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3588 assert!(
3589 !kinds.contains(&UiEventKind::LinkActivated),
3590 "click on the unlinked prefix should not surface a link event; got {kinds:?}",
3591 );
3592 }
3593
3594 #[test]
3595 fn pointer_up_off_target_emits_only_pointer_up() {
3596 let mut core = lay_out_input_tree(false);
3597 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3598 let cx = btn_rect.x + btn_rect.w * 0.5;
3599 let cy = btn_rect.y + btn_rect.h * 0.5;
3600 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3601 let events = core.pointer_up(Pointer::mouse(180.0, 180.0, PointerButton::Primary));
3603 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3604 assert_eq!(
3605 kinds,
3606 vec![UiEventKind::PointerUp],
3607 "drag-off-target should still surface PointerUp so widgets see drag-end"
3608 );
3609 }
3610
3611 #[test]
3612 fn pointer_moved_while_pressed_emits_drag() {
3613 let mut core = lay_out_input_tree(false);
3614 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3615 let cx = btn_rect.x + btn_rect.w * 0.5;
3616 let cy = btn_rect.y + btn_rect.h * 0.5;
3617 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3618 let drag = core
3619 .pointer_moved(Pointer::moving(cx + 30.0, cy))
3620 .events
3621 .into_iter()
3622 .find(|e| e.kind == UiEventKind::Drag)
3623 .expect("drag while pressed");
3624 assert_eq!(drag.target.as_ref().map(|t| t.key.as_str()), Some("btn"));
3625 assert_eq!(drag.pointer, Some((cx + 30.0, cy)));
3626 }
3627
3628 #[test]
3629 fn toast_dismiss_click_removes_toast_and_suppresses_click_event() {
3630 use crate::toast::ToastSpec;
3631 use crate::tree::Size;
3632 let mut core = RunnerCore::new();
3636 core.ui_state
3637 .push_toast(ToastSpec::success("hi"), Instant::now());
3638 let toast_id = core.ui_state.toasts()[0].id;
3639
3640 let mut tree: El = crate::stack(std::iter::empty::<El>())
3644 .width(Size::Fill(1.0))
3645 .height(Size::Fill(1.0));
3646 crate::layout::assign_ids(&mut tree);
3647 let _ = crate::toast::synthesize_toasts(&mut tree, &mut core.ui_state, Instant::now());
3648 crate::layout::layout(
3649 &mut tree,
3650 &mut core.ui_state,
3651 Rect::new(0.0, 0.0, 800.0, 600.0),
3652 );
3653 core.ui_state.sync_focus_order(&tree);
3654 let mut t = PrepareTimings::default();
3655 core.snapshot(&tree, &mut t);
3656
3657 let dismiss_key = format!("toast-dismiss-{toast_id}");
3658 let dismiss_rect = core.rect_of_key(&dismiss_key).expect("dismiss button");
3659 let cx = dismiss_rect.x + dismiss_rect.w * 0.5;
3660 let cy = dismiss_rect.y + dismiss_rect.h * 0.5;
3661
3662 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3663 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
3664 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3665 assert!(
3669 !kinds.contains(&UiEventKind::Click),
3670 "Click on toast-dismiss should not be surfaced: {kinds:?}",
3671 );
3672 assert!(
3673 core.ui_state.toasts().iter().all(|t| t.id != toast_id),
3674 "toast {toast_id} should be dropped after dismiss-click",
3675 );
3676 }
3677
3678 #[test]
3679 fn pointer_moved_without_press_emits_no_drag() {
3680 let mut core = lay_out_input_tree(false);
3681 let events = core.pointer_moved(Pointer::moving(50.0, 50.0)).events;
3682 assert!(!events.iter().any(|e| e.kind == UiEventKind::Drag));
3686 }
3687
3688 #[test]
3689 fn spinner_in_tree_keeps_needs_redraw_set() {
3690 use crate::widgets::spinner::spinner;
3695 let mut tree = crate::column([spinner()]);
3696 let mut core = RunnerCore::new();
3697 let mut t = PrepareTimings::default();
3698 let LayoutPrepared { needs_redraw, .. } = core.prepare_layout(
3699 &mut tree,
3700 Rect::new(0.0, 0.0, 200.0, 200.0),
3701 1.0,
3702 &mut t,
3703 RunnerCore::no_time_shaders,
3704 );
3705 assert!(
3706 needs_redraw,
3707 "tree with a spinner must request continuous redraw",
3708 );
3709
3710 let mut bare = crate::column([crate::widgets::text::text("idle")]);
3714 let mut core2 = RunnerCore::new();
3715 let mut t2 = PrepareTimings::default();
3716 let LayoutPrepared {
3717 needs_redraw: needs_redraw2,
3718 ..
3719 } = core2.prepare_layout(
3720 &mut bare,
3721 Rect::new(0.0, 0.0, 200.0, 200.0),
3722 1.0,
3723 &mut t2,
3724 RunnerCore::no_time_shaders,
3725 );
3726 assert!(
3727 !needs_redraw2,
3728 "tree without time-driven shaders should idle: got needs_redraw={needs_redraw2}",
3729 );
3730 }
3731
3732 #[test]
3733 fn custom_samples_time_shader_keeps_needs_redraw_set() {
3734 let mut tree = crate::column([crate::tree::El::new(crate::tree::Kind::Custom("anim"))
3738 .shader(crate::shader::ShaderBinding::custom("my_animated_glow"))
3739 .width(crate::tree::Size::Fixed(32.0))
3740 .height(crate::tree::Size::Fixed(32.0))]);
3741 let mut core = RunnerCore::new();
3742 let mut t = PrepareTimings::default();
3743
3744 let LayoutPrepared {
3745 needs_redraw: idle, ..
3746 } = core.prepare_layout(
3747 &mut tree,
3748 Rect::new(0.0, 0.0, 200.0, 200.0),
3749 1.0,
3750 &mut t,
3751 RunnerCore::no_time_shaders,
3752 );
3753 assert!(
3754 !idle,
3755 "without a samples_time registration the host should idle",
3756 );
3757
3758 let mut t2 = PrepareTimings::default();
3759 let LayoutPrepared {
3760 needs_redraw: animated,
3761 ..
3762 } = core.prepare_layout(
3763 &mut tree,
3764 Rect::new(0.0, 0.0, 200.0, 200.0),
3765 1.0,
3766 &mut t2,
3767 |handle| matches!(handle, ShaderHandle::Custom("my_animated_glow")),
3768 );
3769 assert!(
3770 animated,
3771 "custom shader registered as samples_time=true must request continuous redraw",
3772 );
3773 }
3774
3775 #[test]
3776 fn redraw_within_aggregates_to_minimum_visible_deadline() {
3777 use std::time::Duration;
3778 let mut tree = crate::column([
3779 crate::widgets::text::text("a")
3781 .redraw_within(Duration::from_millis(16))
3782 .width(crate::tree::Size::Fixed(20.0))
3783 .height(crate::tree::Size::Fixed(20.0)),
3784 crate::widgets::text::text("b")
3786 .redraw_within(Duration::from_millis(50))
3787 .width(crate::tree::Size::Fixed(20.0))
3788 .height(crate::tree::Size::Fixed(20.0)),
3789 ]);
3790 let mut core = RunnerCore::new();
3791 let mut t = PrepareTimings::default();
3792 let LayoutPrepared {
3793 needs_redraw,
3794 next_layout_redraw_in,
3795 ..
3796 } = core.prepare_layout(
3797 &mut tree,
3798 Rect::new(0.0, 0.0, 200.0, 200.0),
3799 1.0,
3800 &mut t,
3801 RunnerCore::no_time_shaders,
3802 );
3803 assert!(needs_redraw, "redraw_within must lift the legacy bool");
3804 assert_eq!(
3805 next_layout_redraw_in,
3806 Some(Duration::from_millis(16)),
3807 "tightest visible deadline wins, on the layout lane",
3808 );
3809 }
3810
3811 #[test]
3812 fn redraw_within_off_screen_widget_is_ignored() {
3813 use std::time::Duration;
3814 let mut tree = crate::column([
3820 crate::tree::spacer().height(crate::tree::Size::Fixed(150.0)),
3821 crate::widgets::text::text("offscreen")
3822 .redraw_within(Duration::from_millis(16))
3823 .width(crate::tree::Size::Fixed(10.0))
3824 .height(crate::tree::Size::Fixed(10.0)),
3825 ]);
3826 let mut core = RunnerCore::new();
3827 let mut t = PrepareTimings::default();
3828 let LayoutPrepared {
3829 next_layout_redraw_in,
3830 ..
3831 } = core.prepare_layout(
3832 &mut tree,
3833 Rect::new(0.0, 0.0, 100.0, 100.0),
3834 1.0,
3835 &mut t,
3836 RunnerCore::no_time_shaders,
3837 );
3838 assert_eq!(
3839 next_layout_redraw_in, None,
3840 "off-screen redraw_within must not contribute to the aggregate",
3841 );
3842 }
3843
3844 #[test]
3845 fn redraw_within_clipped_out_widget_is_ignored() {
3846 use std::time::Duration;
3847
3848 let clipped = crate::column([crate::widgets::text::text("clipped")
3849 .redraw_within(Duration::from_millis(16))
3850 .width(crate::tree::Size::Fixed(10.0))
3851 .height(crate::tree::Size::Fixed(10.0))])
3852 .clip()
3853 .width(crate::tree::Size::Fixed(100.0))
3854 .height(crate::tree::Size::Fixed(20.0))
3855 .layout(|ctx| {
3856 vec![Rect::new(
3857 ctx.container.x,
3858 ctx.container.y + 30.0,
3859 10.0,
3860 10.0,
3861 )]
3862 });
3863 let mut tree = crate::column([clipped]);
3864
3865 let mut core = RunnerCore::new();
3866 let mut t = PrepareTimings::default();
3867 let LayoutPrepared {
3868 next_layout_redraw_in,
3869 ..
3870 } = core.prepare_layout(
3871 &mut tree,
3872 Rect::new(0.0, 0.0, 100.0, 100.0),
3873 1.0,
3874 &mut t,
3875 RunnerCore::no_time_shaders,
3876 );
3877 assert_eq!(
3878 next_layout_redraw_in, None,
3879 "redraw_within inside an inherited clip but outside the clip rect must not contribute",
3880 );
3881 }
3882
3883 #[test]
3884 fn pointer_moved_within_same_hovered_node_does_not_request_redraw() {
3885 let mut core = lay_out_input_tree(false);
3891 let btn = core.rect_of_key("btn").expect("btn rect");
3892 let (cx, cy) = (btn.x + btn.w * 0.5, btn.y + btn.h * 0.5);
3893
3894 let first = core.pointer_moved(Pointer::moving(cx, cy));
3898 assert_eq!(first.events.len(), 1);
3899 assert_eq!(first.events[0].kind, UiEventKind::PointerEnter);
3900 assert_eq!(first.events[0].key.as_deref(), Some("btn"));
3901 assert!(
3902 first.needs_redraw,
3903 "entering a focusable should warrant a redraw",
3904 );
3905
3906 let second = core.pointer_moved(Pointer::moving(cx + 1.0, cy));
3910 assert!(second.events.is_empty());
3911 assert!(
3912 !second.needs_redraw,
3913 "identical hover, no drag → host should idle",
3914 );
3915
3916 let off = core.pointer_moved(Pointer::moving(0.0, 0.0));
3920 assert_eq!(off.events.len(), 1);
3921 assert_eq!(off.events[0].kind, UiEventKind::PointerLeave);
3922 assert_eq!(off.events[0].key.as_deref(), Some("btn"));
3923 assert!(
3924 off.needs_redraw,
3925 "leaving a hovered node still warrants a redraw",
3926 );
3927 }
3928
3929 #[test]
3930 fn pointer_move_within_hover_label_scene_keeps_redrawing() {
3931 use crate::scene::glam::Vec3;
3936 use crate::scene::{
3937 PointData, PointLabels, PointStyle, PointsHandle, ScenePoint, SceneSpec,
3938 };
3939 let pts = PointsHandle::new(PointData {
3940 points: vec![ScenePoint {
3941 position: Vec3::ZERO,
3942 color: [1.0; 4],
3943 }],
3944 });
3945 let spec = SceneSpec::new().points_labeled(
3946 pts,
3947 PointStyle::default(),
3948 PointLabels::new(["a"]).on_hover(),
3949 );
3950 let mut tree = crate::tree::chart3d(spec).key("scene");
3951 let mut core = RunnerCore::new();
3952 crate::layout::layout(
3953 &mut tree,
3954 &mut core.ui_state,
3955 Rect::new(0.0, 0.0, 200.0, 200.0),
3956 );
3957 let mut t = PrepareTimings::default();
3958 core.snapshot(&tree, &mut t);
3959 core.ui_state.tick_scene_cameras(&tree, Instant::now());
3962
3963 let scene = core.rect_of_key("scene").expect("scene rect");
3964 let (cx, cy) = (scene.center_x(), scene.center_y());
3965
3966 let enter = core.pointer_moved(Pointer::moving(cx, cy));
3967 assert!(enter.needs_redraw, "entering a hover-label scene redraws");
3968 let within = core.pointer_moved(Pointer::moving(cx + 3.0, cy - 2.0));
3971 assert!(
3972 within.needs_redraw,
3973 "moving within a hover-label scene keeps redrawing the tooltip",
3974 );
3975 let leave = core.pointer_moved(Pointer::moving(scene.x + scene.w + 20.0, cy));
3977 assert!(leave.needs_redraw, "leaving clears the tooltip");
3978 }
3979
3980 #[test]
3981 fn pointer_moved_between_keyed_targets_emits_leave_then_enter() {
3982 let mut core = lay_out_input_tree(false);
3989 let btn = core.rect_of_key("btn").expect("btn rect");
3990 let ti = core.rect_of_key("ti").expect("ti rect");
3991
3992 let _ = core.pointer_moved(Pointer::moving(btn.x + 4.0, btn.y + 4.0));
3994
3995 let cross = core.pointer_moved(Pointer::moving(ti.x + 4.0, ti.y + 4.0));
3997 let kinds: Vec<UiEventKind> = cross.events.iter().map(|e| e.kind).collect();
3998 assert_eq!(
3999 kinds,
4000 vec![UiEventKind::PointerLeave, UiEventKind::PointerEnter],
4001 "paired Leave-then-Enter on cross-target hover transition",
4002 );
4003 assert_eq!(cross.events[0].key.as_deref(), Some("btn"));
4004 assert_eq!(cross.events[1].key.as_deref(), Some("ti"));
4005 assert!(cross.needs_redraw);
4006 }
4007
4008 #[test]
4009 fn touch_pointer_down_emits_pointer_enter_then_pointer_down() {
4010 let mut core = lay_out_input_tree(false);
4016 let btn = core.rect_of_key("btn").expect("btn rect");
4017 let cx = btn.x + btn.w * 0.5;
4018 let cy = btn.y + btn.h * 0.5;
4019 let events = core.pointer_down(Pointer::touch(
4020 cx,
4021 cy,
4022 PointerButton::Primary,
4023 PointerId::PRIMARY,
4024 ));
4025 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
4026 assert_eq!(
4027 kinds,
4028 vec![UiEventKind::PointerEnter, UiEventKind::PointerDown],
4029 );
4030 for e in &events {
4031 assert_eq!(e.pointer_kind, Some(PointerKind::Touch));
4032 }
4033 assert_eq!(core.ui_state().hovered_key(), Some("btn"));
4034 }
4035
4036 #[test]
4037 fn touch_pointer_up_emits_pointer_leave_after_click() {
4038 let mut core = lay_out_input_tree(false);
4043 let btn = core.rect_of_key("btn").expect("btn rect");
4044 let cx = btn.x + btn.w * 0.5;
4045 let cy = btn.y + btn.h * 0.5;
4046 let _ = core.pointer_down(Pointer::touch(
4047 cx,
4048 cy,
4049 PointerButton::Primary,
4050 PointerId::PRIMARY,
4051 ));
4052 let events = core.pointer_up(Pointer::touch(
4053 cx,
4054 cy,
4055 PointerButton::Primary,
4056 PointerId::PRIMARY,
4057 ));
4058 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
4059 assert_eq!(
4060 kinds,
4061 vec![
4062 UiEventKind::PointerUp,
4063 UiEventKind::Click,
4064 UiEventKind::PointerLeave,
4065 ],
4066 );
4067 assert_eq!(core.ui_state().hovered_key(), None);
4068 }
4069
4070 #[test]
4071 fn touch_pointer_moved_without_press_does_not_emit_hover_transitions() {
4072 let mut core = lay_out_input_tree(false);
4080 let btn = core.rect_of_key("btn").expect("btn rect");
4081 let mut p = Pointer::moving(btn.x + 4.0, btn.y + 4.0);
4082 p.kind = PointerKind::Touch;
4083 let moved = core.pointer_moved(p);
4084 assert!(
4085 moved.events.is_empty(),
4086 "touch move without press should not emit hover events, got {:?}",
4087 moved.events.iter().map(|e| e.kind).collect::<Vec<_>>(),
4088 );
4089 }
4090
4091 #[test]
4092 fn touch_drag_between_targets_still_emits_hover_transitions() {
4093 use crate::tree::*;
4104 let mut tree = crate::column([
4105 crate::widgets::button::button("Btn")
4106 .key("btn")
4107 .consumes_touch_drag(),
4108 crate::widgets::button::button("Other").key("other"),
4109 ])
4110 .padding(10.0);
4111 let mut core = RunnerCore::new();
4112 crate::layout::layout(
4113 &mut tree,
4114 &mut core.ui_state,
4115 Rect::new(0.0, 0.0, 200.0, 200.0),
4116 );
4117 core.ui_state.sync_focus_order(&tree);
4118 let mut t = PrepareTimings::default();
4119 core.snapshot(&tree, &mut t);
4120
4121 let btn = core.rect_of_key("btn").expect("btn rect");
4122 let other = core.rect_of_key("other").expect("other rect");
4123 let _ = core.pointer_down(Pointer::touch(
4124 btn.x + 4.0,
4125 btn.y + 4.0,
4126 PointerButton::Primary,
4127 PointerId::PRIMARY,
4128 ));
4129 let mut move_p = Pointer::moving(other.x + 4.0, other.y + 4.0);
4130 move_p.kind = PointerKind::Touch;
4131 let cross = core.pointer_moved(move_p);
4132 let kinds: Vec<UiEventKind> = cross.events.iter().map(|e| e.kind).collect();
4133 assert!(
4134 kinds.contains(&UiEventKind::PointerLeave)
4135 && kinds.contains(&UiEventKind::PointerEnter),
4136 "touch drag across targets should emit Leave + Enter, got {kinds:?}",
4137 );
4138 assert!(kinds.contains(&UiEventKind::Drag));
4142 }
4143
4144 #[test]
4145 fn would_press_focus_text_input_distinguishes_capture_keys() {
4146 let core = lay_out_input_tree(true);
4151 let ti = core.rect_of_key("ti").expect("ti rect");
4152 let btn = core.rect_of_key("btn").expect("btn rect");
4153
4154 assert!(
4155 core.would_press_focus_text_input(ti.center_x(), ti.center_y()),
4156 "press on capture_keys widget should report true",
4157 );
4158 assert!(
4159 !core.would_press_focus_text_input(btn.center_x(), btn.center_y()),
4160 "press on plain focusable should report false",
4161 );
4162 assert!(!core.would_press_focus_text_input(0.0, 0.0));
4164 }
4165
4166 #[test]
4167 fn touch_jiggle_below_threshold_still_taps() {
4168 let mut core = lay_out_input_tree(false);
4174 let btn = core.rect_of_key("btn").expect("btn rect");
4175 let cx = btn.x + btn.w * 0.5;
4176 let cy = btn.y + btn.h * 0.5;
4177 let _ = core.pointer_down(Pointer::touch(
4178 cx,
4179 cy,
4180 PointerButton::Primary,
4181 PointerId::PRIMARY,
4182 ));
4183 let mut jiggle = Pointer::moving(cx + 3.0, cy + 2.0);
4185 jiggle.kind = PointerKind::Touch;
4186 let _ = core.pointer_moved(jiggle);
4187 let events = core.pointer_up(Pointer::touch(
4188 cx + 3.0,
4189 cy + 2.0,
4190 PointerButton::Primary,
4191 PointerId::PRIMARY,
4192 ));
4193 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
4194 assert!(
4195 kinds.contains(&UiEventKind::Click),
4196 "small jiggle should not commit to scroll, expected Click in {kinds:?}",
4197 );
4198 }
4199
4200 #[test]
4201 fn touch_drag_on_consuming_widget_emits_drag_not_cancel() {
4202 use crate::tree::*;
4207 let mut tree = crate::column([crate::widgets::button::button("Drag me")
4208 .key("draggable")
4209 .consumes_touch_drag()])
4210 .padding(10.0);
4211 let mut core = RunnerCore::new();
4212 crate::layout::layout(
4213 &mut tree,
4214 &mut core.ui_state,
4215 Rect::new(0.0, 0.0, 200.0, 200.0),
4216 );
4217 core.ui_state.sync_focus_order(&tree);
4218 let mut t = PrepareTimings::default();
4219 core.snapshot(&tree, &mut t);
4220
4221 let r = core.rect_of_key("draggable").expect("rect");
4222 let cx = r.x + r.w * 0.5;
4223 let cy = r.y + r.h * 0.5;
4224 let _ = core.pointer_down(Pointer::touch(
4225 cx,
4226 cy,
4227 PointerButton::Primary,
4228 PointerId::PRIMARY,
4229 ));
4230 let mut over = Pointer::moving(cx + 30.0, cy);
4233 over.kind = PointerKind::Touch;
4234 let moved = core.pointer_moved(over);
4235 let kinds: Vec<UiEventKind> = moved.events.iter().map(|e| e.kind).collect();
4236 assert!(
4237 kinds.contains(&UiEventKind::Drag),
4238 "drag-consuming widget should receive Drag past threshold, got {kinds:?}",
4239 );
4240 assert!(
4241 !kinds.contains(&UiEventKind::PointerCancel),
4242 "drag-consuming widget should not see PointerCancel, got {kinds:?}",
4243 );
4244 }
4245
4246 #[test]
4247 fn touch_drag_in_scrollable_cancels_press_and_scrolls() {
4248 use crate::tree::*;
4255 let mut tree = crate::scroll([
4256 crate::widgets::button::button("row 0")
4257 .key("row0")
4258 .height(Size::Fixed(50.0)),
4259 crate::widgets::button::button("row 1")
4260 .key("row1")
4261 .height(Size::Fixed(50.0)),
4262 crate::widgets::button::button("row 2")
4263 .key("row2")
4264 .height(Size::Fixed(50.0)),
4265 crate::widgets::button::button("row 3")
4266 .key("row3")
4267 .height(Size::Fixed(50.0)),
4268 crate::widgets::button::button("row 4")
4269 .key("row4")
4270 .height(Size::Fixed(50.0)),
4271 ])
4272 .key("list")
4273 .height(Size::Fixed(120.0));
4274 let mut core = RunnerCore::new();
4275 crate::layout::layout(
4276 &mut tree,
4277 &mut core.ui_state,
4278 Rect::new(0.0, 0.0, 200.0, 120.0),
4279 );
4280 core.ui_state.sync_focus_order(&tree);
4281 let mut t = PrepareTimings::default();
4282 core.snapshot(&tree, &mut t);
4283 let scroll_id = core
4284 .last_tree
4285 .as_ref()
4286 .map(|t| t.computed_id.clone())
4287 .expect("scroll id");
4288
4289 let row1 = core.rect_of_key("row1").expect("row1");
4294 let cx = row1.x + row1.w * 0.5;
4295 let cy = row1.y + row1.h * 0.5;
4296
4297 let down_events = core.pointer_down(Pointer::touch(
4299 cx,
4300 cy,
4301 PointerButton::Primary,
4302 PointerId::PRIMARY,
4303 ));
4304 assert!(
4306 down_events
4307 .iter()
4308 .any(|e| matches!(e.kind, UiEventKind::PointerDown)),
4309 "expected PointerDown on press",
4310 );
4311
4312 let mut up_finger = Pointer::moving(cx, cy - 40.0);
4316 up_finger.kind = PointerKind::Touch;
4317 let move_events = core.pointer_moved(up_finger);
4318 let kinds: Vec<UiEventKind> = move_events.events.iter().map(|e| e.kind).collect();
4319 assert!(
4320 kinds.contains(&UiEventKind::PointerCancel),
4321 "scroll commit should fire PointerCancel, got {kinds:?}",
4322 );
4323 assert!(
4324 !kinds.contains(&UiEventKind::Drag),
4325 "scroll commit should NOT emit Drag, got {kinds:?}",
4326 );
4327
4328 let offset = core.ui_state().scroll_offset(&scroll_id);
4330 assert!(
4331 offset > 30.0 && offset <= 50.0,
4332 "scroll offset should advance ~40px after a 40px finger drag, got {offset}",
4333 );
4334
4335 let up_events = core.pointer_up(Pointer::touch(
4338 cx,
4339 cy - 40.0,
4340 PointerButton::Primary,
4341 PointerId::PRIMARY,
4342 ));
4343 let up_kinds: Vec<UiEventKind> = up_events.iter().map(|e| e.kind).collect();
4344 assert!(
4345 !up_kinds.contains(&UiEventKind::Click),
4346 "scroll-committed gesture must not fire Click on release, got {up_kinds:?}",
4347 );
4348 }
4349
4350 #[test]
4351 fn touch_scroll_release_starts_momentum_after_fast_swipe_outside_viewport() {
4352 use crate::tree::*;
4353 let mut tree = crate::scroll((0..20).map(|i| {
4354 crate::widgets::button::button(format!("row {i}"))
4355 .key(format!("row{i}"))
4356 .height(Size::Fixed(50.0))
4357 }))
4358 .key("list")
4359 .height(Size::Fixed(120.0));
4360 let mut core = RunnerCore::new();
4361 crate::layout::layout(
4362 &mut tree,
4363 &mut core.ui_state,
4364 Rect::new(0.0, 0.0, 200.0, 120.0),
4365 );
4366 core.ui_state.sync_focus_order(&tree);
4367 let mut t = PrepareTimings::default();
4368 core.snapshot(&tree, &mut t);
4369 let scroll_id = core
4370 .last_tree
4371 .as_ref()
4372 .map(|t| t.computed_id.clone())
4373 .expect("scroll id");
4374
4375 let row1 = core.rect_of_key("row1").expect("row1");
4376 let cx = row1.x + row1.w * 0.5;
4377 let cy = row1.y + row1.h * 0.5;
4378
4379 core.pointer_down(Pointer::touch(
4380 cx,
4381 cy,
4382 PointerButton::Primary,
4383 PointerId::PRIMARY,
4384 ));
4385 let mut up_finger = Pointer::moving(cx, cy - 80.0);
4386 up_finger.kind = PointerKind::Touch;
4387 core.pointer_moved(up_finger);
4388 let before_release = core.ui_state().scroll_offset(&scroll_id);
4389 core.pointer_up(Pointer::touch(
4390 cx,
4391 cy - 80.0,
4392 PointerButton::Primary,
4393 PointerId::PRIMARY,
4394 ));
4395
4396 assert!(
4397 core.ui_state.has_scroll_momentum(),
4398 "quick touch scroll release should retain inertial velocity"
4399 );
4400 assert_eq!(
4401 core.next_input_deadline(Instant::now()),
4402 Some(Duration::ZERO),
4403 "active scroll momentum should request the next layout frame"
4404 );
4405
4406 let ticked = core
4407 .ui_state
4408 .tick_scroll_momentum(Instant::now() + Duration::from_millis(16));
4409 let after_tick = core.ui_state().scroll_offset(&scroll_id);
4410 assert!(ticked, "momentum tick should report visual work");
4411 assert!(
4412 after_tick > before_release,
4413 "momentum should continue in release direction: before={before_release}, after={after_tick}"
4414 );
4415 }
4416
4417 #[test]
4418 fn pointer_left_emits_leave_for_prior_hover() {
4419 let mut core = lay_out_input_tree(false);
4420 let btn = core.rect_of_key("btn").expect("btn rect");
4421 let _ = core.pointer_moved(Pointer::moving(btn.x + 4.0, btn.y + 4.0));
4422
4423 let events = core.pointer_left();
4424 assert_eq!(events.len(), 1);
4425 assert_eq!(events[0].kind, UiEventKind::PointerLeave);
4426 assert_eq!(events[0].key.as_deref(), Some("btn"));
4427 }
4428
4429 #[test]
4430 fn pointer_left_with_no_prior_hover_emits_nothing() {
4431 let mut core = lay_out_input_tree(false);
4432 let events = core.pointer_left();
4435 assert!(events.is_empty());
4436 }
4437
4438 #[test]
4439 fn poll_input_before_long_press_delay_emits_nothing() {
4440 let mut core = lay_out_input_tree(false);
4443 let btn = core.rect_of_key("btn").expect("btn rect");
4444 let cx = btn.x + btn.w * 0.5;
4445 let cy = btn.y + btn.h * 0.5;
4446 let _ = core.pointer_down(Pointer::touch(
4447 cx,
4448 cy,
4449 PointerButton::Primary,
4450 PointerId::PRIMARY,
4451 ));
4452 let polled = core.poll_input(Instant::now() + Duration::from_millis(100));
4454 assert!(polled.is_empty(), "should not fire before delay");
4455 }
4456
4457 #[test]
4458 fn poll_input_after_long_press_delay_fires_cancel_then_long_press() {
4459 let mut core = lay_out_input_tree(false);
4463 let btn = core.rect_of_key("btn").expect("btn rect");
4464 let cx = btn.x + btn.w * 0.5;
4465 let cy = btn.y + btn.h * 0.5;
4466 let _ = core.pointer_down(Pointer::touch(
4467 cx,
4468 cy,
4469 PointerButton::Primary,
4470 PointerId::PRIMARY,
4471 ));
4472 let polled = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
4473 let kinds: Vec<UiEventKind> = polled.iter().map(|e| e.kind).collect();
4474 assert!(
4475 kinds.contains(&UiEventKind::PointerCancel),
4476 "expected PointerCancel before LongPress, got {kinds:?}",
4477 );
4478 let long_press = polled
4479 .iter()
4480 .find(|e| matches!(e.kind, UiEventKind::LongPress))
4481 .expect("LongPress event missing");
4482 assert_eq!(
4483 long_press.key.as_deref(),
4484 Some("btn"),
4485 "LongPress should target the originally pressed node",
4486 );
4487 assert_eq!(
4488 long_press.pointer_kind,
4489 Some(PointerKind::Touch),
4490 "LongPress is touch-only",
4491 );
4492 }
4493
4494 #[test]
4495 fn touch_long_press_on_editable_preserves_drag_extension() {
4496 let mut core = lay_out_input_tree(true);
4497 let ti = core.rect_of_key("ti").expect("ti rect");
4498 let cx = ti.x + 4.0;
4499 let cy = ti.y + ti.h * 0.5;
4500 let _ = core.pointer_down(Pointer::touch(
4501 cx,
4502 cy,
4503 PointerButton::Primary,
4504 PointerId::PRIMARY,
4505 ));
4506
4507 let polled = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
4508 assert!(
4509 polled.iter().any(|e| e.kind == UiEventKind::LongPress),
4510 "editable long-press should emit LongPress"
4511 );
4512 assert!(
4513 !polled.iter().any(|e| e.kind == UiEventKind::PointerCancel),
4514 "editable long-press keeps the press captured so drag can extend selection"
4515 );
4516
4517 let mut moved = Pointer::moving(cx + 40.0, cy);
4518 moved.kind = PointerKind::Touch;
4519 let drag = core.pointer_moved(moved);
4520 assert!(
4521 drag.events.iter().any(|e| e.kind == UiEventKind::Drag),
4522 "held touch move after editable long-press should emit Drag"
4523 );
4524
4525 let up_events = core.pointer_up(Pointer::touch(
4526 cx + 40.0,
4527 cy,
4528 PointerButton::Primary,
4529 PointerId::PRIMARY,
4530 ));
4531 assert!(
4532 up_events.is_empty(),
4533 "long-press release should not synthesize click or pointer-up"
4534 );
4535 }
4536
4537 #[test]
4538 fn pointer_up_after_long_press_emits_no_click() {
4539 let mut core = lay_out_input_tree(false);
4543 let btn = core.rect_of_key("btn").expect("btn rect");
4544 let cx = btn.x + btn.w * 0.5;
4545 let cy = btn.y + btn.h * 0.5;
4546 let _ = core.pointer_down(Pointer::touch(
4547 cx,
4548 cy,
4549 PointerButton::Primary,
4550 PointerId::PRIMARY,
4551 ));
4552 let _ = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
4553 let up_events = core.pointer_up(Pointer::touch(
4554 cx,
4555 cy,
4556 PointerButton::Primary,
4557 PointerId::PRIMARY,
4558 ));
4559 assert!(
4560 up_events.is_empty(),
4561 "lift after long-press emits nothing, got {:?}",
4562 up_events.iter().map(|e| e.kind).collect::<Vec<_>>(),
4563 );
4564 }
4565
4566 #[test]
4567 fn moving_past_threshold_before_long_press_cancels_the_timer() {
4568 let mut core = lay_out_input_tree(false);
4573 let btn = core.rect_of_key("btn").expect("btn rect");
4574 let cx = btn.x + btn.w * 0.5;
4575 let cy = btn.y + btn.h * 0.5;
4576 let _ = core.pointer_down(Pointer::touch(
4577 cx,
4578 cy,
4579 PointerButton::Primary,
4580 PointerId::PRIMARY,
4581 ));
4582 let mut over = Pointer::moving(cx + 30.0, cy);
4584 over.kind = PointerKind::Touch;
4585 let _ = core.pointer_moved(over);
4586 let polled = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
4588 assert!(
4589 polled.is_empty(),
4590 "long-press should not fire after gesture committed",
4591 );
4592 }
4593
4594 #[test]
4595 fn ui_state_hovered_key_returns_leaf_key() {
4596 let mut core = lay_out_input_tree(false);
4597 assert_eq!(core.ui_state().hovered_key(), None);
4598
4599 let btn = core.rect_of_key("btn").expect("btn rect");
4600 core.pointer_moved(Pointer::moving(btn.x + 4.0, btn.y + 4.0));
4601 assert_eq!(core.ui_state().hovered_key(), Some("btn"));
4602
4603 core.pointer_moved(Pointer::moving(0.0, 0.0));
4605 assert_eq!(core.ui_state().hovered_key(), None);
4606 }
4607
4608 #[test]
4609 fn ui_state_is_hovering_within_walks_subtree() {
4610 use crate::tree::*;
4614 let mut tree = crate::column([crate::stack([
4615 crate::widgets::button::button("Inner").key("inner_btn")
4616 ])
4617 .key("card")
4618 .focusable()
4619 .width(Size::Fixed(120.0))
4620 .height(Size::Fixed(60.0))])
4621 .padding(20.0);
4622 let mut core = RunnerCore::new();
4623 crate::layout::layout(
4624 &mut tree,
4625 &mut core.ui_state,
4626 Rect::new(0.0, 0.0, 400.0, 200.0),
4627 );
4628 core.ui_state.sync_focus_order(&tree);
4629 let mut t = PrepareTimings::default();
4630 core.snapshot(&tree, &mut t);
4631
4632 assert!(!core.ui_state().is_hovering_within("card"));
4634 assert!(!core.ui_state().is_hovering_within("inner_btn"));
4635
4636 let inner = core.rect_of_key("inner_btn").expect("inner rect");
4639 core.pointer_moved(Pointer::moving(inner.x + 4.0, inner.y + 4.0));
4640 assert!(core.ui_state().is_hovering_within("card"));
4641 assert!(core.ui_state().is_hovering_within("inner_btn"));
4642
4643 assert!(!core.ui_state().is_hovering_within("not_a_key"));
4645
4646 core.pointer_moved(Pointer::moving(0.0, 0.0));
4648 assert!(!core.ui_state().is_hovering_within("card"));
4649 assert!(!core.ui_state().is_hovering_within("inner_btn"));
4650 }
4651
4652 #[test]
4653 fn hover_driven_scale_via_is_hovering_within_plus_animate() {
4654 use crate::Theme;
4661 use crate::anim::Timing;
4662 use crate::tree::*;
4663
4664 let build_card = |hovering: bool| -> El {
4667 let scale = if hovering { 1.05 } else { 1.0 };
4668 crate::column([crate::stack(
4669 [crate::widgets::button::button("Inner").key("inner_btn")],
4670 )
4671 .key("card")
4672 .focusable()
4673 .scale(scale)
4674 .animate(Timing::SPRING_QUICK)
4675 .width(Size::Fixed(120.0))
4676 .height(Size::Fixed(60.0))])
4677 .padding(20.0)
4678 };
4679
4680 let mut core = RunnerCore::new();
4681 core.ui_state
4684 .set_animation_mode(crate::state::AnimationMode::Settled);
4685
4686 let theme = Theme::default();
4688 let cx_pre = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
4689 assert!(!cx_pre.is_hovering_within("card"));
4690 let mut tree = build_card(cx_pre.is_hovering_within("card"));
4691 crate::layout::layout(
4692 &mut tree,
4693 &mut core.ui_state,
4694 Rect::new(0.0, 0.0, 400.0, 200.0),
4695 );
4696 core.ui_state.sync_focus_order(&tree);
4697 let mut t = PrepareTimings::default();
4698 core.snapshot(&tree, &mut t);
4699 core.ui_state
4700 .tick_visual_animations(&mut tree, web_time::Instant::now(), theme.palette());
4701 let card_at_rest = tree.children[0].clone();
4702 assert!((card_at_rest.scale - 1.0).abs() < 1e-3);
4703
4704 let card_rect = core.rect_of_key("card").expect("card rect");
4706 core.pointer_moved(Pointer::moving(card_rect.x + 4.0, card_rect.y + 4.0));
4707
4708 let cx_hot = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
4711 assert!(cx_hot.is_hovering_within("card"));
4712 let mut tree = build_card(cx_hot.is_hovering_within("card"));
4713 crate::layout::layout(
4714 &mut tree,
4715 &mut core.ui_state,
4716 Rect::new(0.0, 0.0, 400.0, 200.0),
4717 );
4718 core.ui_state.sync_focus_order(&tree);
4719 core.snapshot(&tree, &mut t);
4720 core.ui_state
4721 .tick_visual_animations(&mut tree, web_time::Instant::now(), theme.palette());
4722 let card_hot = tree.children[0].clone();
4723 assert!(
4724 (card_hot.scale - 1.05).abs() < 1e-3,
4725 "hover should drive card scale to 1.05 via animate; got {}",
4726 card_hot.scale,
4727 );
4728
4729 core.pointer_moved(Pointer::moving(0.0, 0.0));
4731 let cx_cold = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
4732 assert!(!cx_cold.is_hovering_within("card"));
4733 let mut tree = build_card(cx_cold.is_hovering_within("card"));
4734 crate::layout::layout(
4735 &mut tree,
4736 &mut core.ui_state,
4737 Rect::new(0.0, 0.0, 400.0, 200.0),
4738 );
4739 core.ui_state.sync_focus_order(&tree);
4740 core.snapshot(&tree, &mut t);
4741 core.ui_state
4742 .tick_visual_animations(&mut tree, web_time::Instant::now(), theme.palette());
4743 let card_after = tree.children[0].clone();
4744 assert!((card_after.scale - 1.0).abs() < 1e-3);
4745 }
4746
4747 #[test]
4748 fn file_dropped_routes_to_keyed_leaf_at_pointer() {
4749 let mut core = lay_out_input_tree(false);
4750 let btn = core.rect_of_key("btn").expect("btn rect");
4751 let path = std::path::PathBuf::from("/tmp/screenshot.png");
4752 let events = core.file_dropped(path.clone(), btn.x + 4.0, btn.y + 4.0);
4753 assert_eq!(events.len(), 1);
4754 let event = &events[0];
4755 assert_eq!(event.kind, UiEventKind::FileDropped);
4756 assert_eq!(event.key.as_deref(), Some("btn"));
4757 assert_eq!(event.path.as_deref(), Some(path.as_path()));
4758 assert_eq!(event.pointer, Some((btn.x + 4.0, btn.y + 4.0)));
4759 }
4760
4761 #[test]
4762 fn file_dropped_outside_keyed_surface_emits_window_level_event() {
4763 let mut core = lay_out_input_tree(false);
4764 let path = std::path::PathBuf::from("/tmp/screenshot.png");
4766 let events = core.file_dropped(path.clone(), 1.0, 1.0);
4767 assert_eq!(events.len(), 1);
4768 let event = &events[0];
4769 assert_eq!(event.kind, UiEventKind::FileDropped);
4770 assert!(
4771 event.target.is_none(),
4772 "drop outside any keyed surface routes window-level",
4773 );
4774 assert!(event.key.is_none());
4775 assert_eq!(event.path.as_deref(), Some(path.as_path()));
4777 }
4778
4779 #[test]
4780 fn file_hovered_then_cancelled_pair() {
4781 let mut core = lay_out_input_tree(false);
4782 let btn = core.rect_of_key("btn").expect("btn rect");
4783 let path = std::path::PathBuf::from("/tmp/a.png");
4784
4785 let hover = core.file_hovered(path.clone(), btn.x + 4.0, btn.y + 4.0);
4786 assert_eq!(hover.len(), 1);
4787 assert_eq!(hover[0].kind, UiEventKind::FileHovered);
4788 assert_eq!(hover[0].key.as_deref(), Some("btn"));
4789 assert_eq!(hover[0].path.as_deref(), Some(path.as_path()));
4790
4791 let cancel = core.file_hover_cancelled();
4792 assert_eq!(cancel.len(), 1);
4793 assert_eq!(cancel[0].kind, UiEventKind::FileHoverCancelled);
4794 assert!(cancel[0].target.is_none());
4795 assert!(cancel[0].path.is_none());
4796 }
4797
4798 #[test]
4799 fn build_cx_hover_accessors_default_off_without_state() {
4800 use crate::Theme;
4801 let theme = Theme::default();
4802 let cx = crate::BuildCx::new(&theme);
4803 assert_eq!(cx.hovered_key(), None);
4804 assert!(!cx.is_hovering_within("anything"));
4805 }
4806
4807 #[test]
4808 fn build_cx_hover_accessors_delegate_when_state_attached() {
4809 use crate::Theme;
4810 let mut core = lay_out_input_tree(false);
4811 let btn = core.rect_of_key("btn").expect("btn rect");
4812 core.pointer_moved(Pointer::moving(btn.x + 4.0, btn.y + 4.0));
4813
4814 let theme = Theme::default();
4815 let cx = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
4816 assert_eq!(cx.hovered_key(), Some("btn"));
4817 assert!(cx.is_hovering_within("btn"));
4818 assert!(!cx.is_hovering_within("ti"));
4819 }
4820
4821 #[test]
4822 fn build_cx_rect_of_key_reads_previous_layout() {
4823 use crate::Theme;
4824 let core = lay_out_input_tree(false);
4825 let theme = Theme::default();
4826
4827 let detached = crate::BuildCx::new(&theme);
4828 assert_eq!(detached.rect_of_key("btn"), None);
4829
4830 let cx = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
4831 assert_eq!(cx.rect_of_key("btn"), core.rect_of_key("btn"));
4832 assert_eq!(cx.rect_of_key("missing"), None);
4833 }
4834
4835 #[test]
4836 fn event_cx_rect_of_key_answers_from_laid_out_state() {
4837 let core = lay_out_input_tree(false);
4838
4839 let detached = crate::EventCx::new();
4840 assert_eq!(detached.rect_of_key("btn"), None);
4841
4842 let cx = crate::EventCx::new().with_ui_state(core.ui_state());
4843 let rect = cx.rect_of_key("btn").expect("btn rect via EventCx");
4844 assert_eq!(Some(rect), core.rect_of_key("btn"));
4845 assert_eq!(cx.rect_of_key("missing"), None);
4846 }
4847
4848 #[test]
4849 fn event_cx_viewport_and_diagnostics_mirror_build_cx() {
4850 let detached = crate::EventCx::new();
4853 assert_eq!(detached.viewport(), None);
4854 assert_eq!(detached.viewport_width(), None);
4855 assert_eq!(detached.viewport_height(), None);
4856 assert!(!detached.viewport_below(10_000.0));
4857 assert!(detached.diagnostics().is_none());
4858
4859 let diagnostics = crate::HostDiagnostics {
4860 backend: "Test",
4861 ..Default::default()
4862 };
4863 let cx = crate::EventCx::new()
4864 .with_viewport(800.0, 600.0)
4865 .with_diagnostics(&diagnostics);
4866 assert_eq!(cx.viewport(), Some((800.0, 600.0)));
4867 assert_eq!(cx.viewport_width(), Some(800.0));
4868 assert_eq!(cx.viewport_height(), Some(600.0));
4869 assert!(cx.viewport_below(900.0));
4870 assert!(!cx.viewport_below(800.0));
4871 assert_eq!(cx.diagnostics().map(|d| d.backend), Some("Test"));
4872 }
4873
4874 fn lay_out_paragraph_tree() -> RunnerCore {
4875 use crate::tree::*;
4876 let mut tree = crate::column([
4877 crate::widgets::text::text("First paragraph of text.")
4878 .key("p1")
4879 .selectable(),
4880 crate::widgets::text::text("Second paragraph of text.")
4881 .key("p2")
4882 .selectable(),
4883 ])
4884 .padding(20.0);
4885 let mut core = RunnerCore::new();
4886 crate::layout::layout(
4887 &mut tree,
4888 &mut core.ui_state,
4889 Rect::new(0.0, 0.0, 400.0, 300.0),
4890 );
4891 core.ui_state.sync_focus_order(&tree);
4892 core.ui_state.sync_selection_order(&tree);
4893 let mut t = PrepareTimings::default();
4894 core.snapshot(&tree, &mut t);
4895 core
4896 }
4897
4898 #[test]
4899 fn pointer_down_on_selectable_text_emits_selection_changed() {
4900 let mut core = lay_out_paragraph_tree();
4901 let p1 = core.rect_of_key("p1").expect("p1 rect");
4902 let cx = p1.x + 4.0;
4903 let cy = p1.y + p1.h * 0.5;
4904 let events = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4905 let sel_event = events
4906 .iter()
4907 .find(|e| e.kind == UiEventKind::SelectionChanged)
4908 .expect("SelectionChanged emitted");
4909 let new_sel = sel_event
4910 .selection
4911 .as_ref()
4912 .expect("SelectionChanged carries a selection");
4913 let range = new_sel.range.as_ref().expect("collapsed selection at hit");
4914 assert_eq!(range.anchor.key, "p1");
4915 assert_eq!(range.head.key, "p1");
4916 assert_eq!(range.anchor.byte, range.head.byte);
4917 assert!(core.ui_state.selection.drag.is_some());
4918 }
4919
4920 #[test]
4921 fn touch_long_press_on_selectable_text_selects_word_and_drags() {
4922 let mut core = lay_out_paragraph_tree();
4923 let p1 = core.rect_of_key("p1").expect("p1 rect");
4924 let p2 = core.rect_of_key("p2").expect("p2 rect");
4925 let x = p1.x + 4.0;
4926 let y = p1.y + p1.h * 0.5;
4927
4928 let _ = core.pointer_down(Pointer::touch(
4929 x,
4930 y,
4931 PointerButton::Primary,
4932 PointerId::PRIMARY,
4933 ));
4934 let polled = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
4935 let selection = polled
4936 .iter()
4937 .rev()
4938 .find(|e| e.kind == UiEventKind::SelectionChanged)
4939 .and_then(|e| e.selection.as_ref())
4940 .expect("touch long-press should select text");
4941 let range = selection.range.as_ref().expect("word selection");
4942 assert_eq!(range.anchor.key, "p1");
4943 assert_eq!(range.head.key, "p1");
4944 assert_eq!(range.anchor.byte, 0);
4945 assert_eq!(range.head.byte, 5);
4946 assert!(
4947 core.ui_state.selection.drag.is_some(),
4948 "long-pressed selectable text should stay ready for drag extension"
4949 );
4950
4951 let mut moved = Pointer::moving(p2.x + 8.0, p2.y + p2.h * 0.5);
4952 moved.kind = PointerKind::Touch;
4953 let events = core.pointer_moved(moved).events;
4954 let selection = events
4955 .iter()
4956 .find(|e| e.kind == UiEventKind::SelectionChanged)
4957 .and_then(|e| e.selection.as_ref())
4958 .unwrap_or(&core.ui_state.current_selection);
4959 let range = selection.range.as_ref().expect("extended selection");
4960 assert_eq!(range.anchor.key, "p1");
4961 assert_eq!(range.head.key, "p2");
4962
4963 let _ = core.pointer_up(Pointer::touch(
4964 p2.x + 8.0,
4965 p2.y + p2.h * 0.5,
4966 PointerButton::Primary,
4967 PointerId::PRIMARY,
4968 ));
4969 assert!(
4970 core.ui_state.selection.drag.is_none(),
4971 "selection drag should end on lift"
4972 );
4973 }
4974
4975 #[test]
4976 fn pointer_drag_on_selectable_text_extends_head() {
4977 let mut core = lay_out_paragraph_tree();
4978 let p1 = core.rect_of_key("p1").expect("p1 rect");
4979 let cx = p1.x + 4.0;
4980 let cy = p1.y + p1.h * 0.5;
4981 core.pointer_moved(Pointer::moving(cx, cy));
4982 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4983
4984 let events = core
4986 .pointer_moved(Pointer::moving(p1.x + p1.w - 10.0, cy))
4987 .events;
4988 let sel_event = events
4989 .iter()
4990 .find(|e| e.kind == UiEventKind::SelectionChanged)
4991 .expect("Drag emits SelectionChanged");
4992 let new_sel = sel_event.selection.as_ref().unwrap();
4993 let range = new_sel.range.as_ref().unwrap();
4994 assert_eq!(range.anchor.key, "p1");
4995 assert_eq!(range.head.key, "p1");
4996 assert!(
4997 range.head.byte > range.anchor.byte,
4998 "head should advance past anchor (anchor={}, head={})",
4999 range.anchor.byte,
5000 range.head.byte
5001 );
5002 }
5003
5004 #[test]
5005 fn double_click_hold_drag_inside_selectable_word_keeps_word_selected() {
5006 let mut core = lay_out_paragraph_tree();
5007 let p1 = core.rect_of_key("p1").expect("p1 rect");
5008 let cx = p1.x + 4.0;
5009 let cy = p1.y + p1.h * 0.5;
5010
5011 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5012 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5013 let down = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5014 let sel = down
5015 .iter()
5016 .find(|e| e.kind == UiEventKind::SelectionChanged)
5017 .and_then(|e| e.selection.as_ref())
5018 .and_then(|s| s.range.as_ref())
5019 .expect("double-click selects word");
5020 assert_eq!(sel.anchor.byte, 0);
5021 assert_eq!(sel.head.byte, 5);
5022
5023 let events = core.pointer_moved(Pointer::moving(cx + 1.0, cy)).events;
5024 assert!(
5025 !events
5026 .iter()
5027 .any(|e| e.kind == UiEventKind::SelectionChanged),
5028 "drag jitter within the double-clicked word should not collapse the selection"
5029 );
5030 let range = core
5031 .ui_state
5032 .current_selection
5033 .range
5034 .as_ref()
5035 .expect("selection persists");
5036 assert_eq!(range.anchor.byte, 0);
5037 assert_eq!(range.head.byte, 5);
5038 }
5039
5040 #[test]
5041 fn pointer_up_clears_drag_but_keeps_selection() {
5042 let mut core = lay_out_paragraph_tree();
5043 let p1 = core.rect_of_key("p1").expect("p1 rect");
5044 let cx = p1.x + 4.0;
5045 let cy = p1.y + p1.h * 0.5;
5046 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5047 core.pointer_moved(Pointer::moving(p1.x + p1.w - 10.0, cy));
5048 let _ = core.pointer_up(Pointer::mouse(
5049 p1.x + p1.w - 10.0,
5050 cy,
5051 PointerButton::Primary,
5052 ));
5053 assert!(
5054 core.ui_state.selection.drag.is_none(),
5055 "drag flag should clear on pointer_up"
5056 );
5057 assert!(
5058 !core.ui_state.current_selection.is_empty(),
5059 "selection itself should persist after pointer_up"
5060 );
5061 }
5062
5063 #[test]
5064 fn drag_past_a_leaf_bottom_keeps_head_in_that_leaf_not_anchor() {
5065 let mut core = lay_out_paragraph_tree();
5071 let p1 = core.rect_of_key("p1").expect("p1 rect");
5072 let p2 = core.rect_of_key("p2").expect("p2 rect");
5073 core.pointer_down(Pointer::mouse(
5075 p1.x + 4.0,
5076 p1.y + p1.h * 0.5,
5077 PointerButton::Primary,
5078 ));
5079 core.pointer_moved(Pointer::moving(p2.x + 8.0, p2.y + p2.h * 0.5));
5081 let events = core
5084 .pointer_moved(Pointer::moving(p2.x + 8.0, p2.y + p2.h + 200.0))
5085 .events;
5086 let sel = events
5087 .iter()
5088 .find(|e| e.kind == UiEventKind::SelectionChanged)
5089 .map(|e| e.selection.as_ref().unwrap().clone())
5090 .unwrap_or_else(|| core.ui_state.current_selection.clone());
5093 let r = sel.range.as_ref().expect("selection still active");
5094 assert_eq!(r.anchor.key, "p1", "anchor unchanged");
5095 assert_eq!(
5096 r.head.key, "p2",
5097 "head must stay in p2 even when pointer is below p2's rect"
5098 );
5099 }
5100
5101 #[test]
5102 fn drag_into_a_sibling_selectable_extends_head_into_that_leaf() {
5103 let mut core = lay_out_paragraph_tree();
5104 let p1 = core.rect_of_key("p1").expect("p1 rect");
5105 let p2 = core.rect_of_key("p2").expect("p2 rect");
5106 core.pointer_down(Pointer::mouse(
5108 p1.x + 4.0,
5109 p1.y + p1.h * 0.5,
5110 PointerButton::Primary,
5111 ));
5112 let events = core
5114 .pointer_moved(Pointer::moving(p2.x + 8.0, p2.y + p2.h * 0.5))
5115 .events;
5116 let sel_event = events
5117 .iter()
5118 .find(|e| e.kind == UiEventKind::SelectionChanged)
5119 .expect("Drag emits SelectionChanged");
5120 let new_sel = sel_event.selection.as_ref().unwrap();
5121 let range = new_sel.range.as_ref().unwrap();
5122 assert_eq!(range.anchor.key, "p1", "anchor stays in p1");
5123 assert_eq!(range.head.key, "p2", "head migrates into p2");
5124 }
5125
5126 #[test]
5127 fn pointer_down_on_focusable_owning_selection_does_not_clear_it() {
5128 let mut core = lay_out_input_tree(true);
5136 core.set_selection(crate::selection::Selection::caret("ti", 3));
5139 let ti = core.rect_of_key("ti").expect("ti rect");
5140 let cx = ti.x + ti.w * 0.5;
5141 let cy = ti.y + ti.h * 0.5;
5142
5143 let events = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5144 let cleared = events.iter().find(|e| {
5145 e.kind == UiEventKind::SelectionChanged
5146 && e.selection.as_ref().map(|s| s.is_empty()).unwrap_or(false)
5147 });
5148 assert!(
5149 cleared.is_none(),
5150 "click on the selection-owning input must not emit a clearing SelectionChanged"
5151 );
5152 assert_eq!(
5153 core.ui_state.current_selection,
5154 crate::selection::Selection::caret("ti", 3),
5155 "runtime mirror is preserved when the click owns the selection"
5156 );
5157 }
5158
5159 #[test]
5160 fn pointer_down_into_a_different_capture_keys_widget_does_not_clear_first() {
5161 let mut core = lay_out_input_tree(true);
5171 core.set_selection(crate::selection::Selection::caret("other", 4));
5173 let ti = core.rect_of_key("ti").expect("ti rect");
5174 let cx = ti.x + ti.w * 0.5;
5175 let cy = ti.y + ti.h * 0.5;
5176
5177 let events = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5178 let cleared = events.iter().any(|e| {
5179 e.kind == UiEventKind::SelectionChanged
5180 && e.selection.as_ref().map(|s| s.is_empty()).unwrap_or(false)
5181 });
5182 assert!(
5183 !cleared,
5184 "click on a different capture_keys widget must not race-clear the selection"
5185 );
5186 }
5187
5188 #[test]
5189 fn pointer_down_on_non_selectable_clears_existing_selection() {
5190 let mut core = lay_out_paragraph_tree();
5191 let p1 = core.rect_of_key("p1").expect("p1 rect");
5192 let cy = p1.y + p1.h * 0.5;
5193 core.pointer_down(Pointer::mouse(p1.x + 4.0, cy, PointerButton::Primary));
5195 core.pointer_up(Pointer::mouse(p1.x + 4.0, cy, PointerButton::Primary));
5196 assert!(!core.ui_state.current_selection.is_empty());
5197
5198 let events = core.pointer_down(Pointer::mouse(2.0, 2.0, PointerButton::Primary));
5200 let cleared = events
5201 .iter()
5202 .find(|e| e.kind == UiEventKind::SelectionChanged)
5203 .expect("clearing emits SelectionChanged");
5204 let new_sel = cleared.selection.as_ref().unwrap();
5205 assert!(new_sel.is_empty(), "new selection should be empty");
5206 assert!(core.ui_state.current_selection.is_empty());
5207 }
5208
5209 #[test]
5210 fn pointer_down_in_dead_space_clears_focus() {
5211 let mut core = lay_out_input_tree(false);
5212 let btn = core.rect_of_key("btn").expect("btn rect");
5213 let cx = btn.x + btn.w * 0.5;
5214 let cy = btn.y + btn.h * 0.5;
5215 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5216 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5217 assert_eq!(
5218 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5219 Some("btn")
5220 );
5221
5222 core.pointer_down(Pointer::mouse(2.0, 2.0, PointerButton::Primary));
5223
5224 assert_eq!(core.ui_state.focused.as_ref().map(|t| t.key.as_str()), None);
5225 }
5226
5227 #[test]
5228 fn key_down_bumps_caret_activity_when_focused_widget_captures_keys() {
5229 let mut core = lay_out_input_tree(true);
5234 let target = core
5235 .ui_state
5236 .focus
5237 .order
5238 .iter()
5239 .find(|t| t.key == "ti")
5240 .cloned();
5241 core.ui_state.set_focus(target); let after_focus = core.ui_state.caret.activity_at.expect("focus bump");
5243
5244 std::thread::sleep(std::time::Duration::from_millis(2));
5245 let _ = core.key_down(UiKey::ArrowRight, KeyModifiers::default(), false);
5246 let after_arrow = core
5247 .ui_state
5248 .caret
5249 .activity_at
5250 .expect("arrow key bumps even without app-side selection");
5251 assert!(
5252 after_arrow > after_focus,
5253 "ArrowRight to a capture_keys focused widget bumps caret activity"
5254 );
5255 }
5256
5257 #[test]
5258 fn text_input_bumps_caret_activity_when_focused() {
5259 let mut core = lay_out_input_tree(true);
5260 let target = core
5261 .ui_state
5262 .focus
5263 .order
5264 .iter()
5265 .find(|t| t.key == "ti")
5266 .cloned();
5267 core.ui_state.set_focus(target);
5268 let after_focus = core.ui_state.caret.activity_at.unwrap();
5269
5270 std::thread::sleep(std::time::Duration::from_millis(2));
5271 let _ = core.text_input("a".into());
5272 let after_text = core.ui_state.caret.activity_at.unwrap();
5273 assert!(
5274 after_text > after_focus,
5275 "TextInput to focused widget bumps caret activity"
5276 );
5277 }
5278
5279 #[test]
5280 fn pointer_down_inside_focused_input_bumps_caret_activity() {
5281 let mut core = lay_out_input_tree(true);
5286 let ti = core.rect_of_key("ti").expect("ti rect");
5287 let cx = ti.x + ti.w * 0.5;
5288 let cy = ti.y + ti.h * 0.5;
5289
5290 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5292 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5293 let after_first = core.ui_state.caret.activity_at.unwrap();
5294
5295 std::thread::sleep(std::time::Duration::from_millis(2));
5298 core.pointer_down(Pointer::mouse(cx + 1.0, cy, PointerButton::Primary));
5299 let after_second = core
5300 .ui_state
5301 .caret
5302 .activity_at
5303 .expect("second click bumps too");
5304 assert!(
5305 after_second > after_first,
5306 "click within already-focused capture_keys widget still bumps"
5307 );
5308 }
5309
5310 #[test]
5311 fn arrow_key_through_apply_event_mutates_selection_and_bumps_on_set() {
5312 use crate::widgets::text_input;
5318 let mut sel = crate::selection::Selection::caret("ti", 2);
5319 let mut value = String::from("hello");
5320
5321 let mut core = RunnerCore::new();
5322 core.set_selection(sel.clone());
5325 let baseline = core.ui_state.caret.activity_at;
5326
5327 let arrow_right = UiEvent {
5329 key: Some("ti".into()),
5330 target: None,
5331 pointer: None,
5332 key_press: Some(crate::event::KeyPress {
5333 key: UiKey::ArrowRight,
5334 modifiers: KeyModifiers::default(),
5335 repeat: false,
5336 }),
5337 text: None,
5338 selection: None,
5339 modifiers: KeyModifiers::default(),
5340 click_count: 0,
5341 path: None,
5342 pointer_kind: None,
5343 wheel_delta: None,
5344 kind: UiEventKind::KeyDown,
5345 };
5346
5347 let mutated = text_input::apply_event(&mut value, &mut sel, "ti", &arrow_right);
5349 assert!(mutated, "ArrowRight should mutate selection");
5350 assert_eq!(
5351 sel.within("ti").unwrap().head,
5352 3,
5353 "head moved one char right (h-e-l-l-o, byte 2 → 3)"
5354 );
5355
5356 std::thread::sleep(std::time::Duration::from_millis(2));
5358 core.set_selection(sel);
5359 let after = core.ui_state.caret.activity_at.unwrap();
5360 if let Some(b) = baseline {
5364 assert!(after > b, "arrow-key flow should bump activity");
5365 }
5366 }
5367
5368 #[test]
5369 fn set_selection_bumps_caret_activity_only_when_value_changes() {
5370 let mut core = lay_out_paragraph_tree();
5371 core.set_selection(crate::selection::Selection::default());
5374 assert!(
5375 core.ui_state.caret.activity_at.is_none(),
5376 "no-op set_selection should not bump activity"
5377 );
5378
5379 let sel_a = crate::selection::Selection::caret("p1", 3);
5381 core.set_selection(sel_a.clone());
5382 let bumped_at = core
5383 .ui_state
5384 .caret
5385 .activity_at
5386 .expect("first real selection bumps");
5387
5388 core.set_selection(sel_a.clone());
5391 assert_eq!(
5392 core.ui_state.caret.activity_at,
5393 Some(bumped_at),
5394 "set_selection with same value is a no-op"
5395 );
5396
5397 std::thread::sleep(std::time::Duration::from_millis(2));
5400 let sel_b = crate::selection::Selection::caret("p1", 7);
5401 core.set_selection(sel_b);
5402 let new_bump = core.ui_state.caret.activity_at.expect("second bump");
5403 assert!(
5404 new_bump > bumped_at,
5405 "moving the caret bumps activity again",
5406 );
5407 }
5408
5409 #[test]
5410 fn escape_clears_active_selection_and_emits_selection_changed() {
5411 let mut core = lay_out_paragraph_tree();
5412 let p1 = core.rect_of_key("p1").expect("p1 rect");
5413 let cy = p1.y + p1.h * 0.5;
5414 core.pointer_down(Pointer::mouse(p1.x + 4.0, cy, PointerButton::Primary));
5416 core.pointer_moved(Pointer::moving(p1.x + p1.w - 10.0, cy));
5417 core.pointer_up(Pointer::mouse(
5418 p1.x + p1.w - 10.0,
5419 cy,
5420 PointerButton::Primary,
5421 ));
5422 assert!(!core.ui_state.current_selection.is_empty());
5423
5424 let events = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
5425 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
5426 assert_eq!(
5427 kinds,
5428 vec![UiEventKind::Escape, UiEventKind::SelectionChanged],
5429 "Esc emits Escape (for popover dismiss) AND SelectionChanged"
5430 );
5431 let cleared = events
5432 .iter()
5433 .find(|e| e.kind == UiEventKind::SelectionChanged)
5434 .unwrap();
5435 assert!(cleared.selection.as_ref().unwrap().is_empty());
5436 assert!(core.ui_state.current_selection.is_empty());
5437 }
5438
5439 #[test]
5440 fn consecutive_clicks_on_same_target_extend_count() {
5441 let mut core = lay_out_input_tree(false);
5442 let btn = core.rect_of_key("btn").expect("btn rect");
5443 let cx = btn.x + btn.w * 0.5;
5444 let cy = btn.y + btn.h * 0.5;
5445
5446 let down1 = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5448 let pd1 = down1
5449 .iter()
5450 .find(|e| e.kind == UiEventKind::PointerDown)
5451 .expect("PointerDown emitted");
5452 assert_eq!(pd1.click_count, 1, "first press starts the sequence");
5453 let up1 = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5454 let click1 = up1
5455 .iter()
5456 .find(|e| e.kind == UiEventKind::Click)
5457 .expect("Click emitted");
5458 assert_eq!(
5459 click1.click_count, 1,
5460 "Click carries the same count as its PointerDown"
5461 );
5462
5463 let down2 = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5465 let pd2 = down2
5466 .iter()
5467 .find(|e| e.kind == UiEventKind::PointerDown)
5468 .unwrap();
5469 assert_eq!(pd2.click_count, 2, "second press extends the sequence");
5470 let up2 = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5471 assert_eq!(
5472 up2.iter()
5473 .find(|e| e.kind == UiEventKind::Click)
5474 .unwrap()
5475 .click_count,
5476 2
5477 );
5478
5479 let down3 = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5481 let pd3 = down3
5482 .iter()
5483 .find(|e| e.kind == UiEventKind::PointerDown)
5484 .unwrap();
5485 assert_eq!(pd3.click_count, 3, "third press → triple-click");
5486 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5487 }
5488
5489 #[test]
5490 fn click_count_resets_when_target_changes() {
5491 let mut core = lay_out_input_tree(false);
5492 let btn = core.rect_of_key("btn").expect("btn rect");
5493 let ti = core.rect_of_key("ti").expect("ti rect");
5494
5495 let down1 = core.pointer_down(Pointer::mouse(
5497 btn.x + btn.w * 0.5,
5498 btn.y + btn.h * 0.5,
5499 PointerButton::Primary,
5500 ));
5501 assert_eq!(
5502 down1
5503 .iter()
5504 .find(|e| e.kind == UiEventKind::PointerDown)
5505 .unwrap()
5506 .click_count,
5507 1
5508 );
5509 let _ = core.pointer_up(Pointer::mouse(
5510 btn.x + btn.w * 0.5,
5511 btn.y + btn.h * 0.5,
5512 PointerButton::Primary,
5513 ));
5514
5515 let down2 = core.pointer_down(Pointer::mouse(
5517 ti.x + ti.w * 0.5,
5518 ti.y + ti.h * 0.5,
5519 PointerButton::Primary,
5520 ));
5521 let pd2 = down2
5522 .iter()
5523 .find(|e| e.kind == UiEventKind::PointerDown)
5524 .unwrap();
5525 assert_eq!(
5526 pd2.click_count, 1,
5527 "press on a new target resets the multi-click sequence"
5528 );
5529 }
5530
5531 #[test]
5532 fn double_click_on_selectable_text_selects_word_at_hit() {
5533 let mut core = lay_out_paragraph_tree();
5534 let p1 = core.rect_of_key("p1").expect("p1 rect");
5535 let cy = p1.y + p1.h * 0.5;
5536 let cx = p1.x + 4.0;
5539 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5540 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5541 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5542 let sel = &core.ui_state.current_selection;
5544 let r = sel.range.as_ref().expect("selection set");
5545 assert_eq!(r.anchor.key, "p1");
5546 assert_eq!(r.head.key, "p1");
5547 assert_eq!(r.anchor.byte.min(r.head.byte), 0);
5549 assert_eq!(r.anchor.byte.max(r.head.byte), 5);
5550 }
5551
5552 #[test]
5553 fn triple_click_on_selectable_text_selects_whole_leaf() {
5554 let mut core = lay_out_paragraph_tree();
5555 let p1 = core.rect_of_key("p1").expect("p1 rect");
5556 let cy = p1.y + p1.h * 0.5;
5557 let cx = p1.x + 4.0;
5558 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5559 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5560 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5561 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5562 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5563 let sel = &core.ui_state.current_selection;
5564 let r = sel.range.as_ref().expect("selection set");
5565 assert_eq!(r.anchor.byte, 0);
5566 assert_eq!(r.head.byte, 24);
5568 }
5569
5570 #[test]
5571 fn click_count_resets_when_press_drifts_outside_distance_window() {
5572 let mut core = lay_out_input_tree(false);
5573 let btn = core.rect_of_key("btn").expect("btn rect");
5574 let cx = btn.x + btn.w * 0.5;
5575 let cy = btn.y + btn.h * 0.5;
5576
5577 let _ = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5578 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5579
5580 let down2 = core.pointer_down(Pointer::mouse(cx + 10.0, cy, PointerButton::Primary));
5583 let pd2 = down2
5584 .iter()
5585 .find(|e| e.kind == UiEventKind::PointerDown)
5586 .unwrap();
5587 assert_eq!(pd2.click_count, 1);
5588 }
5589
5590 #[test]
5591 fn escape_with_no_selection_emits_only_escape() {
5592 let mut core = lay_out_paragraph_tree();
5593 assert!(core.ui_state.current_selection.is_empty());
5594 let events = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
5595 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
5596 assert_eq!(
5597 kinds,
5598 vec![UiEventKind::Escape],
5599 "no selection → no SelectionChanged side-effect"
5600 );
5601 }
5602
5603 fn lay_out_scroll_tree() -> (RunnerCore, String) {
5606 use crate::tree::*;
5607 let mut tree = crate::scroll(
5608 (0..6)
5609 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
5610 )
5611 .gap(12.0)
5612 .height(Size::Fixed(200.0));
5613 let mut core = RunnerCore::new();
5614 crate::layout::layout(
5615 &mut tree,
5616 &mut core.ui_state,
5617 Rect::new(0.0, 0.0, 300.0, 200.0),
5618 );
5619 let scroll_id = tree.computed_id.clone();
5620 let mut t = PrepareTimings::default();
5621 core.snapshot(&tree, &mut t);
5622 (core, scroll_id)
5623 }
5624
5625 #[test]
5626 fn thumb_pointer_down_captures_drag_and_suppresses_events() {
5627 let (mut core, scroll_id) = lay_out_scroll_tree();
5628 let thumb = core
5629 .ui_state
5630 .scroll
5631 .thumb_rects
5632 .get(&scroll_id)
5633 .copied()
5634 .expect("scrollable should have a thumb");
5635 let event = core.pointer_down(Pointer::mouse(
5636 thumb.x + thumb.w * 0.5,
5637 thumb.y + thumb.h * 0.5,
5638 PointerButton::Primary,
5639 ));
5640 assert!(
5641 event.is_empty(),
5642 "thumb press should not emit PointerDown to the app"
5643 );
5644 let drag = core
5645 .ui_state
5646 .scroll
5647 .thumb_drag
5648 .as_ref()
5649 .expect("scroll.thumb_drag should be set after pointer_down on thumb");
5650 assert_eq!(drag.scroll_id, scroll_id);
5651 }
5652
5653 #[test]
5654 fn track_click_above_thumb_pages_up_below_pages_down() {
5655 let (mut core, scroll_id) = lay_out_scroll_tree();
5656 let track = core
5657 .ui_state
5658 .scroll
5659 .thumb_tracks
5660 .get(&scroll_id)
5661 .copied()
5662 .expect("scrollable should have a track");
5663 let thumb = core
5664 .ui_state
5665 .scroll
5666 .thumb_rects
5667 .get(&scroll_id)
5668 .copied()
5669 .unwrap();
5670 let metrics = core
5671 .ui_state
5672 .scroll
5673 .metrics
5674 .get(&scroll_id)
5675 .copied()
5676 .unwrap();
5677
5678 let evt = core.pointer_down(Pointer::mouse(
5680 track.x + track.w * 0.5,
5681 thumb.y + thumb.h + 10.0,
5682 PointerButton::Primary,
5683 ));
5684 assert!(evt.is_empty(), "track press should not surface PointerDown");
5685 assert!(
5686 core.ui_state.scroll.thumb_drag.is_none(),
5687 "track click outside the thumb should not start a drag",
5688 );
5689 let after_down = core.ui_state.scroll_offset(&scroll_id);
5690 let expected_page = (metrics.viewport_h - SCROLL_PAGE_OVERLAP).max(0.0);
5691 assert!(
5692 (after_down - expected_page.min(metrics.max_offset)).abs() < 0.5,
5693 "page-down offset = {after_down} (expected ~{expected_page})",
5694 );
5695 let _ = core.pointer_up(Pointer::mouse(0.0, 0.0, PointerButton::Primary));
5697
5698 let mut tree = lay_out_scroll_tree_only();
5701 crate::layout::layout(
5702 &mut tree,
5703 &mut core.ui_state,
5704 Rect::new(0.0, 0.0, 300.0, 200.0),
5705 );
5706 let mut t = PrepareTimings::default();
5707 core.snapshot(&tree, &mut t);
5708 let track = core
5709 .ui_state
5710 .scroll
5711 .thumb_tracks
5712 .get(&tree.computed_id)
5713 .copied()
5714 .unwrap();
5715 let thumb = core
5716 .ui_state
5717 .scroll
5718 .thumb_rects
5719 .get(&tree.computed_id)
5720 .copied()
5721 .unwrap();
5722
5723 core.pointer_down(Pointer::mouse(
5724 track.x + track.w * 0.5,
5725 thumb.y - 4.0,
5726 PointerButton::Primary,
5727 ));
5728 let after_up = core.ui_state.scroll_offset(&tree.computed_id);
5729 assert!(
5730 after_up < after_down,
5731 "page-up should reduce offset: before={after_down} after={after_up}",
5732 );
5733 }
5734
5735 #[test]
5736 fn scrollbar_press_does_not_bypass_covering_scrim() {
5737 let (mut core, scroll_id) =
5738 lay_out_scroll_tree_with_layer(crate::widgets::overlay::scrim("modal:dismiss"));
5739 let thumb = core
5740 .ui_state
5741 .scroll
5742 .thumb_rects
5743 .get(&scroll_id)
5744 .copied()
5745 .expect("scrollable should have a thumb");
5746
5747 let events = core.pointer_down(Pointer::mouse(
5748 thumb.x + thumb.w * 0.5,
5749 thumb.y + thumb.h * 0.5,
5750 PointerButton::Primary,
5751 ));
5752
5753 assert_eq!(events.len(), 1);
5754 assert_eq!(events[0].kind, UiEventKind::PointerDown);
5755 assert_eq!(events[0].route(), Some("modal:dismiss"));
5756 assert!(
5757 core.ui_state.scroll.thumb_drag.is_none(),
5758 "covered scrollbar thumb must not capture drag",
5759 );
5760 }
5761
5762 #[test]
5763 fn scrollbar_press_does_not_bypass_block_pointer_layer() {
5764 use crate::tree::*;
5765
5766 let (mut core, scroll_id) =
5767 lay_out_scroll_tree_with_layer(El::new(Kind::Group).fill_size().block_pointer());
5768 let thumb = core
5769 .ui_state
5770 .scroll
5771 .thumb_rects
5772 .get(&scroll_id)
5773 .copied()
5774 .expect("scrollable should have a thumb");
5775
5776 let events = core.pointer_down(Pointer::mouse(
5777 thumb.x + thumb.w * 0.5,
5778 thumb.y + thumb.h * 0.5,
5779 PointerButton::Primary,
5780 ));
5781
5782 assert!(
5783 events.is_empty(),
5784 "block_pointer layer should swallow the press without scrolling",
5785 );
5786 assert!(
5787 core.ui_state.scroll.thumb_drag.is_none(),
5788 "covered scrollbar thumb must not capture drag",
5789 );
5790 }
5791
5792 fn lay_out_scroll_tree_only() -> El {
5797 use crate::tree::*;
5798 crate::scroll(
5799 (0..6)
5800 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
5801 )
5802 .gap(12.0)
5803 .height(Size::Fixed(200.0))
5804 }
5805
5806 fn lay_out_scroll_tree_with_layer(layer: El) -> (RunnerCore, String) {
5807 use crate::tree::*;
5808
5809 let scroll = lay_out_scroll_tree_only();
5810 let mut tree = crate::stack([scroll, layer]).fill_size();
5811 let mut core = RunnerCore::new();
5812 crate::layout::layout(
5813 &mut tree,
5814 &mut core.ui_state,
5815 Rect::new(0.0, 0.0, 300.0, 200.0),
5816 );
5817 let scroll_id = tree.children[0].computed_id.clone();
5818 let mut t = PrepareTimings::default();
5819 core.snapshot(&tree, &mut t);
5820 (core, scroll_id)
5821 }
5822
5823 fn lay_out_resizable_tree(layer: Option<El>) -> RunnerCore {
5827 use crate::tree::*;
5828 let row = crate::tree::row([
5829 column(Vec::<El>::new())
5830 .key("nav")
5831 .user_resizable()
5832 .width(Size::Fixed(200.0))
5833 .min_width(120.0)
5834 .max_width(420.0),
5835 column(Vec::<El>::new()).width(Size::Fill(1.0)),
5836 ])
5837 .height(Size::Fill(1.0));
5838 let mut tree = match layer {
5839 Some(layer) => crate::stack([row, layer]).fill_size(),
5840 None => row,
5841 };
5842 let mut core = RunnerCore::new();
5843 crate::layout::layout(
5844 &mut tree,
5845 &mut core.ui_state,
5846 Rect::new(0.0, 0.0, 800.0, 400.0),
5847 );
5848 let mut t = PrepareTimings::default();
5849 core.snapshot(&tree, &mut t);
5850 core
5851 }
5852
5853 #[test]
5854 fn resize_band_drag_updates_user_size_and_emits_resized_on_release() {
5855 let mut core = lay_out_resizable_tree(None);
5856
5857 let events = core.pointer_down(Pointer::mouse(200.0, 150.0, PointerButton::Primary));
5859 assert!(events.is_empty(), "band press must not reach the app");
5860 assert!(core.ui_state.resize.drag.is_some());
5861
5862 let mv = core.pointer_moved(Pointer::mouse(280.0, 150.0, PointerButton::Primary));
5864 assert!(mv.events.is_empty(), "drag is captured, no app events");
5865 assert!(mv.needs_redraw, "size change must redraw");
5866 assert_eq!(core.ui_state.user_size("nav"), Some(280.0));
5867
5868 let _ = core.pointer_moved(Pointer::mouse(700.0, 150.0, PointerButton::Primary));
5870 assert_eq!(core.ui_state.user_size("nav"), Some(420.0));
5871
5872 let up = core.pointer_up(Pointer::mouse(700.0, 150.0, PointerButton::Primary));
5874 assert_eq!(up.len(), 1);
5875 assert_eq!(up[0].kind, UiEventKind::Resized);
5876 assert_eq!(up[0].route(), Some("nav"));
5877 assert!(core.ui_state.resize.drag.is_none());
5878 }
5879
5880 #[test]
5881 fn resize_band_hover_flips_cursor_and_requests_redraw() {
5882 let mut core = lay_out_resizable_tree(None);
5883 let tree = core.last_tree.clone().expect("snapshot stored a tree");
5884
5885 let _ = core.pointer_moved(Pointer::mouse(400.0, 150.0, PointerButton::Primary));
5887 assert_eq!(core.ui_state.cursor(&tree), crate::cursor::Cursor::Default);
5888
5889 let mv = core.pointer_moved(Pointer::mouse(201.0, 150.0, PointerButton::Primary));
5893 assert!(mv.needs_redraw, "band entry must redraw for the cursor");
5894 assert_eq!(core.ui_state.cursor(&tree), crate::cursor::Cursor::EwResize);
5895
5896 let _ = core.pointer_down(Pointer::mouse(201.0, 150.0, PointerButton::Primary));
5899 let _ = core.pointer_moved(Pointer::mouse(390.0, 150.0, PointerButton::Primary));
5900 assert_eq!(core.ui_state.cursor(&tree), crate::cursor::Cursor::EwResize);
5901 }
5902
5903 #[test]
5904 fn resize_band_press_does_not_bypass_covering_scrim() {
5905 let mut core =
5906 lay_out_resizable_tree(Some(crate::widgets::overlay::scrim("modal:dismiss")));
5907
5908 let events = core.pointer_down(Pointer::mouse(200.0, 150.0, PointerButton::Primary));
5909 assert!(
5910 core.ui_state.resize.drag.is_none(),
5911 "covered band must not capture the press",
5912 );
5913 assert_eq!(events.len(), 1);
5914 assert_eq!(events[0].kind, UiEventKind::PointerDown);
5915 assert_eq!(events[0].route(), Some("modal:dismiss"));
5916 }
5917
5918 #[test]
5919 fn thumb_drag_translates_pointer_delta_into_scroll_offset() {
5920 let (mut core, scroll_id) = lay_out_scroll_tree();
5921 let thumb = core
5922 .ui_state
5923 .scroll
5924 .thumb_rects
5925 .get(&scroll_id)
5926 .copied()
5927 .unwrap();
5928 let metrics = core
5929 .ui_state
5930 .scroll
5931 .metrics
5932 .get(&scroll_id)
5933 .copied()
5934 .unwrap();
5935 let track_remaining = (metrics.viewport_h - thumb.h).max(0.0);
5936
5937 let press_y = thumb.y + thumb.h * 0.5;
5938 core.pointer_down(Pointer::mouse(
5939 thumb.x + thumb.w * 0.5,
5940 press_y,
5941 PointerButton::Primary,
5942 ));
5943 let evt = core.pointer_moved(Pointer::moving(thumb.x + thumb.w * 0.5, press_y + 20.0));
5945 assert!(
5946 evt.events.is_empty(),
5947 "thumb-drag move should suppress Drag event",
5948 );
5949 let offset = core.ui_state.scroll_offset(&scroll_id);
5950 let expected = 20.0 * (metrics.max_offset / track_remaining);
5951 assert!(
5952 (offset - expected).abs() < 0.5,
5953 "offset {offset} (expected {expected})",
5954 );
5955 core.pointer_moved(Pointer::moving(thumb.x + thumb.w * 0.5, press_y + 9999.0));
5957 let offset = core.ui_state.scroll_offset(&scroll_id);
5958 assert!(
5959 (offset - metrics.max_offset).abs() < 0.5,
5960 "overshoot offset {offset} (expected {})",
5961 metrics.max_offset
5962 );
5963 let events = core.pointer_up(Pointer::mouse(thumb.x, press_y, PointerButton::Primary));
5965 assert!(events.is_empty(), "thumb release shouldn't emit events");
5966 assert!(core.ui_state.scroll.thumb_drag.is_none());
5967 }
5968
5969 #[test]
5970 fn secondary_click_does_not_steal_focus_or_press() {
5971 let mut core = lay_out_input_tree(false);
5972 let btn_rect = core.rect_of_key("btn").expect("btn rect");
5973 let cx = btn_rect.x + btn_rect.w * 0.5;
5974 let cy = btn_rect.y + btn_rect.h * 0.5;
5975 let ti_rect = core.rect_of_key("ti").expect("ti rect");
5977 let tx = ti_rect.x + ti_rect.w * 0.5;
5978 let ty = ti_rect.y + ti_rect.h * 0.5;
5979 core.pointer_down(Pointer::mouse(tx, ty, PointerButton::Primary));
5980 let _ = core.pointer_up(Pointer::mouse(tx, ty, PointerButton::Primary));
5981 let focused_before = core.ui_state.focused.as_ref().map(|t| t.key.clone());
5982 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Secondary));
5984 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Secondary));
5985 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
5986 assert_eq!(kinds, vec![UiEventKind::SecondaryClick]);
5987 let focused_after = core.ui_state.focused.as_ref().map(|t| t.key.clone());
5988 assert_eq!(
5989 focused_before, focused_after,
5990 "right-click must not steal focus"
5991 );
5992 assert!(
5993 core.ui_state.pressed.is_none(),
5994 "right-click must not set primary press"
5995 );
5996 }
5997
5998 #[test]
5999 fn text_input_routes_to_focused_only() {
6000 let mut core = lay_out_input_tree(false);
6001 assert!(core.text_input("a".into()).is_none());
6003 let btn_rect = core.rect_of_key("btn").expect("btn rect");
6005 let cx = btn_rect.x + btn_rect.w * 0.5;
6006 let cy = btn_rect.y + btn_rect.h * 0.5;
6007 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6008 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
6009 let event = core.text_input("hi".into()).expect("focused → event");
6010 assert_eq!(event.kind, UiEventKind::TextInput);
6011 assert_eq!(event.text.as_deref(), Some("hi"));
6012 assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("btn"));
6013 assert!(core.text_input(String::new()).is_none());
6015 }
6016
6017 #[test]
6018 fn capture_keys_bypasses_tab_traversal_for_focused_node() {
6019 let mut core = lay_out_input_tree(true);
6022 let ti_rect = core.rect_of_key("ti").expect("ti rect");
6023 let tx = ti_rect.x + ti_rect.w * 0.5;
6024 let ty = ti_rect.y + ti_rect.h * 0.5;
6025 core.pointer_down(Pointer::mouse(tx, ty, PointerButton::Primary));
6026 let _ = core.pointer_up(Pointer::mouse(tx, ty, PointerButton::Primary));
6027 assert_eq!(
6028 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6029 Some("ti"),
6030 "primary click on capture_keys node still focuses it"
6031 );
6032
6033 let events = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
6034 assert_eq!(events.len(), 1, "Tab → exactly one KeyDown");
6035 let event = &events[0];
6036 assert_eq!(event.kind, UiEventKind::KeyDown);
6037 assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("ti"));
6038 assert_eq!(
6039 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6040 Some("ti"),
6041 "Tab inside capture_keys must NOT move focus"
6042 );
6043 }
6044
6045 #[test]
6046 fn escape_blurs_capture_keys_after_delivering_raw_keydown() {
6047 let mut core = lay_out_input_tree(true);
6048 let ti_rect = core.rect_of_key("ti").expect("ti rect");
6049 let tx = ti_rect.x + ti_rect.w * 0.5;
6050 let ty = ti_rect.y + ti_rect.h * 0.5;
6051 core.pointer_down(Pointer::mouse(tx, ty, PointerButton::Primary));
6052 let _ = core.pointer_up(Pointer::mouse(tx, ty, PointerButton::Primary));
6053 assert_eq!(
6054 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6055 Some("ti")
6056 );
6057
6058 let events = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
6059
6060 assert_eq!(events.len(), 1);
6061 let event = &events[0];
6062 assert_eq!(event.kind, UiEventKind::KeyDown);
6063 assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("ti"));
6064 assert!(matches!(
6065 event.key_press.as_ref().map(|p| &p.key),
6066 Some(UiKey::Escape)
6067 ));
6068 assert_eq!(core.ui_state.focused.as_ref().map(|t| t.key.as_str()), None);
6069 }
6070
6071 #[test]
6072 fn pointer_down_focus_does_not_raise_focus_visible() {
6073 let mut core = lay_out_input_tree(false);
6076 let btn_rect = core.rect_of_key("btn").expect("btn rect");
6077 let cx = btn_rect.x + btn_rect.w * 0.5;
6078 let cy = btn_rect.y + btn_rect.h * 0.5;
6079 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6080 assert_eq!(
6081 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6082 Some("btn"),
6083 "primary click focuses the button",
6084 );
6085 assert!(
6086 !core.ui_state.focus_visible,
6087 "click focus must not raise focus_visible — ring stays off",
6088 );
6089 }
6090
6091 #[test]
6092 fn tab_key_raises_focus_visible_so_ring_appears() {
6093 let mut core = lay_out_input_tree(false);
6094 let btn_rect = core.rect_of_key("btn").expect("btn rect");
6096 let cx = btn_rect.x + btn_rect.w * 0.5;
6097 let cy = btn_rect.y + btn_rect.h * 0.5;
6098 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6099 assert!(!core.ui_state.focus_visible);
6100 let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
6102 assert!(
6103 core.ui_state.focus_visible,
6104 "Tab must raise focus_visible so the ring paints on the new target",
6105 );
6106 }
6107
6108 #[test]
6109 fn click_after_tab_clears_focus_visible_again() {
6110 let mut core = lay_out_input_tree(false);
6113 let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
6114 assert!(core.ui_state.focus_visible, "Tab raises ring");
6115 let btn_rect = core.rect_of_key("btn").expect("btn rect");
6116 let cx = btn_rect.x + btn_rect.w * 0.5;
6117 let cy = btn_rect.y + btn_rect.h * 0.5;
6118 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6119 assert!(
6120 !core.ui_state.focus_visible,
6121 "pointer-down clears focus_visible — ring fades back out",
6122 );
6123 }
6124
6125 #[test]
6126 fn keypress_on_focused_widget_raises_focus_visible_after_click() {
6127 let mut core = lay_out_input_tree(false);
6131 let btn_rect = core.rect_of_key("btn").expect("btn rect");
6132 let cx = btn_rect.x + btn_rect.w * 0.5;
6133 let cy = btn_rect.y + btn_rect.h * 0.5;
6134 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6135 assert!(!core.ui_state.focus_visible);
6136 let _ = core.key_down(UiKey::ArrowRight, KeyModifiers::default(), false);
6137 assert!(
6138 core.ui_state.focus_visible,
6139 "non-Tab key on focused widget raises focus_visible",
6140 );
6141 }
6142
6143 #[test]
6144 fn selected_text_resolves_a_selection_inside_a_virtual_list() {
6145 use crate::selection::{Selection, SelectionPoint, SelectionRange};
6152 use crate::tree::*;
6153
6154 let mut tree = virtual_list_dyn(
6158 20,
6159 50.0,
6160 |i| format!("row-{i}"),
6161 |i| {
6162 crate::widgets::text::text(format!("row {i} text"))
6163 .key(format!("row-{i}"))
6164 .selectable()
6165 .height(Size::Fixed(50.0))
6166 },
6167 );
6168 let mut core = RunnerCore::new();
6169 crate::layout::layout(
6170 &mut tree,
6171 &mut core.ui_state,
6172 Rect::new(0.0, 0.0, 200.0, 200.0),
6173 );
6174 let mut t = PrepareTimings::default();
6175 core.snapshot(&tree, &mut t);
6176
6177 let selection = Selection {
6179 range: Some(SelectionRange {
6180 anchor: SelectionPoint::new("row-1", 0),
6181 head: SelectionPoint::new("row-1", 9),
6182 }),
6183 };
6184 core.set_selection(selection);
6185
6186 assert_eq!(
6187 core.selected_text().as_deref(),
6188 Some("row 1 tex"),
6189 "runtime.selected_text() must walk last_tree (realized rows) — \
6190 a build-only path would miss virtual_list children entirely",
6191 );
6192 }
6193
6194 #[test]
6195 fn shortcut_chord_does_not_raise_focus_visible() {
6196 let mut core = lay_out_input_tree(false);
6203 let btn_rect = core.rect_of_key("btn").expect("btn rect");
6204 let cx = btn_rect.x + btn_rect.w * 0.5;
6205 let cy = btn_rect.y + btn_rect.h * 0.5;
6206 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6207 assert!(!core.ui_state.focus_visible);
6208
6209 let ctrl = KeyModifiers {
6210 ctrl: true,
6211 ..Default::default()
6212 };
6213 let _ = core.key_down(UiKey::Other("Control".into()), ctrl, false);
6214 assert!(
6215 !core.ui_state.focus_visible,
6216 "bare Ctrl press must not raise focus_visible on a pointer-focused widget",
6217 );
6218 let _ = core.key_down(UiKey::Character("c".into()), ctrl, false);
6219 assert!(
6220 !core.ui_state.focus_visible,
6221 "Ctrl+C is a shortcut, not interaction with the focused widget",
6222 );
6223
6224 let _ = core.key_down(UiKey::Other("Shift".into()), KeyModifiers::default(), false);
6225 assert!(
6226 !core.ui_state.focus_visible,
6227 "bare Shift press must not raise focus_visible",
6228 );
6229 let _ = core.key_down(UiKey::Character("a".into()), KeyModifiers::default(), false);
6230 assert!(
6231 !core.ui_state.focus_visible,
6232 "bare character keys are typing/activation guesses, not navigation",
6233 );
6234 let _ = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
6235 assert!(
6236 !core.ui_state.focus_visible,
6237 "Escape is dismissal, not navigation — no ring",
6238 );
6239 }
6240
6241 #[test]
6242 fn arrow_nav_in_sibling_group_raises_focus_visible() {
6243 let mut core = lay_out_arrow_nav_tree();
6244 core.ui_state.set_focus_visible(false);
6247 let _ = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
6248 assert!(
6249 core.ui_state.focus_visible,
6250 "arrow-nav within an arrow_nav_siblings group is keyboard navigation",
6251 );
6252 }
6253
6254 #[test]
6255 fn capture_keys_falls_back_to_default_when_focus_off_capturing_node() {
6256 let mut core = lay_out_input_tree(true);
6260 let btn_rect = core.rect_of_key("btn").expect("btn rect");
6261 let cx = btn_rect.x + btn_rect.w * 0.5;
6262 let cy = btn_rect.y + btn_rect.h * 0.5;
6263 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6264 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
6265 assert_eq!(
6266 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6267 Some("btn"),
6268 "primary click focuses button"
6269 );
6270 let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
6272 assert_eq!(
6273 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6274 Some("ti"),
6275 "Tab from non-capturing focused does library-default traversal"
6276 );
6277 }
6278
6279 fn lay_out_arrow_nav_tree() -> RunnerCore {
6284 use crate::tree::*;
6285 let mut tree = crate::column([
6286 crate::widgets::button::button("Red").key("opt-red"),
6287 crate::widgets::button::button("Green").key("opt-green"),
6288 crate::widgets::button::button("Blue").key("opt-blue"),
6289 ])
6290 .arrow_nav_siblings()
6291 .padding(10.0);
6292 let mut core = RunnerCore::new();
6293 crate::layout::layout(
6294 &mut tree,
6295 &mut core.ui_state,
6296 Rect::new(0.0, 0.0, 200.0, 300.0),
6297 );
6298 core.ui_state.sync_focus_order(&tree);
6299 let mut t = PrepareTimings::default();
6300 core.snapshot(&tree, &mut t);
6301 let target = core
6304 .ui_state
6305 .focus
6306 .order
6307 .iter()
6308 .find(|t| t.key == "opt-green")
6309 .cloned();
6310 core.ui_state.set_focus(target);
6311 core
6312 }
6313
6314 #[test]
6315 fn arrow_nav_moves_focus_among_siblings() {
6316 let mut core = lay_out_arrow_nav_tree();
6317
6318 let down = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
6321 assert!(down.is_empty(), "arrow-nav consumes the key event");
6322 assert_eq!(
6323 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6324 Some("opt-blue"),
6325 );
6326
6327 core.key_down(UiKey::ArrowUp, KeyModifiers::default(), false);
6329 assert_eq!(
6330 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6331 Some("opt-green"),
6332 );
6333
6334 core.key_down(UiKey::Home, KeyModifiers::default(), false);
6336 assert_eq!(
6337 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6338 Some("opt-red"),
6339 );
6340
6341 core.key_down(UiKey::End, KeyModifiers::default(), false);
6343 assert_eq!(
6344 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6345 Some("opt-blue"),
6346 );
6347 }
6348
6349 #[test]
6350 fn arrow_nav_saturates_at_ends() {
6351 let mut core = lay_out_arrow_nav_tree();
6352 core.key_down(UiKey::Home, KeyModifiers::default(), false);
6354 core.key_down(UiKey::ArrowUp, KeyModifiers::default(), false);
6355 assert_eq!(
6356 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6357 Some("opt-red"),
6358 "ArrowUp at top stays at top — no wrap",
6359 );
6360 core.key_down(UiKey::End, KeyModifiers::default(), false);
6362 core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
6363 assert_eq!(
6364 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6365 Some("opt-blue"),
6366 "ArrowDown at bottom stays at bottom — no wrap",
6367 );
6368 }
6369
6370 #[test]
6371 fn arrow_nav_vertical_lets_horizontal_arrows_fall_through() {
6372 let mut core = lay_out_arrow_nav_tree();
6376 let out = core.key_down(UiKey::ArrowRight, KeyModifiers::default(), false);
6377 assert_eq!(
6378 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6379 Some("opt-green"),
6380 "ArrowRight must not move focus in a Vertical group",
6381 );
6382 assert!(
6383 !out.is_empty(),
6384 "ArrowRight falls through to normal KeyDown routing",
6385 );
6386 }
6387
6388 fn lay_out_horizontal_group() -> RunnerCore {
6391 let mut tree = crate::widgets::toggle::toggle_group(
6392 "view",
6393 &"list",
6394 [("list", "List"), ("grid", "Grid"), ("map", "Map")],
6395 )
6396 .padding(10.0);
6397 let mut core = RunnerCore::new();
6398 crate::layout::layout(
6399 &mut tree,
6400 &mut core.ui_state,
6401 Rect::new(0.0, 0.0, 400.0, 100.0),
6402 );
6403 core.ui_state.sync_focus_order(&tree);
6404 let mut t = PrepareTimings::default();
6405 core.snapshot(&tree, &mut t);
6406 let target = core
6407 .ui_state
6408 .focus
6409 .order
6410 .iter()
6411 .find(|t| t.key == "view:toggle:list")
6412 .cloned();
6413 core.ui_state.set_focus(target);
6414 core
6415 }
6416
6417 #[test]
6418 fn arrow_nav_horizontal_group_steps_on_left_right() {
6419 let mut core = lay_out_horizontal_group();
6422
6423 let right = core.key_down(UiKey::ArrowRight, KeyModifiers::default(), false);
6424 assert!(right.is_empty(), "ArrowRight is consumed by the group");
6425 assert_eq!(
6426 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6427 Some("view:toggle:grid"),
6428 );
6429
6430 core.key_down(UiKey::ArrowLeft, KeyModifiers::default(), false);
6431 assert_eq!(
6432 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6433 Some("view:toggle:list"),
6434 );
6435
6436 core.key_down(UiKey::End, KeyModifiers::default(), false);
6437 assert_eq!(
6438 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6439 Some("view:toggle:map"),
6440 );
6441
6442 let down = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
6443 assert_eq!(
6444 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6445 Some("view:toggle:map"),
6446 "ArrowDown must not move focus in a Horizontal group",
6447 );
6448 assert!(
6449 !down.is_empty(),
6450 "ArrowDown falls through to normal KeyDown routing",
6451 );
6452 }
6453
6454 #[test]
6455 fn arrow_nav_radio_group_steps_on_both_axes() {
6456 let mut tree = crate::widgets::radio::radio_group(
6459 "theme",
6460 &"light",
6461 [("light", "Light"), ("dark", "Dark"), ("auto", "Auto")],
6462 )
6463 .padding(10.0);
6464 let mut core = RunnerCore::new();
6465 crate::layout::layout(
6466 &mut tree,
6467 &mut core.ui_state,
6468 Rect::new(0.0, 0.0, 300.0, 300.0),
6469 );
6470 core.ui_state.sync_focus_order(&tree);
6471 let mut t = PrepareTimings::default();
6472 core.snapshot(&tree, &mut t);
6473 let target = core
6474 .ui_state
6475 .focus
6476 .order
6477 .iter()
6478 .find(|t| t.key == "theme:radio:light")
6479 .cloned();
6480 core.ui_state.set_focus(target);
6481
6482 core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
6483 assert_eq!(
6484 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6485 Some("theme:radio:dark"),
6486 "ArrowDown steps to the next radio",
6487 );
6488 core.key_down(UiKey::ArrowRight, KeyModifiers::default(), false);
6489 assert_eq!(
6490 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6491 Some("theme:radio:auto"),
6492 "ArrowRight also steps in a Both group",
6493 );
6494 core.key_down(UiKey::ArrowLeft, KeyModifiers::default(), false);
6495 assert_eq!(
6496 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6497 Some("theme:radio:dark"),
6498 "ArrowLeft steps back in a Both group",
6499 );
6500 }
6501
6502 fn lay_out_calendar(focus_day: u32, disabled_day: Option<u32>) -> RunnerCore {
6505 use crate::widgets::calendar::{CalendarDay, calendar_month};
6506 let days = (1..=14).map(|d| {
6507 let day = CalendarDay::new(format!("2026-06-{d:02}"), d.to_string());
6508 if Some(d) == disabled_day {
6509 day.disabled()
6510 } else {
6511 day
6512 }
6513 });
6514 let mut tree = calendar_month("cal", "June 2026", days);
6515 let mut core = RunnerCore::new();
6516 crate::layout::layout(
6517 &mut tree,
6518 &mut core.ui_state,
6519 Rect::new(0.0, 0.0, 600.0, 400.0),
6520 );
6521 core.ui_state.sync_focus_order(&tree);
6522 let mut t = PrepareTimings::default();
6523 core.snapshot(&tree, &mut t);
6524 let key = format!("cal:day:2026-06-{focus_day:02}");
6525 let target = core
6526 .ui_state
6527 .focus
6528 .order
6529 .iter()
6530 .find(|t| t.key == key)
6531 .cloned();
6532 assert!(target.is_some(), "day {focus_day} should be focusable");
6533 core.ui_state.set_focus(target);
6534 core
6535 }
6536
6537 #[test]
6538 fn arrow_nav_calendar_grid_moves_two_dimensionally() {
6539 let mut core = lay_out_calendar(3, None);
6542
6543 let down = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
6544 assert!(down.is_empty(), "ArrowDown is consumed by the grid");
6545 assert_eq!(
6546 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6547 Some("cal:day:2026-06-10"),
6548 "ArrowDown lands on the same weekday one week later",
6549 );
6550
6551 core.key_down(UiKey::ArrowRight, KeyModifiers::default(), false);
6552 assert_eq!(
6553 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6554 Some("cal:day:2026-06-11"),
6555 );
6556
6557 core.key_down(UiKey::ArrowUp, KeyModifiers::default(), false);
6558 assert_eq!(
6559 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6560 Some("cal:day:2026-06-04"),
6561 "ArrowUp lands on the same weekday one week earlier",
6562 );
6563
6564 core.key_down(UiKey::ArrowUp, KeyModifiers::default(), false);
6566 assert_eq!(
6567 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6568 Some("cal:day:2026-06-04"),
6569 "ArrowUp at the top edge saturates",
6570 );
6571 }
6572
6573 #[test]
6574 fn arrow_nav_calendar_grid_skips_disabled_days() {
6575 let mut core = lay_out_calendar(3, Some(10));
6579 core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
6580 let focused = core
6581 .ui_state
6582 .focused
6583 .as_ref()
6584 .map(|t| t.key.clone())
6585 .expect("focus survives the move");
6586 assert!(
6587 focused == "cal:day:2026-06-09" || focused == "cal:day:2026-06-11",
6588 "ArrowDown over a disabled day lands on a neighbor in the \
6589 same week, got {focused}",
6590 );
6591 }
6592
6593 fn build_popover_tree(open: bool) -> El {
6597 use crate::widgets::button::button;
6598 use crate::widgets::overlay::overlay;
6599 use crate::widgets::popover::{dropdown, menu_item};
6600 let mut layers: Vec<El> = vec![button("Trigger").key("trigger")];
6601 if open {
6602 layers.push(dropdown(
6603 "menu",
6604 "trigger",
6605 [
6606 menu_item("A").key("item-a"),
6607 menu_item("B").key("item-b"),
6608 menu_item("C").key("item-c"),
6609 ],
6610 ));
6611 }
6612 overlay(layers).padding(20.0)
6613 }
6614
6615 fn run_frame(core: &mut RunnerCore, tree: &mut El) {
6619 let mut t = PrepareTimings::default();
6620 core.prepare_layout(
6621 tree,
6622 Rect::new(0.0, 0.0, 400.0, 300.0),
6623 1.0,
6624 &mut t,
6625 RunnerCore::no_time_shaders,
6626 );
6627 core.snapshot(tree, &mut t);
6628 }
6629
6630 #[test]
6631 fn popover_open_pushes_focus_and_auto_focuses_first_item() {
6632 let mut core = RunnerCore::new();
6633 let mut closed = build_popover_tree(false);
6634 run_frame(&mut core, &mut closed);
6635 let trigger = core
6638 .ui_state
6639 .focus
6640 .order
6641 .iter()
6642 .find(|t| t.key == "trigger")
6643 .cloned();
6644 core.ui_state.set_focus(trigger);
6645 assert_eq!(
6646 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6647 Some("trigger"),
6648 );
6649
6650 let mut open = build_popover_tree(true);
6653 run_frame(&mut core, &mut open);
6654 assert_eq!(
6655 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6656 Some("item-a"),
6657 "popover open should auto-focus the first menu item",
6658 );
6659 assert_eq!(
6660 core.ui_state.popover_focus.focus_stack.len(),
6661 1,
6662 "trigger should be saved on the focus stack",
6663 );
6664 assert_eq!(
6665 core.ui_state.popover_focus.focus_stack[0].key.as_str(),
6666 "trigger",
6667 "saved focus should be the pre-open target",
6668 );
6669 }
6670
6671 #[test]
6672 fn popover_close_restores_focus_to_trigger() {
6673 let mut core = RunnerCore::new();
6674 let mut closed = build_popover_tree(false);
6675 run_frame(&mut core, &mut closed);
6676 let trigger = core
6677 .ui_state
6678 .focus
6679 .order
6680 .iter()
6681 .find(|t| t.key == "trigger")
6682 .cloned();
6683 core.ui_state.set_focus(trigger);
6684
6685 let mut open = build_popover_tree(true);
6687 run_frame(&mut core, &mut open);
6688 assert_eq!(
6689 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6690 Some("item-a"),
6691 );
6692
6693 let mut closed_again = build_popover_tree(false);
6695 run_frame(&mut core, &mut closed_again);
6696 assert_eq!(
6697 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6698 Some("trigger"),
6699 "closing the popover should pop the saved focus",
6700 );
6701 assert!(
6702 core.ui_state.popover_focus.focus_stack.is_empty(),
6703 "focus stack should be drained after restore",
6704 );
6705 }
6706
6707 #[test]
6708 fn popover_close_does_not_override_intentional_focus_move() {
6709 let mut core = RunnerCore::new();
6710 let build = |open: bool| -> El {
6713 use crate::widgets::button::button;
6714 use crate::widgets::overlay::overlay;
6715 use crate::widgets::popover::{dropdown, menu_item};
6716 let main = crate::row([
6717 button("Trigger").key("trigger"),
6718 button("Other").key("other"),
6719 ]);
6720 let mut layers: Vec<El> = vec![main];
6721 if open {
6722 layers.push(dropdown("menu", "trigger", [menu_item("A").key("item-a")]));
6723 }
6724 overlay(layers).padding(20.0)
6725 };
6726
6727 let mut closed = build(false);
6728 run_frame(&mut core, &mut closed);
6729 let trigger = core
6730 .ui_state
6731 .focus
6732 .order
6733 .iter()
6734 .find(|t| t.key == "trigger")
6735 .cloned();
6736 core.ui_state.set_focus(trigger);
6737
6738 let mut open = build(true);
6739 run_frame(&mut core, &mut open);
6740 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
6741
6742 let other = core
6747 .ui_state
6748 .focus
6749 .order
6750 .iter()
6751 .find(|t| t.key == "other")
6752 .cloned();
6753 core.ui_state.set_focus(other);
6754
6755 let mut closed_again = build(false);
6756 run_frame(&mut core, &mut closed_again);
6757 assert_eq!(
6758 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6759 Some("other"),
6760 "focus moved before close should not be overridden by restore",
6761 );
6762 assert!(core.ui_state.popover_focus.focus_stack.is_empty());
6763 }
6764
6765 #[test]
6766 fn nested_popovers_stack_and_unwind_focus_correctly() {
6767 let mut core = RunnerCore::new();
6768 let build = |outer: bool, inner: bool| -> El {
6773 use crate::widgets::button::button;
6774 use crate::widgets::overlay::overlay;
6775 use crate::widgets::popover::{Anchor, popover, popover_panel};
6776 let main = button("Trigger").key("trigger");
6777 let mut layers: Vec<El> = vec![main];
6778 if outer {
6779 layers.push(popover(
6780 "outer",
6781 Anchor::below_key("trigger"),
6782 popover_panel([button("Open inner").key("inner-trigger")]),
6783 ));
6784 }
6785 if inner {
6786 layers.push(popover(
6787 "inner",
6788 Anchor::below_key("inner-trigger"),
6789 popover_panel([button("X").key("inner-a"), button("Y").key("inner-b")]),
6790 ));
6791 }
6792 overlay(layers).padding(20.0)
6793 };
6794
6795 let mut closed = build(false, false);
6797 run_frame(&mut core, &mut closed);
6798 let trigger = core
6799 .ui_state
6800 .focus
6801 .order
6802 .iter()
6803 .find(|t| t.key == "trigger")
6804 .cloned();
6805 core.ui_state.set_focus(trigger);
6806
6807 let mut outer = build(true, false);
6809 run_frame(&mut core, &mut outer);
6810 assert_eq!(
6811 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6812 Some("inner-trigger"),
6813 );
6814 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
6815
6816 let mut both = build(true, true);
6818 run_frame(&mut core, &mut both);
6819 assert_eq!(
6820 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6821 Some("inner-a"),
6822 );
6823 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 2);
6824
6825 let mut outer_only = build(true, false);
6827 run_frame(&mut core, &mut outer_only);
6828 assert_eq!(
6829 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6830 Some("inner-trigger"),
6831 );
6832 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
6833
6834 let mut none = build(false, false);
6836 run_frame(&mut core, &mut none);
6837 assert_eq!(
6838 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6839 Some("trigger"),
6840 );
6841 assert!(core.ui_state.popover_focus.focus_stack.is_empty());
6842 }
6843
6844 #[test]
6845 fn arrow_nav_does_not_intercept_outside_navigable_groups() {
6846 let mut core = lay_out_input_tree(false);
6850 let target = core
6851 .ui_state
6852 .focus
6853 .order
6854 .iter()
6855 .find(|t| t.key == "btn")
6856 .cloned();
6857 core.ui_state.set_focus(target);
6858 let events = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
6859 assert_eq!(
6860 events.len(),
6861 1,
6862 "ArrowDown without navigable parent → event"
6863 );
6864 assert_eq!(events[0].kind, UiEventKind::KeyDown);
6865 }
6866
6867 fn quad(shader: ShaderHandle) -> DrawOp {
6868 DrawOp::Quad {
6869 id: "q".into(),
6870 rect: Rect::new(0.0, 0.0, 10.0, 10.0),
6871 scissor: None,
6872 shader,
6873 uniforms: UniformBlock::new(),
6874 }
6875 }
6876
6877 #[test]
6878 fn prepare_paint_skips_ops_outside_viewport() {
6879 let mut core = RunnerCore::new();
6880 core.set_surface_size(100, 100);
6881 core.viewport_px = (100, 100);
6882 let ops = vec![
6883 DrawOp::Quad {
6884 id: "offscreen".into(),
6885 rect: Rect::new(0.0, 150.0, 10.0, 10.0),
6886 scissor: None,
6887 shader: ShaderHandle::Stock(StockShader::RoundedRect),
6888 uniforms: UniformBlock::new(),
6889 },
6890 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
6891 ];
6892 let mut timings = PrepareTimings::default();
6893 core.prepare_paint(&ops, |_| true, |_| false, &mut NoText, 1.0, &mut timings);
6894
6895 assert_eq!(timings.paint_culled_ops, 1);
6896 assert_eq!(
6897 core.runs.len(),
6898 1,
6899 "only the visible quad should become a paint run"
6900 );
6901 }
6902
6903 #[test]
6904 fn prepare_paint_does_not_shape_text_outside_clip() {
6905 let mut core = RunnerCore::new();
6906 core.set_surface_size(100, 100);
6907 core.viewport_px = (100, 100);
6908 let ops = vec![
6909 DrawOp::GlyphRun {
6910 id: "offscreen-text".into(),
6911 rect: Rect::new(0.0, 150.0, 80.0, 20.0),
6912 scissor: Some(Rect::new(0.0, 0.0, 100.0, 100.0)),
6913 shader: ShaderHandle::Stock(StockShader::Text),
6914 color: Color::srgb_u8a(255, 255, 255, 255),
6915 text: "offscreen".into(),
6916 size: 14.0,
6917 line_height: 20.0,
6918 family: Default::default(),
6919 mono_family: Default::default(),
6920 weight: FontWeight::Regular,
6921 mono: false,
6922 wrap: TextWrap::NoWrap,
6923 anchor: TextAnchor::Start,
6924 layout: empty_text_layout(20.0),
6925 underline: false,
6926 strikethrough: false,
6927 link: None,
6928 tabular_numerals: false,
6929 },
6930 DrawOp::GlyphRun {
6931 id: "visible-text".into(),
6932 rect: Rect::new(0.0, 10.0, 80.0, 20.0),
6933 scissor: Some(Rect::new(0.0, 0.0, 100.0, 100.0)),
6934 shader: ShaderHandle::Stock(StockShader::Text),
6935 color: Color::srgb_u8a(255, 255, 255, 255),
6936 text: "visible".into(),
6937 size: 14.0,
6938 line_height: 20.0,
6939 family: Default::default(),
6940 mono_family: Default::default(),
6941 weight: FontWeight::Regular,
6942 mono: false,
6943 wrap: TextWrap::NoWrap,
6944 anchor: TextAnchor::Start,
6945 layout: empty_text_layout(20.0),
6946 underline: false,
6947 strikethrough: false,
6948 link: None,
6949 tabular_numerals: false,
6950 },
6951 ];
6952 let mut text = CountingText::default();
6953 let mut timings = PrepareTimings::default();
6954 core.prepare_paint(&ops, |_| true, |_| false, &mut text, 1.0, &mut timings);
6955
6956 assert_eq!(timings.paint_culled_ops, 1);
6957 assert_eq!(text.records, 1, "offscreen text must not be shaped");
6958 }
6959
6960 #[test]
6961 fn samples_backdrop_inserts_snapshot_before_first_glass_quad() {
6962 let mut core = RunnerCore::new();
6963 core.set_surface_size(100, 100);
6964 let ops = vec![
6965 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
6966 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
6967 quad(ShaderHandle::Custom("liquid_glass")),
6968 quad(ShaderHandle::Custom("liquid_glass")),
6969 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
6970 ];
6971 let mut timings = PrepareTimings::default();
6972 core.prepare_paint(
6973 &ops,
6974 |_| true,
6975 |s| matches!(s, ShaderHandle::Custom(name) if *name == "liquid_glass"),
6976 &mut NoText,
6977 1.0,
6978 &mut timings,
6979 );
6980
6981 let kinds: Vec<&'static str> = core
6982 .paint_items
6983 .iter()
6984 .map(|p| match p {
6985 PaintItem::QuadRun(_) => "Q",
6986 PaintItem::IconRun(_) => "I",
6987 PaintItem::Text(_) => "T",
6988 PaintItem::Image(_) => "M",
6989 PaintItem::AppTexture(_) => "A",
6990 PaintItem::Vector(_) => "V",
6991 PaintItem::Scene3D(_) => "3",
6992 PaintItem::BackdropSnapshot => "S",
6993 })
6994 .collect();
6995 assert_eq!(
6996 kinds,
6997 vec!["Q", "S", "Q", "Q"],
6998 "expected one stock run, snapshot, then a glass run, then a foreground stock run"
6999 );
7000 }
7001
7002 #[test]
7003 fn no_snapshot_when_no_glass_drawn() {
7004 let mut core = RunnerCore::new();
7005 core.set_surface_size(100, 100);
7006 let ops = vec![
7007 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
7008 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
7009 ];
7010 let mut timings = PrepareTimings::default();
7011 core.prepare_paint(&ops, |_| true, |_| false, &mut NoText, 1.0, &mut timings);
7012 assert!(
7013 !core
7014 .paint_items
7015 .iter()
7016 .any(|p| matches!(p, PaintItem::BackdropSnapshot)),
7017 "no glass shader registered → no snapshot"
7018 );
7019 }
7020
7021 #[test]
7026 fn scene3d_op_emits_paint_item_only_when_recorder_records() {
7027 use crate::scene::{Aabb, CameraState, LightRig, Scene3DData, SceneStyle};
7028
7029 fn scene_op() -> DrawOp {
7030 let scene = Scene3DData {
7031 meshes: Vec::new(),
7032 points: Vec::new(),
7033 lines: Vec::new(),
7034 camera: CameraState::default().resolve(Aabb::EMPTY),
7035 lights: LightRig::default(),
7036 style: SceneStyle::default(),
7037 capture_depth: false,
7038 };
7039 DrawOp::Scene3D {
7040 id: "scene".into(),
7041 rect: Rect::new(0.0, 0.0, 40.0, 40.0),
7042 scissor: None,
7043 scene: std::sync::Arc::new(scene),
7044 }
7045 }
7046
7047 let mut core = RunnerCore::new();
7049 core.set_surface_size(100, 100);
7050 let mut timings = PrepareTimings::default();
7051 core.prepare_paint(
7052 &[scene_op()],
7053 |_| true,
7054 |_| false,
7055 &mut NoText,
7056 1.0,
7057 &mut timings,
7058 );
7059 assert!(
7060 !core
7061 .paint_items
7062 .iter()
7063 .any(|p| matches!(p, PaintItem::Scene3D(_))),
7064 "default no-op recorder must not emit a Scene3D paint item",
7065 );
7066
7067 struct SceneRecorder {
7069 calls: usize,
7070 }
7071 impl TextRecorder for SceneRecorder {
7072 fn record(
7073 &mut self,
7074 _: Rect,
7075 _: Option<PhysicalScissor>,
7076 _: &RunStyle,
7077 _: &str,
7078 _: f32,
7079 _: f32,
7080 _: TextWrap,
7081 _: TextAnchor,
7082 _: f32,
7083 ) -> Range<usize> {
7084 0..0
7085 }
7086 fn record_runs(
7087 &mut self,
7088 _: Rect,
7089 _: Option<PhysicalScissor>,
7090 _: &[(String, RunStyle)],
7091 _: f32,
7092 _: f32,
7093 _: TextWrap,
7094 _: TextAnchor,
7095 _: f32,
7096 ) -> Range<usize> {
7097 0..0
7098 }
7099 fn record_scene3d(
7100 &mut self,
7101 _: Rect,
7102 _: Option<PhysicalScissor>,
7103 id: &str,
7104 _: &std::sync::Arc<Scene3DData>,
7105 _: f32,
7106 ) -> Range<usize> {
7107 assert_eq!(id, "scene", "node id threads through to the recorder");
7108 let start = self.calls;
7109 self.calls += 1;
7110 start..self.calls
7111 }
7112 }
7113
7114 let mut core = RunnerCore::new();
7115 core.set_surface_size(100, 100);
7116 let mut rec = SceneRecorder { calls: 0 };
7117 let mut timings = PrepareTimings::default();
7118 core.prepare_paint(
7119 &[scene_op()],
7120 |_| true,
7121 |_| false,
7122 &mut rec,
7123 1.0,
7124 &mut timings,
7125 );
7126 let scenes = core
7127 .paint_items
7128 .iter()
7129 .filter(|p| matches!(p, PaintItem::Scene3D(_)))
7130 .count();
7131 assert_eq!(
7132 scenes, 1,
7133 "recorded scene must emit exactly one Scene3D item"
7134 );
7135 }
7136
7137 #[test]
7144 fn keyed_scene_still_begins_camera_drag() {
7145 use crate::scene::glam::Vec3;
7146 use crate::scene::{PointData, PointsHandle, ScenePoint, SceneSpec};
7147 use crate::tree::chart3d;
7148
7149 let spec = || {
7150 SceneSpec::new().points(PointsHandle::new(PointData {
7151 points: vec![
7152 ScenePoint {
7153 position: Vec3::splat(-1.0),
7154 color: [1.0; 4],
7155 },
7156 ScenePoint {
7157 position: Vec3::splat(1.0),
7158 color: [1.0; 4],
7159 },
7160 ],
7161 }))
7162 };
7163
7164 let drag_active_after_press = |mut tree: crate::tree::El| {
7167 let mut core = RunnerCore::new();
7168 crate::layout::layout(
7169 &mut tree,
7170 &mut core.ui_state,
7171 Rect::new(0.0, 0.0, 200.0, 200.0),
7172 );
7173 core.ui_state.tick_scene_cameras(&tree, Instant::now());
7174 let mut t = PrepareTimings::default();
7175 core.snapshot(&tree, &mut t);
7176 core.pointer_down(Pointer::mouse(100.0, 100.0, PointerButton::Primary));
7177 core.ui_state.camera_drag_active()
7178 };
7179
7180 assert!(
7182 drag_active_after_press(chart3d(spec())),
7183 "unkeyed scene should begin a camera drag"
7184 );
7185 assert!(
7187 drag_active_after_press(chart3d(spec()).key("scene")),
7188 "keyed scene must still begin a camera drag (its own node hit must not suppress it)"
7189 );
7190 }
7191
7192 #[test]
7193 fn at_most_one_snapshot_per_frame() {
7194 let mut core = RunnerCore::new();
7195 core.set_surface_size(100, 100);
7196 let ops = vec![
7197 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
7198 quad(ShaderHandle::Custom("g")),
7199 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
7200 quad(ShaderHandle::Custom("g")),
7201 ];
7202 let mut timings = PrepareTimings::default();
7203 core.prepare_paint(
7204 &ops,
7205 |_| true,
7206 |s| matches!(s, ShaderHandle::Custom("g")),
7207 &mut NoText,
7208 1.0,
7209 &mut timings,
7210 );
7211 let snapshots = core
7212 .paint_items
7213 .iter()
7214 .filter(|p| matches!(p, PaintItem::BackdropSnapshot))
7215 .count();
7216 assert_eq!(snapshots, 1, "backdrop depth is capped at 1");
7217 }
7218}