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 if self.ui_state.viewport_pan_active() {
449 let changed = self.ui_state.drag_viewport_to(x, y);
450 return PointerMove {
451 events: Vec::new(),
452 needs_redraw: changed,
453 };
454 }
455
456 if self.ui_state.plot_pan_active() {
458 let changed = self.ui_state.drag_plot_to(x, y);
459 return PointerMove {
460 events: Vec::new(),
461 needs_redraw: changed,
462 };
463 }
464
465 if self.ui_state.plot_zoom_active() {
468 let changed = self.ui_state.drag_plot_zoom_to(x, y);
469 return PointerMove {
470 events: Vec::new(),
471 needs_redraw: changed,
472 };
473 }
474
475 let hit = self
476 .last_tree
477 .as_ref()
478 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
479 let prev_hover = self.ui_state.hovered.clone();
483 let hover_changed = self.ui_state.set_hovered(hit, Instant::now());
484 let prev_hovered_link = self.ui_state.hovered_link.clone();
490 let new_hovered_link = self
491 .linkable_tree()
492 .and_then(|t| hit_test::link_at(t, &self.ui_state, (x, y)));
493 let link_hover_changed = new_hovered_link != prev_hovered_link;
494 self.ui_state.hovered_link = new_hovered_link;
495 let prev_hovered_band = self.ui_state.resize.hovered_band.clone();
501 let new_hovered_band = self.ui_state.resize_band_at(x, y).map(|b| b.id.clone());
502 let band_hover_changed = new_hovered_band != prev_hovered_band;
503 self.ui_state.resize.hovered_band = new_hovered_band;
504 let modifiers = self.ui_state.modifiers;
505
506 let mut out = Vec::new();
507
508 let touch_no_press = matches!(kind, PointerKind::Touch) && self.ui_state.pressed.is_none();
524 if hover_changed && !touch_no_press {
525 if let Some(prev) = prev_hover {
526 out.push(UiEvent {
527 key: Some(prev.key.clone()),
528 target: Some(prev),
529 pointer: Some((x, y)),
530 key_press: None,
531 text: None,
532 selection: None,
533 modifiers,
534 click_count: 0,
535 path: None,
536 pointer_kind: Some(kind),
537 wheel_delta: None,
538 kind: UiEventKind::PointerLeave,
539 });
540 }
541 if let Some(new) = self.ui_state.hovered.clone() {
542 out.push(UiEvent {
543 key: Some(new.key.clone()),
544 target: Some(new),
545 pointer: Some((x, y)),
546 key_press: None,
547 text: None,
548 selection: None,
549 modifiers,
550 click_count: 0,
551 path: None,
552 pointer_kind: Some(kind),
553 wheel_delta: None,
554 kind: UiEventKind::PointerEnter,
555 });
556 }
557 }
558
559 if matches!(kind, PointerKind::Touch) {
564 match self.ui_state.touch_gesture.clone() {
565 TouchGestureState::Pending {
566 initial,
567 consumes_drag,
568 started_at,
569 } => {
570 let dx = x - initial.0;
571 let dy = y - initial.1;
572 if (dx * dx + dy * dy).sqrt() < TOUCH_DRAG_THRESHOLD {
573 let needs_redraw = hover_changed
578 || link_hover_changed
579 || band_hover_changed
580 || !out.is_empty();
581 return PointerMove {
582 events: out,
583 needs_redraw,
584 };
585 }
586 if consumes_drag {
587 self.ui_state.touch_gesture = TouchGestureState::None;
592 } else {
593 let now = Instant::now();
600 self.cancel_press_for_scroll(&mut out, x, y, kind, modifiers);
601 let scroll_dy = initial.1 - y;
607 let step = self.last_tree.as_ref().and_then(|tree| {
608 self.ui_state.scroll_by_pointer(tree, initial, scroll_dy)
609 });
610 let dt = now
611 .duration_since(started_at)
612 .as_secs_f32()
613 .max(1.0 / 120.0);
614 let velocity = step.as_ref().map(|s| s.applied_delta / dt).unwrap_or(0.0);
615 self.ui_state.touch_gesture = TouchGestureState::Scrolling {
616 last_pos: (x, y),
617 last_time: now,
618 velocity,
619 scroll_id: step.map(|s| s.scroll_id),
620 };
621 return PointerMove {
622 events: out,
623 needs_redraw: true,
624 };
625 }
626 }
627 TouchGestureState::Scrolling {
628 last_pos,
629 last_time,
630 velocity,
631 scroll_id,
632 } => {
633 let now = Instant::now();
634 let scroll_dy = last_pos.1 - y;
635 let step = scroll_id
636 .as_ref()
637 .and_then(|id| self.ui_state.scroll_by_id(id, scroll_dy))
638 .or_else(|| {
639 self.last_tree.as_ref().and_then(|tree| {
640 self.ui_state.scroll_by_pointer(tree, (x, y), scroll_dy)
641 })
642 });
643 let dt = now.duration_since(last_time).as_secs_f32().max(1.0 / 240.0);
644 let sample_velocity =
645 step.as_ref().map(|s| s.applied_delta / dt).unwrap_or(0.0);
646 let velocity = sample_velocity * 0.65 + velocity * 0.35;
647 self.ui_state.touch_gesture = TouchGestureState::Scrolling {
648 last_pos: (x, y),
649 last_time: now,
650 velocity,
651 scroll_id: step.map(|s| s.scroll_id).or(scroll_id),
652 };
653 return PointerMove {
654 events: out,
655 needs_redraw: true,
656 };
657 }
658 TouchGestureState::None => {
659 }
662 TouchGestureState::LongPressed => {
663 self.extend_selection_drag_at(x, y, kind, modifiers, &mut out);
668 if self.ui_state.pressed.is_none() {
669 let needs_redraw = hover_changed
670 || link_hover_changed
671 || band_hover_changed
672 || !out.is_empty();
673 return PointerMove {
674 events: out,
675 needs_redraw,
676 };
677 }
678 }
679 }
680 }
681
682 self.extend_selection_drag_at(x, y, kind, modifiers, &mut out);
689
690 if let Some(p) = self.ui_state.pressed.clone() {
696 if self.focused_captures_keys() {
700 self.ui_state.bump_caret_activity(Instant::now());
701 }
702 out.push(UiEvent {
703 key: Some(p.key.clone()),
704 target: Some(p),
705 pointer: Some((x, y)),
706 key_press: None,
707 text: None,
708 selection: None,
709 modifiers,
710 click_count: self.ui_state.current_click_count(),
711 path: None,
712 pointer_kind: Some(kind),
713 wheel_delta: None,
714 kind: UiEventKind::Drag,
715 });
716 }
717
718 let over_hover_scene = self.ui_state.pointer_over_hover_scene(x, y)
722 || prev_pos.is_some_and(|(px, py)| self.ui_state.pointer_over_hover_scene(px, py));
723 let over_crosshair_plot = self.ui_state.pointer_over_crosshair_plot(x, y)
726 || prev_pos.is_some_and(|(px, py)| self.ui_state.pointer_over_crosshair_plot(px, py));
727 let needs_redraw = hover_changed
728 || link_hover_changed
729 || band_hover_changed
730 || !out.is_empty()
731 || over_hover_scene
732 || over_crosshair_plot;
733 PointerMove {
734 events: out,
735 needs_redraw,
736 }
737 }
738
739 pub fn pointer_left(&mut self) -> Vec<UiEvent> {
747 let last_pos = self.ui_state.pointer_pos;
748 let prev_hover = self.ui_state.hovered.clone();
749 let modifiers = self.ui_state.modifiers;
750 let kind = self.ui_state.pointer_kind;
755 self.ui_state.pointer_pos = None;
756 self.ui_state.set_hovered(None, Instant::now());
757 self.ui_state.pressed = None;
758 self.ui_state.pressed_secondary = None;
759 self.ui_state.touch_gesture = TouchGestureState::None;
760 self.ui_state.cancel_scroll_momentum();
761 self.ui_state.hovered_link = None;
767 self.ui_state.pressed_link = None;
768 self.ui_state.resize.hovered_band = None;
772 self.ui_state.resize.drag = None;
773
774 let mut out = Vec::new();
775 if let Some(prev) = prev_hover {
776 out.push(UiEvent {
777 key: Some(prev.key.clone()),
778 target: Some(prev),
779 pointer: last_pos,
780 key_press: None,
781 text: None,
782 selection: None,
783 modifiers,
784 click_count: 0,
785 path: None,
786 pointer_kind: Some(kind),
787 wheel_delta: None,
788 kind: UiEventKind::PointerLeave,
789 });
790 }
791 out
792 }
793
794 pub fn file_hovered(&mut self, path: std::path::PathBuf, x: f32, y: f32) -> Vec<UiEvent> {
808 self.ui_state.pointer_pos = Some((x, y));
809 let target = self
810 .last_tree
811 .as_ref()
812 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
813 let key = target.as_ref().map(|t| t.key.clone());
814 vec![UiEvent {
815 key,
816 target,
817 pointer: Some((x, y)),
818 key_press: None,
819 text: None,
820 selection: None,
821 modifiers: self.ui_state.modifiers,
822 click_count: 0,
823 path: Some(path),
824 pointer_kind: None,
825 wheel_delta: None,
826 kind: UiEventKind::FileHovered,
827 }]
828 }
829
830 pub fn file_hover_cancelled(&mut self) -> Vec<UiEvent> {
835 vec![UiEvent {
836 key: None,
837 target: None,
838 pointer: self.ui_state.pointer_pos,
839 key_press: None,
840 text: None,
841 selection: None,
842 modifiers: self.ui_state.modifiers,
843 click_count: 0,
844 path: None,
845 pointer_kind: None,
846 wheel_delta: None,
847 kind: UiEventKind::FileHoverCancelled,
848 }]
849 }
850
851 pub fn file_dropped(&mut self, path: std::path::PathBuf, x: f32, y: f32) -> Vec<UiEvent> {
856 self.ui_state.pointer_pos = Some((x, y));
857 let target = self
858 .last_tree
859 .as_ref()
860 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
861 let key = target.as_ref().map(|t| t.key.clone());
862 vec![UiEvent {
863 key,
864 target,
865 pointer: Some((x, y)),
866 key_press: None,
867 text: None,
868 selection: None,
869 modifiers: self.ui_state.modifiers,
870 click_count: 0,
871 path: Some(path),
872 pointer_kind: None,
873 wheel_delta: None,
874 kind: UiEventKind::FileDropped,
875 }]
876 }
877
878 pub fn pointer_down(&mut self, p: Pointer) -> Vec<UiEvent> {
891 let Pointer {
892 x, y, button, kind, ..
893 } = p;
894 self.ui_state.pointer_kind = kind;
895 self.ui_state.cancel_scroll_momentum();
896 if matches!(button, PointerButton::Primary)
905 && let Some((scroll_id, _track, thumb_rect)) = self
906 .ui_state
907 .thumb_at(x, y)
908 .filter(|(scroll_id, _, _)| self.scrollbar_can_capture(scroll_id, x, y))
909 {
910 let metrics = self
911 .ui_state
912 .scroll
913 .metrics
914 .get(&scroll_id)
915 .copied()
916 .unwrap_or_default();
917 let start_offset = self
918 .ui_state
919 .scroll
920 .offsets
921 .get(&scroll_id)
922 .copied()
923 .unwrap_or(0.0);
924
925 let grabbed = y >= thumb_rect.y && y <= thumb_rect.y + thumb_rect.h;
929 if grabbed {
930 let track_remaining = (metrics.viewport_h - thumb_rect.h).max(0.0);
931 self.ui_state.scroll.thumb_drag = Some(crate::state::ThumbDrag {
932 scroll_id,
933 start_pointer_y: y,
934 start_offset,
935 track_remaining,
936 max_offset: metrics.max_offset,
937 });
938 } else {
939 let page = (metrics.viewport_h - SCROLL_PAGE_OVERLAP).max(0.0);
945 let delta = if y < thumb_rect.y { -page } else { page };
946 let new_offset = (start_offset + delta).clamp(0.0, metrics.max_offset);
947 self.ui_state.scroll.offsets.insert(scroll_id, new_offset);
948 }
949 return Vec::new();
950 }
951
952 if matches!(button, PointerButton::Primary)
959 && let Some(band) = self
960 .ui_state
961 .resize_band_at(x, y)
962 .filter(|b| self.resize_band_can_capture(b, x, y))
963 .cloned()
964 {
965 let anchor = match band.axis {
966 crate::tree::Axis::Column => y,
967 _ => x,
968 };
969 self.ui_state.resize.drag = Some(crate::state::resize::EdgeResizeDrag {
970 id: band.id,
971 key: band.key,
972 axis: band.axis,
973 sign: band.sign,
974 anchor,
975 initial: band.current,
976 min: band.min,
977 max: band.max,
978 });
979 return Vec::new();
980 }
981
982 let hit = self
983 .last_tree
984 .as_ref()
985 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
986
987 if let Some(id) = self.ui_state.scene_at(x, y)
998 && hit.as_ref().is_none_or(|h| h.node_id == id)
999 && let Some(mode) = self
1000 .ui_state
1001 .scene_drag_mode(&id, button, self.ui_state.modifiers)
1002 {
1003 self.ui_state.begin_camera_drag(id, mode, x, y);
1004 return Vec::new();
1005 }
1006
1007 if let Some((vp_id, cfg)) = self.ui_state.viewport_at(x, y)
1020 && cfg.pan_trigger.matches(button, self.ui_state.modifiers)
1021 {
1022 let dedicated = cfg.pan_trigger.button != PointerButton::Primary
1023 || cfg.pan_trigger.modifiers != crate::event::KeyModifiers::default();
1024 let on_background = hit.as_ref().is_none_or(|h| h.node_id == vp_id);
1025 if dedicated || on_background {
1026 self.ui_state.begin_viewport_pan(vp_id, x, y);
1027 return Vec::new();
1028 }
1029 }
1030
1031 if matches!(button, PointerButton::Primary)
1037 && let Some((plot_id, metrics)) = self.ui_state.plot_at(x, y)
1038 && hit.as_ref().is_none_or(|h| h.node_id == plot_id)
1039 {
1040 let clicks =
1043 self.ui_state
1044 .next_click_count(Instant::now(), (x, y), Some(plot_id.as_str()));
1045 if clicks >= 2 {
1046 self.ui_state.reset_plot_view(&plot_id);
1047 } else {
1048 let pan = match metrics.controls {
1050 crate::plot::PlotControls::ZoomDrag => self.ui_state.modifiers.shift,
1051 crate::plot::PlotControls::PanDrag => !self.ui_state.modifiers.shift,
1052 };
1053 if pan {
1054 self.ui_state.begin_plot_pan(plot_id, x, y);
1055 } else {
1056 self.ui_state.begin_plot_zoom(plot_id, x, y);
1057 }
1058 }
1059 return Vec::new();
1060 }
1061
1062 if !matches!(button, PointerButton::Primary) {
1067 self.ui_state.pressed_secondary = hit.map(|h| (h, button));
1070 return Vec::new();
1071 }
1072
1073 self.ui_state.pressed_link = self
1081 .linkable_tree()
1082 .and_then(|t| hit_test::link_at(t, &self.ui_state, (x, y)));
1083 self.ui_state.set_focus_from_pointer(hit.clone());
1084 self.ui_state.set_focus_visible(false);
1088 self.ui_state.pressed = hit.clone();
1089 self.ui_state.tooltip.dismissed_for_hover = true;
1092 let modifiers = self.ui_state.modifiers;
1093
1094 let now = Instant::now();
1097 let click_count =
1098 self.ui_state
1099 .next_click_count(now, (x, y), hit.as_ref().map(|t| t.node_id.as_str()));
1100
1101 let mut out = Vec::new();
1102
1103 if matches!(kind, PointerKind::Touch) {
1111 let prev_hover = self.ui_state.hovered.clone();
1112 let hover_changed = self.ui_state.set_hovered(hit.clone(), now);
1113 if hover_changed {
1114 if let Some(prev) = prev_hover {
1115 out.push(UiEvent {
1116 key: Some(prev.key.clone()),
1117 target: Some(prev),
1118 pointer: Some((x, y)),
1119 key_press: None,
1120 text: None,
1121 selection: None,
1122 modifiers,
1123 click_count: 0,
1124 path: None,
1125 pointer_kind: Some(kind),
1126 wheel_delta: None,
1127 kind: UiEventKind::PointerLeave,
1128 });
1129 }
1130 if let Some(new) = hit.clone() {
1131 out.push(UiEvent {
1132 key: Some(new.key.clone()),
1133 target: Some(new),
1134 pointer: Some((x, y)),
1135 key_press: None,
1136 text: None,
1137 selection: None,
1138 modifiers,
1139 click_count: 0,
1140 path: None,
1141 pointer_kind: Some(kind),
1142 wheel_delta: None,
1143 kind: UiEventKind::PointerEnter,
1144 });
1145 }
1146 }
1147 let consumes_drag = hit
1155 .as_ref()
1156 .and_then(|t| {
1157 self.last_tree
1158 .as_ref()
1159 .and_then(|tree| find_consumes_touch_drag(tree, &t.node_id, false))
1160 })
1161 .unwrap_or(false);
1162 self.ui_state.touch_gesture = TouchGestureState::Pending {
1163 initial: (x, y),
1164 consumes_drag,
1165 started_at: now,
1166 };
1167 }
1168
1169 if let Some(p) = hit.clone() {
1170 if self.focused_captures_keys() {
1177 self.ui_state.bump_caret_activity(now);
1178 }
1179 out.push(UiEvent {
1180 key: Some(p.key.clone()),
1181 target: Some(p),
1182 pointer: Some((x, y)),
1183 key_press: None,
1184 text: None,
1185 selection: None,
1186 modifiers,
1187 click_count,
1188 path: None,
1189 pointer_kind: Some(kind),
1190 wheel_delta: None,
1191 kind: UiEventKind::PointerDown,
1192 });
1193 }
1194
1195 if let Some(point) = self
1203 .last_tree
1204 .as_ref()
1205 .and_then(|t| hit_test::selection_point_at(t, &self.ui_state, (x, y)))
1206 {
1207 self.start_selection_drag(point, &mut out, modifiers, (x, y), click_count, kind);
1208 } else if !self.ui_state.current_selection.is_empty() {
1209 let click_handles_selection = match (&hit, &self.ui_state.current_selection.range) {
1231 (Some(h), Some(range)) => {
1232 h.key == range.anchor.key
1233 || h.key == range.head.key
1234 || self
1235 .last_tree
1236 .as_ref()
1237 .and_then(|t| find_capture_keys(t, &h.node_id))
1238 .unwrap_or(false)
1239 }
1240 _ => false,
1241 };
1242 if !click_handles_selection {
1243 out.push(selection_event(
1244 crate::selection::Selection::default(),
1245 modifiers,
1246 Some((x, y)),
1247 Some(kind),
1248 ));
1249 self.ui_state.current_selection = crate::selection::Selection::default();
1250 self.ui_state.selection.drag = None;
1251 }
1252 }
1253
1254 out
1255 }
1256
1257 fn start_selection_drag(
1265 &mut self,
1266 point: crate::selection::SelectionPoint,
1267 out: &mut Vec<UiEvent>,
1268 modifiers: KeyModifiers,
1269 pointer: (f32, f32),
1270 click_count: u8,
1271 kind: PointerKind,
1272 ) {
1273 let leaf_text = self
1274 .last_tree
1275 .as_ref()
1276 .and_then(|t| crate::selection::find_keyed_text(t, &point.key))
1277 .unwrap_or_default();
1278 let (anchor_byte, head_byte) = match click_count {
1279 2 => crate::selection::word_range_at(&leaf_text, point.byte),
1280 n if n >= 3 => (0, leaf_text.len()),
1281 _ => (point.byte, point.byte),
1282 };
1283 let granularity = match click_count {
1284 2 => SelectionDragGranularity::Word,
1285 n if n >= 3 => SelectionDragGranularity::Leaf,
1286 _ => SelectionDragGranularity::Character,
1287 };
1288 let anchor = crate::selection::SelectionPoint::new(point.key.clone(), anchor_byte);
1289 let head = crate::selection::SelectionPoint::new(point.key.clone(), head_byte);
1290 let new_sel = crate::selection::Selection {
1291 range: Some(crate::selection::SelectionRange {
1292 anchor: anchor.clone(),
1293 head: head.clone(),
1294 }),
1295 };
1296 self.ui_state.current_selection = new_sel.clone();
1297 self.ui_state.selection.drag = Some(crate::state::SelectionDrag {
1298 anchor,
1299 head,
1300 granularity,
1301 });
1302 out.push(selection_event(
1303 new_sel,
1304 modifiers,
1305 Some(pointer),
1306 Some(kind),
1307 ));
1308 }
1309
1310 fn extend_selection_drag_at(
1311 &mut self,
1312 x: f32,
1313 y: f32,
1314 kind: PointerKind,
1315 modifiers: KeyModifiers,
1316 out: &mut Vec<UiEvent>,
1317 ) {
1318 let Some(drag) = self.ui_state.selection.drag.clone() else {
1319 return;
1320 };
1321 let Some(tree) = self.last_tree.as_ref() else {
1322 return;
1323 };
1324 let raw_head =
1325 head_for_drag(tree, &self.ui_state, (x, y)).unwrap_or_else(|| drag.anchor.clone());
1326 let (anchor, head) = selection_range_for_drag(tree, &self.ui_state, &drag, raw_head);
1327 let new_sel = crate::selection::Selection {
1328 range: Some(crate::selection::SelectionRange { anchor, head }),
1329 };
1330 if new_sel != self.ui_state.current_selection {
1331 self.ui_state.current_selection = new_sel.clone();
1332 out.push(selection_event(
1333 new_sel,
1334 modifiers,
1335 Some((x, y)),
1336 Some(kind),
1337 ));
1338 }
1339 }
1340
1341 fn resize_band_can_capture(
1349 &self,
1350 band: &crate::state::resize::ResizeBand,
1351 x: f32,
1352 y: f32,
1353 ) -> bool {
1354 let Some(tree) = self.last_tree.as_ref() else {
1355 return false;
1356 };
1357 hit_test::hit_test_target(tree, &self.ui_state, (x, y))
1358 .is_none_or(|target| target_id_in_subtree(&band.container_id, &target.node_id))
1359 }
1360
1361 fn scrollbar_can_capture(&self, scroll_id: &str, x: f32, y: f32) -> bool {
1362 let Some(tree) = self.last_tree.as_ref() else {
1363 return false;
1364 };
1365 let target_allows_capture = hit_test::hit_test_target(tree, &self.ui_state, (x, y))
1366 .is_none_or(|target| target_id_in_subtree(scroll_id, &target.node_id));
1367 if !target_allows_capture {
1368 return false;
1369 }
1370 hit_test::scroll_targets_at(tree, &self.ui_state, (x, y))
1371 .iter()
1372 .any(|id| id == scroll_id)
1373 }
1374
1375 fn cancel_press_for_scroll(
1385 &mut self,
1386 out: &mut Vec<UiEvent>,
1387 x: f32,
1388 y: f32,
1389 kind: PointerKind,
1390 modifiers: KeyModifiers,
1391 ) {
1392 let pressed = self.ui_state.pressed.take();
1393 let hovered = self.ui_state.hovered.clone();
1394 self.ui_state.set_hovered(None, Instant::now());
1395 self.ui_state.pressed_secondary = None;
1396 self.ui_state.pressed_link = None;
1397 self.ui_state.selection.drag = None;
1398 if let Some(p) = pressed {
1399 out.push(UiEvent {
1400 key: Some(p.key.clone()),
1401 target: Some(p),
1402 pointer: Some((x, y)),
1403 key_press: None,
1404 text: None,
1405 selection: None,
1406 modifiers,
1407 click_count: 0,
1408 path: None,
1409 pointer_kind: Some(kind),
1410 wheel_delta: None,
1411 kind: UiEventKind::PointerCancel,
1412 });
1413 }
1414 if let Some(h) = hovered {
1415 out.push(UiEvent {
1416 key: Some(h.key.clone()),
1417 target: Some(h),
1418 pointer: Some((x, y)),
1419 key_press: None,
1420 text: None,
1421 selection: None,
1422 modifiers,
1423 click_count: 0,
1424 path: None,
1425 pointer_kind: Some(kind),
1426 wheel_delta: None,
1427 kind: UiEventKind::PointerLeave,
1428 });
1429 }
1430 }
1431
1432 pub fn pointer_up(&mut self, p: Pointer) -> Vec<UiEvent> {
1440 let Pointer {
1441 x, y, button, kind, ..
1442 } = p;
1443 self.ui_state.pointer_kind = kind;
1444 if matches!(button, PointerButton::Primary) && self.ui_state.scroll.thumb_drag.is_some() {
1449 self.ui_state.scroll.thumb_drag = None;
1450 self.ui_state.touch_gesture = TouchGestureState::None;
1451 return Vec::new();
1452 }
1453
1454 if matches!(button, PointerButton::Primary)
1460 && let Some(drag) = self.ui_state.resize.drag.take()
1461 {
1462 self.ui_state.touch_gesture = TouchGestureState::None;
1463 let Some(key) = drag.key else {
1464 return Vec::new();
1465 };
1466 return vec![UiEvent {
1467 key: Some(key),
1468 target: None,
1469 pointer: Some((x, y)),
1470 key_press: None,
1471 text: None,
1472 selection: None,
1473 modifiers: self.ui_state.modifiers,
1474 click_count: 0,
1475 path: None,
1476 pointer_kind: Some(kind),
1477 wheel_delta: None,
1478 kind: UiEventKind::Resized,
1479 }];
1480 }
1481
1482 if self.ui_state.end_camera_drag() {
1486 self.ui_state.touch_gesture = TouchGestureState::None;
1487 return Vec::new();
1488 }
1489
1490 if self.ui_state.end_viewport_pan() {
1494 self.ui_state.touch_gesture = TouchGestureState::None;
1495 return Vec::new();
1496 }
1497
1498 if self.ui_state.end_plot_pan() {
1500 self.ui_state.touch_gesture = TouchGestureState::None;
1501 return Vec::new();
1502 }
1503
1504 if self.ui_state.end_plot_zoom() {
1506 self.ui_state.touch_gesture = TouchGestureState::None;
1507 return Vec::new();
1508 }
1509
1510 let was_long_pressed =
1516 matches!(self.ui_state.touch_gesture, TouchGestureState::LongPressed);
1517 let momentum = match &self.ui_state.touch_gesture {
1518 TouchGestureState::Scrolling {
1519 velocity,
1520 scroll_id,
1521 ..
1522 } if matches!(kind, PointerKind::Touch) => {
1523 Some((scroll_id.clone(), *velocity, Instant::now()))
1524 }
1525 _ => None,
1526 };
1527 let was_scrolling_or_long = matches!(
1528 self.ui_state.touch_gesture,
1529 TouchGestureState::Scrolling { .. } | TouchGestureState::LongPressed
1530 );
1531 self.ui_state.touch_gesture = TouchGestureState::None;
1532 if was_scrolling_or_long {
1533 if let Some((scroll_id, velocity, now)) = momentum {
1534 self.ui_state
1535 .start_scroll_momentum(scroll_id, velocity, now);
1536 }
1537 if was_long_pressed {
1538 self.ui_state.pressed = None;
1539 self.ui_state.pressed_secondary = None;
1540 self.ui_state.pressed_link = None;
1541 self.ui_state.selection.drag = None;
1542 self.ui_state.set_hovered(None, Instant::now());
1543 }
1544 return Vec::new();
1545 }
1546
1547 if matches!(button, PointerButton::Primary) {
1550 self.ui_state.selection.drag = None;
1551 }
1552
1553 let hit = self
1554 .last_tree
1555 .as_ref()
1556 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
1557 let modifiers = self.ui_state.modifiers;
1558 let mut out = Vec::new();
1559 match button {
1560 PointerButton::Primary => {
1561 let pressed = self.ui_state.pressed.take();
1562 let click_count = self.ui_state.current_click_count();
1563 if let Some(p) = pressed.clone() {
1564 out.push(UiEvent {
1565 key: Some(p.key.clone()),
1566 target: Some(p),
1567 pointer: Some((x, y)),
1568 key_press: None,
1569 text: None,
1570 selection: None,
1571 modifiers,
1572 click_count,
1573 path: None,
1574 pointer_kind: Some(kind),
1575 wheel_delta: None,
1576 kind: UiEventKind::PointerUp,
1577 });
1578 }
1579 if let (Some(p), Some(h)) = (pressed, hit)
1580 && p.node_id == h.node_id
1581 {
1582 if let Some(id) = toast::parse_dismiss_key(&p.key) {
1588 self.ui_state.dismiss_toast(id);
1589 } else {
1590 out.push(UiEvent {
1591 key: Some(p.key.clone()),
1592 target: Some(p),
1593 pointer: Some((x, y)),
1594 key_press: None,
1595 text: None,
1596 selection: None,
1597 modifiers,
1598 click_count,
1599 path: None,
1600 pointer_kind: Some(kind),
1601 wheel_delta: None,
1602 kind: UiEventKind::Click,
1603 });
1604 }
1605 }
1606 if let Some(pressed_url) = self.ui_state.pressed_link.take() {
1612 let up_link = self
1613 .linkable_tree()
1614 .and_then(|t| hit_test::link_at(t, &self.ui_state, (x, y)));
1615 if up_link.as_ref() == Some(&pressed_url) {
1616 out.push(UiEvent {
1617 key: Some(pressed_url),
1618 target: None,
1619 pointer: Some((x, y)),
1620 key_press: None,
1621 text: None,
1622 selection: None,
1623 modifiers,
1624 click_count: 1,
1625 path: None,
1626 pointer_kind: Some(kind),
1627 wheel_delta: None,
1628 kind: UiEventKind::LinkActivated,
1629 });
1630 }
1631 }
1632 }
1633 PointerButton::Secondary | PointerButton::Middle => {
1634 let pressed = self.ui_state.pressed_secondary.take();
1635 if let (Some((p, b)), Some(h)) = (pressed, hit)
1636 && b == button
1637 && p.node_id == h.node_id
1638 {
1639 let event_kind = match button {
1640 PointerButton::Secondary => UiEventKind::SecondaryClick,
1641 PointerButton::Middle => UiEventKind::MiddleClick,
1642 PointerButton::Primary => unreachable!(),
1643 };
1644 out.push(UiEvent {
1645 key: Some(p.key.clone()),
1646 target: Some(p),
1647 pointer: Some((x, y)),
1648 key_press: None,
1649 text: None,
1650 selection: None,
1651 modifiers,
1652 click_count: 1,
1653 path: None,
1654 pointer_kind: Some(kind),
1655 wheel_delta: None,
1656 kind: event_kind,
1657 });
1658 }
1659 }
1660 }
1661
1662 if matches!(kind, PointerKind::Touch)
1668 && let Some(prev) = self.ui_state.hovered.clone()
1669 {
1670 self.ui_state.set_hovered(None, Instant::now());
1671 out.push(UiEvent {
1672 key: Some(prev.key.clone()),
1673 target: Some(prev),
1674 pointer: Some((x, y)),
1675 key_press: None,
1676 text: None,
1677 selection: None,
1678 modifiers,
1679 click_count: 0,
1680 path: None,
1681 pointer_kind: Some(kind),
1682 wheel_delta: None,
1683 kind: UiEventKind::PointerLeave,
1684 });
1685 }
1686
1687 out
1688 }
1689
1690 pub fn key_down(&mut self, key: UiKey, modifiers: KeyModifiers, repeat: bool) -> Vec<UiEvent> {
1691 if self.focused_captures_keys() {
1699 if let Some(event) = self.ui_state.try_hotkey(&key, modifiers, repeat) {
1700 return vec![event];
1701 }
1702 self.ui_state.bump_caret_activity(Instant::now());
1709 self.ui_state.set_focus_visible(true);
1710 let blur_after = matches!(key, UiKey::Escape);
1711 let out = self
1712 .ui_state
1713 .key_down_raw(key, modifiers, repeat)
1714 .into_iter()
1715 .collect();
1716 if blur_after {
1717 self.ui_state.set_focus(None);
1718 self.ui_state.set_focus_visible(false);
1719 }
1720 return out;
1721 }
1722
1723 if matches!(
1733 key,
1734 UiKey::ArrowUp
1735 | UiKey::ArrowDown
1736 | UiKey::ArrowLeft
1737 | UiKey::ArrowRight
1738 | UiKey::Home
1739 | UiKey::End
1740 ) && let Some((mode, members)) = self.focused_arrow_nav_group()
1741 && mode.handles(&key)
1742 {
1743 if let Some(event) = self.ui_state.try_hotkey(&key, modifiers, repeat) {
1744 return vec![event];
1745 }
1746 self.move_focus_in_group(&key, mode, &members);
1747 return Vec::new();
1748 }
1749
1750 let mut out: Vec<UiEvent> = self
1751 .ui_state
1752 .key_down(key, modifiers, repeat)
1753 .into_iter()
1754 .collect();
1755
1756 if matches!(out.first().map(|e| e.kind), Some(UiEventKind::Escape))
1764 && !self.ui_state.current_selection.is_empty()
1765 {
1766 self.ui_state.current_selection = crate::selection::Selection::default();
1767 self.ui_state.selection.drag = None;
1768 out.push(selection_event(
1769 crate::selection::Selection::default(),
1770 modifiers,
1771 None,
1772 None,
1773 ));
1774 }
1775
1776 out
1777 }
1778
1779 fn focused_arrow_nav_group(&self) -> Option<(crate::tree::ArrowNav, Vec<UiTarget>)> {
1785 let focused = self.ui_state.focused.as_ref()?;
1786 let tree = self.last_tree.as_ref()?;
1787 focus::arrow_nav_group(tree, &self.ui_state, &focused.node_id)
1788 }
1789
1790 fn move_focus_in_group(
1799 &mut self,
1800 key: &UiKey,
1801 mode: crate::tree::ArrowNav,
1802 members: &[UiTarget],
1803 ) {
1804 if members.is_empty() {
1805 return;
1806 }
1807 let focused_id = match self.ui_state.focused.as_ref() {
1808 Some(t) => t.node_id.clone(),
1809 None => return,
1810 };
1811 let idx = members.iter().position(|t| t.node_id == focused_id);
1812 let grid = mode == crate::tree::ArrowNav::Grid;
1813 let next_idx = match (key, idx) {
1814 (UiKey::ArrowUp, Some(i)) if grid => match grid_vertical_step(members, i, -1.0) {
1815 Some(j) => j,
1816 None => return,
1817 },
1818 (UiKey::ArrowDown, Some(i)) if grid => match grid_vertical_step(members, i, 1.0) {
1819 Some(j) => j,
1820 None => return,
1821 },
1822 (UiKey::ArrowUp | UiKey::ArrowLeft, Some(i)) => i.saturating_sub(1),
1823 (UiKey::ArrowDown | UiKey::ArrowRight, Some(i)) => (i + 1).min(members.len() - 1),
1824 (UiKey::Home, _) => 0,
1825 (UiKey::End, _) => members.len() - 1,
1826 _ => return,
1827 };
1828 if Some(next_idx) != idx {
1829 self.ui_state.set_focus(Some(members[next_idx].clone()));
1830 self.ui_state.set_focus_visible(true);
1831 }
1832 }
1833
1834 pub fn focused_captures_keys(&self) -> bool {
1841 let Some(focused) = self.ui_state.focused.as_ref() else {
1842 return false;
1843 };
1844 let Some(tree) = self.last_tree.as_ref() else {
1845 return false;
1846 };
1847 find_capture_keys(tree, &focused.node_id).unwrap_or(false)
1848 }
1849
1850 pub fn text_input(&mut self, text: String) -> Option<UiEvent> {
1856 if text.is_empty() {
1857 return None;
1858 }
1859 let target = self.ui_state.focused.clone()?;
1860 let modifiers = self.ui_state.modifiers;
1861 self.ui_state.bump_caret_activity(Instant::now());
1864 Some(UiEvent {
1865 key: Some(target.key.clone()),
1866 target: Some(target),
1867 pointer: None,
1868 key_press: None,
1869 text: Some(text),
1870 selection: None,
1871 modifiers,
1872 click_count: 0,
1873 path: None,
1874 pointer_kind: None,
1875 wheel_delta: None,
1876 kind: UiEventKind::TextInput,
1877 })
1878 }
1879
1880 pub fn set_hotkeys(&mut self, hotkeys: Vec<(KeyChord, String)>) {
1881 self.ui_state.set_hotkeys(hotkeys);
1882 }
1883
1884 pub fn set_selection(&mut self, selection: crate::selection::Selection) {
1889 if self.ui_state.current_selection != selection {
1890 self.ui_state.bump_caret_activity(Instant::now());
1891 }
1892 self.ui_state.current_selection = selection;
1893 }
1894
1895 pub fn selected_text(&self) -> Option<String> {
1909 self.selected_text_for(&self.ui_state.current_selection)
1910 }
1911
1912 pub fn selected_text_for(&self, selection: &crate::selection::Selection) -> Option<String> {
1918 let tree = self.last_tree.as_ref()?;
1919 crate::selection::selected_text(tree, selection)
1920 }
1921
1922 pub fn push_toasts(&mut self, specs: Vec<crate::toast::ToastSpec>) {
1928 let now = Instant::now();
1929 for spec in specs {
1930 self.ui_state.push_toast(spec, now);
1931 }
1932 }
1933
1934 pub fn dismiss_toast(&mut self, id: u64) {
1938 self.ui_state.dismiss_toast(id);
1939 }
1940
1941 pub fn push_focus_requests(&mut self, keys: Vec<String>) {
1947 self.ui_state.push_focus_requests(keys);
1948 }
1949
1950 pub fn push_scroll_requests(&mut self, requests: Vec<crate::scroll::ScrollRequest>) {
1956 self.ui_state.push_scroll_requests(requests);
1957 }
1958
1959 pub fn push_viewport_requests(&mut self, requests: Vec<crate::viewport::ViewportRequest>) {
1966 self.ui_state.push_viewport_requests(requests);
1967 }
1968
1969 pub fn set_animation_mode(&mut self, mode: AnimationMode) {
1970 self.ui_state.set_animation_mode(mode);
1971 }
1972
1973 pub fn pointer_wheel(&mut self, x: f32, y: f32, dy: f32) -> bool {
1974 let Some(tree) = self.last_tree.as_ref() else {
1975 return false;
1976 };
1977 self.ui_state.cancel_scroll_momentum();
1978 if self.ui_state.camera_wheel_zoom(x, y, dy) {
1981 return true;
1982 }
1983 if self.ui_state.viewport_wheel_zoom(x, y, dy) {
1987 return true;
1988 }
1989 if self.ui_state.plot_wheel_zoom(x, y, dy) {
1992 return true;
1993 }
1994 self.ui_state.pointer_wheel(tree, (x, y), dy)
1995 }
1996
1997 pub fn pointer_wheel_event(&mut self, x: f32, y: f32, dx: f32, dy: f32) -> Option<UiEvent> {
2004 if dx.abs() <= f32::EPSILON && dy.abs() <= f32::EPSILON {
2005 return None;
2006 }
2007 let tree = self.last_tree.as_ref()?;
2008 let target = hit_test::hit_test_target(tree, &self.ui_state, (x, y))?;
2009 self.ui_state.cancel_scroll_momentum();
2010 Some(UiEvent {
2011 key: Some(target.key.clone()),
2012 target: Some(target),
2013 pointer: Some((x, y)),
2014 key_press: None,
2015 text: None,
2016 selection: None,
2017 modifiers: self.ui_state.modifiers,
2018 click_count: 0,
2019 path: None,
2020 pointer_kind: Some(self.ui_state.pointer_kind),
2021 wheel_delta: Some((dx, dy)),
2022 kind: UiEventKind::PointerWheel,
2023 })
2024 }
2025
2026 pub fn poll_input(&mut self, now: Instant) -> Vec<UiEvent> {
2041 let TouchGestureState::Pending {
2042 initial,
2043 started_at,
2044 ..
2045 } = self.ui_state.touch_gesture.clone()
2046 else {
2047 return Vec::new();
2048 };
2049 if now.duration_since(started_at) < LONG_PRESS_DELAY {
2050 return Vec::new();
2051 }
2052 let mut out = Vec::new();
2053 let modifiers = self.ui_state.modifiers;
2054 let kind = PointerKind::Touch;
2055 let (x, y) = initial;
2056 let press_target = self.ui_state.pressed.clone();
2057 let preserves_press_for_drag = press_target.as_ref().is_some_and(|t| {
2058 self.last_tree
2059 .as_ref()
2060 .and_then(|tree| find_capture_keys(tree, &t.node_id))
2061 .unwrap_or(false)
2062 });
2063 if preserves_press_for_drag {
2064 self.ui_state.pressed_secondary = None;
2065 self.ui_state.pressed_link = None;
2066 self.ui_state.selection.drag = None;
2067 } else {
2068 self.cancel_press_for_scroll(&mut out, x, y, kind, modifiers);
2075 }
2076 if let Some(t) = press_target {
2077 out.push(UiEvent {
2078 key: Some(t.key.clone()),
2079 target: Some(t),
2080 pointer: Some((x, y)),
2081 key_press: None,
2082 text: None,
2083 selection: None,
2084 modifiers,
2085 click_count: 0,
2086 path: None,
2087 pointer_kind: Some(kind),
2088 wheel_delta: None,
2089 kind: UiEventKind::LongPress,
2090 });
2091 } else {
2092 out.push(UiEvent {
2096 key: None,
2097 target: None,
2098 pointer: Some((x, y)),
2099 key_press: None,
2100 text: None,
2101 selection: None,
2102 modifiers,
2103 click_count: 0,
2104 path: None,
2105 pointer_kind: Some(kind),
2106 wheel_delta: None,
2107 kind: UiEventKind::LongPress,
2108 });
2109 }
2110 if !preserves_press_for_drag
2111 && let Some(point) = self
2112 .last_tree
2113 .as_ref()
2114 .and_then(|t| hit_test::selection_point_at(t, &self.ui_state, (x, y)))
2115 {
2116 self.start_selection_drag(point, &mut out, modifiers, (x, y), 2, kind);
2117 }
2118 self.ui_state.touch_gesture = TouchGestureState::LongPressed;
2119 out
2120 }
2121
2122 pub fn next_input_deadline(&self, now: Instant) -> Option<std::time::Duration> {
2132 if self.ui_state.has_scroll_momentum() {
2133 return Some(std::time::Duration::ZERO);
2134 }
2135 let TouchGestureState::Pending { started_at, .. } = self.ui_state.touch_gesture.clone()
2136 else {
2137 return None;
2138 };
2139 let elapsed = now.duration_since(started_at);
2140 Some(LONG_PRESS_DELAY.saturating_sub(elapsed))
2141 }
2142
2143 pub fn prepare_layout<F>(
2160 &mut self,
2161 root: &mut El,
2162 viewport: Rect,
2163 scale_factor: f32,
2164 timings: &mut PrepareTimings,
2165 samples_time: F,
2166 ) -> LayoutPrepared
2167 where
2168 F: Fn(&ShaderHandle) -> bool,
2169 {
2170 let t0 = Instant::now();
2171 let scroll_momentum_pending = self.ui_state.tick_scroll_momentum(t0);
2172 let mut needs_redraw = {
2179 crate::profile_span!("prepare::layout");
2180 {
2181 crate::profile_span!("prepare::layout::assign_ids");
2182 layout::assign_ids(root);
2183 }
2184 let tooltip_pending = {
2185 crate::profile_span!("prepare::layout::tooltip");
2186 tooltip::synthesize_tooltip(root, &self.ui_state, t0)
2187 };
2188 let toast_pending = {
2189 crate::profile_span!("prepare::layout::toast");
2190 toast::synthesize_toasts(root, &mut self.ui_state, t0)
2191 };
2192 {
2193 crate::profile_span!("prepare::layout::apply_metrics");
2194 self.theme.apply_metrics(root);
2195 }
2196 {
2197 crate::profile_span!("prepare::layout::layout");
2198 layout::layout_post_assign(root, &mut self.ui_state, viewport);
2205 self.ui_state.clear_pending_scroll_requests();
2210 self.ui_state.clear_pending_viewport_requests();
2212 self.ui_state.gc_scroll_state(root);
2215 self.ui_state.gc_viewport_state(root);
2217 self.ui_state.gc_plot_state(root);
2219 self.ui_state.prepare_plots(root);
2223 }
2224 {
2225 crate::profile_span!("prepare::layout::sync_focus_order");
2226 self.ui_state.sync_focus_order(root);
2227 }
2228 {
2229 crate::profile_span!("prepare::layout::sync_selection_order");
2230 self.ui_state.sync_selection_order(root);
2231 }
2232 {
2233 crate::profile_span!("prepare::layout::sync_popover_focus");
2234 focus::sync_popover_focus(root, &mut self.ui_state);
2235 }
2236 {
2237 crate::profile_span!("prepare::layout::drain_focus_requests");
2242 self.ui_state.drain_focus_requests();
2243 }
2244 {
2245 crate::profile_span!("prepare::layout::apply_state");
2246 self.ui_state.apply_to_state();
2247 }
2248 self.viewport_px = self.surface_size_override.unwrap_or_else(|| {
2249 (
2250 (viewport.w * scale_factor).ceil().max(1.0) as u32,
2251 (viewport.h * scale_factor).ceil().max(1.0) as u32,
2252 )
2253 });
2254 let animations = {
2255 crate::profile_span!("prepare::layout::tick_animations");
2256 self.ui_state
2257 .tick_visual_animations(root, Instant::now(), self.theme.palette())
2258 };
2259 let cameras_animating = {
2264 crate::profile_span!("prepare::layout::tick_cameras");
2265 self.ui_state.tick_scene_cameras(root, Instant::now())
2266 };
2267 animations
2268 || cameras_animating
2269 || tooltip_pending
2270 || toast_pending
2271 || scroll_momentum_pending
2272 };
2273 let t_after_layout = Instant::now();
2274 timings.layout_intrinsic_cache = layout::take_intrinsic_cache_stats();
2275 timings.layout_prune = layout::take_prune_stats();
2276 let (ops, draw_ops_stats) = {
2277 crate::profile_span!("prepare::draw_ops");
2278 let mut stats = DrawOpsStats::default();
2279 let ops = draw_ops::draw_ops_with_theme_and_stats(
2280 root,
2281 &self.ui_state,
2282 &self.theme,
2283 &mut stats,
2284 );
2285 (ops, stats)
2286 };
2287 let t_after_draw_ops = Instant::now();
2288 timings.layout = t_after_layout - t0;
2289 timings.draw_ops = t_after_draw_ops - t_after_layout;
2290 timings.draw_ops_culled_text_ops = draw_ops_stats.culled_text_ops;
2291 self.ui_state
2294 .set_hovered_scene_point(draw_ops_stats.hovered_scene_point);
2295 timings.text_layout_cache = crate::text::metrics::take_shape_cache_stats();
2296
2297 let shader_needs_redraw = ops.iter().any(|op| op_is_continuous(op, &samples_time));
2314 let widget_redraw =
2315 aggregate_redraw_within(root, viewport, &self.ui_state.layout.computed_rects);
2316 let input_deadline = self.next_input_deadline(Instant::now());
2322 let widget_redraw = match (widget_redraw, input_deadline) {
2323 (Some(a), Some(b)) => Some(a.min(b)),
2324 (a, b) => a.or(b),
2325 };
2326
2327 let next_layout_redraw_in = match (needs_redraw, widget_redraw) {
2328 (true, _) => Some(std::time::Duration::ZERO),
2331 (false, d) => d,
2332 };
2333 let next_paint_redraw_in = if shader_needs_redraw {
2334 Some(std::time::Duration::ZERO)
2335 } else {
2336 None
2337 };
2338 if next_layout_redraw_in.is_some() || next_paint_redraw_in.is_some() {
2339 needs_redraw = true;
2340 }
2341
2342 LayoutPrepared {
2347 ops,
2348 needs_redraw,
2349 next_layout_redraw_in,
2350 next_paint_redraw_in,
2351 }
2352 }
2353
2354 pub fn prepare_paint_cached<F1, F2>(
2367 &mut self,
2368 is_registered: F1,
2369 samples_backdrop: F2,
2370 text: &mut dyn TextRecorder,
2371 scale_factor: f32,
2372 timings: &mut PrepareTimings,
2373 ) where
2374 F1: Fn(&ShaderHandle) -> bool,
2375 F2: Fn(&ShaderHandle) -> bool,
2376 {
2377 let ops = std::mem::take(&mut self.last_ops);
2381 self.prepare_paint(
2382 &ops,
2383 is_registered,
2384 samples_backdrop,
2385 text,
2386 scale_factor,
2387 timings,
2388 );
2389 self.last_ops = ops;
2390 }
2391
2392 pub fn no_time_shaders(_shader: &ShaderHandle) -> bool {
2397 false
2398 }
2399
2400 pub fn scan_continuous_shaders<F>(&self, samples_time: F) -> Option<std::time::Duration>
2407 where
2408 F: Fn(&ShaderHandle) -> bool,
2409 {
2410 let any = self
2411 .last_ops
2412 .iter()
2413 .any(|op| op_is_continuous(op, &samples_time));
2414 if any {
2415 Some(std::time::Duration::ZERO)
2416 } else {
2417 None
2418 }
2419 }
2420
2421 pub fn prepare_paint<F1, F2>(
2432 &mut self,
2433 ops: &[DrawOp],
2434 is_registered: F1,
2435 samples_backdrop: F2,
2436 text: &mut dyn TextRecorder,
2437 scale_factor: f32,
2438 timings: &mut PrepareTimings,
2439 ) where
2440 F1: Fn(&ShaderHandle) -> bool,
2441 F2: Fn(&ShaderHandle) -> bool,
2442 {
2443 crate::profile_span!("prepare::paint");
2444 let t0 = Instant::now();
2445 self.quad_scratch.clear();
2446 self.runs.clear();
2447 self.paint_items.clear();
2448
2449 let mut current: Option<(ShaderHandle, Option<PhysicalScissor>)> = None;
2450 let mut run_first: u32 = 0;
2451 let mut snapshot_emitted = false;
2454
2455 for op in ops {
2456 match op {
2457 DrawOp::Quad {
2458 rect,
2459 scissor,
2460 shader,
2461 uniforms,
2462 ..
2463 } => {
2464 if !is_registered(shader) {
2465 continue;
2466 }
2467 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2468 timings.paint_culled_ops += 1;
2469 continue;
2470 }
2471 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2472 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2473 timings.paint_culled_ops += 1;
2474 continue;
2475 }
2476 if !snapshot_emitted && samples_backdrop(shader) {
2477 close_run(
2478 &mut self.runs,
2479 &mut self.paint_items,
2480 current,
2481 run_first,
2482 self.quad_scratch.len() as u32,
2483 );
2484 current = None;
2485 run_first = self.quad_scratch.len() as u32;
2486 self.paint_items.push(PaintItem::BackdropSnapshot);
2487 snapshot_emitted = true;
2488 }
2489 let inst = match shader {
2494 ShaderHandle::Custom(name) => match self.shader_slot_maps.get(name) {
2495 Some(slot_map) => {
2496 let mut unknown = Vec::new();
2497 let inst = crate::paint::pack_instance_named(
2498 *rect,
2499 uniforms,
2500 self.working_color_space,
2501 slot_map,
2502 &mut unknown,
2503 );
2504 for key in unknown {
2505 if self.warned_shader_uniforms.insert((name, key)) {
2506 let declared: Vec<&str> =
2507 slot_map.declared().map(|(n, _)| n).collect();
2508 log::warn!(
2509 "damascene: shader `{name}` declares no \
2510 instance attribute named `{key}` — the \
2511 value lands nowhere. Declared names: \
2512 {declared:?} (plus the positional aliases \
2513 vec_a..vec_e). Rename one side so the Rust \
2514 packing and WGSL unpacking agree."
2515 );
2516 }
2517 }
2518 inst
2519 }
2520 None => {
2521 pack_instance_in(*rect, *shader, uniforms, self.working_color_space)
2522 }
2523 },
2524 _ => pack_instance_in(*rect, *shader, uniforms, self.working_color_space),
2525 };
2526
2527 let key = (*shader, phys);
2528 if current != Some(key) {
2529 close_run(
2530 &mut self.runs,
2531 &mut self.paint_items,
2532 current,
2533 run_first,
2534 self.quad_scratch.len() as u32,
2535 );
2536 current = Some(key);
2537 run_first = self.quad_scratch.len() as u32;
2538 }
2539 self.quad_scratch.push(inst);
2540 }
2541 DrawOp::GlyphRun {
2542 rect,
2543 scissor,
2544 color,
2545 text: glyph_text,
2546 size,
2547 line_height,
2548 family,
2549 mono_family,
2550 weight,
2551 mono,
2552 wrap,
2553 anchor,
2554 underline,
2555 strikethrough,
2556 link,
2557 tabular_numerals,
2558 ..
2559 } => {
2560 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2561 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2562 timings.paint_culled_ops += 1;
2563 continue;
2564 }
2565 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2566 timings.paint_culled_ops += 1;
2567 continue;
2568 }
2569 close_run(
2570 &mut self.runs,
2571 &mut self.paint_items,
2572 current,
2573 run_first,
2574 self.quad_scratch.len() as u32,
2575 );
2576 current = None;
2577 run_first = self.quad_scratch.len() as u32;
2578
2579 let mut style = crate::text::atlas::RunStyle::new(*weight, *color)
2580 .family(*family)
2581 .mono_family(*mono_family);
2582 if *mono {
2583 style = style.mono();
2584 }
2585 if *tabular_numerals {
2586 style = style.tabular_numerals();
2587 }
2588 if *underline {
2589 style = style.underline();
2590 }
2591 if *strikethrough {
2592 style = style.strikethrough();
2593 }
2594 if let Some(url) = link {
2595 style = style.with_link(url.clone());
2596 }
2597 let layers = text.record(
2598 *rect,
2599 phys,
2600 &style,
2601 glyph_text,
2602 *size,
2603 *line_height,
2604 *wrap,
2605 *anchor,
2606 scale_factor,
2607 );
2608 for index in layers {
2609 self.paint_items.push(PaintItem::Text(index));
2610 }
2611 }
2612 DrawOp::AttributedText {
2613 rect,
2614 scissor,
2615 runs,
2616 size,
2617 line_height,
2618 wrap,
2619 anchor,
2620 ..
2621 } => {
2622 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2623 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2624 timings.paint_culled_ops += 1;
2625 continue;
2626 }
2627 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2628 timings.paint_culled_ops += 1;
2629 continue;
2630 }
2631 close_run(
2632 &mut self.runs,
2633 &mut self.paint_items,
2634 current,
2635 run_first,
2636 self.quad_scratch.len() as u32,
2637 );
2638 current = None;
2639 run_first = self.quad_scratch.len() as u32;
2640
2641 let layers = text.record_runs(
2642 *rect,
2643 phys,
2644 runs,
2645 *size,
2646 *line_height,
2647 *wrap,
2648 *anchor,
2649 scale_factor,
2650 );
2651 for index in layers {
2652 self.paint_items.push(PaintItem::Text(index));
2653 }
2654 }
2655 DrawOp::Icon {
2656 rect,
2657 scissor,
2658 source,
2659 color,
2660 size,
2661 stroke_width,
2662 ..
2663 } => {
2664 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2665 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2666 timings.paint_culled_ops += 1;
2667 continue;
2668 }
2669 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2670 timings.paint_culled_ops += 1;
2671 continue;
2672 }
2673 close_run(
2674 &mut self.runs,
2675 &mut self.paint_items,
2676 current,
2677 run_first,
2678 self.quad_scratch.len() as u32,
2679 );
2680 current = None;
2681 run_first = self.quad_scratch.len() as u32;
2682
2683 let recorded = text.record_icon(
2684 *rect,
2685 phys,
2686 source,
2687 *color,
2688 *size,
2689 *stroke_width,
2690 scale_factor,
2691 );
2692 match recorded {
2693 RecordedPaint::Text(layers) => {
2694 for index in layers {
2695 self.paint_items.push(PaintItem::Text(index));
2696 }
2697 }
2698 RecordedPaint::Icon(runs) => {
2699 for index in runs {
2700 self.paint_items.push(PaintItem::IconRun(index));
2701 }
2702 }
2703 }
2704 }
2705 DrawOp::Image {
2706 rect,
2707 scissor,
2708 image,
2709 tint,
2710 radius,
2711 fit,
2712 range_limit,
2713 ..
2714 } => {
2715 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2716 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2717 timings.paint_culled_ops += 1;
2718 continue;
2719 }
2720 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2721 timings.paint_culled_ops += 1;
2722 continue;
2723 }
2724 close_run(
2725 &mut self.runs,
2726 &mut self.paint_items,
2727 current,
2728 run_first,
2729 self.quad_scratch.len() as u32,
2730 );
2731 current = None;
2732 run_first = self.quad_scratch.len() as u32;
2733
2734 let recorded = text.record_image(
2735 *rect,
2736 phys,
2737 image,
2738 *tint,
2739 *radius,
2740 *fit,
2741 *range_limit,
2742 scale_factor,
2743 );
2744 for index in recorded {
2745 self.paint_items.push(PaintItem::Image(index));
2746 }
2747 }
2748 DrawOp::AppTexture {
2749 rect,
2750 scissor,
2751 texture,
2752 alpha,
2753 transform,
2754 ..
2755 } => {
2756 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2757 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2758 timings.paint_culled_ops += 1;
2759 continue;
2760 }
2761 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2762 timings.paint_culled_ops += 1;
2763 continue;
2764 }
2765 close_run(
2766 &mut self.runs,
2767 &mut self.paint_items,
2768 current,
2769 run_first,
2770 self.quad_scratch.len() as u32,
2771 );
2772 current = None;
2773 run_first = self.quad_scratch.len() as u32;
2774
2775 let recorded = text.record_app_texture(
2776 *rect,
2777 phys,
2778 texture,
2779 *alpha,
2780 *transform,
2781 scale_factor,
2782 );
2783 for index in recorded {
2784 self.paint_items.push(PaintItem::AppTexture(index));
2785 }
2786 }
2787 DrawOp::Vector {
2788 rect,
2789 scissor,
2790 asset,
2791 render_mode,
2792 ..
2793 } => {
2794 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2795 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2796 timings.paint_culled_ops += 1;
2797 continue;
2798 }
2799 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2800 timings.paint_culled_ops += 1;
2801 continue;
2802 }
2803 close_run(
2804 &mut self.runs,
2805 &mut self.paint_items,
2806 current,
2807 run_first,
2808 self.quad_scratch.len() as u32,
2809 );
2810 current = None;
2811 run_first = self.quad_scratch.len() as u32;
2812
2813 let recorded =
2814 text.record_vector(*rect, phys, asset, *render_mode, scale_factor);
2815 for index in recorded {
2816 self.paint_items.push(PaintItem::Vector(index));
2817 }
2818 }
2819 DrawOp::Scene3D {
2820 id,
2821 rect,
2822 scissor,
2823 scene,
2824 } => {
2825 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2826 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2827 timings.paint_culled_ops += 1;
2828 continue;
2829 }
2830 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2831 timings.paint_culled_ops += 1;
2832 continue;
2833 }
2834 close_run(
2840 &mut self.runs,
2841 &mut self.paint_items,
2842 current,
2843 run_first,
2844 self.quad_scratch.len() as u32,
2845 );
2846 current = None;
2847 run_first = self.quad_scratch.len() as u32;
2848
2849 let recorded = text.record_scene3d(*rect, phys, id, scene, scale_factor);
2850 for index in recorded {
2851 self.paint_items.push(PaintItem::Scene3D(index));
2852 }
2853 }
2854 DrawOp::BackdropSnapshot => {
2855 close_run(
2856 &mut self.runs,
2857 &mut self.paint_items,
2858 current,
2859 run_first,
2860 self.quad_scratch.len() as u32,
2861 );
2862 current = None;
2863 run_first = self.quad_scratch.len() as u32;
2864 if !snapshot_emitted {
2867 self.paint_items.push(PaintItem::BackdropSnapshot);
2868 snapshot_emitted = true;
2869 }
2870 }
2871 }
2872 }
2873 close_run(
2874 &mut self.runs,
2875 &mut self.paint_items,
2876 current,
2877 run_first,
2878 self.quad_scratch.len() as u32,
2879 );
2880 timings.paint = Instant::now() - t0;
2881 }
2882
2883 pub fn snapshot(&mut self, root: &El, timings: &mut PrepareTimings) {
2888 crate::profile_span!("prepare::snapshot");
2889 let t0 = Instant::now();
2890 self.last_tree_has_links = tree_has_links(root);
2891 self.last_tree = Some(root.clone());
2892 timings.snapshot = Instant::now() - t0;
2893 }
2894
2895 fn linkable_tree(&self) -> Option<&El> {
2900 self.last_tree.as_ref().filter(|_| self.last_tree_has_links)
2901 }
2902}
2903
2904fn paint_rect_visible(
2905 rect: Rect,
2906 scissor: Option<Rect>,
2907 viewport_px: (u32, u32),
2908 scale_factor: f32,
2909) -> bool {
2910 if rect.w <= 0.0 || rect.h <= 0.0 {
2911 return false;
2912 }
2913 let scale = scale_factor.max(f32::EPSILON);
2914 let viewport = Rect::new(
2915 0.0,
2916 0.0,
2917 viewport_px.0 as f32 / scale,
2918 viewport_px.1 as f32 / scale,
2919 );
2920 let Some(clip) = scissor.map_or(Some(viewport), |s| s.intersect(viewport)) else {
2921 return false;
2922 };
2923 rect.intersect(clip).is_some()
2924}
2925
2926fn target_id_in_subtree(root_id: &str, target_id: &str) -> bool {
2927 target_id == root_id
2928 || target_id
2929 .strip_prefix(root_id)
2930 .is_some_and(|rest| rest.starts_with('.'))
2931}
2932
2933fn op_is_continuous<F>(op: &DrawOp, samples_time: &F) -> bool
2940where
2941 F: Fn(&ShaderHandle) -> bool,
2942{
2943 match op.shader() {
2944 Some(handle @ ShaderHandle::Stock(s)) => s.is_continuous() || samples_time(handle),
2945 Some(handle @ ShaderHandle::Custom(_)) => samples_time(handle),
2946 None => false,
2947 }
2948}
2949
2950fn aggregate_redraw_within(
2956 node: &El,
2957 viewport: Rect,
2958 rects: &rustc_hash::FxHashMap<String, Rect>,
2959) -> Option<std::time::Duration> {
2960 let mut acc: Option<std::time::Duration> = None;
2961 visit_redraw_within(node, viewport, rects, VisibilityClip::Unclipped, &mut acc);
2962 acc
2963}
2964
2965#[derive(Clone, Copy)]
2966enum VisibilityClip {
2967 Unclipped,
2968 Clipped(Rect),
2969 Empty,
2970}
2971
2972impl VisibilityClip {
2973 fn intersect(self, rect: Rect) -> Self {
2974 if rect.w <= 0.0 || rect.h <= 0.0 {
2975 return Self::Empty;
2976 }
2977 match self {
2978 Self::Unclipped => Self::Clipped(rect),
2979 Self::Clipped(prev) => prev
2980 .intersect(rect)
2981 .map(Self::Clipped)
2982 .unwrap_or(Self::Empty),
2983 Self::Empty => Self::Empty,
2984 }
2985 }
2986
2987 fn permits(self, rect: Rect) -> bool {
2988 if rect.w <= 0.0 || rect.h <= 0.0 {
2989 return false;
2990 }
2991 match self {
2992 Self::Unclipped => true,
2993 Self::Clipped(clip) => rect.intersect(clip).is_some(),
2994 Self::Empty => false,
2995 }
2996 }
2997}
2998
2999fn visit_redraw_within(
3000 node: &El,
3001 viewport: Rect,
3002 rects: &rustc_hash::FxHashMap<String, Rect>,
3003 inherited_clip: VisibilityClip,
3004 acc: &mut Option<std::time::Duration>,
3005) {
3006 let rect = rects.get(&node.computed_id).copied();
3007 if let Some(d) = node.redraw_within {
3008 if let Some(rect) = rect
3009 && rect.w > 0.0
3010 && rect.h > 0.0
3011 && rect.intersect(viewport).is_some()
3012 && inherited_clip.permits(rect)
3013 {
3014 *acc = Some(match *acc {
3015 Some(prev) => prev.min(d),
3016 None => d,
3017 });
3018 }
3019 }
3020 let child_clip = if node.clip {
3021 rect.map(|r| inherited_clip.intersect(r))
3022 .unwrap_or(VisibilityClip::Empty)
3023 } else {
3024 inherited_clip
3025 };
3026 for child in &node.children {
3027 visit_redraw_within(child, viewport, rects, child_clip, acc);
3028 }
3029}
3030
3031fn grid_vertical_step(members: &[UiTarget], from: usize, dir: f32) -> Option<usize> {
3041 let cur = &members[from].rect;
3042 let (cx, cy) = (cur.x + cur.w / 2.0, cur.y + cur.h / 2.0);
3043 members
3044 .iter()
3045 .enumerate()
3046 .filter(|(i, t)| {
3047 let ty = t.rect.y + t.rect.h / 2.0;
3048 *i != from && (ty - cy) * dir > cur.h / 2.0
3052 })
3053 .min_by(|(_, a), (_, b)| {
3054 let key = |t: &UiTarget| {
3055 let tx = t.rect.x + t.rect.w / 2.0;
3056 let ty = t.rect.y + t.rect.h / 2.0;
3057 ((ty - cy).abs(), (tx - cx).abs())
3058 };
3059 key(a)
3060 .partial_cmp(&key(b))
3061 .unwrap_or(std::cmp::Ordering::Equal)
3062 })
3063 .map(|(i, _)| i)
3064}
3065
3066fn tree_has_links(node: &El) -> bool {
3074 node.text_link.is_some() || node.children.iter().any(tree_has_links)
3075}
3076
3077pub(crate) fn find_capture_keys(node: &El, id: &str) -> Option<bool> {
3078 if node.computed_id == id {
3079 return Some(node.capture_keys);
3080 }
3081 node.children.iter().find_map(|c| find_capture_keys(c, id))
3082}
3083
3084fn find_consumes_touch_drag(node: &El, id: &str, ancestor_consumes: bool) -> Option<bool> {
3094 let consumes = ancestor_consumes || node.consumes_touch_drag;
3095 if node.computed_id == id {
3096 return Some(consumes);
3097 }
3098 node.children
3099 .iter()
3100 .find_map(|c| find_consumes_touch_drag(c, id, consumes))
3101}
3102
3103fn selection_event(
3105 new_sel: crate::selection::Selection,
3106 modifiers: KeyModifiers,
3107 pointer: Option<(f32, f32)>,
3108 pointer_kind: Option<PointerKind>,
3109) -> UiEvent {
3110 UiEvent {
3111 kind: UiEventKind::SelectionChanged,
3112 key: None,
3113 target: None,
3114 pointer,
3115 key_press: None,
3116 text: None,
3117 selection: Some(new_sel),
3118 modifiers,
3119 click_count: 0,
3120 path: None,
3121 pointer_kind,
3122 wheel_delta: None,
3123 }
3124}
3125
3126fn head_for_drag(
3138 root: &El,
3139 ui_state: &UiState,
3140 point: (f32, f32),
3141) -> Option<crate::selection::SelectionPoint> {
3142 if let Some(p) = hit_test::selection_point_at(root, ui_state, point) {
3143 return Some(p);
3144 }
3145
3146 let order = &ui_state.selection.order;
3147 if order.is_empty() {
3148 return None;
3149 }
3150 let target = order
3155 .iter()
3156 .find(|t| point.1 >= t.rect.y && point.1 < t.rect.y + t.rect.h)
3157 .or_else(|| {
3158 order.iter().min_by(|a, b| {
3159 let da = y_distance(a.rect, point.1);
3160 let db = y_distance(b.rect, point.1);
3161 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
3162 })
3163 })?;
3164 let target_rect = target.rect;
3165 let cy = point
3166 .1
3167 .clamp(target_rect.y, target_rect.y + target_rect.h - 1.0);
3168 if let Some(p) = hit_test::selection_point_at(root, ui_state, (point.0, cy)) {
3169 return Some(p);
3170 }
3171 let leaf_len = find_text_len(root, &target.node_id).unwrap_or(0);
3174 let byte = if point.0 < target_rect.x { 0 } else { leaf_len };
3175 Some(crate::selection::SelectionPoint {
3176 key: target.key.clone(),
3177 byte,
3178 })
3179}
3180
3181fn selection_range_for_drag(
3182 root: &El,
3183 ui_state: &UiState,
3184 drag: &crate::state::SelectionDrag,
3185 raw_head: crate::selection::SelectionPoint,
3186) -> (
3187 crate::selection::SelectionPoint,
3188 crate::selection::SelectionPoint,
3189) {
3190 match drag.granularity {
3191 SelectionDragGranularity::Character => (drag.anchor.clone(), raw_head),
3192 SelectionDragGranularity::Word => {
3193 let text = crate::selection::find_keyed_text(root, &raw_head.key).unwrap_or_default();
3194 let (lo, hi) = crate::selection::word_range_at(&text, raw_head.byte);
3195 if point_cmp(ui_state, &raw_head, &drag.anchor) == Ordering::Less {
3196 (
3197 drag.head.clone(),
3198 crate::selection::SelectionPoint::new(raw_head.key, lo),
3199 )
3200 } else {
3201 (
3202 drag.anchor.clone(),
3203 crate::selection::SelectionPoint::new(raw_head.key, hi),
3204 )
3205 }
3206 }
3207 SelectionDragGranularity::Leaf => {
3208 let len = crate::selection::find_keyed_text(root, &raw_head.key)
3209 .map(|text| text.len())
3210 .unwrap_or(raw_head.byte);
3211 if point_cmp(ui_state, &raw_head, &drag.anchor) == Ordering::Less {
3212 (
3213 drag.head.clone(),
3214 crate::selection::SelectionPoint::new(raw_head.key, 0),
3215 )
3216 } else {
3217 (
3218 drag.anchor.clone(),
3219 crate::selection::SelectionPoint::new(raw_head.key, len),
3220 )
3221 }
3222 }
3223 }
3224}
3225
3226fn point_cmp(
3227 ui_state: &UiState,
3228 a: &crate::selection::SelectionPoint,
3229 b: &crate::selection::SelectionPoint,
3230) -> Ordering {
3231 let order_index = |key: &str| {
3232 ui_state
3233 .selection
3234 .order
3235 .iter()
3236 .position(|target| target.key == key)
3237 .unwrap_or(usize::MAX)
3238 };
3239 order_index(&a.key)
3240 .cmp(&order_index(&b.key))
3241 .then_with(|| a.byte.cmp(&b.byte))
3242}
3243
3244fn y_distance(rect: Rect, y: f32) -> f32 {
3245 if y < rect.y {
3246 rect.y - y
3247 } else if y > rect.y + rect.h {
3248 y - (rect.y + rect.h)
3249 } else {
3250 0.0
3251 }
3252}
3253
3254fn find_text_len(node: &El, id: &str) -> Option<usize> {
3255 if node.computed_id == id {
3256 if let Some(source) = &node.selection_source {
3257 return Some(source.visible_len());
3258 }
3259 return node.text.as_ref().map(|t| t.len());
3260 }
3261 node.children.iter().find_map(|c| find_text_len(c, id))
3262}
3263
3264pub enum RecordedPaint {
3267 Text(Range<usize>),
3268 Icon(Range<usize>),
3269}
3270
3271pub trait TextRecorder {
3275 #[allow(clippy::too_many_arguments)]
3283 fn record(
3284 &mut self,
3285 rect: Rect,
3286 scissor: Option<PhysicalScissor>,
3287 style: &RunStyle,
3288 text: &str,
3289 size: f32,
3290 line_height: f32,
3291 wrap: TextWrap,
3292 anchor: TextAnchor,
3293 scale_factor: f32,
3294 ) -> Range<usize>;
3295
3296 #[allow(clippy::too_many_arguments)]
3301 fn record_runs(
3302 &mut self,
3303 rect: Rect,
3304 scissor: Option<PhysicalScissor>,
3305 runs: &[(String, RunStyle)],
3306 size: f32,
3307 line_height: f32,
3308 wrap: TextWrap,
3309 anchor: TextAnchor,
3310 scale_factor: f32,
3311 ) -> Range<usize>;
3312
3313 #[allow(clippy::too_many_arguments)]
3319 fn record_icon(
3320 &mut self,
3321 rect: Rect,
3322 scissor: Option<PhysicalScissor>,
3323 source: &crate::icons::svg::IconSource,
3324 color: Color,
3325 size: f32,
3326 _stroke_width: f32,
3327 scale_factor: f32,
3328 ) -> RecordedPaint {
3329 let glyph = match source {
3330 crate::icons::svg::IconSource::Builtin(name) => name.fallback_glyph(),
3331 crate::icons::svg::IconSource::Custom(_) => "?",
3332 crate::icons::svg::IconSource::UnknownName(_) => {
3333 crate::tree::IconName::AlertCircle.fallback_glyph()
3334 }
3335 };
3336 RecordedPaint::Text(self.record(
3337 rect,
3338 scissor,
3339 &RunStyle::new(FontWeight::Regular, color),
3340 glyph,
3341 size,
3342 crate::text::metrics::line_height(size),
3343 TextWrap::NoWrap,
3344 TextAnchor::Middle,
3345 scale_factor,
3346 ))
3347 }
3348
3349 #[allow(clippy::too_many_arguments)]
3356 fn record_image(
3357 &mut self,
3358 _rect: Rect,
3359 _scissor: Option<PhysicalScissor>,
3360 _image: &crate::image::Image,
3361 _tint: Option<Color>,
3362 _radius: crate::tree::Corners,
3363 _fit: crate::image::ImageFit,
3364 _range_limit: crate::image::DynamicRangeLimit,
3365 _scale_factor: f32,
3366 ) -> Range<usize> {
3367 0..0
3368 }
3369
3370 fn record_app_texture(
3376 &mut self,
3377 _rect: Rect,
3378 _scissor: Option<PhysicalScissor>,
3379 _texture: &crate::surface::AppTexture,
3380 _alpha: crate::surface::SurfaceAlpha,
3381 _transform: crate::affine::Affine2,
3382 _scale_factor: f32,
3383 ) -> Range<usize> {
3384 0..0
3385 }
3386
3387 fn record_vector(
3393 &mut self,
3394 _rect: Rect,
3395 _scissor: Option<PhysicalScissor>,
3396 _asset: &crate::vector::VectorAsset,
3397 _render_mode: crate::vector::VectorRenderMode,
3398 _scale_factor: f32,
3399 ) -> Range<usize> {
3400 0..0
3401 }
3402
3403 fn record_scene3d(
3414 &mut self,
3415 _rect: Rect,
3416 _scissor: Option<PhysicalScissor>,
3417 _id: &str,
3418 _scene: &std::sync::Arc<crate::scene::Scene3DData>,
3419 _scale_factor: f32,
3420 ) -> Range<usize> {
3421 0..0
3422 }
3423}
3424
3425#[cfg(test)]
3426mod tests {
3427 use super::*;
3428 use crate::event::PointerId;
3429 use crate::shader::{ShaderHandle, StockShader, UniformBlock};
3430
3431 struct NoText;
3433 impl TextRecorder for NoText {
3434 fn record(
3435 &mut self,
3436 _rect: Rect,
3437 _scissor: Option<PhysicalScissor>,
3438 _style: &RunStyle,
3439 _text: &str,
3440 _size: f32,
3441 _line_height: f32,
3442 _wrap: TextWrap,
3443 _anchor: TextAnchor,
3444 _scale_factor: f32,
3445 ) -> Range<usize> {
3446 0..0
3447 }
3448 fn record_runs(
3449 &mut self,
3450 _rect: Rect,
3451 _scissor: Option<PhysicalScissor>,
3452 _runs: &[(String, RunStyle)],
3453 _size: f32,
3454 _line_height: f32,
3455 _wrap: TextWrap,
3456 _anchor: TextAnchor,
3457 _scale_factor: f32,
3458 ) -> Range<usize> {
3459 0..0
3460 }
3461 }
3462
3463 #[derive(Default)]
3464 struct CountingText {
3465 records: usize,
3466 }
3467
3468 impl TextRecorder for CountingText {
3469 fn record(
3470 &mut self,
3471 _rect: Rect,
3472 _scissor: Option<PhysicalScissor>,
3473 _style: &RunStyle,
3474 _text: &str,
3475 _size: f32,
3476 _line_height: f32,
3477 _wrap: TextWrap,
3478 _anchor: TextAnchor,
3479 _scale_factor: f32,
3480 ) -> Range<usize> {
3481 self.records += 1;
3482 0..0
3483 }
3484
3485 fn record_runs(
3486 &mut self,
3487 _rect: Rect,
3488 _scissor: Option<PhysicalScissor>,
3489 _runs: &[(String, RunStyle)],
3490 _size: f32,
3491 _line_height: f32,
3492 _wrap: TextWrap,
3493 _anchor: TextAnchor,
3494 _scale_factor: f32,
3495 ) -> Range<usize> {
3496 self.records += 1;
3497 0..0
3498 }
3499 }
3500
3501 fn empty_text_layout(line_height: f32) -> crate::text::metrics::TextLayout {
3502 crate::text::metrics::TextLayout {
3503 lines: Vec::new(),
3504 width: 0.0,
3505 height: 0.0,
3506 line_height,
3507 }
3508 }
3509
3510 fn lay_out_input_tree(capture: bool) -> RunnerCore {
3517 use crate::tree::*;
3518 let ti = if capture {
3519 crate::widgets::text::text("input").key("ti").capture_keys()
3520 } else {
3521 crate::widgets::text::text("noop").key("ti").focusable()
3522 };
3523 let mut tree =
3524 crate::column([crate::widgets::button::button("Btn").key("btn"), ti]).padding(10.0);
3525 let mut core = RunnerCore::new();
3526 crate::layout::layout(
3527 &mut tree,
3528 &mut core.ui_state,
3529 Rect::new(0.0, 0.0, 200.0, 200.0),
3530 );
3531 core.ui_state.sync_focus_order(&tree);
3532 let mut t = PrepareTimings::default();
3533 core.snapshot(&tree, &mut t);
3534 core
3535 }
3536
3537 #[test]
3538 fn pointer_up_emits_pointer_up_then_click() {
3539 let mut core = lay_out_input_tree(false);
3540 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3541 let cx = btn_rect.x + btn_rect.w * 0.5;
3542 let cy = btn_rect.y + btn_rect.h * 0.5;
3543 core.pointer_moved(Pointer::moving(cx, cy));
3544 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3545 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
3546 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3547 assert_eq!(kinds, vec![UiEventKind::PointerUp, UiEventKind::Click]);
3548 }
3549
3550 #[test]
3551 fn pointer_wheel_event_routes_to_keyed_target() {
3552 let mut core = lay_out_input_tree(false);
3553 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3554 let cx = btn_rect.center_x();
3555 let cy = btn_rect.center_y();
3556
3557 let event = core
3558 .pointer_wheel_event(cx, cy, 0.0, 40.0)
3559 .expect("wheel over keyed button");
3560
3561 assert_eq!(event.kind, UiEventKind::PointerWheel);
3562 assert_eq!(event.route(), Some("btn"));
3563 assert_eq!(event.pointer_pos(), Some((cx, cy)));
3564 assert_eq!(event.wheel_delta(), Some((0.0, 40.0)));
3565 assert_eq!(event.wheel_dy(), Some(40.0));
3566 assert_eq!(event.target_rect(), Some(btn_rect));
3567 assert_eq!(event.pointer_kind, Some(PointerKind::Mouse));
3568 }
3569
3570 fn lay_out_link_tree() -> (RunnerCore, Rect, &'static str) {
3576 use crate::tree::*;
3577 const URL: &str = "https://github.com/computer-whisperer/damascene";
3578 let mut tree = crate::column([crate::text_runs([
3579 crate::text("Visit "),
3580 crate::text("github.com/computer-whisperer/damascene").link(URL),
3581 crate::text("."),
3582 ])])
3583 .padding(10.0);
3584 let mut core = RunnerCore::new();
3585 crate::layout::layout(
3586 &mut tree,
3587 &mut core.ui_state,
3588 Rect::new(0.0, 0.0, 600.0, 200.0),
3589 );
3590 core.ui_state.sync_focus_order(&tree);
3591 let mut t = PrepareTimings::default();
3592 core.snapshot(&tree, &mut t);
3593 let para = core
3594 .last_tree
3595 .as_ref()
3596 .and_then(|t| t.children.first())
3597 .map(|p| core.ui_state.rect(&p.computed_id))
3598 .expect("paragraph rect");
3599 (core, para, URL)
3600 }
3601
3602 #[test]
3603 fn snapshot_computes_the_links_flag_that_gates_link_walks() {
3604 let core = lay_out_input_tree(false);
3607 assert!(
3608 !core.last_tree_has_links,
3609 "input tree has no links; the flag must stay off"
3610 );
3611 let (core, ..) = lay_out_link_tree();
3612 assert!(
3613 core.last_tree_has_links,
3614 "linked paragraph must flip the flag on"
3615 );
3616 }
3617
3618 #[test]
3619 fn pointer_up_on_link_emits_link_activated_with_url() {
3620 let (mut core, para, url) = lay_out_link_tree();
3621 let cx = para.x + 100.0;
3625 let cy = para.y + para.h * 0.5;
3626 core.pointer_moved(Pointer::moving(cx, cy));
3627 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3628 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
3629 let link = events
3630 .iter()
3631 .find(|e| e.kind == UiEventKind::LinkActivated)
3632 .expect("LinkActivated event");
3633 assert_eq!(link.key.as_deref(), Some(url));
3634 }
3635
3636 #[test]
3637 fn pointer_up_after_drag_off_link_does_not_activate() {
3638 let (mut core, para, _url) = lay_out_link_tree();
3639 let press_x = para.x + 100.0;
3640 let cy = para.y + para.h * 0.5;
3641 core.pointer_moved(Pointer::moving(press_x, cy));
3642 core.pointer_down(Pointer::mouse(press_x, cy, PointerButton::Primary));
3643 let events = core.pointer_up(Pointer::mouse(press_x, 180.0, PointerButton::Primary));
3647 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3648 assert!(
3649 !kinds.contains(&UiEventKind::LinkActivated),
3650 "drag-off-link should cancel the link activation; got {kinds:?}",
3651 );
3652 }
3653
3654 #[test]
3655 fn disabled_control_resolves_not_allowed_cursor_on_hover() {
3656 use crate::cursor::Cursor;
3661 let mut tree = crate::widgets::button::button("Save")
3662 .key("save")
3663 .disabled();
3664 let mut core = RunnerCore::new();
3665 crate::layout::layout(
3666 &mut tree,
3667 &mut core.ui_state,
3668 Rect::new(0.0, 0.0, 200.0, 100.0),
3669 );
3670 let rect = core.ui_state.rect(&tree.computed_id);
3671 let mut t = PrepareTimings::default();
3672 core.snapshot(&tree, &mut t);
3673 core.pointer_moved(Pointer::moving(
3674 rect.x + rect.w * 0.5,
3675 rect.y + rect.h * 0.5,
3676 ));
3677 let snap = core.last_tree.as_ref().expect("tree").clone();
3678 assert_eq!(core.ui_state.cursor(&snap), Cursor::NotAllowed);
3679 }
3680
3681 #[test]
3682 fn pointer_moved_over_link_resolves_cursor_to_pointer_and_requests_redraw() {
3683 use crate::cursor::Cursor;
3684 let (mut core, para, _url) = lay_out_link_tree();
3685 let cx = para.x + 100.0;
3686 let cy = para.y + para.h * 0.5;
3687 let initial = core.pointer_moved(Pointer::moving(para.x - 50.0, cy));
3689 assert!(
3690 !initial.needs_redraw,
3691 "moving in empty space shouldn't request a redraw"
3692 );
3693 let tree = core.last_tree.as_ref().expect("tree").clone();
3694 assert_eq!(
3695 core.ui_state.cursor(&tree),
3696 Cursor::Default,
3697 "no link under pointer → default cursor"
3698 );
3699 let onto = core.pointer_moved(Pointer::moving(cx, cy));
3702 assert!(
3703 onto.needs_redraw,
3704 "entering a link region should flag a redraw so the cursor refresh isn't stale"
3705 );
3706 assert_eq!(
3707 core.ui_state.cursor(&tree),
3708 Cursor::Pointer,
3709 "pointer over a link → Pointer cursor"
3710 );
3711 let off = core.pointer_moved(Pointer::moving(para.x - 50.0, cy));
3714 assert!(
3715 off.needs_redraw,
3716 "leaving a link region should flag a redraw"
3717 );
3718 assert_eq!(core.ui_state.cursor(&tree), Cursor::Default);
3719 }
3720
3721 #[test]
3722 fn pointer_up_on_unlinked_text_does_not_emit_link_activated() {
3723 let (mut core, para, _url) = lay_out_link_tree();
3724 let cx = para.x + 1.0;
3727 let cy = para.y + para.h * 0.5;
3728 core.pointer_moved(Pointer::moving(cx, cy));
3729 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3730 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
3731 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3732 assert!(
3733 !kinds.contains(&UiEventKind::LinkActivated),
3734 "click on the unlinked prefix should not surface a link event; got {kinds:?}",
3735 );
3736 }
3737
3738 #[test]
3739 fn pointer_up_off_target_emits_only_pointer_up() {
3740 let mut core = lay_out_input_tree(false);
3741 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3742 let cx = btn_rect.x + btn_rect.w * 0.5;
3743 let cy = btn_rect.y + btn_rect.h * 0.5;
3744 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3745 let events = core.pointer_up(Pointer::mouse(180.0, 180.0, PointerButton::Primary));
3747 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3748 assert_eq!(
3749 kinds,
3750 vec![UiEventKind::PointerUp],
3751 "drag-off-target should still surface PointerUp so widgets see drag-end"
3752 );
3753 }
3754
3755 #[test]
3756 fn pointer_moved_while_pressed_emits_drag() {
3757 let mut core = lay_out_input_tree(false);
3758 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3759 let cx = btn_rect.x + btn_rect.w * 0.5;
3760 let cy = btn_rect.y + btn_rect.h * 0.5;
3761 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3762 let drag = core
3763 .pointer_moved(Pointer::moving(cx + 30.0, cy))
3764 .events
3765 .into_iter()
3766 .find(|e| e.kind == UiEventKind::Drag)
3767 .expect("drag while pressed");
3768 assert_eq!(drag.target.as_ref().map(|t| t.key.as_str()), Some("btn"));
3769 assert_eq!(drag.pointer, Some((cx + 30.0, cy)));
3770 }
3771
3772 #[test]
3773 fn toast_dismiss_click_removes_toast_and_suppresses_click_event() {
3774 use crate::toast::ToastSpec;
3775 use crate::tree::Size;
3776 let mut core = RunnerCore::new();
3780 core.ui_state
3781 .push_toast(ToastSpec::success("hi"), Instant::now());
3782 let toast_id = core.ui_state.toasts()[0].id;
3783
3784 let mut tree: El = crate::stack(std::iter::empty::<El>())
3788 .width(Size::Fill(1.0))
3789 .height(Size::Fill(1.0));
3790 crate::layout::assign_ids(&mut tree);
3791 let _ = crate::toast::synthesize_toasts(&mut tree, &mut core.ui_state, Instant::now());
3792 crate::layout::layout(
3793 &mut tree,
3794 &mut core.ui_state,
3795 Rect::new(0.0, 0.0, 800.0, 600.0),
3796 );
3797 core.ui_state.sync_focus_order(&tree);
3798 let mut t = PrepareTimings::default();
3799 core.snapshot(&tree, &mut t);
3800
3801 let dismiss_key = format!("toast-dismiss-{toast_id}");
3802 let dismiss_rect = core.rect_of_key(&dismiss_key).expect("dismiss button");
3803 let cx = dismiss_rect.x + dismiss_rect.w * 0.5;
3804 let cy = dismiss_rect.y + dismiss_rect.h * 0.5;
3805
3806 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3807 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
3808 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3809 assert!(
3813 !kinds.contains(&UiEventKind::Click),
3814 "Click on toast-dismiss should not be surfaced: {kinds:?}",
3815 );
3816 assert!(
3817 core.ui_state.toasts().iter().all(|t| t.id != toast_id),
3818 "toast {toast_id} should be dropped after dismiss-click",
3819 );
3820 }
3821
3822 #[test]
3823 fn pointer_moved_without_press_emits_no_drag() {
3824 let mut core = lay_out_input_tree(false);
3825 let events = core.pointer_moved(Pointer::moving(50.0, 50.0)).events;
3826 assert!(!events.iter().any(|e| e.kind == UiEventKind::Drag));
3830 }
3831
3832 #[test]
3833 fn spinner_in_tree_keeps_needs_redraw_set() {
3834 use crate::widgets::spinner::spinner;
3839 let mut tree = crate::column([spinner()]);
3840 let mut core = RunnerCore::new();
3841 let mut t = PrepareTimings::default();
3842 let LayoutPrepared { needs_redraw, .. } = core.prepare_layout(
3843 &mut tree,
3844 Rect::new(0.0, 0.0, 200.0, 200.0),
3845 1.0,
3846 &mut t,
3847 RunnerCore::no_time_shaders,
3848 );
3849 assert!(
3850 needs_redraw,
3851 "tree with a spinner must request continuous redraw",
3852 );
3853
3854 let mut bare = crate::column([crate::widgets::text::text("idle")]);
3858 let mut core2 = RunnerCore::new();
3859 let mut t2 = PrepareTimings::default();
3860 let LayoutPrepared {
3861 needs_redraw: needs_redraw2,
3862 ..
3863 } = core2.prepare_layout(
3864 &mut bare,
3865 Rect::new(0.0, 0.0, 200.0, 200.0),
3866 1.0,
3867 &mut t2,
3868 RunnerCore::no_time_shaders,
3869 );
3870 assert!(
3871 !needs_redraw2,
3872 "tree without time-driven shaders should idle: got needs_redraw={needs_redraw2}",
3873 );
3874 }
3875
3876 #[test]
3877 fn custom_samples_time_shader_keeps_needs_redraw_set() {
3878 let mut tree = crate::column([crate::tree::El::new(crate::tree::Kind::Custom("anim"))
3882 .shader(crate::shader::ShaderBinding::custom("my_animated_glow"))
3883 .width(crate::tree::Size::Fixed(32.0))
3884 .height(crate::tree::Size::Fixed(32.0))]);
3885 let mut core = RunnerCore::new();
3886 let mut t = PrepareTimings::default();
3887
3888 let LayoutPrepared {
3889 needs_redraw: idle, ..
3890 } = core.prepare_layout(
3891 &mut tree,
3892 Rect::new(0.0, 0.0, 200.0, 200.0),
3893 1.0,
3894 &mut t,
3895 RunnerCore::no_time_shaders,
3896 );
3897 assert!(
3898 !idle,
3899 "without a samples_time registration the host should idle",
3900 );
3901
3902 let mut t2 = PrepareTimings::default();
3903 let LayoutPrepared {
3904 needs_redraw: animated,
3905 ..
3906 } = core.prepare_layout(
3907 &mut tree,
3908 Rect::new(0.0, 0.0, 200.0, 200.0),
3909 1.0,
3910 &mut t2,
3911 |handle| matches!(handle, ShaderHandle::Custom("my_animated_glow")),
3912 );
3913 assert!(
3914 animated,
3915 "custom shader registered as samples_time=true must request continuous redraw",
3916 );
3917 }
3918
3919 #[test]
3920 fn redraw_within_aggregates_to_minimum_visible_deadline() {
3921 use std::time::Duration;
3922 let mut tree = crate::column([
3923 crate::widgets::text::text("a")
3925 .redraw_within(Duration::from_millis(16))
3926 .width(crate::tree::Size::Fixed(20.0))
3927 .height(crate::tree::Size::Fixed(20.0)),
3928 crate::widgets::text::text("b")
3930 .redraw_within(Duration::from_millis(50))
3931 .width(crate::tree::Size::Fixed(20.0))
3932 .height(crate::tree::Size::Fixed(20.0)),
3933 ]);
3934 let mut core = RunnerCore::new();
3935 let mut t = PrepareTimings::default();
3936 let LayoutPrepared {
3937 needs_redraw,
3938 next_layout_redraw_in,
3939 ..
3940 } = core.prepare_layout(
3941 &mut tree,
3942 Rect::new(0.0, 0.0, 200.0, 200.0),
3943 1.0,
3944 &mut t,
3945 RunnerCore::no_time_shaders,
3946 );
3947 assert!(needs_redraw, "redraw_within must lift the legacy bool");
3948 assert_eq!(
3949 next_layout_redraw_in,
3950 Some(Duration::from_millis(16)),
3951 "tightest visible deadline wins, on the layout lane",
3952 );
3953 }
3954
3955 #[test]
3956 fn redraw_within_off_screen_widget_is_ignored() {
3957 use std::time::Duration;
3958 let mut tree = crate::column([
3964 crate::tree::spacer().height(crate::tree::Size::Fixed(150.0)),
3965 crate::widgets::text::text("offscreen")
3966 .redraw_within(Duration::from_millis(16))
3967 .width(crate::tree::Size::Fixed(10.0))
3968 .height(crate::tree::Size::Fixed(10.0)),
3969 ]);
3970 let mut core = RunnerCore::new();
3971 let mut t = PrepareTimings::default();
3972 let LayoutPrepared {
3973 next_layout_redraw_in,
3974 ..
3975 } = core.prepare_layout(
3976 &mut tree,
3977 Rect::new(0.0, 0.0, 100.0, 100.0),
3978 1.0,
3979 &mut t,
3980 RunnerCore::no_time_shaders,
3981 );
3982 assert_eq!(
3983 next_layout_redraw_in, None,
3984 "off-screen redraw_within must not contribute to the aggregate",
3985 );
3986 }
3987
3988 #[test]
3989 fn redraw_within_clipped_out_widget_is_ignored() {
3990 use std::time::Duration;
3991
3992 let clipped = crate::column([crate::widgets::text::text("clipped")
3993 .redraw_within(Duration::from_millis(16))
3994 .width(crate::tree::Size::Fixed(10.0))
3995 .height(crate::tree::Size::Fixed(10.0))])
3996 .clip()
3997 .width(crate::tree::Size::Fixed(100.0))
3998 .height(crate::tree::Size::Fixed(20.0))
3999 .layout(|ctx| {
4000 vec![Rect::new(
4001 ctx.container.x,
4002 ctx.container.y + 30.0,
4003 10.0,
4004 10.0,
4005 )]
4006 });
4007 let mut tree = crate::column([clipped]);
4008
4009 let mut core = RunnerCore::new();
4010 let mut t = PrepareTimings::default();
4011 let LayoutPrepared {
4012 next_layout_redraw_in,
4013 ..
4014 } = core.prepare_layout(
4015 &mut tree,
4016 Rect::new(0.0, 0.0, 100.0, 100.0),
4017 1.0,
4018 &mut t,
4019 RunnerCore::no_time_shaders,
4020 );
4021 assert_eq!(
4022 next_layout_redraw_in, None,
4023 "redraw_within inside an inherited clip but outside the clip rect must not contribute",
4024 );
4025 }
4026
4027 #[test]
4028 fn pointer_moved_within_same_hovered_node_does_not_request_redraw() {
4029 let mut core = lay_out_input_tree(false);
4035 let btn = core.rect_of_key("btn").expect("btn rect");
4036 let (cx, cy) = (btn.x + btn.w * 0.5, btn.y + btn.h * 0.5);
4037
4038 let first = core.pointer_moved(Pointer::moving(cx, cy));
4042 assert_eq!(first.events.len(), 1);
4043 assert_eq!(first.events[0].kind, UiEventKind::PointerEnter);
4044 assert_eq!(first.events[0].key.as_deref(), Some("btn"));
4045 assert!(
4046 first.needs_redraw,
4047 "entering a focusable should warrant a redraw",
4048 );
4049
4050 let second = core.pointer_moved(Pointer::moving(cx + 1.0, cy));
4054 assert!(second.events.is_empty());
4055 assert!(
4056 !second.needs_redraw,
4057 "identical hover, no drag → host should idle",
4058 );
4059
4060 let off = core.pointer_moved(Pointer::moving(0.0, 0.0));
4064 assert_eq!(off.events.len(), 1);
4065 assert_eq!(off.events[0].kind, UiEventKind::PointerLeave);
4066 assert_eq!(off.events[0].key.as_deref(), Some("btn"));
4067 assert!(
4068 off.needs_redraw,
4069 "leaving a hovered node still warrants a redraw",
4070 );
4071 }
4072
4073 #[test]
4074 fn pointer_move_within_hover_label_scene_keeps_redrawing() {
4075 use crate::scene::glam::Vec3;
4080 use crate::scene::{
4081 PointData, PointLabels, PointStyle, PointsHandle, ScenePoint, SceneSpec,
4082 };
4083 let pts = PointsHandle::new(PointData {
4084 points: vec![ScenePoint {
4085 position: Vec3::ZERO,
4086 color: [1.0; 4],
4087 }],
4088 });
4089 let spec = SceneSpec::new().points_labeled(
4090 pts,
4091 PointStyle::default(),
4092 PointLabels::new(["a"]).on_hover(),
4093 );
4094 let mut tree = crate::tree::chart3d(spec).key("scene");
4095 let mut core = RunnerCore::new();
4096 crate::layout::layout(
4097 &mut tree,
4098 &mut core.ui_state,
4099 Rect::new(0.0, 0.0, 200.0, 200.0),
4100 );
4101 let mut t = PrepareTimings::default();
4102 core.snapshot(&tree, &mut t);
4103 core.ui_state.tick_scene_cameras(&tree, Instant::now());
4106
4107 let scene = core.rect_of_key("scene").expect("scene rect");
4108 let (cx, cy) = (scene.center_x(), scene.center_y());
4109
4110 let enter = core.pointer_moved(Pointer::moving(cx, cy));
4111 assert!(enter.needs_redraw, "entering a hover-label scene redraws");
4112 let within = core.pointer_moved(Pointer::moving(cx + 3.0, cy - 2.0));
4115 assert!(
4116 within.needs_redraw,
4117 "moving within a hover-label scene keeps redrawing the tooltip",
4118 );
4119 let leave = core.pointer_moved(Pointer::moving(scene.x + scene.w + 20.0, cy));
4121 assert!(leave.needs_redraw, "leaving clears the tooltip");
4122 }
4123
4124 #[test]
4125 fn pointer_moved_between_keyed_targets_emits_leave_then_enter() {
4126 let mut core = lay_out_input_tree(false);
4133 let btn = core.rect_of_key("btn").expect("btn rect");
4134 let ti = core.rect_of_key("ti").expect("ti rect");
4135
4136 let _ = core.pointer_moved(Pointer::moving(btn.x + 4.0, btn.y + 4.0));
4138
4139 let cross = core.pointer_moved(Pointer::moving(ti.x + 4.0, ti.y + 4.0));
4141 let kinds: Vec<UiEventKind> = cross.events.iter().map(|e| e.kind).collect();
4142 assert_eq!(
4143 kinds,
4144 vec![UiEventKind::PointerLeave, UiEventKind::PointerEnter],
4145 "paired Leave-then-Enter on cross-target hover transition",
4146 );
4147 assert_eq!(cross.events[0].key.as_deref(), Some("btn"));
4148 assert_eq!(cross.events[1].key.as_deref(), Some("ti"));
4149 assert!(cross.needs_redraw);
4150 }
4151
4152 #[test]
4153 fn touch_pointer_down_emits_pointer_enter_then_pointer_down() {
4154 let mut core = lay_out_input_tree(false);
4160 let btn = core.rect_of_key("btn").expect("btn rect");
4161 let cx = btn.x + btn.w * 0.5;
4162 let cy = btn.y + btn.h * 0.5;
4163 let events = core.pointer_down(Pointer::touch(
4164 cx,
4165 cy,
4166 PointerButton::Primary,
4167 PointerId::PRIMARY,
4168 ));
4169 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
4170 assert_eq!(
4171 kinds,
4172 vec![UiEventKind::PointerEnter, UiEventKind::PointerDown],
4173 );
4174 for e in &events {
4175 assert_eq!(e.pointer_kind, Some(PointerKind::Touch));
4176 }
4177 assert_eq!(core.ui_state().hovered_key(), Some("btn"));
4178 }
4179
4180 #[test]
4181 fn touch_pointer_up_emits_pointer_leave_after_click() {
4182 let mut core = lay_out_input_tree(false);
4187 let btn = core.rect_of_key("btn").expect("btn rect");
4188 let cx = btn.x + btn.w * 0.5;
4189 let cy = btn.y + btn.h * 0.5;
4190 let _ = core.pointer_down(Pointer::touch(
4191 cx,
4192 cy,
4193 PointerButton::Primary,
4194 PointerId::PRIMARY,
4195 ));
4196 let events = core.pointer_up(Pointer::touch(
4197 cx,
4198 cy,
4199 PointerButton::Primary,
4200 PointerId::PRIMARY,
4201 ));
4202 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
4203 assert_eq!(
4204 kinds,
4205 vec![
4206 UiEventKind::PointerUp,
4207 UiEventKind::Click,
4208 UiEventKind::PointerLeave,
4209 ],
4210 );
4211 assert_eq!(core.ui_state().hovered_key(), None);
4212 }
4213
4214 #[test]
4215 fn touch_pointer_moved_without_press_does_not_emit_hover_transitions() {
4216 let mut core = lay_out_input_tree(false);
4224 let btn = core.rect_of_key("btn").expect("btn rect");
4225 let mut p = Pointer::moving(btn.x + 4.0, btn.y + 4.0);
4226 p.kind = PointerKind::Touch;
4227 let moved = core.pointer_moved(p);
4228 assert!(
4229 moved.events.is_empty(),
4230 "touch move without press should not emit hover events, got {:?}",
4231 moved.events.iter().map(|e| e.kind).collect::<Vec<_>>(),
4232 );
4233 }
4234
4235 #[test]
4236 fn touch_drag_between_targets_still_emits_hover_transitions() {
4237 use crate::tree::*;
4248 let mut tree = crate::column([
4249 crate::widgets::button::button("Btn")
4250 .key("btn")
4251 .consumes_touch_drag(),
4252 crate::widgets::button::button("Other").key("other"),
4253 ])
4254 .padding(10.0);
4255 let mut core = RunnerCore::new();
4256 crate::layout::layout(
4257 &mut tree,
4258 &mut core.ui_state,
4259 Rect::new(0.0, 0.0, 200.0, 200.0),
4260 );
4261 core.ui_state.sync_focus_order(&tree);
4262 let mut t = PrepareTimings::default();
4263 core.snapshot(&tree, &mut t);
4264
4265 let btn = core.rect_of_key("btn").expect("btn rect");
4266 let other = core.rect_of_key("other").expect("other rect");
4267 let _ = core.pointer_down(Pointer::touch(
4268 btn.x + 4.0,
4269 btn.y + 4.0,
4270 PointerButton::Primary,
4271 PointerId::PRIMARY,
4272 ));
4273 let mut move_p = Pointer::moving(other.x + 4.0, other.y + 4.0);
4274 move_p.kind = PointerKind::Touch;
4275 let cross = core.pointer_moved(move_p);
4276 let kinds: Vec<UiEventKind> = cross.events.iter().map(|e| e.kind).collect();
4277 assert!(
4278 kinds.contains(&UiEventKind::PointerLeave)
4279 && kinds.contains(&UiEventKind::PointerEnter),
4280 "touch drag across targets should emit Leave + Enter, got {kinds:?}",
4281 );
4282 assert!(kinds.contains(&UiEventKind::Drag));
4286 }
4287
4288 #[test]
4289 fn would_press_focus_text_input_distinguishes_capture_keys() {
4290 let core = lay_out_input_tree(true);
4295 let ti = core.rect_of_key("ti").expect("ti rect");
4296 let btn = core.rect_of_key("btn").expect("btn rect");
4297
4298 assert!(
4299 core.would_press_focus_text_input(ti.center_x(), ti.center_y()),
4300 "press on capture_keys widget should report true",
4301 );
4302 assert!(
4303 !core.would_press_focus_text_input(btn.center_x(), btn.center_y()),
4304 "press on plain focusable should report false",
4305 );
4306 assert!(!core.would_press_focus_text_input(0.0, 0.0));
4308 }
4309
4310 #[test]
4311 fn touch_jiggle_below_threshold_still_taps() {
4312 let mut core = lay_out_input_tree(false);
4318 let btn = core.rect_of_key("btn").expect("btn rect");
4319 let cx = btn.x + btn.w * 0.5;
4320 let cy = btn.y + btn.h * 0.5;
4321 let _ = core.pointer_down(Pointer::touch(
4322 cx,
4323 cy,
4324 PointerButton::Primary,
4325 PointerId::PRIMARY,
4326 ));
4327 let mut jiggle = Pointer::moving(cx + 3.0, cy + 2.0);
4329 jiggle.kind = PointerKind::Touch;
4330 let _ = core.pointer_moved(jiggle);
4331 let events = core.pointer_up(Pointer::touch(
4332 cx + 3.0,
4333 cy + 2.0,
4334 PointerButton::Primary,
4335 PointerId::PRIMARY,
4336 ));
4337 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
4338 assert!(
4339 kinds.contains(&UiEventKind::Click),
4340 "small jiggle should not commit to scroll, expected Click in {kinds:?}",
4341 );
4342 }
4343
4344 #[test]
4345 fn touch_drag_on_consuming_widget_emits_drag_not_cancel() {
4346 use crate::tree::*;
4351 let mut tree = crate::column([crate::widgets::button::button("Drag me")
4352 .key("draggable")
4353 .consumes_touch_drag()])
4354 .padding(10.0);
4355 let mut core = RunnerCore::new();
4356 crate::layout::layout(
4357 &mut tree,
4358 &mut core.ui_state,
4359 Rect::new(0.0, 0.0, 200.0, 200.0),
4360 );
4361 core.ui_state.sync_focus_order(&tree);
4362 let mut t = PrepareTimings::default();
4363 core.snapshot(&tree, &mut t);
4364
4365 let r = core.rect_of_key("draggable").expect("rect");
4366 let cx = r.x + r.w * 0.5;
4367 let cy = r.y + r.h * 0.5;
4368 let _ = core.pointer_down(Pointer::touch(
4369 cx,
4370 cy,
4371 PointerButton::Primary,
4372 PointerId::PRIMARY,
4373 ));
4374 let mut over = Pointer::moving(cx + 30.0, cy);
4377 over.kind = PointerKind::Touch;
4378 let moved = core.pointer_moved(over);
4379 let kinds: Vec<UiEventKind> = moved.events.iter().map(|e| e.kind).collect();
4380 assert!(
4381 kinds.contains(&UiEventKind::Drag),
4382 "drag-consuming widget should receive Drag past threshold, got {kinds:?}",
4383 );
4384 assert!(
4385 !kinds.contains(&UiEventKind::PointerCancel),
4386 "drag-consuming widget should not see PointerCancel, got {kinds:?}",
4387 );
4388 }
4389
4390 #[test]
4391 fn touch_drag_in_scrollable_cancels_press_and_scrolls() {
4392 use crate::tree::*;
4399 let mut tree = crate::scroll([
4400 crate::widgets::button::button("row 0")
4401 .key("row0")
4402 .height(Size::Fixed(50.0)),
4403 crate::widgets::button::button("row 1")
4404 .key("row1")
4405 .height(Size::Fixed(50.0)),
4406 crate::widgets::button::button("row 2")
4407 .key("row2")
4408 .height(Size::Fixed(50.0)),
4409 crate::widgets::button::button("row 3")
4410 .key("row3")
4411 .height(Size::Fixed(50.0)),
4412 crate::widgets::button::button("row 4")
4413 .key("row4")
4414 .height(Size::Fixed(50.0)),
4415 ])
4416 .key("list")
4417 .height(Size::Fixed(120.0));
4418 let mut core = RunnerCore::new();
4419 crate::layout::layout(
4420 &mut tree,
4421 &mut core.ui_state,
4422 Rect::new(0.0, 0.0, 200.0, 120.0),
4423 );
4424 core.ui_state.sync_focus_order(&tree);
4425 let mut t = PrepareTimings::default();
4426 core.snapshot(&tree, &mut t);
4427 let scroll_id = core
4428 .last_tree
4429 .as_ref()
4430 .map(|t| t.computed_id.clone())
4431 .expect("scroll id");
4432
4433 let row1 = core.rect_of_key("row1").expect("row1");
4438 let cx = row1.x + row1.w * 0.5;
4439 let cy = row1.y + row1.h * 0.5;
4440
4441 let down_events = core.pointer_down(Pointer::touch(
4443 cx,
4444 cy,
4445 PointerButton::Primary,
4446 PointerId::PRIMARY,
4447 ));
4448 assert!(
4450 down_events
4451 .iter()
4452 .any(|e| matches!(e.kind, UiEventKind::PointerDown)),
4453 "expected PointerDown on press",
4454 );
4455
4456 let mut up_finger = Pointer::moving(cx, cy - 40.0);
4460 up_finger.kind = PointerKind::Touch;
4461 let move_events = core.pointer_moved(up_finger);
4462 let kinds: Vec<UiEventKind> = move_events.events.iter().map(|e| e.kind).collect();
4463 assert!(
4464 kinds.contains(&UiEventKind::PointerCancel),
4465 "scroll commit should fire PointerCancel, got {kinds:?}",
4466 );
4467 assert!(
4468 !kinds.contains(&UiEventKind::Drag),
4469 "scroll commit should NOT emit Drag, got {kinds:?}",
4470 );
4471
4472 let offset = core.ui_state().scroll_offset(&scroll_id);
4474 assert!(
4475 offset > 30.0 && offset <= 50.0,
4476 "scroll offset should advance ~40px after a 40px finger drag, got {offset}",
4477 );
4478
4479 let up_events = core.pointer_up(Pointer::touch(
4482 cx,
4483 cy - 40.0,
4484 PointerButton::Primary,
4485 PointerId::PRIMARY,
4486 ));
4487 let up_kinds: Vec<UiEventKind> = up_events.iter().map(|e| e.kind).collect();
4488 assert!(
4489 !up_kinds.contains(&UiEventKind::Click),
4490 "scroll-committed gesture must not fire Click on release, got {up_kinds:?}",
4491 );
4492 }
4493
4494 #[test]
4495 fn touch_scroll_release_starts_momentum_after_fast_swipe_outside_viewport() {
4496 use crate::tree::*;
4497 let mut tree = crate::scroll((0..20).map(|i| {
4498 crate::widgets::button::button(format!("row {i}"))
4499 .key(format!("row{i}"))
4500 .height(Size::Fixed(50.0))
4501 }))
4502 .key("list")
4503 .height(Size::Fixed(120.0));
4504 let mut core = RunnerCore::new();
4505 crate::layout::layout(
4506 &mut tree,
4507 &mut core.ui_state,
4508 Rect::new(0.0, 0.0, 200.0, 120.0),
4509 );
4510 core.ui_state.sync_focus_order(&tree);
4511 let mut t = PrepareTimings::default();
4512 core.snapshot(&tree, &mut t);
4513 let scroll_id = core
4514 .last_tree
4515 .as_ref()
4516 .map(|t| t.computed_id.clone())
4517 .expect("scroll id");
4518
4519 let row1 = core.rect_of_key("row1").expect("row1");
4520 let cx = row1.x + row1.w * 0.5;
4521 let cy = row1.y + row1.h * 0.5;
4522
4523 core.pointer_down(Pointer::touch(
4524 cx,
4525 cy,
4526 PointerButton::Primary,
4527 PointerId::PRIMARY,
4528 ));
4529 let mut up_finger = Pointer::moving(cx, cy - 80.0);
4530 up_finger.kind = PointerKind::Touch;
4531 core.pointer_moved(up_finger);
4532 let before_release = core.ui_state().scroll_offset(&scroll_id);
4533 core.pointer_up(Pointer::touch(
4534 cx,
4535 cy - 80.0,
4536 PointerButton::Primary,
4537 PointerId::PRIMARY,
4538 ));
4539
4540 assert!(
4541 core.ui_state.has_scroll_momentum(),
4542 "quick touch scroll release should retain inertial velocity"
4543 );
4544 assert_eq!(
4545 core.next_input_deadline(Instant::now()),
4546 Some(Duration::ZERO),
4547 "active scroll momentum should request the next layout frame"
4548 );
4549
4550 let ticked = core
4551 .ui_state
4552 .tick_scroll_momentum(Instant::now() + Duration::from_millis(16));
4553 let after_tick = core.ui_state().scroll_offset(&scroll_id);
4554 assert!(ticked, "momentum tick should report visual work");
4555 assert!(
4556 after_tick > before_release,
4557 "momentum should continue in release direction: before={before_release}, after={after_tick}"
4558 );
4559 }
4560
4561 #[test]
4562 fn pointer_left_emits_leave_for_prior_hover() {
4563 let mut core = lay_out_input_tree(false);
4564 let btn = core.rect_of_key("btn").expect("btn rect");
4565 let _ = core.pointer_moved(Pointer::moving(btn.x + 4.0, btn.y + 4.0));
4566
4567 let events = core.pointer_left();
4568 assert_eq!(events.len(), 1);
4569 assert_eq!(events[0].kind, UiEventKind::PointerLeave);
4570 assert_eq!(events[0].key.as_deref(), Some("btn"));
4571 }
4572
4573 #[test]
4574 fn pointer_left_with_no_prior_hover_emits_nothing() {
4575 let mut core = lay_out_input_tree(false);
4576 let events = core.pointer_left();
4579 assert!(events.is_empty());
4580 }
4581
4582 #[test]
4583 fn poll_input_before_long_press_delay_emits_nothing() {
4584 let mut core = lay_out_input_tree(false);
4587 let btn = core.rect_of_key("btn").expect("btn rect");
4588 let cx = btn.x + btn.w * 0.5;
4589 let cy = btn.y + btn.h * 0.5;
4590 let _ = core.pointer_down(Pointer::touch(
4591 cx,
4592 cy,
4593 PointerButton::Primary,
4594 PointerId::PRIMARY,
4595 ));
4596 let polled = core.poll_input(Instant::now() + Duration::from_millis(100));
4598 assert!(polled.is_empty(), "should not fire before delay");
4599 }
4600
4601 #[test]
4602 fn poll_input_after_long_press_delay_fires_cancel_then_long_press() {
4603 let mut core = lay_out_input_tree(false);
4607 let btn = core.rect_of_key("btn").expect("btn rect");
4608 let cx = btn.x + btn.w * 0.5;
4609 let cy = btn.y + btn.h * 0.5;
4610 let _ = core.pointer_down(Pointer::touch(
4611 cx,
4612 cy,
4613 PointerButton::Primary,
4614 PointerId::PRIMARY,
4615 ));
4616 let polled = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
4617 let kinds: Vec<UiEventKind> = polled.iter().map(|e| e.kind).collect();
4618 assert!(
4619 kinds.contains(&UiEventKind::PointerCancel),
4620 "expected PointerCancel before LongPress, got {kinds:?}",
4621 );
4622 let long_press = polled
4623 .iter()
4624 .find(|e| matches!(e.kind, UiEventKind::LongPress))
4625 .expect("LongPress event missing");
4626 assert_eq!(
4627 long_press.key.as_deref(),
4628 Some("btn"),
4629 "LongPress should target the originally pressed node",
4630 );
4631 assert_eq!(
4632 long_press.pointer_kind,
4633 Some(PointerKind::Touch),
4634 "LongPress is touch-only",
4635 );
4636 }
4637
4638 #[test]
4639 fn touch_long_press_on_editable_preserves_drag_extension() {
4640 let mut core = lay_out_input_tree(true);
4641 let ti = core.rect_of_key("ti").expect("ti rect");
4642 let cx = ti.x + 4.0;
4643 let cy = ti.y + ti.h * 0.5;
4644 let _ = core.pointer_down(Pointer::touch(
4645 cx,
4646 cy,
4647 PointerButton::Primary,
4648 PointerId::PRIMARY,
4649 ));
4650
4651 let polled = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
4652 assert!(
4653 polled.iter().any(|e| e.kind == UiEventKind::LongPress),
4654 "editable long-press should emit LongPress"
4655 );
4656 assert!(
4657 !polled.iter().any(|e| e.kind == UiEventKind::PointerCancel),
4658 "editable long-press keeps the press captured so drag can extend selection"
4659 );
4660
4661 let mut moved = Pointer::moving(cx + 40.0, cy);
4662 moved.kind = PointerKind::Touch;
4663 let drag = core.pointer_moved(moved);
4664 assert!(
4665 drag.events.iter().any(|e| e.kind == UiEventKind::Drag),
4666 "held touch move after editable long-press should emit Drag"
4667 );
4668
4669 let up_events = core.pointer_up(Pointer::touch(
4670 cx + 40.0,
4671 cy,
4672 PointerButton::Primary,
4673 PointerId::PRIMARY,
4674 ));
4675 assert!(
4676 up_events.is_empty(),
4677 "long-press release should not synthesize click or pointer-up"
4678 );
4679 }
4680
4681 #[test]
4682 fn pointer_up_after_long_press_emits_no_click() {
4683 let mut core = lay_out_input_tree(false);
4687 let btn = core.rect_of_key("btn").expect("btn rect");
4688 let cx = btn.x + btn.w * 0.5;
4689 let cy = btn.y + btn.h * 0.5;
4690 let _ = core.pointer_down(Pointer::touch(
4691 cx,
4692 cy,
4693 PointerButton::Primary,
4694 PointerId::PRIMARY,
4695 ));
4696 let _ = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
4697 let up_events = core.pointer_up(Pointer::touch(
4698 cx,
4699 cy,
4700 PointerButton::Primary,
4701 PointerId::PRIMARY,
4702 ));
4703 assert!(
4704 up_events.is_empty(),
4705 "lift after long-press emits nothing, got {:?}",
4706 up_events.iter().map(|e| e.kind).collect::<Vec<_>>(),
4707 );
4708 }
4709
4710 #[test]
4711 fn moving_past_threshold_before_long_press_cancels_the_timer() {
4712 let mut core = lay_out_input_tree(false);
4717 let btn = core.rect_of_key("btn").expect("btn rect");
4718 let cx = btn.x + btn.w * 0.5;
4719 let cy = btn.y + btn.h * 0.5;
4720 let _ = core.pointer_down(Pointer::touch(
4721 cx,
4722 cy,
4723 PointerButton::Primary,
4724 PointerId::PRIMARY,
4725 ));
4726 let mut over = Pointer::moving(cx + 30.0, cy);
4728 over.kind = PointerKind::Touch;
4729 let _ = core.pointer_moved(over);
4730 let polled = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
4732 assert!(
4733 polled.is_empty(),
4734 "long-press should not fire after gesture committed",
4735 );
4736 }
4737
4738 #[test]
4739 fn ui_state_hovered_key_returns_leaf_key() {
4740 let mut core = lay_out_input_tree(false);
4741 assert_eq!(core.ui_state().hovered_key(), None);
4742
4743 let btn = core.rect_of_key("btn").expect("btn rect");
4744 core.pointer_moved(Pointer::moving(btn.x + 4.0, btn.y + 4.0));
4745 assert_eq!(core.ui_state().hovered_key(), Some("btn"));
4746
4747 core.pointer_moved(Pointer::moving(0.0, 0.0));
4749 assert_eq!(core.ui_state().hovered_key(), None);
4750 }
4751
4752 #[test]
4753 fn ui_state_is_hovering_within_walks_subtree() {
4754 use crate::tree::*;
4758 let mut tree = crate::column([crate::stack([
4759 crate::widgets::button::button("Inner").key("inner_btn")
4760 ])
4761 .key("card")
4762 .focusable()
4763 .width(Size::Fixed(120.0))
4764 .height(Size::Fixed(60.0))])
4765 .padding(20.0);
4766 let mut core = RunnerCore::new();
4767 crate::layout::layout(
4768 &mut tree,
4769 &mut core.ui_state,
4770 Rect::new(0.0, 0.0, 400.0, 200.0),
4771 );
4772 core.ui_state.sync_focus_order(&tree);
4773 let mut t = PrepareTimings::default();
4774 core.snapshot(&tree, &mut t);
4775
4776 assert!(!core.ui_state().is_hovering_within("card"));
4778 assert!(!core.ui_state().is_hovering_within("inner_btn"));
4779
4780 let inner = core.rect_of_key("inner_btn").expect("inner rect");
4783 core.pointer_moved(Pointer::moving(inner.x + 4.0, inner.y + 4.0));
4784 assert!(core.ui_state().is_hovering_within("card"));
4785 assert!(core.ui_state().is_hovering_within("inner_btn"));
4786
4787 assert!(!core.ui_state().is_hovering_within("not_a_key"));
4789
4790 core.pointer_moved(Pointer::moving(0.0, 0.0));
4792 assert!(!core.ui_state().is_hovering_within("card"));
4793 assert!(!core.ui_state().is_hovering_within("inner_btn"));
4794 }
4795
4796 #[test]
4797 fn hover_driven_scale_via_is_hovering_within_plus_animate() {
4798 use crate::Theme;
4805 use crate::anim::Timing;
4806 use crate::tree::*;
4807
4808 let build_card = |hovering: bool| -> El {
4811 let scale = if hovering { 1.05 } else { 1.0 };
4812 crate::column([crate::stack(
4813 [crate::widgets::button::button("Inner").key("inner_btn")],
4814 )
4815 .key("card")
4816 .focusable()
4817 .scale(scale)
4818 .animate(Timing::SPRING_QUICK)
4819 .width(Size::Fixed(120.0))
4820 .height(Size::Fixed(60.0))])
4821 .padding(20.0)
4822 };
4823
4824 let mut core = RunnerCore::new();
4825 core.ui_state
4828 .set_animation_mode(crate::state::AnimationMode::Settled);
4829
4830 let theme = Theme::default();
4832 let cx_pre = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
4833 assert!(!cx_pre.is_hovering_within("card"));
4834 let mut tree = build_card(cx_pre.is_hovering_within("card"));
4835 crate::layout::layout(
4836 &mut tree,
4837 &mut core.ui_state,
4838 Rect::new(0.0, 0.0, 400.0, 200.0),
4839 );
4840 core.ui_state.sync_focus_order(&tree);
4841 let mut t = PrepareTimings::default();
4842 core.snapshot(&tree, &mut t);
4843 core.ui_state
4844 .tick_visual_animations(&mut tree, web_time::Instant::now(), theme.palette());
4845 let card_at_rest = tree.children[0].clone();
4846 assert!((card_at_rest.scale - 1.0).abs() < 1e-3);
4847
4848 let card_rect = core.rect_of_key("card").expect("card rect");
4850 core.pointer_moved(Pointer::moving(card_rect.x + 4.0, card_rect.y + 4.0));
4851
4852 let cx_hot = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
4855 assert!(cx_hot.is_hovering_within("card"));
4856 let mut tree = build_card(cx_hot.is_hovering_within("card"));
4857 crate::layout::layout(
4858 &mut tree,
4859 &mut core.ui_state,
4860 Rect::new(0.0, 0.0, 400.0, 200.0),
4861 );
4862 core.ui_state.sync_focus_order(&tree);
4863 core.snapshot(&tree, &mut t);
4864 core.ui_state
4865 .tick_visual_animations(&mut tree, web_time::Instant::now(), theme.palette());
4866 let card_hot = tree.children[0].clone();
4867 assert!(
4868 (card_hot.scale - 1.05).abs() < 1e-3,
4869 "hover should drive card scale to 1.05 via animate; got {}",
4870 card_hot.scale,
4871 );
4872
4873 core.pointer_moved(Pointer::moving(0.0, 0.0));
4875 let cx_cold = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
4876 assert!(!cx_cold.is_hovering_within("card"));
4877 let mut tree = build_card(cx_cold.is_hovering_within("card"));
4878 crate::layout::layout(
4879 &mut tree,
4880 &mut core.ui_state,
4881 Rect::new(0.0, 0.0, 400.0, 200.0),
4882 );
4883 core.ui_state.sync_focus_order(&tree);
4884 core.snapshot(&tree, &mut t);
4885 core.ui_state
4886 .tick_visual_animations(&mut tree, web_time::Instant::now(), theme.palette());
4887 let card_after = tree.children[0].clone();
4888 assert!((card_after.scale - 1.0).abs() < 1e-3);
4889 }
4890
4891 #[test]
4892 fn file_dropped_routes_to_keyed_leaf_at_pointer() {
4893 let mut core = lay_out_input_tree(false);
4894 let btn = core.rect_of_key("btn").expect("btn rect");
4895 let path = std::path::PathBuf::from("/tmp/screenshot.png");
4896 let events = core.file_dropped(path.clone(), btn.x + 4.0, btn.y + 4.0);
4897 assert_eq!(events.len(), 1);
4898 let event = &events[0];
4899 assert_eq!(event.kind, UiEventKind::FileDropped);
4900 assert_eq!(event.key.as_deref(), Some("btn"));
4901 assert_eq!(event.path.as_deref(), Some(path.as_path()));
4902 assert_eq!(event.pointer, Some((btn.x + 4.0, btn.y + 4.0)));
4903 }
4904
4905 #[test]
4906 fn file_dropped_outside_keyed_surface_emits_window_level_event() {
4907 let mut core = lay_out_input_tree(false);
4908 let path = std::path::PathBuf::from("/tmp/screenshot.png");
4910 let events = core.file_dropped(path.clone(), 1.0, 1.0);
4911 assert_eq!(events.len(), 1);
4912 let event = &events[0];
4913 assert_eq!(event.kind, UiEventKind::FileDropped);
4914 assert!(
4915 event.target.is_none(),
4916 "drop outside any keyed surface routes window-level",
4917 );
4918 assert!(event.key.is_none());
4919 assert_eq!(event.path.as_deref(), Some(path.as_path()));
4921 }
4922
4923 #[test]
4924 fn file_hovered_then_cancelled_pair() {
4925 let mut core = lay_out_input_tree(false);
4926 let btn = core.rect_of_key("btn").expect("btn rect");
4927 let path = std::path::PathBuf::from("/tmp/a.png");
4928
4929 let hover = core.file_hovered(path.clone(), btn.x + 4.0, btn.y + 4.0);
4930 assert_eq!(hover.len(), 1);
4931 assert_eq!(hover[0].kind, UiEventKind::FileHovered);
4932 assert_eq!(hover[0].key.as_deref(), Some("btn"));
4933 assert_eq!(hover[0].path.as_deref(), Some(path.as_path()));
4934
4935 let cancel = core.file_hover_cancelled();
4936 assert_eq!(cancel.len(), 1);
4937 assert_eq!(cancel[0].kind, UiEventKind::FileHoverCancelled);
4938 assert!(cancel[0].target.is_none());
4939 assert!(cancel[0].path.is_none());
4940 }
4941
4942 #[test]
4943 fn build_cx_hover_accessors_default_off_without_state() {
4944 use crate::Theme;
4945 let theme = Theme::default();
4946 let cx = crate::BuildCx::new(&theme);
4947 assert_eq!(cx.hovered_key(), None);
4948 assert!(!cx.is_hovering_within("anything"));
4949 }
4950
4951 #[test]
4952 fn build_cx_hover_accessors_delegate_when_state_attached() {
4953 use crate::Theme;
4954 let mut core = lay_out_input_tree(false);
4955 let btn = core.rect_of_key("btn").expect("btn rect");
4956 core.pointer_moved(Pointer::moving(btn.x + 4.0, btn.y + 4.0));
4957
4958 let theme = Theme::default();
4959 let cx = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
4960 assert_eq!(cx.hovered_key(), Some("btn"));
4961 assert!(cx.is_hovering_within("btn"));
4962 assert!(!cx.is_hovering_within("ti"));
4963 }
4964
4965 #[test]
4966 fn build_cx_rect_of_key_reads_previous_layout() {
4967 use crate::Theme;
4968 let core = lay_out_input_tree(false);
4969 let theme = Theme::default();
4970
4971 let detached = crate::BuildCx::new(&theme);
4972 assert_eq!(detached.rect_of_key("btn"), None);
4973
4974 let cx = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
4975 assert_eq!(cx.rect_of_key("btn"), core.rect_of_key("btn"));
4976 assert_eq!(cx.rect_of_key("missing"), None);
4977 }
4978
4979 #[test]
4980 fn event_cx_rect_of_key_answers_from_laid_out_state() {
4981 let core = lay_out_input_tree(false);
4982
4983 let detached = crate::EventCx::new();
4984 assert_eq!(detached.rect_of_key("btn"), None);
4985
4986 let cx = crate::EventCx::new().with_ui_state(core.ui_state());
4987 let rect = cx.rect_of_key("btn").expect("btn rect via EventCx");
4988 assert_eq!(Some(rect), core.rect_of_key("btn"));
4989 assert_eq!(cx.rect_of_key("missing"), None);
4990 }
4991
4992 #[test]
4993 fn event_cx_viewport_and_diagnostics_mirror_build_cx() {
4994 let detached = crate::EventCx::new();
4997 assert_eq!(detached.viewport(), None);
4998 assert_eq!(detached.viewport_width(), None);
4999 assert_eq!(detached.viewport_height(), None);
5000 assert!(!detached.viewport_below(10_000.0));
5001 assert!(detached.diagnostics().is_none());
5002
5003 let diagnostics = crate::HostDiagnostics {
5004 backend: "Test",
5005 ..Default::default()
5006 };
5007 let cx = crate::EventCx::new()
5008 .with_viewport(800.0, 600.0)
5009 .with_diagnostics(&diagnostics);
5010 assert_eq!(cx.viewport(), Some((800.0, 600.0)));
5011 assert_eq!(cx.viewport_width(), Some(800.0));
5012 assert_eq!(cx.viewport_height(), Some(600.0));
5013 assert!(cx.viewport_below(900.0));
5014 assert!(!cx.viewport_below(800.0));
5015 assert_eq!(cx.diagnostics().map(|d| d.backend), Some("Test"));
5016 }
5017
5018 fn lay_out_paragraph_tree() -> RunnerCore {
5019 use crate::tree::*;
5020 let mut tree = crate::column([
5021 crate::widgets::text::text("First paragraph of text.")
5022 .key("p1")
5023 .selectable(),
5024 crate::widgets::text::text("Second paragraph of text.")
5025 .key("p2")
5026 .selectable(),
5027 ])
5028 .padding(20.0);
5029 let mut core = RunnerCore::new();
5030 crate::layout::layout(
5031 &mut tree,
5032 &mut core.ui_state,
5033 Rect::new(0.0, 0.0, 400.0, 300.0),
5034 );
5035 core.ui_state.sync_focus_order(&tree);
5036 core.ui_state.sync_selection_order(&tree);
5037 let mut t = PrepareTimings::default();
5038 core.snapshot(&tree, &mut t);
5039 core
5040 }
5041
5042 #[test]
5043 fn pointer_down_on_selectable_text_emits_selection_changed() {
5044 let mut core = lay_out_paragraph_tree();
5045 let p1 = core.rect_of_key("p1").expect("p1 rect");
5046 let cx = p1.x + 4.0;
5047 let cy = p1.y + p1.h * 0.5;
5048 let events = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5049 let sel_event = events
5050 .iter()
5051 .find(|e| e.kind == UiEventKind::SelectionChanged)
5052 .expect("SelectionChanged emitted");
5053 let new_sel = sel_event
5054 .selection
5055 .as_ref()
5056 .expect("SelectionChanged carries a selection");
5057 let range = new_sel.range.as_ref().expect("collapsed selection at hit");
5058 assert_eq!(range.anchor.key, "p1");
5059 assert_eq!(range.head.key, "p1");
5060 assert_eq!(range.anchor.byte, range.head.byte);
5061 assert!(core.ui_state.selection.drag.is_some());
5062 }
5063
5064 #[test]
5065 fn touch_long_press_on_selectable_text_selects_word_and_drags() {
5066 let mut core = lay_out_paragraph_tree();
5067 let p1 = core.rect_of_key("p1").expect("p1 rect");
5068 let p2 = core.rect_of_key("p2").expect("p2 rect");
5069 let x = p1.x + 4.0;
5070 let y = p1.y + p1.h * 0.5;
5071
5072 let _ = core.pointer_down(Pointer::touch(
5073 x,
5074 y,
5075 PointerButton::Primary,
5076 PointerId::PRIMARY,
5077 ));
5078 let polled = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
5079 let selection = polled
5080 .iter()
5081 .rev()
5082 .find(|e| e.kind == UiEventKind::SelectionChanged)
5083 .and_then(|e| e.selection.as_ref())
5084 .expect("touch long-press should select text");
5085 let range = selection.range.as_ref().expect("word selection");
5086 assert_eq!(range.anchor.key, "p1");
5087 assert_eq!(range.head.key, "p1");
5088 assert_eq!(range.anchor.byte, 0);
5089 assert_eq!(range.head.byte, 5);
5090 assert!(
5091 core.ui_state.selection.drag.is_some(),
5092 "long-pressed selectable text should stay ready for drag extension"
5093 );
5094
5095 let mut moved = Pointer::moving(p2.x + 8.0, p2.y + p2.h * 0.5);
5096 moved.kind = PointerKind::Touch;
5097 let events = core.pointer_moved(moved).events;
5098 let selection = events
5099 .iter()
5100 .find(|e| e.kind == UiEventKind::SelectionChanged)
5101 .and_then(|e| e.selection.as_ref())
5102 .unwrap_or(&core.ui_state.current_selection);
5103 let range = selection.range.as_ref().expect("extended selection");
5104 assert_eq!(range.anchor.key, "p1");
5105 assert_eq!(range.head.key, "p2");
5106
5107 let _ = core.pointer_up(Pointer::touch(
5108 p2.x + 8.0,
5109 p2.y + p2.h * 0.5,
5110 PointerButton::Primary,
5111 PointerId::PRIMARY,
5112 ));
5113 assert!(
5114 core.ui_state.selection.drag.is_none(),
5115 "selection drag should end on lift"
5116 );
5117 }
5118
5119 #[test]
5120 fn pointer_drag_on_selectable_text_extends_head() {
5121 let mut core = lay_out_paragraph_tree();
5122 let p1 = core.rect_of_key("p1").expect("p1 rect");
5123 let cx = p1.x + 4.0;
5124 let cy = p1.y + p1.h * 0.5;
5125 core.pointer_moved(Pointer::moving(cx, cy));
5126 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5127
5128 let events = core
5130 .pointer_moved(Pointer::moving(p1.x + p1.w - 10.0, cy))
5131 .events;
5132 let sel_event = events
5133 .iter()
5134 .find(|e| e.kind == UiEventKind::SelectionChanged)
5135 .expect("Drag emits SelectionChanged");
5136 let new_sel = sel_event.selection.as_ref().unwrap();
5137 let range = new_sel.range.as_ref().unwrap();
5138 assert_eq!(range.anchor.key, "p1");
5139 assert_eq!(range.head.key, "p1");
5140 assert!(
5141 range.head.byte > range.anchor.byte,
5142 "head should advance past anchor (anchor={}, head={})",
5143 range.anchor.byte,
5144 range.head.byte
5145 );
5146 }
5147
5148 #[test]
5149 fn double_click_hold_drag_inside_selectable_word_keeps_word_selected() {
5150 let mut core = lay_out_paragraph_tree();
5151 let p1 = core.rect_of_key("p1").expect("p1 rect");
5152 let cx = p1.x + 4.0;
5153 let cy = p1.y + p1.h * 0.5;
5154
5155 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5156 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5157 let down = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5158 let sel = down
5159 .iter()
5160 .find(|e| e.kind == UiEventKind::SelectionChanged)
5161 .and_then(|e| e.selection.as_ref())
5162 .and_then(|s| s.range.as_ref())
5163 .expect("double-click selects word");
5164 assert_eq!(sel.anchor.byte, 0);
5165 assert_eq!(sel.head.byte, 5);
5166
5167 let events = core.pointer_moved(Pointer::moving(cx + 1.0, cy)).events;
5168 assert!(
5169 !events
5170 .iter()
5171 .any(|e| e.kind == UiEventKind::SelectionChanged),
5172 "drag jitter within the double-clicked word should not collapse the selection"
5173 );
5174 let range = core
5175 .ui_state
5176 .current_selection
5177 .range
5178 .as_ref()
5179 .expect("selection persists");
5180 assert_eq!(range.anchor.byte, 0);
5181 assert_eq!(range.head.byte, 5);
5182 }
5183
5184 #[test]
5185 fn pointer_up_clears_drag_but_keeps_selection() {
5186 let mut core = lay_out_paragraph_tree();
5187 let p1 = core.rect_of_key("p1").expect("p1 rect");
5188 let cx = p1.x + 4.0;
5189 let cy = p1.y + p1.h * 0.5;
5190 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5191 core.pointer_moved(Pointer::moving(p1.x + p1.w - 10.0, cy));
5192 let _ = core.pointer_up(Pointer::mouse(
5193 p1.x + p1.w - 10.0,
5194 cy,
5195 PointerButton::Primary,
5196 ));
5197 assert!(
5198 core.ui_state.selection.drag.is_none(),
5199 "drag flag should clear on pointer_up"
5200 );
5201 assert!(
5202 !core.ui_state.current_selection.is_empty(),
5203 "selection itself should persist after pointer_up"
5204 );
5205 }
5206
5207 #[test]
5208 fn drag_past_a_leaf_bottom_keeps_head_in_that_leaf_not_anchor() {
5209 let mut core = lay_out_paragraph_tree();
5215 let p1 = core.rect_of_key("p1").expect("p1 rect");
5216 let p2 = core.rect_of_key("p2").expect("p2 rect");
5217 core.pointer_down(Pointer::mouse(
5219 p1.x + 4.0,
5220 p1.y + p1.h * 0.5,
5221 PointerButton::Primary,
5222 ));
5223 core.pointer_moved(Pointer::moving(p2.x + 8.0, p2.y + p2.h * 0.5));
5225 let events = core
5228 .pointer_moved(Pointer::moving(p2.x + 8.0, p2.y + p2.h + 200.0))
5229 .events;
5230 let sel = events
5231 .iter()
5232 .find(|e| e.kind == UiEventKind::SelectionChanged)
5233 .map(|e| e.selection.as_ref().unwrap().clone())
5234 .unwrap_or_else(|| core.ui_state.current_selection.clone());
5237 let r = sel.range.as_ref().expect("selection still active");
5238 assert_eq!(r.anchor.key, "p1", "anchor unchanged");
5239 assert_eq!(
5240 r.head.key, "p2",
5241 "head must stay in p2 even when pointer is below p2's rect"
5242 );
5243 }
5244
5245 #[test]
5246 fn drag_into_a_sibling_selectable_extends_head_into_that_leaf() {
5247 let mut core = lay_out_paragraph_tree();
5248 let p1 = core.rect_of_key("p1").expect("p1 rect");
5249 let p2 = core.rect_of_key("p2").expect("p2 rect");
5250 core.pointer_down(Pointer::mouse(
5252 p1.x + 4.0,
5253 p1.y + p1.h * 0.5,
5254 PointerButton::Primary,
5255 ));
5256 let events = core
5258 .pointer_moved(Pointer::moving(p2.x + 8.0, p2.y + p2.h * 0.5))
5259 .events;
5260 let sel_event = events
5261 .iter()
5262 .find(|e| e.kind == UiEventKind::SelectionChanged)
5263 .expect("Drag emits SelectionChanged");
5264 let new_sel = sel_event.selection.as_ref().unwrap();
5265 let range = new_sel.range.as_ref().unwrap();
5266 assert_eq!(range.anchor.key, "p1", "anchor stays in p1");
5267 assert_eq!(range.head.key, "p2", "head migrates into p2");
5268 }
5269
5270 #[test]
5271 fn pointer_down_on_focusable_owning_selection_does_not_clear_it() {
5272 let mut core = lay_out_input_tree(true);
5280 core.set_selection(crate::selection::Selection::caret("ti", 3));
5283 let ti = core.rect_of_key("ti").expect("ti rect");
5284 let cx = ti.x + ti.w * 0.5;
5285 let cy = ti.y + ti.h * 0.5;
5286
5287 let events = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5288 let cleared = events.iter().find(|e| {
5289 e.kind == UiEventKind::SelectionChanged
5290 && e.selection.as_ref().map(|s| s.is_empty()).unwrap_or(false)
5291 });
5292 assert!(
5293 cleared.is_none(),
5294 "click on the selection-owning input must not emit a clearing SelectionChanged"
5295 );
5296 assert_eq!(
5297 core.ui_state.current_selection,
5298 crate::selection::Selection::caret("ti", 3),
5299 "runtime mirror is preserved when the click owns the selection"
5300 );
5301 }
5302
5303 #[test]
5304 fn pointer_down_into_a_different_capture_keys_widget_does_not_clear_first() {
5305 let mut core = lay_out_input_tree(true);
5315 core.set_selection(crate::selection::Selection::caret("other", 4));
5317 let ti = core.rect_of_key("ti").expect("ti rect");
5318 let cx = ti.x + ti.w * 0.5;
5319 let cy = ti.y + ti.h * 0.5;
5320
5321 let events = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5322 let cleared = events.iter().any(|e| {
5323 e.kind == UiEventKind::SelectionChanged
5324 && e.selection.as_ref().map(|s| s.is_empty()).unwrap_or(false)
5325 });
5326 assert!(
5327 !cleared,
5328 "click on a different capture_keys widget must not race-clear the selection"
5329 );
5330 }
5331
5332 #[test]
5333 fn pointer_down_on_non_selectable_clears_existing_selection() {
5334 let mut core = lay_out_paragraph_tree();
5335 let p1 = core.rect_of_key("p1").expect("p1 rect");
5336 let cy = p1.y + p1.h * 0.5;
5337 core.pointer_down(Pointer::mouse(p1.x + 4.0, cy, PointerButton::Primary));
5339 core.pointer_up(Pointer::mouse(p1.x + 4.0, cy, PointerButton::Primary));
5340 assert!(!core.ui_state.current_selection.is_empty());
5341
5342 let events = core.pointer_down(Pointer::mouse(2.0, 2.0, PointerButton::Primary));
5344 let cleared = events
5345 .iter()
5346 .find(|e| e.kind == UiEventKind::SelectionChanged)
5347 .expect("clearing emits SelectionChanged");
5348 let new_sel = cleared.selection.as_ref().unwrap();
5349 assert!(new_sel.is_empty(), "new selection should be empty");
5350 assert!(core.ui_state.current_selection.is_empty());
5351 }
5352
5353 #[test]
5354 fn pointer_down_in_dead_space_clears_focus() {
5355 let mut core = lay_out_input_tree(false);
5356 let btn = core.rect_of_key("btn").expect("btn rect");
5357 let cx = btn.x + btn.w * 0.5;
5358 let cy = btn.y + btn.h * 0.5;
5359 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5360 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5361 assert_eq!(
5362 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5363 Some("btn")
5364 );
5365
5366 core.pointer_down(Pointer::mouse(2.0, 2.0, PointerButton::Primary));
5367
5368 assert_eq!(core.ui_state.focused.as_ref().map(|t| t.key.as_str()), None);
5369 }
5370
5371 #[test]
5372 fn key_down_bumps_caret_activity_when_focused_widget_captures_keys() {
5373 let mut core = lay_out_input_tree(true);
5378 let target = core
5379 .ui_state
5380 .focus
5381 .order
5382 .iter()
5383 .find(|t| t.key == "ti")
5384 .cloned();
5385 core.ui_state.set_focus(target); let after_focus = core.ui_state.caret.activity_at.expect("focus bump");
5387
5388 std::thread::sleep(std::time::Duration::from_millis(2));
5389 let _ = core.key_down(UiKey::ArrowRight, KeyModifiers::default(), false);
5390 let after_arrow = core
5391 .ui_state
5392 .caret
5393 .activity_at
5394 .expect("arrow key bumps even without app-side selection");
5395 assert!(
5396 after_arrow > after_focus,
5397 "ArrowRight to a capture_keys focused widget bumps caret activity"
5398 );
5399 }
5400
5401 #[test]
5402 fn text_input_bumps_caret_activity_when_focused() {
5403 let mut core = lay_out_input_tree(true);
5404 let target = core
5405 .ui_state
5406 .focus
5407 .order
5408 .iter()
5409 .find(|t| t.key == "ti")
5410 .cloned();
5411 core.ui_state.set_focus(target);
5412 let after_focus = core.ui_state.caret.activity_at.unwrap();
5413
5414 std::thread::sleep(std::time::Duration::from_millis(2));
5415 let _ = core.text_input("a".into());
5416 let after_text = core.ui_state.caret.activity_at.unwrap();
5417 assert!(
5418 after_text > after_focus,
5419 "TextInput to focused widget bumps caret activity"
5420 );
5421 }
5422
5423 #[test]
5424 fn pointer_down_inside_focused_input_bumps_caret_activity() {
5425 let mut core = lay_out_input_tree(true);
5430 let ti = core.rect_of_key("ti").expect("ti rect");
5431 let cx = ti.x + ti.w * 0.5;
5432 let cy = ti.y + ti.h * 0.5;
5433
5434 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5436 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5437 let after_first = core.ui_state.caret.activity_at.unwrap();
5438
5439 std::thread::sleep(std::time::Duration::from_millis(2));
5442 core.pointer_down(Pointer::mouse(cx + 1.0, cy, PointerButton::Primary));
5443 let after_second = core
5444 .ui_state
5445 .caret
5446 .activity_at
5447 .expect("second click bumps too");
5448 assert!(
5449 after_second > after_first,
5450 "click within already-focused capture_keys widget still bumps"
5451 );
5452 }
5453
5454 #[test]
5455 fn arrow_key_through_apply_event_mutates_selection_and_bumps_on_set() {
5456 use crate::widgets::text_input;
5462 let mut sel = crate::selection::Selection::caret("ti", 2);
5463 let mut value = String::from("hello");
5464
5465 let mut core = RunnerCore::new();
5466 core.set_selection(sel.clone());
5469 let baseline = core.ui_state.caret.activity_at;
5470
5471 let arrow_right = UiEvent {
5473 key: Some("ti".into()),
5474 target: None,
5475 pointer: None,
5476 key_press: Some(crate::event::KeyPress {
5477 key: UiKey::ArrowRight,
5478 modifiers: KeyModifiers::default(),
5479 repeat: false,
5480 }),
5481 text: None,
5482 selection: None,
5483 modifiers: KeyModifiers::default(),
5484 click_count: 0,
5485 path: None,
5486 pointer_kind: None,
5487 wheel_delta: None,
5488 kind: UiEventKind::KeyDown,
5489 };
5490
5491 let mutated = text_input::apply_event(&mut value, &mut sel, &arrow_right, "ti");
5493 assert!(mutated, "ArrowRight should mutate selection");
5494 assert_eq!(
5495 sel.within("ti").unwrap().head,
5496 3,
5497 "head moved one char right (h-e-l-l-o, byte 2 → 3)"
5498 );
5499
5500 std::thread::sleep(std::time::Duration::from_millis(2));
5502 core.set_selection(sel);
5503 let after = core.ui_state.caret.activity_at.unwrap();
5504 if let Some(b) = baseline {
5508 assert!(after > b, "arrow-key flow should bump activity");
5509 }
5510 }
5511
5512 #[test]
5513 fn set_selection_bumps_caret_activity_only_when_value_changes() {
5514 let mut core = lay_out_paragraph_tree();
5515 core.set_selection(crate::selection::Selection::default());
5518 assert!(
5519 core.ui_state.caret.activity_at.is_none(),
5520 "no-op set_selection should not bump activity"
5521 );
5522
5523 let sel_a = crate::selection::Selection::caret("p1", 3);
5525 core.set_selection(sel_a.clone());
5526 let bumped_at = core
5527 .ui_state
5528 .caret
5529 .activity_at
5530 .expect("first real selection bumps");
5531
5532 core.set_selection(sel_a.clone());
5535 assert_eq!(
5536 core.ui_state.caret.activity_at,
5537 Some(bumped_at),
5538 "set_selection with same value is a no-op"
5539 );
5540
5541 std::thread::sleep(std::time::Duration::from_millis(2));
5544 let sel_b = crate::selection::Selection::caret("p1", 7);
5545 core.set_selection(sel_b);
5546 let new_bump = core.ui_state.caret.activity_at.expect("second bump");
5547 assert!(
5548 new_bump > bumped_at,
5549 "moving the caret bumps activity again",
5550 );
5551 }
5552
5553 #[test]
5554 fn escape_clears_active_selection_and_emits_selection_changed() {
5555 let mut core = lay_out_paragraph_tree();
5556 let p1 = core.rect_of_key("p1").expect("p1 rect");
5557 let cy = p1.y + p1.h * 0.5;
5558 core.pointer_down(Pointer::mouse(p1.x + 4.0, cy, PointerButton::Primary));
5560 core.pointer_moved(Pointer::moving(p1.x + p1.w - 10.0, cy));
5561 core.pointer_up(Pointer::mouse(
5562 p1.x + p1.w - 10.0,
5563 cy,
5564 PointerButton::Primary,
5565 ));
5566 assert!(!core.ui_state.current_selection.is_empty());
5567
5568 let events = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
5569 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
5570 assert_eq!(
5571 kinds,
5572 vec![UiEventKind::Escape, UiEventKind::SelectionChanged],
5573 "Esc emits Escape (for popover dismiss) AND SelectionChanged"
5574 );
5575 let cleared = events
5576 .iter()
5577 .find(|e| e.kind == UiEventKind::SelectionChanged)
5578 .unwrap();
5579 assert!(cleared.selection.as_ref().unwrap().is_empty());
5580 assert!(core.ui_state.current_selection.is_empty());
5581 }
5582
5583 #[test]
5584 fn consecutive_clicks_on_same_target_extend_count() {
5585 let mut core = lay_out_input_tree(false);
5586 let btn = core.rect_of_key("btn").expect("btn rect");
5587 let cx = btn.x + btn.w * 0.5;
5588 let cy = btn.y + btn.h * 0.5;
5589
5590 let down1 = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5592 let pd1 = down1
5593 .iter()
5594 .find(|e| e.kind == UiEventKind::PointerDown)
5595 .expect("PointerDown emitted");
5596 assert_eq!(pd1.click_count, 1, "first press starts the sequence");
5597 let up1 = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5598 let click1 = up1
5599 .iter()
5600 .find(|e| e.kind == UiEventKind::Click)
5601 .expect("Click emitted");
5602 assert_eq!(
5603 click1.click_count, 1,
5604 "Click carries the same count as its PointerDown"
5605 );
5606
5607 let down2 = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5609 let pd2 = down2
5610 .iter()
5611 .find(|e| e.kind == UiEventKind::PointerDown)
5612 .unwrap();
5613 assert_eq!(pd2.click_count, 2, "second press extends the sequence");
5614 let up2 = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5615 assert_eq!(
5616 up2.iter()
5617 .find(|e| e.kind == UiEventKind::Click)
5618 .unwrap()
5619 .click_count,
5620 2
5621 );
5622
5623 let down3 = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5625 let pd3 = down3
5626 .iter()
5627 .find(|e| e.kind == UiEventKind::PointerDown)
5628 .unwrap();
5629 assert_eq!(pd3.click_count, 3, "third press → triple-click");
5630 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5631 }
5632
5633 #[test]
5634 fn click_count_resets_when_target_changes() {
5635 let mut core = lay_out_input_tree(false);
5636 let btn = core.rect_of_key("btn").expect("btn rect");
5637 let ti = core.rect_of_key("ti").expect("ti rect");
5638
5639 let down1 = core.pointer_down(Pointer::mouse(
5641 btn.x + btn.w * 0.5,
5642 btn.y + btn.h * 0.5,
5643 PointerButton::Primary,
5644 ));
5645 assert_eq!(
5646 down1
5647 .iter()
5648 .find(|e| e.kind == UiEventKind::PointerDown)
5649 .unwrap()
5650 .click_count,
5651 1
5652 );
5653 let _ = core.pointer_up(Pointer::mouse(
5654 btn.x + btn.w * 0.5,
5655 btn.y + btn.h * 0.5,
5656 PointerButton::Primary,
5657 ));
5658
5659 let down2 = core.pointer_down(Pointer::mouse(
5661 ti.x + ti.w * 0.5,
5662 ti.y + ti.h * 0.5,
5663 PointerButton::Primary,
5664 ));
5665 let pd2 = down2
5666 .iter()
5667 .find(|e| e.kind == UiEventKind::PointerDown)
5668 .unwrap();
5669 assert_eq!(
5670 pd2.click_count, 1,
5671 "press on a new target resets the multi-click sequence"
5672 );
5673 }
5674
5675 #[test]
5676 fn double_click_on_selectable_text_selects_word_at_hit() {
5677 let mut core = lay_out_paragraph_tree();
5678 let p1 = core.rect_of_key("p1").expect("p1 rect");
5679 let cy = p1.y + p1.h * 0.5;
5680 let cx = p1.x + 4.0;
5683 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5684 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5685 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5686 let sel = &core.ui_state.current_selection;
5688 let r = sel.range.as_ref().expect("selection set");
5689 assert_eq!(r.anchor.key, "p1");
5690 assert_eq!(r.head.key, "p1");
5691 assert_eq!(r.anchor.byte.min(r.head.byte), 0);
5693 assert_eq!(r.anchor.byte.max(r.head.byte), 5);
5694 }
5695
5696 #[test]
5697 fn triple_click_on_selectable_text_selects_whole_leaf() {
5698 let mut core = lay_out_paragraph_tree();
5699 let p1 = core.rect_of_key("p1").expect("p1 rect");
5700 let cy = p1.y + p1.h * 0.5;
5701 let cx = p1.x + 4.0;
5702 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5703 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5704 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5705 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5706 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5707 let sel = &core.ui_state.current_selection;
5708 let r = sel.range.as_ref().expect("selection set");
5709 assert_eq!(r.anchor.byte, 0);
5710 assert_eq!(r.head.byte, 24);
5712 }
5713
5714 #[test]
5715 fn click_count_resets_when_press_drifts_outside_distance_window() {
5716 let mut core = lay_out_input_tree(false);
5717 let btn = core.rect_of_key("btn").expect("btn rect");
5718 let cx = btn.x + btn.w * 0.5;
5719 let cy = btn.y + btn.h * 0.5;
5720
5721 let _ = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5722 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5723
5724 let down2 = core.pointer_down(Pointer::mouse(cx + 10.0, cy, PointerButton::Primary));
5727 let pd2 = down2
5728 .iter()
5729 .find(|e| e.kind == UiEventKind::PointerDown)
5730 .unwrap();
5731 assert_eq!(pd2.click_count, 1);
5732 }
5733
5734 #[test]
5735 fn escape_with_no_selection_emits_only_escape() {
5736 let mut core = lay_out_paragraph_tree();
5737 assert!(core.ui_state.current_selection.is_empty());
5738 let events = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
5739 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
5740 assert_eq!(
5741 kinds,
5742 vec![UiEventKind::Escape],
5743 "no selection → no SelectionChanged side-effect"
5744 );
5745 }
5746
5747 fn lay_out_scroll_tree() -> (RunnerCore, String) {
5750 use crate::tree::*;
5751 let mut tree = crate::scroll(
5752 (0..6)
5753 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
5754 )
5755 .gap(12.0)
5756 .height(Size::Fixed(200.0));
5757 let mut core = RunnerCore::new();
5758 crate::layout::layout(
5759 &mut tree,
5760 &mut core.ui_state,
5761 Rect::new(0.0, 0.0, 300.0, 200.0),
5762 );
5763 let scroll_id = tree.computed_id.clone();
5764 let mut t = PrepareTimings::default();
5765 core.snapshot(&tree, &mut t);
5766 (core, scroll_id)
5767 }
5768
5769 #[test]
5770 fn thumb_pointer_down_captures_drag_and_suppresses_events() {
5771 let (mut core, scroll_id) = lay_out_scroll_tree();
5772 let thumb = core
5773 .ui_state
5774 .scroll
5775 .thumb_rects
5776 .get(&scroll_id)
5777 .copied()
5778 .expect("scrollable should have a thumb");
5779 let event = core.pointer_down(Pointer::mouse(
5780 thumb.x + thumb.w * 0.5,
5781 thumb.y + thumb.h * 0.5,
5782 PointerButton::Primary,
5783 ));
5784 assert!(
5785 event.is_empty(),
5786 "thumb press should not emit PointerDown to the app"
5787 );
5788 let drag = core
5789 .ui_state
5790 .scroll
5791 .thumb_drag
5792 .as_ref()
5793 .expect("scroll.thumb_drag should be set after pointer_down on thumb");
5794 assert_eq!(drag.scroll_id, scroll_id);
5795 }
5796
5797 #[test]
5798 fn track_click_above_thumb_pages_up_below_pages_down() {
5799 let (mut core, scroll_id) = lay_out_scroll_tree();
5800 let track = core
5801 .ui_state
5802 .scroll
5803 .thumb_tracks
5804 .get(&scroll_id)
5805 .copied()
5806 .expect("scrollable should have a track");
5807 let thumb = core
5808 .ui_state
5809 .scroll
5810 .thumb_rects
5811 .get(&scroll_id)
5812 .copied()
5813 .unwrap();
5814 let metrics = core
5815 .ui_state
5816 .scroll
5817 .metrics
5818 .get(&scroll_id)
5819 .copied()
5820 .unwrap();
5821
5822 let evt = core.pointer_down(Pointer::mouse(
5824 track.x + track.w * 0.5,
5825 thumb.y + thumb.h + 10.0,
5826 PointerButton::Primary,
5827 ));
5828 assert!(evt.is_empty(), "track press should not surface PointerDown");
5829 assert!(
5830 core.ui_state.scroll.thumb_drag.is_none(),
5831 "track click outside the thumb should not start a drag",
5832 );
5833 let after_down = core.ui_state.scroll_offset(&scroll_id);
5834 let expected_page = (metrics.viewport_h - SCROLL_PAGE_OVERLAP).max(0.0);
5835 assert!(
5836 (after_down - expected_page.min(metrics.max_offset)).abs() < 0.5,
5837 "page-down offset = {after_down} (expected ~{expected_page})",
5838 );
5839 let _ = core.pointer_up(Pointer::mouse(0.0, 0.0, PointerButton::Primary));
5841
5842 let mut tree = lay_out_scroll_tree_only();
5845 crate::layout::layout(
5846 &mut tree,
5847 &mut core.ui_state,
5848 Rect::new(0.0, 0.0, 300.0, 200.0),
5849 );
5850 let mut t = PrepareTimings::default();
5851 core.snapshot(&tree, &mut t);
5852 let track = core
5853 .ui_state
5854 .scroll
5855 .thumb_tracks
5856 .get(&tree.computed_id)
5857 .copied()
5858 .unwrap();
5859 let thumb = core
5860 .ui_state
5861 .scroll
5862 .thumb_rects
5863 .get(&tree.computed_id)
5864 .copied()
5865 .unwrap();
5866
5867 core.pointer_down(Pointer::mouse(
5868 track.x + track.w * 0.5,
5869 thumb.y - 4.0,
5870 PointerButton::Primary,
5871 ));
5872 let after_up = core.ui_state.scroll_offset(&tree.computed_id);
5873 assert!(
5874 after_up < after_down,
5875 "page-up should reduce offset: before={after_down} after={after_up}",
5876 );
5877 }
5878
5879 #[test]
5880 fn scrollbar_press_does_not_bypass_covering_scrim() {
5881 let (mut core, scroll_id) =
5882 lay_out_scroll_tree_with_layer(crate::widgets::overlay::scrim("modal:dismiss"));
5883 let thumb = core
5884 .ui_state
5885 .scroll
5886 .thumb_rects
5887 .get(&scroll_id)
5888 .copied()
5889 .expect("scrollable should have a thumb");
5890
5891 let events = core.pointer_down(Pointer::mouse(
5892 thumb.x + thumb.w * 0.5,
5893 thumb.y + thumb.h * 0.5,
5894 PointerButton::Primary,
5895 ));
5896
5897 assert_eq!(events.len(), 1);
5898 assert_eq!(events[0].kind, UiEventKind::PointerDown);
5899 assert_eq!(events[0].route(), Some("modal:dismiss"));
5900 assert!(
5901 core.ui_state.scroll.thumb_drag.is_none(),
5902 "covered scrollbar thumb must not capture drag",
5903 );
5904 }
5905
5906 #[test]
5907 fn scrollbar_press_does_not_bypass_block_pointer_layer() {
5908 use crate::tree::*;
5909
5910 let (mut core, scroll_id) =
5911 lay_out_scroll_tree_with_layer(El::new(Kind::Group).fill_size().block_pointer());
5912 let thumb = core
5913 .ui_state
5914 .scroll
5915 .thumb_rects
5916 .get(&scroll_id)
5917 .copied()
5918 .expect("scrollable should have a thumb");
5919
5920 let events = core.pointer_down(Pointer::mouse(
5921 thumb.x + thumb.w * 0.5,
5922 thumb.y + thumb.h * 0.5,
5923 PointerButton::Primary,
5924 ));
5925
5926 assert!(
5927 events.is_empty(),
5928 "block_pointer layer should swallow the press without scrolling",
5929 );
5930 assert!(
5931 core.ui_state.scroll.thumb_drag.is_none(),
5932 "covered scrollbar thumb must not capture drag",
5933 );
5934 }
5935
5936 fn lay_out_scroll_tree_only() -> El {
5941 use crate::tree::*;
5942 crate::scroll(
5943 (0..6)
5944 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
5945 )
5946 .gap(12.0)
5947 .height(Size::Fixed(200.0))
5948 }
5949
5950 fn lay_out_scroll_tree_with_layer(layer: El) -> (RunnerCore, String) {
5951 use crate::tree::*;
5952
5953 let scroll = lay_out_scroll_tree_only();
5954 let mut tree = crate::stack([scroll, layer]).fill_size();
5955 let mut core = RunnerCore::new();
5956 crate::layout::layout(
5957 &mut tree,
5958 &mut core.ui_state,
5959 Rect::new(0.0, 0.0, 300.0, 200.0),
5960 );
5961 let scroll_id = tree.children[0].computed_id.clone();
5962 let mut t = PrepareTimings::default();
5963 core.snapshot(&tree, &mut t);
5964 (core, scroll_id)
5965 }
5966
5967 fn lay_out_resizable_tree(layer: Option<El>) -> RunnerCore {
5971 use crate::tree::*;
5972 let row = crate::tree::row([
5973 column(Vec::<El>::new())
5974 .key("nav")
5975 .user_resizable()
5976 .width(Size::Fixed(200.0))
5977 .min_width(120.0)
5978 .max_width(420.0),
5979 column(Vec::<El>::new()).width(Size::Fill(1.0)),
5980 ])
5981 .height(Size::Fill(1.0));
5982 let mut tree = match layer {
5983 Some(layer) => crate::stack([row, layer]).fill_size(),
5984 None => row,
5985 };
5986 let mut core = RunnerCore::new();
5987 crate::layout::layout(
5988 &mut tree,
5989 &mut core.ui_state,
5990 Rect::new(0.0, 0.0, 800.0, 400.0),
5991 );
5992 let mut t = PrepareTimings::default();
5993 core.snapshot(&tree, &mut t);
5994 core
5995 }
5996
5997 #[test]
5998 fn resize_band_drag_updates_user_size_and_emits_resized_on_release() {
5999 let mut core = lay_out_resizable_tree(None);
6000
6001 let events = core.pointer_down(Pointer::mouse(200.0, 150.0, PointerButton::Primary));
6003 assert!(events.is_empty(), "band press must not reach the app");
6004 assert!(core.ui_state.resize.drag.is_some());
6005
6006 let mv = core.pointer_moved(Pointer::mouse(280.0, 150.0, PointerButton::Primary));
6008 assert!(mv.events.is_empty(), "drag is captured, no app events");
6009 assert!(mv.needs_redraw, "size change must redraw");
6010 assert_eq!(core.ui_state.user_size("nav"), Some(280.0));
6011
6012 let _ = core.pointer_moved(Pointer::mouse(700.0, 150.0, PointerButton::Primary));
6014 assert_eq!(core.ui_state.user_size("nav"), Some(420.0));
6015
6016 let up = core.pointer_up(Pointer::mouse(700.0, 150.0, PointerButton::Primary));
6018 assert_eq!(up.len(), 1);
6019 assert_eq!(up[0].kind, UiEventKind::Resized);
6020 assert_eq!(up[0].route(), Some("nav"));
6021 assert!(core.ui_state.resize.drag.is_none());
6022 }
6023
6024 #[test]
6025 fn resize_band_hover_flips_cursor_and_requests_redraw() {
6026 let mut core = lay_out_resizable_tree(None);
6027 let tree = core.last_tree.clone().expect("snapshot stored a tree");
6028
6029 let _ = core.pointer_moved(Pointer::mouse(400.0, 150.0, PointerButton::Primary));
6031 assert_eq!(core.ui_state.cursor(&tree), crate::cursor::Cursor::Default);
6032
6033 let mv = core.pointer_moved(Pointer::mouse(201.0, 150.0, PointerButton::Primary));
6037 assert!(mv.needs_redraw, "band entry must redraw for the cursor");
6038 assert_eq!(core.ui_state.cursor(&tree), crate::cursor::Cursor::EwResize);
6039
6040 let _ = core.pointer_down(Pointer::mouse(201.0, 150.0, PointerButton::Primary));
6043 let _ = core.pointer_moved(Pointer::mouse(390.0, 150.0, PointerButton::Primary));
6044 assert_eq!(core.ui_state.cursor(&tree), crate::cursor::Cursor::EwResize);
6045 }
6046
6047 #[test]
6048 fn resize_band_press_does_not_bypass_covering_scrim() {
6049 let mut core =
6050 lay_out_resizable_tree(Some(crate::widgets::overlay::scrim("modal:dismiss")));
6051
6052 let events = core.pointer_down(Pointer::mouse(200.0, 150.0, PointerButton::Primary));
6053 assert!(
6054 core.ui_state.resize.drag.is_none(),
6055 "covered band must not capture the press",
6056 );
6057 assert_eq!(events.len(), 1);
6058 assert_eq!(events[0].kind, UiEventKind::PointerDown);
6059 assert_eq!(events[0].route(), Some("modal:dismiss"));
6060 }
6061
6062 #[test]
6063 fn thumb_drag_translates_pointer_delta_into_scroll_offset() {
6064 let (mut core, scroll_id) = lay_out_scroll_tree();
6065 let thumb = core
6066 .ui_state
6067 .scroll
6068 .thumb_rects
6069 .get(&scroll_id)
6070 .copied()
6071 .unwrap();
6072 let metrics = core
6073 .ui_state
6074 .scroll
6075 .metrics
6076 .get(&scroll_id)
6077 .copied()
6078 .unwrap();
6079 let track_remaining = (metrics.viewport_h - thumb.h).max(0.0);
6080
6081 let press_y = thumb.y + thumb.h * 0.5;
6082 core.pointer_down(Pointer::mouse(
6083 thumb.x + thumb.w * 0.5,
6084 press_y,
6085 PointerButton::Primary,
6086 ));
6087 let evt = core.pointer_moved(Pointer::moving(thumb.x + thumb.w * 0.5, press_y + 20.0));
6089 assert!(
6090 evt.events.is_empty(),
6091 "thumb-drag move should suppress Drag event",
6092 );
6093 let offset = core.ui_state.scroll_offset(&scroll_id);
6094 let expected = 20.0 * (metrics.max_offset / track_remaining);
6095 assert!(
6096 (offset - expected).abs() < 0.5,
6097 "offset {offset} (expected {expected})",
6098 );
6099 core.pointer_moved(Pointer::moving(thumb.x + thumb.w * 0.5, press_y + 9999.0));
6101 let offset = core.ui_state.scroll_offset(&scroll_id);
6102 assert!(
6103 (offset - metrics.max_offset).abs() < 0.5,
6104 "overshoot offset {offset} (expected {})",
6105 metrics.max_offset
6106 );
6107 let events = core.pointer_up(Pointer::mouse(thumb.x, press_y, PointerButton::Primary));
6109 assert!(events.is_empty(), "thumb release shouldn't emit events");
6110 assert!(core.ui_state.scroll.thumb_drag.is_none());
6111 }
6112
6113 #[test]
6114 fn secondary_click_does_not_steal_focus_or_press() {
6115 let mut core = lay_out_input_tree(false);
6116 let btn_rect = core.rect_of_key("btn").expect("btn rect");
6117 let cx = btn_rect.x + btn_rect.w * 0.5;
6118 let cy = btn_rect.y + btn_rect.h * 0.5;
6119 let ti_rect = core.rect_of_key("ti").expect("ti rect");
6121 let tx = ti_rect.x + ti_rect.w * 0.5;
6122 let ty = ti_rect.y + ti_rect.h * 0.5;
6123 core.pointer_down(Pointer::mouse(tx, ty, PointerButton::Primary));
6124 let _ = core.pointer_up(Pointer::mouse(tx, ty, PointerButton::Primary));
6125 let focused_before = core.ui_state.focused.as_ref().map(|t| t.key.clone());
6126 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Secondary));
6128 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Secondary));
6129 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
6130 assert_eq!(kinds, vec![UiEventKind::SecondaryClick]);
6131 let focused_after = core.ui_state.focused.as_ref().map(|t| t.key.clone());
6132 assert_eq!(
6133 focused_before, focused_after,
6134 "right-click must not steal focus"
6135 );
6136 assert!(
6137 core.ui_state.pressed.is_none(),
6138 "right-click must not set primary press"
6139 );
6140 }
6141
6142 #[test]
6143 fn text_input_routes_to_focused_only() {
6144 let mut core = lay_out_input_tree(false);
6145 assert!(core.text_input("a".into()).is_none());
6147 let btn_rect = core.rect_of_key("btn").expect("btn rect");
6149 let cx = btn_rect.x + btn_rect.w * 0.5;
6150 let cy = btn_rect.y + btn_rect.h * 0.5;
6151 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6152 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
6153 let event = core.text_input("hi".into()).expect("focused → event");
6154 assert_eq!(event.kind, UiEventKind::TextInput);
6155 assert_eq!(event.text.as_deref(), Some("hi"));
6156 assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("btn"));
6157 assert!(core.text_input(String::new()).is_none());
6159 }
6160
6161 #[test]
6162 fn capture_keys_bypasses_tab_traversal_for_focused_node() {
6163 let mut core = lay_out_input_tree(true);
6166 let ti_rect = core.rect_of_key("ti").expect("ti rect");
6167 let tx = ti_rect.x + ti_rect.w * 0.5;
6168 let ty = ti_rect.y + ti_rect.h * 0.5;
6169 core.pointer_down(Pointer::mouse(tx, ty, PointerButton::Primary));
6170 let _ = core.pointer_up(Pointer::mouse(tx, ty, PointerButton::Primary));
6171 assert_eq!(
6172 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6173 Some("ti"),
6174 "primary click on capture_keys node still focuses it"
6175 );
6176
6177 let events = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
6178 assert_eq!(events.len(), 1, "Tab → exactly one KeyDown");
6179 let event = &events[0];
6180 assert_eq!(event.kind, UiEventKind::KeyDown);
6181 assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("ti"));
6182 assert_eq!(
6183 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6184 Some("ti"),
6185 "Tab inside capture_keys must NOT move focus"
6186 );
6187 }
6188
6189 #[test]
6190 fn escape_blurs_capture_keys_after_delivering_raw_keydown() {
6191 let mut core = lay_out_input_tree(true);
6192 let ti_rect = core.rect_of_key("ti").expect("ti rect");
6193 let tx = ti_rect.x + ti_rect.w * 0.5;
6194 let ty = ti_rect.y + ti_rect.h * 0.5;
6195 core.pointer_down(Pointer::mouse(tx, ty, PointerButton::Primary));
6196 let _ = core.pointer_up(Pointer::mouse(tx, ty, PointerButton::Primary));
6197 assert_eq!(
6198 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6199 Some("ti")
6200 );
6201
6202 let events = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
6203
6204 assert_eq!(events.len(), 1);
6205 let event = &events[0];
6206 assert_eq!(event.kind, UiEventKind::KeyDown);
6207 assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("ti"));
6208 assert!(matches!(
6209 event.key_press.as_ref().map(|p| &p.key),
6210 Some(UiKey::Escape)
6211 ));
6212 assert_eq!(core.ui_state.focused.as_ref().map(|t| t.key.as_str()), None);
6213 }
6214
6215 #[test]
6216 fn pointer_down_focus_does_not_raise_focus_visible() {
6217 let mut core = lay_out_input_tree(false);
6220 let btn_rect = core.rect_of_key("btn").expect("btn rect");
6221 let cx = btn_rect.x + btn_rect.w * 0.5;
6222 let cy = btn_rect.y + btn_rect.h * 0.5;
6223 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6224 assert_eq!(
6225 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6226 Some("btn"),
6227 "primary click focuses the button",
6228 );
6229 assert!(
6230 !core.ui_state.focus_visible,
6231 "click focus must not raise focus_visible — ring stays off",
6232 );
6233 }
6234
6235 #[test]
6236 fn tab_key_raises_focus_visible_so_ring_appears() {
6237 let mut core = lay_out_input_tree(false);
6238 let btn_rect = core.rect_of_key("btn").expect("btn rect");
6240 let cx = btn_rect.x + btn_rect.w * 0.5;
6241 let cy = btn_rect.y + btn_rect.h * 0.5;
6242 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6243 assert!(!core.ui_state.focus_visible);
6244 let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
6246 assert!(
6247 core.ui_state.focus_visible,
6248 "Tab must raise focus_visible so the ring paints on the new target",
6249 );
6250 }
6251
6252 #[test]
6253 fn click_after_tab_clears_focus_visible_again() {
6254 let mut core = lay_out_input_tree(false);
6257 let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
6258 assert!(core.ui_state.focus_visible, "Tab raises ring");
6259 let btn_rect = core.rect_of_key("btn").expect("btn rect");
6260 let cx = btn_rect.x + btn_rect.w * 0.5;
6261 let cy = btn_rect.y + btn_rect.h * 0.5;
6262 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6263 assert!(
6264 !core.ui_state.focus_visible,
6265 "pointer-down clears focus_visible — ring fades back out",
6266 );
6267 }
6268
6269 #[test]
6270 fn keypress_on_focused_widget_raises_focus_visible_after_click() {
6271 let mut core = lay_out_input_tree(false);
6275 let btn_rect = core.rect_of_key("btn").expect("btn rect");
6276 let cx = btn_rect.x + btn_rect.w * 0.5;
6277 let cy = btn_rect.y + btn_rect.h * 0.5;
6278 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6279 assert!(!core.ui_state.focus_visible);
6280 let _ = core.key_down(UiKey::ArrowRight, KeyModifiers::default(), false);
6281 assert!(
6282 core.ui_state.focus_visible,
6283 "non-Tab key on focused widget raises focus_visible",
6284 );
6285 }
6286
6287 #[test]
6288 fn selected_text_resolves_a_selection_inside_a_virtual_list() {
6289 use crate::selection::{Selection, SelectionPoint, SelectionRange};
6296 use crate::tree::*;
6297
6298 let mut tree = virtual_list_dyn(
6302 20,
6303 50.0,
6304 |i| format!("row-{i}"),
6305 |i| {
6306 crate::widgets::text::text(format!("row {i} text"))
6307 .key(format!("row-{i}"))
6308 .selectable()
6309 .height(Size::Fixed(50.0))
6310 },
6311 );
6312 let mut core = RunnerCore::new();
6313 crate::layout::layout(
6314 &mut tree,
6315 &mut core.ui_state,
6316 Rect::new(0.0, 0.0, 200.0, 200.0),
6317 );
6318 let mut t = PrepareTimings::default();
6319 core.snapshot(&tree, &mut t);
6320
6321 let selection = Selection {
6323 range: Some(SelectionRange {
6324 anchor: SelectionPoint::new("row-1", 0),
6325 head: SelectionPoint::new("row-1", 9),
6326 }),
6327 };
6328 core.set_selection(selection);
6329
6330 assert_eq!(
6331 core.selected_text().as_deref(),
6332 Some("row 1 tex"),
6333 "runtime.selected_text() must walk last_tree (realized rows) — \
6334 a build-only path would miss virtual_list children entirely",
6335 );
6336 }
6337
6338 #[test]
6339 fn shortcut_chord_does_not_raise_focus_visible() {
6340 let mut core = lay_out_input_tree(false);
6347 let btn_rect = core.rect_of_key("btn").expect("btn rect");
6348 let cx = btn_rect.x + btn_rect.w * 0.5;
6349 let cy = btn_rect.y + btn_rect.h * 0.5;
6350 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6351 assert!(!core.ui_state.focus_visible);
6352
6353 let ctrl = KeyModifiers {
6354 ctrl: true,
6355 ..Default::default()
6356 };
6357 let _ = core.key_down(UiKey::Other("Control".into()), ctrl, false);
6358 assert!(
6359 !core.ui_state.focus_visible,
6360 "bare Ctrl press must not raise focus_visible on a pointer-focused widget",
6361 );
6362 let _ = core.key_down(UiKey::Character("c".into()), ctrl, false);
6363 assert!(
6364 !core.ui_state.focus_visible,
6365 "Ctrl+C is a shortcut, not interaction with the focused widget",
6366 );
6367
6368 let _ = core.key_down(UiKey::Other("Shift".into()), KeyModifiers::default(), false);
6369 assert!(
6370 !core.ui_state.focus_visible,
6371 "bare Shift press must not raise focus_visible",
6372 );
6373 let _ = core.key_down(UiKey::Character("a".into()), KeyModifiers::default(), false);
6374 assert!(
6375 !core.ui_state.focus_visible,
6376 "bare character keys are typing/activation guesses, not navigation",
6377 );
6378 let _ = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
6379 assert!(
6380 !core.ui_state.focus_visible,
6381 "Escape is dismissal, not navigation — no ring",
6382 );
6383 }
6384
6385 #[test]
6386 fn arrow_nav_in_sibling_group_raises_focus_visible() {
6387 let mut core = lay_out_arrow_nav_tree();
6388 core.ui_state.set_focus_visible(false);
6391 let _ = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
6392 assert!(
6393 core.ui_state.focus_visible,
6394 "arrow-nav within an arrow_nav_siblings group is keyboard navigation",
6395 );
6396 }
6397
6398 #[test]
6399 fn capture_keys_falls_back_to_default_when_focus_off_capturing_node() {
6400 let mut core = lay_out_input_tree(true);
6404 let btn_rect = core.rect_of_key("btn").expect("btn rect");
6405 let cx = btn_rect.x + btn_rect.w * 0.5;
6406 let cy = btn_rect.y + btn_rect.h * 0.5;
6407 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6408 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
6409 assert_eq!(
6410 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6411 Some("btn"),
6412 "primary click focuses button"
6413 );
6414 let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
6416 assert_eq!(
6417 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6418 Some("ti"),
6419 "Tab from non-capturing focused does library-default traversal"
6420 );
6421 }
6422
6423 fn lay_out_arrow_nav_tree() -> RunnerCore {
6428 use crate::tree::*;
6429 let mut tree = crate::column([
6430 crate::widgets::button::button("Red").key("opt-red"),
6431 crate::widgets::button::button("Green").key("opt-green"),
6432 crate::widgets::button::button("Blue").key("opt-blue"),
6433 ])
6434 .arrow_nav_siblings()
6435 .padding(10.0);
6436 let mut core = RunnerCore::new();
6437 crate::layout::layout(
6438 &mut tree,
6439 &mut core.ui_state,
6440 Rect::new(0.0, 0.0, 200.0, 300.0),
6441 );
6442 core.ui_state.sync_focus_order(&tree);
6443 let mut t = PrepareTimings::default();
6444 core.snapshot(&tree, &mut t);
6445 let target = core
6448 .ui_state
6449 .focus
6450 .order
6451 .iter()
6452 .find(|t| t.key == "opt-green")
6453 .cloned();
6454 core.ui_state.set_focus(target);
6455 core
6456 }
6457
6458 #[test]
6459 fn arrow_nav_moves_focus_among_siblings() {
6460 let mut core = lay_out_arrow_nav_tree();
6461
6462 let down = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
6465 assert!(down.is_empty(), "arrow-nav consumes the key event");
6466 assert_eq!(
6467 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6468 Some("opt-blue"),
6469 );
6470
6471 core.key_down(UiKey::ArrowUp, KeyModifiers::default(), false);
6473 assert_eq!(
6474 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6475 Some("opt-green"),
6476 );
6477
6478 core.key_down(UiKey::Home, KeyModifiers::default(), false);
6480 assert_eq!(
6481 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6482 Some("opt-red"),
6483 );
6484
6485 core.key_down(UiKey::End, KeyModifiers::default(), false);
6487 assert_eq!(
6488 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6489 Some("opt-blue"),
6490 );
6491 }
6492
6493 #[test]
6494 fn arrow_nav_saturates_at_ends() {
6495 let mut core = lay_out_arrow_nav_tree();
6496 core.key_down(UiKey::Home, KeyModifiers::default(), false);
6498 core.key_down(UiKey::ArrowUp, KeyModifiers::default(), false);
6499 assert_eq!(
6500 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6501 Some("opt-red"),
6502 "ArrowUp at top stays at top — no wrap",
6503 );
6504 core.key_down(UiKey::End, KeyModifiers::default(), false);
6506 core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
6507 assert_eq!(
6508 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6509 Some("opt-blue"),
6510 "ArrowDown at bottom stays at bottom — no wrap",
6511 );
6512 }
6513
6514 #[test]
6515 fn arrow_nav_vertical_lets_horizontal_arrows_fall_through() {
6516 let mut core = lay_out_arrow_nav_tree();
6520 let out = core.key_down(UiKey::ArrowRight, KeyModifiers::default(), false);
6521 assert_eq!(
6522 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6523 Some("opt-green"),
6524 "ArrowRight must not move focus in a Vertical group",
6525 );
6526 assert!(
6527 !out.is_empty(),
6528 "ArrowRight falls through to normal KeyDown routing",
6529 );
6530 }
6531
6532 fn lay_out_horizontal_group() -> RunnerCore {
6535 let mut tree = crate::widgets::toggle::toggle_group(
6536 "view",
6537 &"list",
6538 [("list", "List"), ("grid", "Grid"), ("map", "Map")],
6539 )
6540 .padding(10.0);
6541 let mut core = RunnerCore::new();
6542 crate::layout::layout(
6543 &mut tree,
6544 &mut core.ui_state,
6545 Rect::new(0.0, 0.0, 400.0, 100.0),
6546 );
6547 core.ui_state.sync_focus_order(&tree);
6548 let mut t = PrepareTimings::default();
6549 core.snapshot(&tree, &mut t);
6550 let target = core
6551 .ui_state
6552 .focus
6553 .order
6554 .iter()
6555 .find(|t| t.key == "view:toggle:list")
6556 .cloned();
6557 core.ui_state.set_focus(target);
6558 core
6559 }
6560
6561 #[test]
6562 fn arrow_nav_horizontal_group_steps_on_left_right() {
6563 let mut core = lay_out_horizontal_group();
6566
6567 let right = core.key_down(UiKey::ArrowRight, KeyModifiers::default(), false);
6568 assert!(right.is_empty(), "ArrowRight is consumed by the group");
6569 assert_eq!(
6570 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6571 Some("view:toggle:grid"),
6572 );
6573
6574 core.key_down(UiKey::ArrowLeft, KeyModifiers::default(), false);
6575 assert_eq!(
6576 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6577 Some("view:toggle:list"),
6578 );
6579
6580 core.key_down(UiKey::End, KeyModifiers::default(), false);
6581 assert_eq!(
6582 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6583 Some("view:toggle:map"),
6584 );
6585
6586 let down = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
6587 assert_eq!(
6588 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6589 Some("view:toggle:map"),
6590 "ArrowDown must not move focus in a Horizontal group",
6591 );
6592 assert!(
6593 !down.is_empty(),
6594 "ArrowDown falls through to normal KeyDown routing",
6595 );
6596 }
6597
6598 #[test]
6599 fn arrow_nav_radio_group_steps_on_both_axes() {
6600 let mut tree = crate::widgets::radio::radio_group(
6603 "theme",
6604 &"light",
6605 [("light", "Light"), ("dark", "Dark"), ("auto", "Auto")],
6606 )
6607 .padding(10.0);
6608 let mut core = RunnerCore::new();
6609 crate::layout::layout(
6610 &mut tree,
6611 &mut core.ui_state,
6612 Rect::new(0.0, 0.0, 300.0, 300.0),
6613 );
6614 core.ui_state.sync_focus_order(&tree);
6615 let mut t = PrepareTimings::default();
6616 core.snapshot(&tree, &mut t);
6617 let target = core
6618 .ui_state
6619 .focus
6620 .order
6621 .iter()
6622 .find(|t| t.key == "theme:radio:light")
6623 .cloned();
6624 core.ui_state.set_focus(target);
6625
6626 core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
6627 assert_eq!(
6628 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6629 Some("theme:radio:dark"),
6630 "ArrowDown steps to the next radio",
6631 );
6632 core.key_down(UiKey::ArrowRight, KeyModifiers::default(), false);
6633 assert_eq!(
6634 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6635 Some("theme:radio:auto"),
6636 "ArrowRight also steps in a Both group",
6637 );
6638 core.key_down(UiKey::ArrowLeft, KeyModifiers::default(), false);
6639 assert_eq!(
6640 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6641 Some("theme:radio:dark"),
6642 "ArrowLeft steps back in a Both group",
6643 );
6644 }
6645
6646 fn lay_out_calendar(focus_day: u32, disabled_day: Option<u32>) -> RunnerCore {
6649 use crate::widgets::calendar::{CalendarDay, calendar_month};
6650 let days = (1..=14).map(|d| {
6651 let day = CalendarDay::new(format!("2026-06-{d:02}"), d.to_string());
6652 if Some(d) == disabled_day {
6653 day.disabled()
6654 } else {
6655 day
6656 }
6657 });
6658 let mut tree = calendar_month("cal", "June 2026", days);
6659 let mut core = RunnerCore::new();
6660 crate::layout::layout(
6661 &mut tree,
6662 &mut core.ui_state,
6663 Rect::new(0.0, 0.0, 600.0, 400.0),
6664 );
6665 core.ui_state.sync_focus_order(&tree);
6666 let mut t = PrepareTimings::default();
6667 core.snapshot(&tree, &mut t);
6668 let key = format!("cal:day:2026-06-{focus_day:02}");
6669 let target = core
6670 .ui_state
6671 .focus
6672 .order
6673 .iter()
6674 .find(|t| t.key == key)
6675 .cloned();
6676 assert!(target.is_some(), "day {focus_day} should be focusable");
6677 core.ui_state.set_focus(target);
6678 core
6679 }
6680
6681 #[test]
6682 fn arrow_nav_calendar_grid_moves_two_dimensionally() {
6683 let mut core = lay_out_calendar(3, None);
6686
6687 let down = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
6688 assert!(down.is_empty(), "ArrowDown is consumed by the grid");
6689 assert_eq!(
6690 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6691 Some("cal:day:2026-06-10"),
6692 "ArrowDown lands on the same weekday one week later",
6693 );
6694
6695 core.key_down(UiKey::ArrowRight, KeyModifiers::default(), false);
6696 assert_eq!(
6697 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6698 Some("cal:day:2026-06-11"),
6699 );
6700
6701 core.key_down(UiKey::ArrowUp, KeyModifiers::default(), false);
6702 assert_eq!(
6703 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6704 Some("cal:day:2026-06-04"),
6705 "ArrowUp lands on the same weekday one week earlier",
6706 );
6707
6708 core.key_down(UiKey::ArrowUp, KeyModifiers::default(), false);
6710 assert_eq!(
6711 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6712 Some("cal:day:2026-06-04"),
6713 "ArrowUp at the top edge saturates",
6714 );
6715 }
6716
6717 #[test]
6718 fn arrow_nav_calendar_grid_skips_disabled_days() {
6719 let mut core = lay_out_calendar(3, Some(10));
6723 core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
6724 let focused = core
6725 .ui_state
6726 .focused
6727 .as_ref()
6728 .map(|t| t.key.clone())
6729 .expect("focus survives the move");
6730 assert!(
6731 focused == "cal:day:2026-06-09" || focused == "cal:day:2026-06-11",
6732 "ArrowDown over a disabled day lands on a neighbor in the \
6733 same week, got {focused}",
6734 );
6735 }
6736
6737 fn build_popover_tree(open: bool) -> El {
6741 use crate::widgets::button::button;
6742 use crate::widgets::overlay::overlay;
6743 use crate::widgets::popover::{dropdown, menu_item};
6744 let mut layers: Vec<El> = vec![button("Trigger").key("trigger")];
6745 if open {
6746 layers.push(dropdown(
6747 "menu",
6748 "trigger",
6749 [
6750 menu_item("A").key("item-a"),
6751 menu_item("B").key("item-b"),
6752 menu_item("C").key("item-c"),
6753 ],
6754 ));
6755 }
6756 overlay(layers).padding(20.0)
6757 }
6758
6759 fn run_frame(core: &mut RunnerCore, tree: &mut El) {
6763 let mut t = PrepareTimings::default();
6764 core.prepare_layout(
6765 tree,
6766 Rect::new(0.0, 0.0, 400.0, 300.0),
6767 1.0,
6768 &mut t,
6769 RunnerCore::no_time_shaders,
6770 );
6771 core.snapshot(tree, &mut t);
6772 }
6773
6774 #[test]
6775 fn popover_open_pushes_focus_and_auto_focuses_first_item() {
6776 let mut core = RunnerCore::new();
6777 let mut closed = build_popover_tree(false);
6778 run_frame(&mut core, &mut closed);
6779 let trigger = core
6782 .ui_state
6783 .focus
6784 .order
6785 .iter()
6786 .find(|t| t.key == "trigger")
6787 .cloned();
6788 core.ui_state.set_focus(trigger);
6789 assert_eq!(
6790 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6791 Some("trigger"),
6792 );
6793
6794 let mut open = build_popover_tree(true);
6797 run_frame(&mut core, &mut open);
6798 assert_eq!(
6799 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6800 Some("item-a"),
6801 "popover open should auto-focus the first menu item",
6802 );
6803 assert_eq!(
6804 core.ui_state.popover_focus.focus_stack.len(),
6805 1,
6806 "trigger should be saved on the focus stack",
6807 );
6808 assert_eq!(
6809 core.ui_state.popover_focus.focus_stack[0].key.as_str(),
6810 "trigger",
6811 "saved focus should be the pre-open target",
6812 );
6813 }
6814
6815 #[test]
6816 fn popover_close_restores_focus_to_trigger() {
6817 let mut core = RunnerCore::new();
6818 let mut closed = build_popover_tree(false);
6819 run_frame(&mut core, &mut closed);
6820 let trigger = core
6821 .ui_state
6822 .focus
6823 .order
6824 .iter()
6825 .find(|t| t.key == "trigger")
6826 .cloned();
6827 core.ui_state.set_focus(trigger);
6828
6829 let mut open = build_popover_tree(true);
6831 run_frame(&mut core, &mut open);
6832 assert_eq!(
6833 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6834 Some("item-a"),
6835 );
6836
6837 let mut closed_again = build_popover_tree(false);
6839 run_frame(&mut core, &mut closed_again);
6840 assert_eq!(
6841 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6842 Some("trigger"),
6843 "closing the popover should pop the saved focus",
6844 );
6845 assert!(
6846 core.ui_state.popover_focus.focus_stack.is_empty(),
6847 "focus stack should be drained after restore",
6848 );
6849 }
6850
6851 #[test]
6852 fn popover_close_does_not_override_intentional_focus_move() {
6853 let mut core = RunnerCore::new();
6854 let build = |open: bool| -> El {
6857 use crate::widgets::button::button;
6858 use crate::widgets::overlay::overlay;
6859 use crate::widgets::popover::{dropdown, menu_item};
6860 let main = crate::row([
6861 button("Trigger").key("trigger"),
6862 button("Other").key("other"),
6863 ]);
6864 let mut layers: Vec<El> = vec![main];
6865 if open {
6866 layers.push(dropdown("menu", "trigger", [menu_item("A").key("item-a")]));
6867 }
6868 overlay(layers).padding(20.0)
6869 };
6870
6871 let mut closed = build(false);
6872 run_frame(&mut core, &mut closed);
6873 let trigger = core
6874 .ui_state
6875 .focus
6876 .order
6877 .iter()
6878 .find(|t| t.key == "trigger")
6879 .cloned();
6880 core.ui_state.set_focus(trigger);
6881
6882 let mut open = build(true);
6883 run_frame(&mut core, &mut open);
6884 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
6885
6886 let other = core
6891 .ui_state
6892 .focus
6893 .order
6894 .iter()
6895 .find(|t| t.key == "other")
6896 .cloned();
6897 core.ui_state.set_focus(other);
6898
6899 let mut closed_again = build(false);
6900 run_frame(&mut core, &mut closed_again);
6901 assert_eq!(
6902 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6903 Some("other"),
6904 "focus moved before close should not be overridden by restore",
6905 );
6906 assert!(core.ui_state.popover_focus.focus_stack.is_empty());
6907 }
6908
6909 #[test]
6910 fn nested_popovers_stack_and_unwind_focus_correctly() {
6911 let mut core = RunnerCore::new();
6912 let build = |outer: bool, inner: bool| -> El {
6917 use crate::widgets::button::button;
6918 use crate::widgets::overlay::overlay;
6919 use crate::widgets::popover::{Anchor, popover, popover_panel};
6920 let main = button("Trigger").key("trigger");
6921 let mut layers: Vec<El> = vec![main];
6922 if outer {
6923 layers.push(popover(
6924 "outer",
6925 Anchor::below_key("trigger"),
6926 popover_panel([button("Open inner").key("inner-trigger")]),
6927 ));
6928 }
6929 if inner {
6930 layers.push(popover(
6931 "inner",
6932 Anchor::below_key("inner-trigger"),
6933 popover_panel([button("X").key("inner-a"), button("Y").key("inner-b")]),
6934 ));
6935 }
6936 overlay(layers).padding(20.0)
6937 };
6938
6939 let mut closed = build(false, false);
6941 run_frame(&mut core, &mut closed);
6942 let trigger = core
6943 .ui_state
6944 .focus
6945 .order
6946 .iter()
6947 .find(|t| t.key == "trigger")
6948 .cloned();
6949 core.ui_state.set_focus(trigger);
6950
6951 let mut outer = build(true, false);
6953 run_frame(&mut core, &mut outer);
6954 assert_eq!(
6955 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6956 Some("inner-trigger"),
6957 );
6958 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
6959
6960 let mut both = build(true, true);
6962 run_frame(&mut core, &mut both);
6963 assert_eq!(
6964 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6965 Some("inner-a"),
6966 );
6967 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 2);
6968
6969 let mut outer_only = build(true, false);
6971 run_frame(&mut core, &mut outer_only);
6972 assert_eq!(
6973 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6974 Some("inner-trigger"),
6975 );
6976 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
6977
6978 let mut none = build(false, false);
6980 run_frame(&mut core, &mut none);
6981 assert_eq!(
6982 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6983 Some("trigger"),
6984 );
6985 assert!(core.ui_state.popover_focus.focus_stack.is_empty());
6986 }
6987
6988 #[test]
6989 fn arrow_nav_does_not_intercept_outside_navigable_groups() {
6990 let mut core = lay_out_input_tree(false);
6994 let target = core
6995 .ui_state
6996 .focus
6997 .order
6998 .iter()
6999 .find(|t| t.key == "btn")
7000 .cloned();
7001 core.ui_state.set_focus(target);
7002 let events = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
7003 assert_eq!(
7004 events.len(),
7005 1,
7006 "ArrowDown without navigable parent → event"
7007 );
7008 assert_eq!(events[0].kind, UiEventKind::KeyDown);
7009 }
7010
7011 fn quad(shader: ShaderHandle) -> DrawOp {
7012 DrawOp::Quad {
7013 id: "q".into(),
7014 rect: Rect::new(0.0, 0.0, 10.0, 10.0),
7015 scissor: None,
7016 shader,
7017 uniforms: UniformBlock::new(),
7018 }
7019 }
7020
7021 #[test]
7022 fn prepare_paint_skips_ops_outside_viewport() {
7023 let mut core = RunnerCore::new();
7024 core.set_surface_size(100, 100);
7025 core.viewport_px = (100, 100);
7026 let ops = vec![
7027 DrawOp::Quad {
7028 id: "offscreen".into(),
7029 rect: Rect::new(0.0, 150.0, 10.0, 10.0),
7030 scissor: None,
7031 shader: ShaderHandle::Stock(StockShader::RoundedRect),
7032 uniforms: UniformBlock::new(),
7033 },
7034 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
7035 ];
7036 let mut timings = PrepareTimings::default();
7037 core.prepare_paint(&ops, |_| true, |_| false, &mut NoText, 1.0, &mut timings);
7038
7039 assert_eq!(timings.paint_culled_ops, 1);
7040 assert_eq!(
7041 core.runs.len(),
7042 1,
7043 "only the visible quad should become a paint run"
7044 );
7045 }
7046
7047 #[test]
7048 fn prepare_paint_does_not_shape_text_outside_clip() {
7049 let mut core = RunnerCore::new();
7050 core.set_surface_size(100, 100);
7051 core.viewport_px = (100, 100);
7052 let ops = vec![
7053 DrawOp::GlyphRun {
7054 id: "offscreen-text".into(),
7055 rect: Rect::new(0.0, 150.0, 80.0, 20.0),
7056 scissor: Some(Rect::new(0.0, 0.0, 100.0, 100.0)),
7057 shader: ShaderHandle::Stock(StockShader::Text),
7058 color: Color::srgb_u8a(255, 255, 255, 255),
7059 text: "offscreen".into(),
7060 size: 14.0,
7061 line_height: 20.0,
7062 family: Default::default(),
7063 mono_family: Default::default(),
7064 weight: FontWeight::Regular,
7065 mono: false,
7066 wrap: TextWrap::NoWrap,
7067 anchor: TextAnchor::Start,
7068 layout: empty_text_layout(20.0),
7069 underline: false,
7070 strikethrough: false,
7071 link: None,
7072 tabular_numerals: false,
7073 },
7074 DrawOp::GlyphRun {
7075 id: "visible-text".into(),
7076 rect: Rect::new(0.0, 10.0, 80.0, 20.0),
7077 scissor: Some(Rect::new(0.0, 0.0, 100.0, 100.0)),
7078 shader: ShaderHandle::Stock(StockShader::Text),
7079 color: Color::srgb_u8a(255, 255, 255, 255),
7080 text: "visible".into(),
7081 size: 14.0,
7082 line_height: 20.0,
7083 family: Default::default(),
7084 mono_family: Default::default(),
7085 weight: FontWeight::Regular,
7086 mono: false,
7087 wrap: TextWrap::NoWrap,
7088 anchor: TextAnchor::Start,
7089 layout: empty_text_layout(20.0),
7090 underline: false,
7091 strikethrough: false,
7092 link: None,
7093 tabular_numerals: false,
7094 },
7095 ];
7096 let mut text = CountingText::default();
7097 let mut timings = PrepareTimings::default();
7098 core.prepare_paint(&ops, |_| true, |_| false, &mut text, 1.0, &mut timings);
7099
7100 assert_eq!(timings.paint_culled_ops, 1);
7101 assert_eq!(text.records, 1, "offscreen text must not be shaped");
7102 }
7103
7104 #[test]
7105 fn samples_backdrop_inserts_snapshot_before_first_glass_quad() {
7106 let mut core = RunnerCore::new();
7107 core.set_surface_size(100, 100);
7108 let ops = vec![
7109 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
7110 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
7111 quad(ShaderHandle::Custom("liquid_glass")),
7112 quad(ShaderHandle::Custom("liquid_glass")),
7113 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
7114 ];
7115 let mut timings = PrepareTimings::default();
7116 core.prepare_paint(
7117 &ops,
7118 |_| true,
7119 |s| matches!(s, ShaderHandle::Custom(name) if *name == "liquid_glass"),
7120 &mut NoText,
7121 1.0,
7122 &mut timings,
7123 );
7124
7125 let kinds: Vec<&'static str> = core
7126 .paint_items
7127 .iter()
7128 .map(|p| match p {
7129 PaintItem::QuadRun(_) => "Q",
7130 PaintItem::IconRun(_) => "I",
7131 PaintItem::Text(_) => "T",
7132 PaintItem::Image(_) => "M",
7133 PaintItem::AppTexture(_) => "A",
7134 PaintItem::Vector(_) => "V",
7135 PaintItem::Scene3D(_) => "3",
7136 PaintItem::BackdropSnapshot => "S",
7137 })
7138 .collect();
7139 assert_eq!(
7140 kinds,
7141 vec!["Q", "S", "Q", "Q"],
7142 "expected one stock run, snapshot, then a glass run, then a foreground stock run"
7143 );
7144 }
7145
7146 #[test]
7147 fn no_snapshot_when_no_glass_drawn() {
7148 let mut core = RunnerCore::new();
7149 core.set_surface_size(100, 100);
7150 let ops = vec![
7151 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
7152 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
7153 ];
7154 let mut timings = PrepareTimings::default();
7155 core.prepare_paint(&ops, |_| true, |_| false, &mut NoText, 1.0, &mut timings);
7156 assert!(
7157 !core
7158 .paint_items
7159 .iter()
7160 .any(|p| matches!(p, PaintItem::BackdropSnapshot)),
7161 "no glass shader registered → no snapshot"
7162 );
7163 }
7164
7165 #[test]
7170 fn scene3d_op_emits_paint_item_only_when_recorder_records() {
7171 use crate::scene::{Aabb, CameraState, LightRig, Scene3DData, SceneStyle};
7172
7173 fn scene_op() -> DrawOp {
7174 let scene = Scene3DData {
7175 meshes: Vec::new(),
7176 points: Vec::new(),
7177 lines: Vec::new(),
7178 camera: CameraState::default().resolve(Aabb::EMPTY),
7179 lights: LightRig::default(),
7180 style: SceneStyle::default(),
7181 capture_depth: false,
7182 };
7183 DrawOp::Scene3D {
7184 id: "scene".into(),
7185 rect: Rect::new(0.0, 0.0, 40.0, 40.0),
7186 scissor: None,
7187 scene: std::sync::Arc::new(scene),
7188 }
7189 }
7190
7191 let mut core = RunnerCore::new();
7193 core.set_surface_size(100, 100);
7194 let mut timings = PrepareTimings::default();
7195 core.prepare_paint(
7196 &[scene_op()],
7197 |_| true,
7198 |_| false,
7199 &mut NoText,
7200 1.0,
7201 &mut timings,
7202 );
7203 assert!(
7204 !core
7205 .paint_items
7206 .iter()
7207 .any(|p| matches!(p, PaintItem::Scene3D(_))),
7208 "default no-op recorder must not emit a Scene3D paint item",
7209 );
7210
7211 struct SceneRecorder {
7213 calls: usize,
7214 }
7215 impl TextRecorder for SceneRecorder {
7216 fn record(
7217 &mut self,
7218 _: Rect,
7219 _: Option<PhysicalScissor>,
7220 _: &RunStyle,
7221 _: &str,
7222 _: f32,
7223 _: f32,
7224 _: TextWrap,
7225 _: TextAnchor,
7226 _: f32,
7227 ) -> Range<usize> {
7228 0..0
7229 }
7230 fn record_runs(
7231 &mut self,
7232 _: Rect,
7233 _: Option<PhysicalScissor>,
7234 _: &[(String, RunStyle)],
7235 _: f32,
7236 _: f32,
7237 _: TextWrap,
7238 _: TextAnchor,
7239 _: f32,
7240 ) -> Range<usize> {
7241 0..0
7242 }
7243 fn record_scene3d(
7244 &mut self,
7245 _: Rect,
7246 _: Option<PhysicalScissor>,
7247 id: &str,
7248 _: &std::sync::Arc<Scene3DData>,
7249 _: f32,
7250 ) -> Range<usize> {
7251 assert_eq!(id, "scene", "node id threads through to the recorder");
7252 let start = self.calls;
7253 self.calls += 1;
7254 start..self.calls
7255 }
7256 }
7257
7258 let mut core = RunnerCore::new();
7259 core.set_surface_size(100, 100);
7260 let mut rec = SceneRecorder { calls: 0 };
7261 let mut timings = PrepareTimings::default();
7262 core.prepare_paint(
7263 &[scene_op()],
7264 |_| true,
7265 |_| false,
7266 &mut rec,
7267 1.0,
7268 &mut timings,
7269 );
7270 let scenes = core
7271 .paint_items
7272 .iter()
7273 .filter(|p| matches!(p, PaintItem::Scene3D(_)))
7274 .count();
7275 assert_eq!(
7276 scenes, 1,
7277 "recorded scene must emit exactly one Scene3D item"
7278 );
7279 }
7280
7281 #[test]
7288 fn keyed_scene_still_begins_camera_drag() {
7289 use crate::scene::glam::Vec3;
7290 use crate::scene::{PointData, PointsHandle, ScenePoint, SceneSpec};
7291 use crate::tree::chart3d;
7292
7293 let spec = || {
7294 SceneSpec::new().points(PointsHandle::new(PointData {
7295 points: vec![
7296 ScenePoint {
7297 position: Vec3::splat(-1.0),
7298 color: [1.0; 4],
7299 },
7300 ScenePoint {
7301 position: Vec3::splat(1.0),
7302 color: [1.0; 4],
7303 },
7304 ],
7305 }))
7306 };
7307
7308 let drag_active_after_press = |mut tree: crate::tree::El| {
7311 let mut core = RunnerCore::new();
7312 crate::layout::layout(
7313 &mut tree,
7314 &mut core.ui_state,
7315 Rect::new(0.0, 0.0, 200.0, 200.0),
7316 );
7317 core.ui_state.tick_scene_cameras(&tree, Instant::now());
7318 let mut t = PrepareTimings::default();
7319 core.snapshot(&tree, &mut t);
7320 core.pointer_down(Pointer::mouse(100.0, 100.0, PointerButton::Primary));
7321 core.ui_state.camera_drag_active()
7322 };
7323
7324 assert!(
7326 drag_active_after_press(chart3d(spec())),
7327 "unkeyed scene should begin a camera drag"
7328 );
7329 assert!(
7331 drag_active_after_press(chart3d(spec()).key("scene")),
7332 "keyed scene must still begin a camera drag (its own node hit must not suppress it)"
7333 );
7334 }
7335
7336 #[test]
7340 fn plot_control_scheme_routes_primary_drag() {
7341 use crate::event::KeyModifiers;
7342 use crate::plot::{PlotControls, PlotSpec, Sample, Scale, SeriesHandle, line};
7343 use crate::tree::plot;
7344
7345 let make = |controls| {
7346 let h = SeriesHandle::new(vec![Sample::new(0.0, 0.0), Sample::new(10.0, 10.0)]);
7347 plot(
7348 PlotSpec::new()
7349 .x(Scale::linear())
7350 .y(Scale::linear())
7351 .add_mark(line(&h))
7352 .controls(controls),
7353 )
7354 .key("p")
7355 };
7356
7357 let press = |mut tree: crate::tree::El, shift: bool| {
7359 let mut core = RunnerCore::new();
7360 crate::layout::layout(
7361 &mut tree,
7362 &mut core.ui_state,
7363 Rect::new(0.0, 0.0, 200.0, 200.0),
7364 );
7365 core.ui_state.prepare_plots(&tree);
7366 let mut t = PrepareTimings::default();
7367 core.snapshot(&tree, &mut t);
7368 core.ui_state.set_modifiers(KeyModifiers {
7369 shift,
7370 ..Default::default()
7371 });
7372 core.pointer_down(Pointer::mouse(100.0, 100.0, PointerButton::Primary));
7373 (
7374 core.ui_state.plot_zoom_active(),
7375 core.ui_state.plot_pan_active(),
7376 )
7377 };
7378
7379 assert_eq!(press(make(PlotControls::ZoomDrag), false), (true, false));
7381 assert_eq!(press(make(PlotControls::ZoomDrag), true), (false, true));
7382 assert_eq!(press(make(PlotControls::PanDrag), false), (false, true));
7384 assert_eq!(press(make(PlotControls::PanDrag), true), (true, false));
7385 }
7386
7387 #[test]
7388 fn at_most_one_snapshot_per_frame() {
7389 let mut core = RunnerCore::new();
7390 core.set_surface_size(100, 100);
7391 let ops = vec![
7392 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
7393 quad(ShaderHandle::Custom("g")),
7394 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
7395 quad(ShaderHandle::Custom("g")),
7396 ];
7397 let mut timings = PrepareTimings::default();
7398 core.prepare_paint(
7399 &ops,
7400 |_| true,
7401 |s| matches!(s, ShaderHandle::Custom("g")),
7402 &mut NoText,
7403 1.0,
7404 &mut timings,
7405 );
7406 let snapshots = core
7407 .paint_items
7408 .iter()
7409 .filter(|p| matches!(p, PaintItem::BackdropSnapshot))
7410 .count();
7411 assert_eq!(snapshots, 1, "backdrop depth is capped at 1");
7412 }
7413}