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, LogicalKey, NamedKey, PhysicalKey, Pointer, PointerButton, PointerKind,
60 UiEvent, UiEventKind, 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, (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, (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_ref()));
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, (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, (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, (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(
1691 &mut self,
1692 logical: LogicalKey,
1693 physical: PhysicalKey,
1694 modifiers: KeyModifiers,
1695 repeat: bool,
1696 ) -> Vec<UiEvent> {
1697 if self.focused_captures_keys() {
1705 if let Some(event) = self
1706 .ui_state
1707 .try_hotkey(&logical, physical, modifiers, repeat)
1708 {
1709 return vec![event];
1710 }
1711 self.ui_state.bump_caret_activity(Instant::now());
1718 self.ui_state.set_focus_visible(true);
1719 let blur_after = logical.named() == Some(NamedKey::Escape);
1720 let out = self
1721 .ui_state
1722 .key_down_raw(logical, physical, modifiers, repeat)
1723 .into_iter()
1724 .collect();
1725 if blur_after {
1726 self.ui_state.set_focus(None);
1727 self.ui_state.set_focus_visible(false);
1728 }
1729 return out;
1730 }
1731
1732 if matches!(
1742 logical.named(),
1743 Some(
1744 NamedKey::ArrowUp
1745 | NamedKey::ArrowDown
1746 | NamedKey::ArrowLeft
1747 | NamedKey::ArrowRight
1748 | NamedKey::Home
1749 | NamedKey::End
1750 )
1751 ) && let Some((mode, members)) = self.focused_arrow_nav_group()
1752 && mode.handles(&logical)
1753 {
1754 if let Some(event) = self
1755 .ui_state
1756 .try_hotkey(&logical, physical, modifiers, repeat)
1757 {
1758 return vec![event];
1759 }
1760 self.move_focus_in_group(&logical, mode, &members);
1761 return Vec::new();
1762 }
1763
1764 let mut out: Vec<UiEvent> = self
1765 .ui_state
1766 .key_down(logical, physical, modifiers, repeat)
1767 .into_iter()
1768 .collect();
1769
1770 if matches!(out.first().map(|e| e.kind), Some(UiEventKind::Escape))
1778 && !self.ui_state.current_selection.is_empty()
1779 {
1780 self.ui_state.current_selection = crate::selection::Selection::default();
1781 self.ui_state.selection.drag = None;
1782 out.push(selection_event(
1783 crate::selection::Selection::default(),
1784 modifiers,
1785 None,
1786 None,
1787 ));
1788 }
1789
1790 out
1791 }
1792
1793 fn focused_arrow_nav_group(&self) -> Option<(crate::tree::ArrowNav, Vec<UiTarget>)> {
1799 let focused = self.ui_state.focused.as_ref()?;
1800 let tree = self.last_tree.as_ref()?;
1801 focus::arrow_nav_group(tree, &focused.node_id)
1802 }
1803
1804 fn move_focus_in_group(
1813 &mut self,
1814 logical: &LogicalKey,
1815 mode: crate::tree::ArrowNav,
1816 members: &[UiTarget],
1817 ) {
1818 if members.is_empty() {
1819 return;
1820 }
1821 let focused_id = match self.ui_state.focused.as_ref() {
1822 Some(t) => t.node_id.clone(),
1823 None => return,
1824 };
1825 let idx = members.iter().position(|t| t.node_id == focused_id);
1826 let grid = mode == crate::tree::ArrowNav::Grid;
1827 let next_idx = match (logical.named(), idx) {
1828 (Some(NamedKey::ArrowUp), Some(i)) if grid => {
1829 match grid_vertical_step(members, i, -1.0) {
1830 Some(j) => j,
1831 None => return,
1832 }
1833 }
1834 (Some(NamedKey::ArrowDown), Some(i)) if grid => {
1835 match grid_vertical_step(members, i, 1.0) {
1836 Some(j) => j,
1837 None => return,
1838 }
1839 }
1840 (Some(NamedKey::ArrowUp | NamedKey::ArrowLeft), Some(i)) => i.saturating_sub(1),
1841 (Some(NamedKey::ArrowDown | NamedKey::ArrowRight), Some(i)) => {
1842 (i + 1).min(members.len() - 1)
1843 }
1844 (Some(NamedKey::Home), _) => 0,
1845 (Some(NamedKey::End), _) => members.len() - 1,
1846 _ => return,
1847 };
1848 if Some(next_idx) != idx {
1849 self.ui_state.set_focus(Some(members[next_idx].clone()));
1850 self.ui_state.set_focus_visible(true);
1851 }
1852 }
1853
1854 pub fn focused_captures_keys(&self) -> bool {
1861 let Some(focused) = self.ui_state.focused.as_ref() else {
1862 return false;
1863 };
1864 let Some(tree) = self.last_tree.as_ref() else {
1865 return false;
1866 };
1867 find_capture_keys(tree, &focused.node_id).unwrap_or(false)
1868 }
1869
1870 pub fn text_input(&mut self, text: String) -> Option<UiEvent> {
1876 if text.is_empty() {
1877 return None;
1878 }
1879 let target = self.ui_state.focused.clone()?;
1880 let modifiers = self.ui_state.modifiers;
1881 self.ui_state.bump_caret_activity(Instant::now());
1884 Some(UiEvent {
1885 key: Some(target.key.clone()),
1886 target: Some(target),
1887 pointer: None,
1888 key_press: None,
1889 text: Some(text),
1890 selection: None,
1891 modifiers,
1892 click_count: 0,
1893 path: None,
1894 pointer_kind: None,
1895 wheel_delta: None,
1896 kind: UiEventKind::TextInput,
1897 })
1898 }
1899
1900 pub fn set_hotkeys(&mut self, hotkeys: Vec<(KeyChord, String)>) {
1901 self.ui_state.set_hotkeys(hotkeys);
1902 }
1903
1904 pub fn set_selection(&mut self, selection: crate::selection::Selection) {
1909 if self.ui_state.current_selection != selection {
1910 self.ui_state.bump_caret_activity(Instant::now());
1911 }
1912 self.ui_state.current_selection = selection;
1913 }
1914
1915 pub fn selected_text(&self) -> Option<String> {
1929 self.selected_text_for(&self.ui_state.current_selection)
1930 }
1931
1932 pub fn selected_text_for(&self, selection: &crate::selection::Selection) -> Option<String> {
1938 let tree = self.last_tree.as_ref()?;
1939 crate::selection::selected_text(tree, selection)
1940 }
1941
1942 pub fn push_toasts(&mut self, specs: Vec<crate::toast::ToastSpec>) {
1948 let now = Instant::now();
1949 for spec in specs {
1950 self.ui_state.push_toast(spec, now);
1951 }
1952 }
1953
1954 pub fn dismiss_toast(&mut self, id: u64) {
1958 self.ui_state.dismiss_toast(id);
1959 }
1960
1961 pub fn push_focus_requests(&mut self, keys: Vec<String>) {
1967 self.ui_state.push_focus_requests(keys);
1968 }
1969
1970 pub fn push_scroll_requests(&mut self, requests: Vec<crate::scroll::ScrollRequest>) {
1976 self.ui_state.push_scroll_requests(requests);
1977 }
1978
1979 pub fn push_viewport_requests(&mut self, requests: Vec<crate::viewport::ViewportRequest>) {
1986 self.ui_state.push_viewport_requests(requests);
1987 }
1988
1989 pub fn push_plot_requests(&mut self, requests: Vec<crate::plot::PlotRequest>) {
1993 self.ui_state.push_plot_requests(requests);
1994 }
1995
1996 pub fn set_animation_mode(&mut self, mode: AnimationMode) {
1997 self.ui_state.set_animation_mode(mode);
1998 }
1999
2000 pub fn pointer_wheel(&mut self, x: f32, y: f32, dy: f32) -> bool {
2001 let Some(tree) = self.last_tree.as_ref() else {
2002 return false;
2003 };
2004 self.ui_state.cancel_scroll_momentum();
2005 if self.ui_state.camera_wheel_zoom(tree, x, y, dy) {
2008 return true;
2009 }
2010 if self.ui_state.viewport_wheel_zoom(tree, x, y, dy) {
2014 return true;
2015 }
2016 if self.ui_state.plot_wheel_zoom(tree, x, y, dy) {
2019 return true;
2020 }
2021 self.ui_state.pointer_wheel(tree, (x, y), dy)
2022 }
2023
2024 pub fn pointer_wheel_event(&mut self, x: f32, y: f32, dx: f32, dy: f32) -> Option<UiEvent> {
2031 if dx.abs() <= f32::EPSILON && dy.abs() <= f32::EPSILON {
2032 return None;
2033 }
2034 let tree = self.last_tree.as_ref()?;
2035 let target = hit_test::hit_test_target(tree, &self.ui_state, (x, y))?;
2036 self.ui_state.cancel_scroll_momentum();
2037 Some(UiEvent {
2038 key: Some(target.key.clone()),
2039 target: Some(target),
2040 pointer: Some((x, y)),
2041 key_press: None,
2042 text: None,
2043 selection: None,
2044 modifiers: self.ui_state.modifiers,
2045 click_count: 0,
2046 path: None,
2047 pointer_kind: Some(self.ui_state.pointer_kind),
2048 wheel_delta: Some((dx, dy)),
2049 kind: UiEventKind::PointerWheel,
2050 })
2051 }
2052
2053 pub fn poll_input(&mut self, now: Instant) -> Vec<UiEvent> {
2068 let TouchGestureState::Pending {
2069 initial,
2070 started_at,
2071 ..
2072 } = self.ui_state.touch_gesture.clone()
2073 else {
2074 return Vec::new();
2075 };
2076 if now.duration_since(started_at) < LONG_PRESS_DELAY {
2077 return Vec::new();
2078 }
2079 let mut out = Vec::new();
2080 let modifiers = self.ui_state.modifiers;
2081 let kind = PointerKind::Touch;
2082 let (x, y) = initial;
2083 let press_target = self.ui_state.pressed.clone();
2084 let preserves_press_for_drag = press_target.as_ref().is_some_and(|t| {
2085 self.last_tree
2086 .as_ref()
2087 .and_then(|tree| find_capture_keys(tree, &t.node_id))
2088 .unwrap_or(false)
2089 });
2090 if preserves_press_for_drag {
2091 self.ui_state.pressed_secondary = None;
2092 self.ui_state.pressed_link = None;
2093 self.ui_state.selection.drag = None;
2094 } else {
2095 self.cancel_press_for_scroll(&mut out, x, y, kind, modifiers);
2102 }
2103 if let Some(t) = press_target {
2104 out.push(UiEvent {
2105 key: Some(t.key.clone()),
2106 target: Some(t),
2107 pointer: Some((x, y)),
2108 key_press: None,
2109 text: None,
2110 selection: None,
2111 modifiers,
2112 click_count: 0,
2113 path: None,
2114 pointer_kind: Some(kind),
2115 wheel_delta: None,
2116 kind: UiEventKind::LongPress,
2117 });
2118 } else {
2119 out.push(UiEvent {
2123 key: None,
2124 target: None,
2125 pointer: Some((x, y)),
2126 key_press: None,
2127 text: None,
2128 selection: None,
2129 modifiers,
2130 click_count: 0,
2131 path: None,
2132 pointer_kind: Some(kind),
2133 wheel_delta: None,
2134 kind: UiEventKind::LongPress,
2135 });
2136 }
2137 if !preserves_press_for_drag
2138 && let Some(point) = self
2139 .last_tree
2140 .as_ref()
2141 .and_then(|t| hit_test::selection_point_at(t, (x, y)))
2142 {
2143 self.start_selection_drag(point, &mut out, modifiers, (x, y), 2, kind);
2144 }
2145 self.ui_state.touch_gesture = TouchGestureState::LongPressed;
2146 out
2147 }
2148
2149 pub fn next_input_deadline(&self, now: Instant) -> Option<std::time::Duration> {
2159 if self.ui_state.has_scroll_momentum() {
2160 return Some(std::time::Duration::ZERO);
2161 }
2162 let TouchGestureState::Pending { started_at, .. } = self.ui_state.touch_gesture.clone()
2163 else {
2164 return None;
2165 };
2166 let elapsed = now.duration_since(started_at);
2167 Some(LONG_PRESS_DELAY.saturating_sub(elapsed))
2168 }
2169
2170 pub fn prepare_layout<F>(
2187 &mut self,
2188 root: &mut El,
2189 viewport: Rect,
2190 scale_factor: f32,
2191 timings: &mut PrepareTimings,
2192 samples_time: F,
2193 ) -> LayoutPrepared
2194 where
2195 F: Fn(&ShaderHandle) -> bool,
2196 {
2197 let t0 = Instant::now();
2198 let scroll_momentum_pending = self.ui_state.tick_scroll_momentum(t0);
2199 let mut needs_redraw = {
2206 crate::profile_span!("prepare::layout");
2207 {
2208 crate::profile_span!("prepare::layout::assign_ids");
2209 layout::assign_ids(root);
2210 }
2211 let tooltip_pending = {
2212 crate::profile_span!("prepare::layout::tooltip");
2213 tooltip::synthesize_tooltip(root, &self.ui_state, t0)
2214 };
2215 let toast_pending = {
2216 crate::profile_span!("prepare::layout::toast");
2217 toast::synthesize_toasts(root, &mut self.ui_state, t0)
2218 };
2219 {
2220 crate::profile_span!("prepare::layout::apply_metrics");
2221 self.theme.apply_metrics(root);
2222 }
2223 {
2224 crate::profile_span!("prepare::layout::layout");
2225 layout::layout_post_assign(root, &mut self.ui_state, viewport);
2232 self.ui_state.clear_pending_scroll_requests();
2237 self.ui_state.clear_pending_viewport_requests();
2239 self.ui_state.gc_side_maps(root);
2244 self.ui_state.prepare_plots(root);
2248 self.ui_state.clear_pending_plot_requests();
2251 }
2252 {
2253 crate::profile_span!("prepare::layout::sync_orders");
2254 self.ui_state.sync_focus_and_selection_order(root);
2255 }
2256 {
2257 crate::profile_span!("prepare::layout::sync_popover_focus");
2258 focus::sync_popover_focus(root, &mut self.ui_state);
2259 }
2260 {
2261 crate::profile_span!("prepare::layout::drain_focus_requests");
2266 self.ui_state.drain_focus_requests();
2267 }
2268 {
2269 crate::profile_span!("prepare::layout::apply_state");
2270 self.ui_state.apply_to_state();
2271 }
2272 self.viewport_px = self.surface_size_override.unwrap_or_else(|| {
2273 (
2274 (viewport.w * scale_factor).ceil().max(1.0) as u32,
2275 (viewport.h * scale_factor).ceil().max(1.0) as u32,
2276 )
2277 });
2278 let animations = {
2279 crate::profile_span!("prepare::layout::tick_animations");
2280 self.ui_state
2281 .tick_visual_animations(root, Instant::now(), self.theme.palette())
2282 };
2283 let cameras_animating = {
2288 crate::profile_span!("prepare::layout::tick_cameras");
2289 self.ui_state.tick_scene_cameras(root, Instant::now())
2290 };
2291 animations
2292 || cameras_animating
2293 || tooltip_pending
2294 || toast_pending
2295 || scroll_momentum_pending
2296 };
2297 let t_after_layout = Instant::now();
2298 timings.layout_intrinsic_cache = layout::take_intrinsic_cache_stats();
2299 timings.layout_prune = layout::take_prune_stats();
2300 let (ops, draw_ops_stats) = {
2301 crate::profile_span!("prepare::draw_ops");
2302 let mut stats = DrawOpsStats::default();
2303 let ops = draw_ops::draw_ops_with_theme_and_stats(
2304 root,
2305 &self.ui_state,
2306 &self.theme,
2307 &mut stats,
2308 );
2309 (ops, stats)
2310 };
2311 let t_after_draw_ops = Instant::now();
2312 timings.layout = t_after_layout - t0;
2313 timings.draw_ops = t_after_draw_ops - t_after_layout;
2314 timings.draw_ops_culled_text_ops = draw_ops_stats.culled_text_ops;
2315 self.ui_state
2318 .set_hovered_scene_point(draw_ops_stats.hovered_scene_point);
2319 timings.text_layout_cache = crate::text::metrics::take_shape_cache_stats();
2320
2321 let shader_needs_redraw = ops.iter().any(|op| op_is_continuous(op, &samples_time));
2338 let widget_redraw = draw_ops_stats.redraw_within;
2339 let input_deadline = self.next_input_deadline(Instant::now());
2345 let widget_redraw = match (widget_redraw, input_deadline) {
2346 (Some(a), Some(b)) => Some(a.min(b)),
2347 (a, b) => a.or(b),
2348 };
2349
2350 let next_layout_redraw_in = match (needs_redraw, widget_redraw) {
2351 (true, _) => Some(std::time::Duration::ZERO),
2354 (false, d) => d,
2355 };
2356 let next_paint_redraw_in = if shader_needs_redraw {
2357 Some(std::time::Duration::ZERO)
2358 } else {
2359 None
2360 };
2361 if next_layout_redraw_in.is_some() || next_paint_redraw_in.is_some() {
2362 needs_redraw = true;
2363 }
2364
2365 LayoutPrepared {
2370 ops,
2371 needs_redraw,
2372 next_layout_redraw_in,
2373 next_paint_redraw_in,
2374 }
2375 }
2376
2377 pub fn prepare_paint_cached<F1, F2>(
2390 &mut self,
2391 is_registered: F1,
2392 samples_backdrop: F2,
2393 text: &mut dyn TextRecorder,
2394 scale_factor: f32,
2395 timings: &mut PrepareTimings,
2396 ) where
2397 F1: Fn(&ShaderHandle) -> bool,
2398 F2: Fn(&ShaderHandle) -> bool,
2399 {
2400 let ops = std::mem::take(&mut self.last_ops);
2404 self.prepare_paint(
2405 &ops,
2406 is_registered,
2407 samples_backdrop,
2408 text,
2409 scale_factor,
2410 timings,
2411 );
2412 self.last_ops = ops;
2413 }
2414
2415 pub fn no_time_shaders(_shader: &ShaderHandle) -> bool {
2420 false
2421 }
2422
2423 pub fn scan_continuous_shaders<F>(&self, samples_time: F) -> Option<std::time::Duration>
2430 where
2431 F: Fn(&ShaderHandle) -> bool,
2432 {
2433 let any = self
2434 .last_ops
2435 .iter()
2436 .any(|op| op_is_continuous(op, &samples_time));
2437 if any {
2438 Some(std::time::Duration::ZERO)
2439 } else {
2440 None
2441 }
2442 }
2443
2444 pub fn prepare_paint<F1, F2>(
2455 &mut self,
2456 ops: &[DrawOp],
2457 is_registered: F1,
2458 samples_backdrop: F2,
2459 text: &mut dyn TextRecorder,
2460 scale_factor: f32,
2461 timings: &mut PrepareTimings,
2462 ) where
2463 F1: Fn(&ShaderHandle) -> bool,
2464 F2: Fn(&ShaderHandle) -> bool,
2465 {
2466 crate::profile_span!("prepare::paint");
2467 let t0 = Instant::now();
2468 self.quad_scratch.clear();
2469 self.runs.clear();
2470 self.paint_items.clear();
2471
2472 let mut current: Option<(ShaderHandle, Option<PhysicalScissor>)> = None;
2473 let mut run_first: u32 = 0;
2474 let mut snapshot_emitted = false;
2477
2478 for op in ops {
2479 match op {
2480 DrawOp::Quad {
2481 rect,
2482 scissor,
2483 shader,
2484 uniforms,
2485 ..
2486 } => {
2487 if !is_registered(shader) {
2488 continue;
2489 }
2490 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2491 timings.paint_culled_ops += 1;
2492 continue;
2493 }
2494 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2495 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2496 timings.paint_culled_ops += 1;
2497 continue;
2498 }
2499 if !snapshot_emitted && samples_backdrop(shader) {
2500 close_run(
2501 &mut self.runs,
2502 &mut self.paint_items,
2503 current,
2504 run_first,
2505 self.quad_scratch.len() as u32,
2506 );
2507 current = None;
2508 run_first = self.quad_scratch.len() as u32;
2509 self.paint_items.push(PaintItem::BackdropSnapshot);
2510 snapshot_emitted = true;
2511 }
2512 let inst = match shader {
2517 ShaderHandle::Custom(name) => match self.shader_slot_maps.get(name) {
2518 Some(slot_map) => {
2519 let mut unknown = Vec::new();
2520 let inst = crate::paint::pack_instance_named(
2521 *rect,
2522 uniforms,
2523 self.working_color_space,
2524 slot_map,
2525 &mut unknown,
2526 );
2527 for key in unknown {
2528 if self.warned_shader_uniforms.insert((name, key)) {
2529 let declared: Vec<&str> =
2530 slot_map.declared().map(|(n, _)| n).collect();
2531 log::warn!(
2532 "damascene: shader `{name}` declares no \
2533 instance attribute named `{key}` — the \
2534 value lands nowhere. Declared names: \
2535 {declared:?} (plus the positional aliases \
2536 vec_a..vec_e). Rename one side so the Rust \
2537 packing and WGSL unpacking agree."
2538 );
2539 }
2540 }
2541 inst
2542 }
2543 None => {
2544 pack_instance_in(*rect, *shader, uniforms, self.working_color_space)
2545 }
2546 },
2547 _ => pack_instance_in(*rect, *shader, uniforms, self.working_color_space),
2548 };
2549
2550 let key = (*shader, phys);
2551 if current != Some(key) {
2552 close_run(
2553 &mut self.runs,
2554 &mut self.paint_items,
2555 current,
2556 run_first,
2557 self.quad_scratch.len() as u32,
2558 );
2559 current = Some(key);
2560 run_first = self.quad_scratch.len() as u32;
2561 }
2562 self.quad_scratch.push(inst);
2563 }
2564 DrawOp::GlyphRun {
2565 rect,
2566 scissor,
2567 color,
2568 text: glyph_text,
2569 size,
2570 line_height,
2571 family,
2572 mono_family,
2573 weight,
2574 mono,
2575 wrap,
2576 anchor,
2577 underline,
2578 strikethrough,
2579 link,
2580 tabular_numerals,
2581 ..
2582 } => {
2583 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2584 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2585 timings.paint_culled_ops += 1;
2586 continue;
2587 }
2588 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2589 timings.paint_culled_ops += 1;
2590 continue;
2591 }
2592 close_run(
2593 &mut self.runs,
2594 &mut self.paint_items,
2595 current,
2596 run_first,
2597 self.quad_scratch.len() as u32,
2598 );
2599 current = None;
2600 run_first = self.quad_scratch.len() as u32;
2601
2602 let mut style = crate::text::atlas::RunStyle::new(*weight, *color)
2603 .family(*family)
2604 .mono_family(*mono_family);
2605 if *mono {
2606 style = style.mono();
2607 }
2608 if *tabular_numerals {
2609 style = style.tabular_numerals();
2610 }
2611 if *underline {
2612 style = style.underline();
2613 }
2614 if *strikethrough {
2615 style = style.strikethrough();
2616 }
2617 if let Some(url) = link {
2618 style = style.with_link(url.clone());
2619 }
2620 let layers = text.record(
2621 *rect,
2622 phys,
2623 &style,
2624 glyph_text,
2625 *size,
2626 *line_height,
2627 *wrap,
2628 *anchor,
2629 scale_factor,
2630 );
2631 for index in layers {
2632 self.paint_items.push(PaintItem::Text(index));
2633 }
2634 }
2635 DrawOp::AttributedText {
2636 rect,
2637 scissor,
2638 runs,
2639 size,
2640 line_height,
2641 wrap,
2642 anchor,
2643 ..
2644 } => {
2645 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2646 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2647 timings.paint_culled_ops += 1;
2648 continue;
2649 }
2650 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2651 timings.paint_culled_ops += 1;
2652 continue;
2653 }
2654 close_run(
2655 &mut self.runs,
2656 &mut self.paint_items,
2657 current,
2658 run_first,
2659 self.quad_scratch.len() as u32,
2660 );
2661 current = None;
2662 run_first = self.quad_scratch.len() as u32;
2663
2664 let layers = text.record_runs(
2665 *rect,
2666 phys,
2667 runs,
2668 *size,
2669 *line_height,
2670 *wrap,
2671 *anchor,
2672 scale_factor,
2673 );
2674 for index in layers {
2675 self.paint_items.push(PaintItem::Text(index));
2676 }
2677 }
2678 DrawOp::Icon {
2679 rect,
2680 scissor,
2681 source,
2682 color,
2683 size,
2684 stroke_width,
2685 ..
2686 } => {
2687 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2688 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2689 timings.paint_culled_ops += 1;
2690 continue;
2691 }
2692 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2693 timings.paint_culled_ops += 1;
2694 continue;
2695 }
2696 close_run(
2697 &mut self.runs,
2698 &mut self.paint_items,
2699 current,
2700 run_first,
2701 self.quad_scratch.len() as u32,
2702 );
2703 current = None;
2704 run_first = self.quad_scratch.len() as u32;
2705
2706 let recorded = text.record_icon(
2707 *rect,
2708 phys,
2709 source,
2710 *color,
2711 *size,
2712 *stroke_width,
2713 scale_factor,
2714 );
2715 match recorded {
2716 RecordedPaint::Text(layers) => {
2717 for index in layers {
2718 self.paint_items.push(PaintItem::Text(index));
2719 }
2720 }
2721 RecordedPaint::Icon(runs) => {
2722 for index in runs {
2723 self.paint_items.push(PaintItem::IconRun(index));
2724 }
2725 }
2726 }
2727 }
2728 DrawOp::Image {
2729 rect,
2730 scissor,
2731 image,
2732 tint,
2733 radius,
2734 fit,
2735 range_limit,
2736 ..
2737 } => {
2738 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2739 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2740 timings.paint_culled_ops += 1;
2741 continue;
2742 }
2743 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2744 timings.paint_culled_ops += 1;
2745 continue;
2746 }
2747 close_run(
2748 &mut self.runs,
2749 &mut self.paint_items,
2750 current,
2751 run_first,
2752 self.quad_scratch.len() as u32,
2753 );
2754 current = None;
2755 run_first = self.quad_scratch.len() as u32;
2756
2757 let recorded = text.record_image(
2758 *rect,
2759 phys,
2760 image,
2761 *tint,
2762 *radius,
2763 *fit,
2764 *range_limit,
2765 scale_factor,
2766 );
2767 for index in recorded {
2768 self.paint_items.push(PaintItem::Image(index));
2769 }
2770 }
2771 DrawOp::AppTexture {
2772 rect,
2773 scissor,
2774 texture,
2775 alpha,
2776 transform,
2777 ..
2778 } => {
2779 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2780 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2781 timings.paint_culled_ops += 1;
2782 continue;
2783 }
2784 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2785 timings.paint_culled_ops += 1;
2786 continue;
2787 }
2788 close_run(
2789 &mut self.runs,
2790 &mut self.paint_items,
2791 current,
2792 run_first,
2793 self.quad_scratch.len() as u32,
2794 );
2795 current = None;
2796 run_first = self.quad_scratch.len() as u32;
2797
2798 let recorded = text.record_app_texture(
2799 *rect,
2800 phys,
2801 texture,
2802 *alpha,
2803 *transform,
2804 scale_factor,
2805 );
2806 for index in recorded {
2807 self.paint_items.push(PaintItem::AppTexture(index));
2808 }
2809 }
2810 DrawOp::Vector {
2811 rect,
2812 scissor,
2813 asset,
2814 render_mode,
2815 ..
2816 } => {
2817 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2818 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2819 timings.paint_culled_ops += 1;
2820 continue;
2821 }
2822 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2823 timings.paint_culled_ops += 1;
2824 continue;
2825 }
2826 close_run(
2827 &mut self.runs,
2828 &mut self.paint_items,
2829 current,
2830 run_first,
2831 self.quad_scratch.len() as u32,
2832 );
2833 current = None;
2834 run_first = self.quad_scratch.len() as u32;
2835
2836 let recorded =
2837 text.record_vector(*rect, phys, asset, *render_mode, scale_factor);
2838 for index in recorded {
2839 self.paint_items.push(PaintItem::Vector(index));
2840 }
2841 }
2842 DrawOp::Scene3D {
2843 id,
2844 rect,
2845 scissor,
2846 scene,
2847 } => {
2848 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2849 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2850 timings.paint_culled_ops += 1;
2851 continue;
2852 }
2853 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2854 timings.paint_culled_ops += 1;
2855 continue;
2856 }
2857 close_run(
2863 &mut self.runs,
2864 &mut self.paint_items,
2865 current,
2866 run_first,
2867 self.quad_scratch.len() as u32,
2868 );
2869 current = None;
2870 run_first = self.quad_scratch.len() as u32;
2871
2872 let recorded = text.record_scene3d(*rect, phys, id, scene, scale_factor);
2873 for index in recorded {
2874 self.paint_items.push(PaintItem::Scene3D(index));
2875 }
2876 }
2877 DrawOp::BackdropSnapshot => {
2878 close_run(
2879 &mut self.runs,
2880 &mut self.paint_items,
2881 current,
2882 run_first,
2883 self.quad_scratch.len() as u32,
2884 );
2885 current = None;
2886 run_first = self.quad_scratch.len() as u32;
2887 if !snapshot_emitted {
2890 self.paint_items.push(PaintItem::BackdropSnapshot);
2891 snapshot_emitted = true;
2892 }
2893 }
2894 }
2895 }
2896 close_run(
2897 &mut self.runs,
2898 &mut self.paint_items,
2899 current,
2900 run_first,
2901 self.quad_scratch.len() as u32,
2902 );
2903 timings.paint = Instant::now() - t0;
2904 }
2905
2906 pub fn snapshot(&mut self, root: &El, timings: &mut PrepareTimings) {
2914 self.snapshot_owned(root.clone(), timings);
2915 }
2916
2917 pub fn snapshot_owned(&mut self, root: El, timings: &mut PrepareTimings) {
2922 crate::profile_span!("prepare::snapshot");
2923 let t0 = Instant::now();
2924 self.last_tree_has_links = tree_has_links(&root);
2925 self.last_tree = Some(root);
2926 timings.snapshot = Instant::now() - t0;
2927 }
2928
2929 pub fn snapshot_cursor(&self) -> crate::cursor::Cursor {
2933 self.last_tree
2934 .as_ref()
2935 .map(|tree| self.ui_state.cursor(tree))
2936 .unwrap_or_default()
2937 }
2938
2939 fn linkable_tree(&self) -> Option<&El> {
2944 self.last_tree.as_ref().filter(|_| self.last_tree_has_links)
2945 }
2946}
2947
2948fn paint_rect_visible(
2949 rect: Rect,
2950 scissor: Option<Rect>,
2951 viewport_px: (u32, u32),
2952 scale_factor: f32,
2953) -> bool {
2954 if rect.w <= 0.0 || rect.h <= 0.0 {
2955 return false;
2956 }
2957 let scale = scale_factor.max(f32::EPSILON);
2958 let viewport = Rect::new(
2959 0.0,
2960 0.0,
2961 viewport_px.0 as f32 / scale,
2962 viewport_px.1 as f32 / scale,
2963 );
2964 let Some(clip) = scissor.map_or(Some(viewport), |s| s.intersect(viewport)) else {
2965 return false;
2966 };
2967 rect.intersect(clip).is_some()
2968}
2969
2970fn target_id_in_subtree(root_id: &str, target_id: &str) -> bool {
2971 target_id == root_id
2972 || target_id
2973 .strip_prefix(root_id)
2974 .is_some_and(|rest| rest.starts_with('.'))
2975}
2976
2977fn op_is_continuous<F>(op: &DrawOp, samples_time: &F) -> bool
2984where
2985 F: Fn(&ShaderHandle) -> bool,
2986{
2987 match op.shader() {
2988 Some(handle @ ShaderHandle::Stock(s)) => s.is_continuous() || samples_time(handle),
2989 Some(handle @ ShaderHandle::Custom(_)) => samples_time(handle),
2990 None => false,
2991 }
2992}
2993
2994fn grid_vertical_step(members: &[UiTarget], from: usize, dir: f32) -> Option<usize> {
3004 let cur = &members[from].rect;
3005 let (cx, cy) = (cur.x + cur.w / 2.0, cur.y + cur.h / 2.0);
3006 members
3007 .iter()
3008 .enumerate()
3009 .filter(|(i, t)| {
3010 let ty = t.rect.y + t.rect.h / 2.0;
3011 *i != from && (ty - cy) * dir > cur.h / 2.0
3015 })
3016 .min_by(|(_, a), (_, b)| {
3017 let key = |t: &UiTarget| {
3018 let tx = t.rect.x + t.rect.w / 2.0;
3019 let ty = t.rect.y + t.rect.h / 2.0;
3020 ((ty - cy).abs(), (tx - cx).abs())
3021 };
3022 key(a)
3023 .partial_cmp(&key(b))
3024 .unwrap_or(std::cmp::Ordering::Equal)
3025 })
3026 .map(|(i, _)| i)
3027}
3028
3029fn tree_has_links(node: &El) -> bool {
3037 node.text_link.is_some() || node.children.iter().any(tree_has_links)
3038}
3039
3040pub(crate) fn find_capture_keys(node: &El, id: &str) -> Option<bool> {
3041 if node.computed_id == id.into() {
3042 return Some(node.capture_keys);
3043 }
3044 node.children.iter().find_map(|c| find_capture_keys(c, id))
3045}
3046
3047fn find_consumes_touch_drag(node: &El, id: &str, ancestor_consumes: bool) -> Option<bool> {
3057 let consumes = ancestor_consumes || node.consumes_touch_drag;
3058 if node.computed_id == id.into() {
3059 return Some(consumes);
3060 }
3061 node.children
3062 .iter()
3063 .find_map(|c| find_consumes_touch_drag(c, id, consumes))
3064}
3065
3066fn selection_event(
3068 new_sel: crate::selection::Selection,
3069 modifiers: KeyModifiers,
3070 pointer: Option<(f32, f32)>,
3071 pointer_kind: Option<PointerKind>,
3072) -> UiEvent {
3073 UiEvent {
3074 kind: UiEventKind::SelectionChanged,
3075 key: None,
3076 target: None,
3077 pointer,
3078 key_press: None,
3079 text: None,
3080 selection: Some(new_sel),
3081 modifiers,
3082 click_count: 0,
3083 path: None,
3084 pointer_kind,
3085 wheel_delta: None,
3086 }
3087}
3088
3089fn head_for_drag(
3101 root: &El,
3102 ui_state: &UiState,
3103 point: (f32, f32),
3104) -> Option<crate::selection::SelectionPoint> {
3105 if let Some(p) = hit_test::selection_point_at(root, point) {
3106 return Some(p);
3107 }
3108
3109 let order = &ui_state.selection.order;
3110 if order.is_empty() {
3111 return None;
3112 }
3113 let target = order
3118 .iter()
3119 .find(|t| point.1 >= t.rect.y && point.1 < t.rect.y + t.rect.h)
3120 .or_else(|| {
3121 order.iter().min_by(|a, b| {
3122 let da = y_distance(a.rect, point.1);
3123 let db = y_distance(b.rect, point.1);
3124 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
3125 })
3126 })?;
3127 let target_rect = target.rect;
3128 let cy = point
3129 .1
3130 .clamp(target_rect.y, target_rect.y + target_rect.h - 1.0);
3131 if let Some(p) = hit_test::selection_point_at(root, (point.0, cy)) {
3132 return Some(p);
3133 }
3134 let leaf_len = find_text_len(root, &target.node_id).unwrap_or(0);
3137 let byte = if point.0 < target_rect.x { 0 } else { leaf_len };
3138 Some(crate::selection::SelectionPoint {
3139 key: target.key.clone(),
3140 byte,
3141 })
3142}
3143
3144fn selection_range_for_drag(
3145 root: &El,
3146 ui_state: &UiState,
3147 drag: &crate::state::SelectionDrag,
3148 raw_head: crate::selection::SelectionPoint,
3149) -> (
3150 crate::selection::SelectionPoint,
3151 crate::selection::SelectionPoint,
3152) {
3153 match drag.granularity {
3154 SelectionDragGranularity::Character => (drag.anchor.clone(), raw_head),
3155 SelectionDragGranularity::Word => {
3156 let text = crate::selection::find_keyed_text(root, &raw_head.key).unwrap_or_default();
3157 let (lo, hi) = crate::selection::word_range_at(&text, raw_head.byte);
3158 if point_cmp(ui_state, &raw_head, &drag.anchor) == Ordering::Less {
3159 (
3160 drag.head.clone(),
3161 crate::selection::SelectionPoint::new(raw_head.key, lo),
3162 )
3163 } else {
3164 (
3165 drag.anchor.clone(),
3166 crate::selection::SelectionPoint::new(raw_head.key, hi),
3167 )
3168 }
3169 }
3170 SelectionDragGranularity::Leaf => {
3171 let len = crate::selection::find_keyed_text(root, &raw_head.key)
3172 .map(|text| text.len())
3173 .unwrap_or(raw_head.byte);
3174 if point_cmp(ui_state, &raw_head, &drag.anchor) == Ordering::Less {
3175 (
3176 drag.head.clone(),
3177 crate::selection::SelectionPoint::new(raw_head.key, 0),
3178 )
3179 } else {
3180 (
3181 drag.anchor.clone(),
3182 crate::selection::SelectionPoint::new(raw_head.key, len),
3183 )
3184 }
3185 }
3186 }
3187}
3188
3189fn point_cmp(
3190 ui_state: &UiState,
3191 a: &crate::selection::SelectionPoint,
3192 b: &crate::selection::SelectionPoint,
3193) -> Ordering {
3194 let order_index = |key: &str| {
3195 ui_state
3196 .selection
3197 .order
3198 .iter()
3199 .position(|target| target.key == key)
3200 .unwrap_or(usize::MAX)
3201 };
3202 order_index(&a.key)
3203 .cmp(&order_index(&b.key))
3204 .then_with(|| a.byte.cmp(&b.byte))
3205}
3206
3207fn y_distance(rect: Rect, y: f32) -> f32 {
3208 if y < rect.y {
3209 rect.y - y
3210 } else if y > rect.y + rect.h {
3211 y - (rect.y + rect.h)
3212 } else {
3213 0.0
3214 }
3215}
3216
3217fn find_text_len(node: &El, id: &str) -> Option<usize> {
3218 if node.computed_id == id.into() {
3219 if let Some(source) = &node.selection_source {
3220 return Some(source.visible_len());
3221 }
3222 return node.text.as_ref().map(|t| t.len());
3223 }
3224 node.children.iter().find_map(|c| find_text_len(c, id))
3225}
3226
3227pub enum RecordedPaint {
3230 Text(Range<usize>),
3231 Icon(Range<usize>),
3232}
3233
3234pub trait TextRecorder {
3238 #[allow(clippy::too_many_arguments)]
3246 fn record(
3247 &mut self,
3248 rect: Rect,
3249 scissor: Option<PhysicalScissor>,
3250 style: &RunStyle,
3251 text: &str,
3252 size: f32,
3253 line_height: f32,
3254 wrap: TextWrap,
3255 anchor: TextAnchor,
3256 scale_factor: f32,
3257 ) -> Range<usize>;
3258
3259 #[allow(clippy::too_many_arguments)]
3264 fn record_runs(
3265 &mut self,
3266 rect: Rect,
3267 scissor: Option<PhysicalScissor>,
3268 runs: &[(String, RunStyle)],
3269 size: f32,
3270 line_height: f32,
3271 wrap: TextWrap,
3272 anchor: TextAnchor,
3273 scale_factor: f32,
3274 ) -> Range<usize>;
3275
3276 #[allow(clippy::too_many_arguments)]
3282 fn record_icon(
3283 &mut self,
3284 rect: Rect,
3285 scissor: Option<PhysicalScissor>,
3286 source: &crate::icons::svg::IconSource,
3287 color: Color,
3288 size: f32,
3289 _stroke_width: f32,
3290 scale_factor: f32,
3291 ) -> RecordedPaint {
3292 let glyph = match source {
3293 crate::icons::svg::IconSource::Builtin(name) => name.fallback_glyph(),
3294 crate::icons::svg::IconSource::Custom(_) => "?",
3295 crate::icons::svg::IconSource::UnknownName(_) => {
3296 crate::tree::IconName::AlertCircle.fallback_glyph()
3297 }
3298 };
3299 RecordedPaint::Text(self.record(
3300 rect,
3301 scissor,
3302 &RunStyle::new(FontWeight::Regular, color),
3303 glyph,
3304 size,
3305 crate::text::metrics::line_height(size),
3306 TextWrap::NoWrap,
3307 TextAnchor::Middle,
3308 scale_factor,
3309 ))
3310 }
3311
3312 #[allow(clippy::too_many_arguments)]
3319 fn record_image(
3320 &mut self,
3321 _rect: Rect,
3322 _scissor: Option<PhysicalScissor>,
3323 _image: &crate::image::Image,
3324 _tint: Option<Color>,
3325 _radius: crate::tree::Corners,
3326 _fit: crate::image::ImageFit,
3327 _range_limit: crate::image::DynamicRangeLimit,
3328 _scale_factor: f32,
3329 ) -> Range<usize> {
3330 0..0
3331 }
3332
3333 fn record_app_texture(
3339 &mut self,
3340 _rect: Rect,
3341 _scissor: Option<PhysicalScissor>,
3342 _texture: &crate::surface::AppTexture,
3343 _alpha: crate::surface::SurfaceAlpha,
3344 _transform: crate::affine::Affine2,
3345 _scale_factor: f32,
3346 ) -> Range<usize> {
3347 0..0
3348 }
3349
3350 fn record_vector(
3356 &mut self,
3357 _rect: Rect,
3358 _scissor: Option<PhysicalScissor>,
3359 _asset: &crate::vector::VectorAsset,
3360 _render_mode: crate::vector::VectorRenderMode,
3361 _scale_factor: f32,
3362 ) -> Range<usize> {
3363 0..0
3364 }
3365
3366 fn record_scene3d(
3377 &mut self,
3378 _rect: Rect,
3379 _scissor: Option<PhysicalScissor>,
3380 _id: &str,
3381 _scene: &std::sync::Arc<crate::scene::Scene3DData>,
3382 _scale_factor: f32,
3383 ) -> Range<usize> {
3384 0..0
3385 }
3386}
3387
3388#[cfg(test)]
3389mod tests {
3390 use super::*;
3391 use crate::event::PointerId;
3392 use crate::shader::{ShaderHandle, StockShader, UniformBlock};
3393
3394 struct NoText;
3396 impl TextRecorder for NoText {
3397 fn record(
3398 &mut self,
3399 _rect: Rect,
3400 _scissor: Option<PhysicalScissor>,
3401 _style: &RunStyle,
3402 _text: &str,
3403 _size: f32,
3404 _line_height: f32,
3405 _wrap: TextWrap,
3406 _anchor: TextAnchor,
3407 _scale_factor: f32,
3408 ) -> Range<usize> {
3409 0..0
3410 }
3411 fn record_runs(
3412 &mut self,
3413 _rect: Rect,
3414 _scissor: Option<PhysicalScissor>,
3415 _runs: &[(String, RunStyle)],
3416 _size: f32,
3417 _line_height: f32,
3418 _wrap: TextWrap,
3419 _anchor: TextAnchor,
3420 _scale_factor: f32,
3421 ) -> Range<usize> {
3422 0..0
3423 }
3424 }
3425
3426 #[derive(Default)]
3427 struct CountingText {
3428 records: usize,
3429 }
3430
3431 impl TextRecorder for CountingText {
3432 fn record(
3433 &mut self,
3434 _rect: Rect,
3435 _scissor: Option<PhysicalScissor>,
3436 _style: &RunStyle,
3437 _text: &str,
3438 _size: f32,
3439 _line_height: f32,
3440 _wrap: TextWrap,
3441 _anchor: TextAnchor,
3442 _scale_factor: f32,
3443 ) -> Range<usize> {
3444 self.records += 1;
3445 0..0
3446 }
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 self.records += 1;
3460 0..0
3461 }
3462 }
3463
3464 fn empty_text_layout(line_height: f32) -> crate::text::metrics::TextLayout {
3465 crate::text::metrics::TextLayout {
3466 lines: Vec::new(),
3467 width: 0.0,
3468 height: 0.0,
3469 line_height,
3470 }
3471 }
3472
3473 fn lay_out_input_tree(capture: bool) -> RunnerCore {
3480 use crate::tree::*;
3481 let ti = if capture {
3482 crate::widgets::text::text("input").key("ti").capture_keys()
3483 } else {
3484 crate::widgets::text::text("noop").key("ti").focusable()
3485 };
3486 let mut tree =
3487 crate::column([crate::widgets::button::button("Btn").key("btn"), ti]).padding(10.0);
3488 let mut core = RunnerCore::new();
3489 crate::layout::layout(
3490 &mut tree,
3491 &mut core.ui_state,
3492 Rect::new(0.0, 0.0, 200.0, 200.0),
3493 );
3494 core.ui_state.sync_focus_order(&tree);
3495 let mut t = PrepareTimings::default();
3496 core.snapshot(&tree, &mut t);
3497 core
3498 }
3499
3500 #[test]
3501 fn pointer_up_emits_pointer_up_then_click() {
3502 let mut core = lay_out_input_tree(false);
3503 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3504 let cx = btn_rect.x + btn_rect.w * 0.5;
3505 let cy = btn_rect.y + btn_rect.h * 0.5;
3506 core.pointer_moved(Pointer::moving(cx, cy));
3507 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3508 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
3509 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3510 assert_eq!(kinds, vec![UiEventKind::PointerUp, UiEventKind::Click]);
3511 }
3512
3513 #[test]
3514 fn pointer_wheel_event_routes_to_keyed_target() {
3515 let mut core = lay_out_input_tree(false);
3516 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3517 let cx = btn_rect.center_x();
3518 let cy = btn_rect.center_y();
3519
3520 let event = core
3521 .pointer_wheel_event(cx, cy, 0.0, 40.0)
3522 .expect("wheel over keyed button");
3523
3524 assert_eq!(event.kind, UiEventKind::PointerWheel);
3525 assert_eq!(event.route(), Some("btn"));
3526 assert_eq!(event.pointer_pos(), Some((cx, cy)));
3527 assert_eq!(event.wheel_delta(), Some((0.0, 40.0)));
3528 assert_eq!(event.wheel_dy(), Some(40.0));
3529 assert_eq!(event.target_rect(), Some(btn_rect));
3530 assert_eq!(event.pointer_kind, Some(PointerKind::Mouse));
3531 }
3532
3533 fn lay_out_link_tree() -> (RunnerCore, Rect, &'static str) {
3539 use crate::tree::*;
3540 const URL: &str = "https://github.com/computer-whisperer/damascene";
3541 let mut tree = crate::column([crate::text_runs([
3542 crate::text("Visit "),
3543 crate::text("github.com/computer-whisperer/damascene").link(URL),
3544 crate::text("."),
3545 ])])
3546 .padding(10.0);
3547 let mut core = RunnerCore::new();
3548 crate::layout::layout(
3549 &mut tree,
3550 &mut core.ui_state,
3551 Rect::new(0.0, 0.0, 600.0, 200.0),
3552 );
3553 core.ui_state.sync_focus_order(&tree);
3554 let mut t = PrepareTimings::default();
3555 core.snapshot(&tree, &mut t);
3556 let para = core
3557 .last_tree
3558 .as_ref()
3559 .and_then(|t| t.children.first())
3560 .map(|p| p.computed_rect)
3561 .expect("paragraph rect");
3562 (core, para, URL)
3563 }
3564
3565 #[test]
3566 fn snapshot_computes_the_links_flag_that_gates_link_walks() {
3567 let core = lay_out_input_tree(false);
3570 assert!(
3571 !core.last_tree_has_links,
3572 "input tree has no links; the flag must stay off"
3573 );
3574 let (core, ..) = lay_out_link_tree();
3575 assert!(
3576 core.last_tree_has_links,
3577 "linked paragraph must flip the flag on"
3578 );
3579 }
3580
3581 #[test]
3582 fn pointer_up_on_link_emits_link_activated_with_url() {
3583 let (mut core, para, url) = lay_out_link_tree();
3584 let cx = para.x + 100.0;
3588 let cy = para.y + para.h * 0.5;
3589 core.pointer_moved(Pointer::moving(cx, cy));
3590 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3591 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
3592 let link = events
3593 .iter()
3594 .find(|e| e.kind == UiEventKind::LinkActivated)
3595 .expect("LinkActivated event");
3596 assert_eq!(link.key.as_deref(), Some(url));
3597 }
3598
3599 #[test]
3600 fn pointer_up_after_drag_off_link_does_not_activate() {
3601 let (mut core, para, _url) = lay_out_link_tree();
3602 let press_x = para.x + 100.0;
3603 let cy = para.y + para.h * 0.5;
3604 core.pointer_moved(Pointer::moving(press_x, cy));
3605 core.pointer_down(Pointer::mouse(press_x, cy, PointerButton::Primary));
3606 let events = core.pointer_up(Pointer::mouse(press_x, 180.0, PointerButton::Primary));
3610 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3611 assert!(
3612 !kinds.contains(&UiEventKind::LinkActivated),
3613 "drag-off-link should cancel the link activation; got {kinds:?}",
3614 );
3615 }
3616
3617 #[test]
3618 fn disabled_control_resolves_not_allowed_cursor_on_hover() {
3619 use crate::cursor::Cursor;
3624 let mut tree = crate::widgets::button::button("Save")
3625 .key("save")
3626 .disabled();
3627 let mut core = RunnerCore::new();
3628 crate::layout::layout(
3629 &mut tree,
3630 &mut core.ui_state,
3631 Rect::new(0.0, 0.0, 200.0, 100.0),
3632 );
3633 let rect = tree.computed_rect;
3634 let mut t = PrepareTimings::default();
3635 core.snapshot(&tree, &mut t);
3636 core.pointer_moved(Pointer::moving(
3637 rect.x + rect.w * 0.5,
3638 rect.y + rect.h * 0.5,
3639 ));
3640 let snap = core.last_tree.as_ref().expect("tree").clone();
3641 assert_eq!(core.ui_state.cursor(&snap), Cursor::NotAllowed);
3642 }
3643
3644 #[test]
3645 fn pointer_moved_over_link_resolves_cursor_to_pointer_and_requests_redraw() {
3646 use crate::cursor::Cursor;
3647 let (mut core, para, _url) = lay_out_link_tree();
3648 let cx = para.x + 100.0;
3649 let cy = para.y + para.h * 0.5;
3650 let initial = core.pointer_moved(Pointer::moving(para.x - 50.0, cy));
3652 assert!(
3653 !initial.needs_redraw,
3654 "moving in empty space shouldn't request a redraw"
3655 );
3656 let tree = core.last_tree.as_ref().expect("tree").clone();
3657 assert_eq!(
3658 core.ui_state.cursor(&tree),
3659 Cursor::Default,
3660 "no link under pointer → default cursor"
3661 );
3662 let onto = core.pointer_moved(Pointer::moving(cx, cy));
3665 assert!(
3666 onto.needs_redraw,
3667 "entering a link region should flag a redraw so the cursor refresh isn't stale"
3668 );
3669 assert_eq!(
3670 core.ui_state.cursor(&tree),
3671 Cursor::Pointer,
3672 "pointer over a link → Pointer cursor"
3673 );
3674 let off = core.pointer_moved(Pointer::moving(para.x - 50.0, cy));
3677 assert!(
3678 off.needs_redraw,
3679 "leaving a link region should flag a redraw"
3680 );
3681 assert_eq!(core.ui_state.cursor(&tree), Cursor::Default);
3682 }
3683
3684 #[test]
3685 fn pointer_up_on_unlinked_text_does_not_emit_link_activated() {
3686 let (mut core, para, _url) = lay_out_link_tree();
3687 let cx = para.x + 1.0;
3690 let cy = para.y + para.h * 0.5;
3691 core.pointer_moved(Pointer::moving(cx, cy));
3692 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3693 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
3694 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3695 assert!(
3696 !kinds.contains(&UiEventKind::LinkActivated),
3697 "click on the unlinked prefix should not surface a link event; got {kinds:?}",
3698 );
3699 }
3700
3701 #[test]
3702 fn pointer_up_off_target_emits_only_pointer_up() {
3703 let mut core = lay_out_input_tree(false);
3704 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3705 let cx = btn_rect.x + btn_rect.w * 0.5;
3706 let cy = btn_rect.y + btn_rect.h * 0.5;
3707 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3708 let events = core.pointer_up(Pointer::mouse(180.0, 180.0, PointerButton::Primary));
3710 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3711 assert_eq!(
3712 kinds,
3713 vec![UiEventKind::PointerUp],
3714 "drag-off-target should still surface PointerUp so widgets see drag-end"
3715 );
3716 }
3717
3718 #[test]
3719 fn pointer_moved_while_pressed_emits_drag() {
3720 let mut core = lay_out_input_tree(false);
3721 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3722 let cx = btn_rect.x + btn_rect.w * 0.5;
3723 let cy = btn_rect.y + btn_rect.h * 0.5;
3724 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3725 let drag = core
3726 .pointer_moved(Pointer::moving(cx + 30.0, cy))
3727 .events
3728 .into_iter()
3729 .find(|e| e.kind == UiEventKind::Drag)
3730 .expect("drag while pressed");
3731 assert_eq!(drag.target.as_ref().map(|t| t.key.as_str()), Some("btn"));
3732 assert_eq!(drag.pointer, Some((cx + 30.0, cy)));
3733 }
3734
3735 #[test]
3736 fn toast_dismiss_click_removes_toast_and_suppresses_click_event() {
3737 use crate::toast::ToastSpec;
3738 use crate::tree::Size;
3739 let mut core = RunnerCore::new();
3743 core.ui_state
3744 .push_toast(ToastSpec::success("hi"), Instant::now());
3745 let toast_id = core.ui_state.toasts()[0].id;
3746
3747 let mut tree: El = crate::stack(std::iter::empty::<El>())
3751 .width(Size::Fill(1.0))
3752 .height(Size::Fill(1.0));
3753 crate::layout::assign_ids(&mut tree);
3754 let _ = crate::toast::synthesize_toasts(&mut tree, &mut core.ui_state, Instant::now());
3755 crate::layout::layout(
3756 &mut tree,
3757 &mut core.ui_state,
3758 Rect::new(0.0, 0.0, 800.0, 600.0),
3759 );
3760 core.ui_state.sync_focus_order(&tree);
3761 let mut t = PrepareTimings::default();
3762 core.snapshot(&tree, &mut t);
3763
3764 let dismiss_key = format!("toast-dismiss-{toast_id}");
3765 let dismiss_rect = core.rect_of_key(&dismiss_key).expect("dismiss button");
3766 let cx = dismiss_rect.x + dismiss_rect.w * 0.5;
3767 let cy = dismiss_rect.y + dismiss_rect.h * 0.5;
3768
3769 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3770 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
3771 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3772 assert!(
3776 !kinds.contains(&UiEventKind::Click),
3777 "Click on toast-dismiss should not be surfaced: {kinds:?}",
3778 );
3779 assert!(
3780 core.ui_state.toasts().iter().all(|t| t.id != toast_id),
3781 "toast {toast_id} should be dropped after dismiss-click",
3782 );
3783 }
3784
3785 #[test]
3786 fn pointer_moved_without_press_emits_no_drag() {
3787 let mut core = lay_out_input_tree(false);
3788 let events = core.pointer_moved(Pointer::moving(50.0, 50.0)).events;
3789 assert!(!events.iter().any(|e| e.kind == UiEventKind::Drag));
3793 }
3794
3795 #[test]
3796 fn spinner_in_tree_keeps_needs_redraw_set() {
3797 use crate::widgets::spinner::spinner;
3802 let mut tree = crate::column([spinner()]);
3803 let mut core = RunnerCore::new();
3804 let mut t = PrepareTimings::default();
3805 let LayoutPrepared { needs_redraw, .. } = core.prepare_layout(
3806 &mut tree,
3807 Rect::new(0.0, 0.0, 200.0, 200.0),
3808 1.0,
3809 &mut t,
3810 RunnerCore::no_time_shaders,
3811 );
3812 assert!(
3813 needs_redraw,
3814 "tree with a spinner must request continuous redraw",
3815 );
3816
3817 let mut bare = crate::column([crate::widgets::text::text("idle")]);
3821 let mut core2 = RunnerCore::new();
3822 let mut t2 = PrepareTimings::default();
3823 let LayoutPrepared {
3824 needs_redraw: needs_redraw2,
3825 ..
3826 } = core2.prepare_layout(
3827 &mut bare,
3828 Rect::new(0.0, 0.0, 200.0, 200.0),
3829 1.0,
3830 &mut t2,
3831 RunnerCore::no_time_shaders,
3832 );
3833 assert!(
3834 !needs_redraw2,
3835 "tree without time-driven shaders should idle: got needs_redraw={needs_redraw2}",
3836 );
3837 }
3838
3839 #[test]
3840 fn custom_samples_time_shader_keeps_needs_redraw_set() {
3841 let mut tree = crate::column([crate::tree::El::new(crate::tree::Kind::Custom("anim"))
3845 .shader(crate::shader::ShaderBinding::custom("my_animated_glow"))
3846 .width(crate::tree::Size::Fixed(32.0))
3847 .height(crate::tree::Size::Fixed(32.0))]);
3848 let mut core = RunnerCore::new();
3849 let mut t = PrepareTimings::default();
3850
3851 let LayoutPrepared {
3852 needs_redraw: idle, ..
3853 } = core.prepare_layout(
3854 &mut tree,
3855 Rect::new(0.0, 0.0, 200.0, 200.0),
3856 1.0,
3857 &mut t,
3858 RunnerCore::no_time_shaders,
3859 );
3860 assert!(
3861 !idle,
3862 "without a samples_time registration the host should idle",
3863 );
3864
3865 let mut t2 = PrepareTimings::default();
3866 let LayoutPrepared {
3867 needs_redraw: animated,
3868 ..
3869 } = core.prepare_layout(
3870 &mut tree,
3871 Rect::new(0.0, 0.0, 200.0, 200.0),
3872 1.0,
3873 &mut t2,
3874 |handle| matches!(handle, ShaderHandle::Custom("my_animated_glow")),
3875 );
3876 assert!(
3877 animated,
3878 "custom shader registered as samples_time=true must request continuous redraw",
3879 );
3880 }
3881
3882 #[test]
3883 fn redraw_within_aggregates_to_minimum_visible_deadline() {
3884 use std::time::Duration;
3885 let mut tree = crate::column([
3886 crate::widgets::text::text("a")
3888 .redraw_within(Duration::from_millis(16))
3889 .width(crate::tree::Size::Fixed(20.0))
3890 .height(crate::tree::Size::Fixed(20.0)),
3891 crate::widgets::text::text("b")
3893 .redraw_within(Duration::from_millis(50))
3894 .width(crate::tree::Size::Fixed(20.0))
3895 .height(crate::tree::Size::Fixed(20.0)),
3896 ]);
3897 let mut core = RunnerCore::new();
3898 let mut t = PrepareTimings::default();
3899 let LayoutPrepared {
3900 needs_redraw,
3901 next_layout_redraw_in,
3902 ..
3903 } = core.prepare_layout(
3904 &mut tree,
3905 Rect::new(0.0, 0.0, 200.0, 200.0),
3906 1.0,
3907 &mut t,
3908 RunnerCore::no_time_shaders,
3909 );
3910 assert!(needs_redraw, "redraw_within must lift the legacy bool");
3911 assert_eq!(
3912 next_layout_redraw_in,
3913 Some(Duration::from_millis(16)),
3914 "tightest visible deadline wins, on the layout lane",
3915 );
3916 }
3917
3918 #[test]
3919 fn redraw_within_off_screen_widget_is_ignored() {
3920 use std::time::Duration;
3921 let mut tree = crate::column([
3927 crate::tree::spacer().height(crate::tree::Size::Fixed(150.0)),
3928 crate::widgets::text::text("offscreen")
3929 .redraw_within(Duration::from_millis(16))
3930 .width(crate::tree::Size::Fixed(10.0))
3931 .height(crate::tree::Size::Fixed(10.0)),
3932 ]);
3933 let mut core = RunnerCore::new();
3934 let mut t = PrepareTimings::default();
3935 let LayoutPrepared {
3936 next_layout_redraw_in,
3937 ..
3938 } = core.prepare_layout(
3939 &mut tree,
3940 Rect::new(0.0, 0.0, 100.0, 100.0),
3941 1.0,
3942 &mut t,
3943 RunnerCore::no_time_shaders,
3944 );
3945 assert_eq!(
3946 next_layout_redraw_in, None,
3947 "off-screen redraw_within must not contribute to the aggregate",
3948 );
3949 }
3950
3951 #[test]
3952 fn redraw_within_clipped_out_widget_is_ignored() {
3953 use std::time::Duration;
3954
3955 let clipped = crate::column([crate::widgets::text::text("clipped")
3956 .redraw_within(Duration::from_millis(16))
3957 .width(crate::tree::Size::Fixed(10.0))
3958 .height(crate::tree::Size::Fixed(10.0))])
3959 .clip()
3960 .width(crate::tree::Size::Fixed(100.0))
3961 .height(crate::tree::Size::Fixed(20.0))
3962 .layout(|ctx| {
3963 vec![Rect::new(
3964 ctx.container.x,
3965 ctx.container.y + 30.0,
3966 10.0,
3967 10.0,
3968 )]
3969 });
3970 let mut tree = crate::column([clipped]);
3971
3972 let mut core = RunnerCore::new();
3973 let mut t = PrepareTimings::default();
3974 let LayoutPrepared {
3975 next_layout_redraw_in,
3976 ..
3977 } = core.prepare_layout(
3978 &mut tree,
3979 Rect::new(0.0, 0.0, 100.0, 100.0),
3980 1.0,
3981 &mut t,
3982 RunnerCore::no_time_shaders,
3983 );
3984 assert_eq!(
3985 next_layout_redraw_in, None,
3986 "redraw_within inside an inherited clip but outside the clip rect must not contribute",
3987 );
3988 }
3989
3990 #[test]
3991 fn pointer_moved_within_same_hovered_node_does_not_request_redraw() {
3992 let mut core = lay_out_input_tree(false);
3998 let btn = core.rect_of_key("btn").expect("btn rect");
3999 let (cx, cy) = (btn.x + btn.w * 0.5, btn.y + btn.h * 0.5);
4000
4001 let first = core.pointer_moved(Pointer::moving(cx, cy));
4005 assert_eq!(first.events.len(), 1);
4006 assert_eq!(first.events[0].kind, UiEventKind::PointerEnter);
4007 assert_eq!(first.events[0].key.as_deref(), Some("btn"));
4008 assert!(
4009 first.needs_redraw,
4010 "entering a focusable should warrant a redraw",
4011 );
4012
4013 let second = core.pointer_moved(Pointer::moving(cx + 1.0, cy));
4017 assert!(second.events.is_empty());
4018 assert!(
4019 !second.needs_redraw,
4020 "identical hover, no drag → host should idle",
4021 );
4022
4023 let off = core.pointer_moved(Pointer::moving(0.0, 0.0));
4027 assert_eq!(off.events.len(), 1);
4028 assert_eq!(off.events[0].kind, UiEventKind::PointerLeave);
4029 assert_eq!(off.events[0].key.as_deref(), Some("btn"));
4030 assert!(
4031 off.needs_redraw,
4032 "leaving a hovered node still warrants a redraw",
4033 );
4034 }
4035
4036 #[test]
4037 fn pointer_move_within_hover_label_scene_keeps_redrawing() {
4038 use crate::scene::glam::Vec3;
4043 use crate::scene::{
4044 PointData, PointLabels, PointStyle, PointsHandle, ScenePoint, SceneSpec,
4045 };
4046 let pts = PointsHandle::new(PointData {
4047 points: vec![ScenePoint {
4048 position: Vec3::ZERO,
4049 color: [1.0; 4],
4050 }],
4051 });
4052 let spec = SceneSpec::new().points_labeled(
4053 pts,
4054 PointStyle::default(),
4055 PointLabels::new(["a"]).on_hover(),
4056 );
4057 let mut tree = crate::tree::chart3d(spec).key("scene");
4058 let mut core = RunnerCore::new();
4059 crate::layout::layout(
4060 &mut tree,
4061 &mut core.ui_state,
4062 Rect::new(0.0, 0.0, 200.0, 200.0),
4063 );
4064 let mut t = PrepareTimings::default();
4065 core.snapshot(&tree, &mut t);
4066 core.ui_state.tick_scene_cameras(&tree, Instant::now());
4069
4070 let scene = core.rect_of_key("scene").expect("scene rect");
4071 let (cx, cy) = (scene.center_x(), scene.center_y());
4072
4073 let enter = core.pointer_moved(Pointer::moving(cx, cy));
4074 assert!(enter.needs_redraw, "entering a hover-label scene redraws");
4075 let within = core.pointer_moved(Pointer::moving(cx + 3.0, cy - 2.0));
4078 assert!(
4079 within.needs_redraw,
4080 "moving within a hover-label scene keeps redrawing the tooltip",
4081 );
4082 let leave = core.pointer_moved(Pointer::moving(scene.x + scene.w + 20.0, cy));
4084 assert!(leave.needs_redraw, "leaving clears the tooltip");
4085 }
4086
4087 #[test]
4088 fn pointer_moved_between_keyed_targets_emits_leave_then_enter() {
4089 let mut core = lay_out_input_tree(false);
4096 let btn = core.rect_of_key("btn").expect("btn rect");
4097 let ti = core.rect_of_key("ti").expect("ti rect");
4098
4099 let _ = core.pointer_moved(Pointer::moving(btn.x + 4.0, btn.y + 4.0));
4101
4102 let cross = core.pointer_moved(Pointer::moving(ti.x + 4.0, ti.y + 4.0));
4104 let kinds: Vec<UiEventKind> = cross.events.iter().map(|e| e.kind).collect();
4105 assert_eq!(
4106 kinds,
4107 vec![UiEventKind::PointerLeave, UiEventKind::PointerEnter],
4108 "paired Leave-then-Enter on cross-target hover transition",
4109 );
4110 assert_eq!(cross.events[0].key.as_deref(), Some("btn"));
4111 assert_eq!(cross.events[1].key.as_deref(), Some("ti"));
4112 assert!(cross.needs_redraw);
4113 }
4114
4115 #[test]
4116 fn touch_pointer_down_emits_pointer_enter_then_pointer_down() {
4117 let mut core = lay_out_input_tree(false);
4123 let btn = core.rect_of_key("btn").expect("btn rect");
4124 let cx = btn.x + btn.w * 0.5;
4125 let cy = btn.y + btn.h * 0.5;
4126 let events = core.pointer_down(Pointer::touch(
4127 cx,
4128 cy,
4129 PointerButton::Primary,
4130 PointerId::PRIMARY,
4131 ));
4132 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
4133 assert_eq!(
4134 kinds,
4135 vec![UiEventKind::PointerEnter, UiEventKind::PointerDown],
4136 );
4137 for e in &events {
4138 assert_eq!(e.pointer_kind, Some(PointerKind::Touch));
4139 }
4140 assert_eq!(core.ui_state().hovered_key(), Some("btn"));
4141 }
4142
4143 #[test]
4144 fn touch_pointer_up_emits_pointer_leave_after_click() {
4145 let mut core = lay_out_input_tree(false);
4150 let btn = core.rect_of_key("btn").expect("btn rect");
4151 let cx = btn.x + btn.w * 0.5;
4152 let cy = btn.y + btn.h * 0.5;
4153 let _ = core.pointer_down(Pointer::touch(
4154 cx,
4155 cy,
4156 PointerButton::Primary,
4157 PointerId::PRIMARY,
4158 ));
4159 let events = core.pointer_up(Pointer::touch(
4160 cx,
4161 cy,
4162 PointerButton::Primary,
4163 PointerId::PRIMARY,
4164 ));
4165 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
4166 assert_eq!(
4167 kinds,
4168 vec![
4169 UiEventKind::PointerUp,
4170 UiEventKind::Click,
4171 UiEventKind::PointerLeave,
4172 ],
4173 );
4174 assert_eq!(core.ui_state().hovered_key(), None);
4175 }
4176
4177 #[test]
4178 fn touch_pointer_moved_without_press_does_not_emit_hover_transitions() {
4179 let mut core = lay_out_input_tree(false);
4187 let btn = core.rect_of_key("btn").expect("btn rect");
4188 let mut p = Pointer::moving(btn.x + 4.0, btn.y + 4.0);
4189 p.kind = PointerKind::Touch;
4190 let moved = core.pointer_moved(p);
4191 assert!(
4192 moved.events.is_empty(),
4193 "touch move without press should not emit hover events, got {:?}",
4194 moved.events.iter().map(|e| e.kind).collect::<Vec<_>>(),
4195 );
4196 }
4197
4198 #[test]
4199 fn touch_drag_between_targets_still_emits_hover_transitions() {
4200 use crate::tree::*;
4211 let mut tree = crate::column([
4212 crate::widgets::button::button("Btn")
4213 .key("btn")
4214 .consumes_touch_drag(),
4215 crate::widgets::button::button("Other").key("other"),
4216 ])
4217 .padding(10.0);
4218 let mut core = RunnerCore::new();
4219 crate::layout::layout(
4220 &mut tree,
4221 &mut core.ui_state,
4222 Rect::new(0.0, 0.0, 200.0, 200.0),
4223 );
4224 core.ui_state.sync_focus_order(&tree);
4225 let mut t = PrepareTimings::default();
4226 core.snapshot(&tree, &mut t);
4227
4228 let btn = core.rect_of_key("btn").expect("btn rect");
4229 let other = core.rect_of_key("other").expect("other rect");
4230 let _ = core.pointer_down(Pointer::touch(
4231 btn.x + 4.0,
4232 btn.y + 4.0,
4233 PointerButton::Primary,
4234 PointerId::PRIMARY,
4235 ));
4236 let mut move_p = Pointer::moving(other.x + 4.0, other.y + 4.0);
4237 move_p.kind = PointerKind::Touch;
4238 let cross = core.pointer_moved(move_p);
4239 let kinds: Vec<UiEventKind> = cross.events.iter().map(|e| e.kind).collect();
4240 assert!(
4241 kinds.contains(&UiEventKind::PointerLeave)
4242 && kinds.contains(&UiEventKind::PointerEnter),
4243 "touch drag across targets should emit Leave + Enter, got {kinds:?}",
4244 );
4245 assert!(kinds.contains(&UiEventKind::Drag));
4249 }
4250
4251 #[test]
4252 fn would_press_focus_text_input_distinguishes_capture_keys() {
4253 let core = lay_out_input_tree(true);
4258 let ti = core.rect_of_key("ti").expect("ti rect");
4259 let btn = core.rect_of_key("btn").expect("btn rect");
4260
4261 assert!(
4262 core.would_press_focus_text_input(ti.center_x(), ti.center_y()),
4263 "press on capture_keys widget should report true",
4264 );
4265 assert!(
4266 !core.would_press_focus_text_input(btn.center_x(), btn.center_y()),
4267 "press on plain focusable should report false",
4268 );
4269 assert!(!core.would_press_focus_text_input(0.0, 0.0));
4271 }
4272
4273 #[test]
4274 fn touch_jiggle_below_threshold_still_taps() {
4275 let mut core = lay_out_input_tree(false);
4281 let btn = core.rect_of_key("btn").expect("btn rect");
4282 let cx = btn.x + btn.w * 0.5;
4283 let cy = btn.y + btn.h * 0.5;
4284 let _ = core.pointer_down(Pointer::touch(
4285 cx,
4286 cy,
4287 PointerButton::Primary,
4288 PointerId::PRIMARY,
4289 ));
4290 let mut jiggle = Pointer::moving(cx + 3.0, cy + 2.0);
4292 jiggle.kind = PointerKind::Touch;
4293 let _ = core.pointer_moved(jiggle);
4294 let events = core.pointer_up(Pointer::touch(
4295 cx + 3.0,
4296 cy + 2.0,
4297 PointerButton::Primary,
4298 PointerId::PRIMARY,
4299 ));
4300 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
4301 assert!(
4302 kinds.contains(&UiEventKind::Click),
4303 "small jiggle should not commit to scroll, expected Click in {kinds:?}",
4304 );
4305 }
4306
4307 #[test]
4308 fn touch_drag_on_consuming_widget_emits_drag_not_cancel() {
4309 use crate::tree::*;
4314 let mut tree = crate::column([crate::widgets::button::button("Drag me")
4315 .key("draggable")
4316 .consumes_touch_drag()])
4317 .padding(10.0);
4318 let mut core = RunnerCore::new();
4319 crate::layout::layout(
4320 &mut tree,
4321 &mut core.ui_state,
4322 Rect::new(0.0, 0.0, 200.0, 200.0),
4323 );
4324 core.ui_state.sync_focus_order(&tree);
4325 let mut t = PrepareTimings::default();
4326 core.snapshot(&tree, &mut t);
4327
4328 let r = core.rect_of_key("draggable").expect("rect");
4329 let cx = r.x + r.w * 0.5;
4330 let cy = r.y + r.h * 0.5;
4331 let _ = core.pointer_down(Pointer::touch(
4332 cx,
4333 cy,
4334 PointerButton::Primary,
4335 PointerId::PRIMARY,
4336 ));
4337 let mut over = Pointer::moving(cx + 30.0, cy);
4340 over.kind = PointerKind::Touch;
4341 let moved = core.pointer_moved(over);
4342 let kinds: Vec<UiEventKind> = moved.events.iter().map(|e| e.kind).collect();
4343 assert!(
4344 kinds.contains(&UiEventKind::Drag),
4345 "drag-consuming widget should receive Drag past threshold, got {kinds:?}",
4346 );
4347 assert!(
4348 !kinds.contains(&UiEventKind::PointerCancel),
4349 "drag-consuming widget should not see PointerCancel, got {kinds:?}",
4350 );
4351 }
4352
4353 #[test]
4354 fn touch_drag_in_scrollable_cancels_press_and_scrolls() {
4355 use crate::tree::*;
4362 let mut tree = crate::scroll([
4363 crate::widgets::button::button("row 0")
4364 .key("row0")
4365 .height(Size::Fixed(50.0)),
4366 crate::widgets::button::button("row 1")
4367 .key("row1")
4368 .height(Size::Fixed(50.0)),
4369 crate::widgets::button::button("row 2")
4370 .key("row2")
4371 .height(Size::Fixed(50.0)),
4372 crate::widgets::button::button("row 3")
4373 .key("row3")
4374 .height(Size::Fixed(50.0)),
4375 crate::widgets::button::button("row 4")
4376 .key("row4")
4377 .height(Size::Fixed(50.0)),
4378 ])
4379 .key("list")
4380 .height(Size::Fixed(120.0));
4381 let mut core = RunnerCore::new();
4382 crate::layout::layout(
4383 &mut tree,
4384 &mut core.ui_state,
4385 Rect::new(0.0, 0.0, 200.0, 120.0),
4386 );
4387 core.ui_state.sync_focus_order(&tree);
4388 let mut t = PrepareTimings::default();
4389 core.snapshot(&tree, &mut t);
4390 let scroll_id = core
4391 .last_tree
4392 .as_ref()
4393 .map(|t| t.computed_id.clone())
4394 .expect("scroll id");
4395
4396 let row1 = core.rect_of_key("row1").expect("row1");
4401 let cx = row1.x + row1.w * 0.5;
4402 let cy = row1.y + row1.h * 0.5;
4403
4404 let down_events = core.pointer_down(Pointer::touch(
4406 cx,
4407 cy,
4408 PointerButton::Primary,
4409 PointerId::PRIMARY,
4410 ));
4411 assert!(
4413 down_events
4414 .iter()
4415 .any(|e| matches!(e.kind, UiEventKind::PointerDown)),
4416 "expected PointerDown on press",
4417 );
4418
4419 let mut up_finger = Pointer::moving(cx, cy - 40.0);
4423 up_finger.kind = PointerKind::Touch;
4424 let move_events = core.pointer_moved(up_finger);
4425 let kinds: Vec<UiEventKind> = move_events.events.iter().map(|e| e.kind).collect();
4426 assert!(
4427 kinds.contains(&UiEventKind::PointerCancel),
4428 "scroll commit should fire PointerCancel, got {kinds:?}",
4429 );
4430 assert!(
4431 !kinds.contains(&UiEventKind::Drag),
4432 "scroll commit should NOT emit Drag, got {kinds:?}",
4433 );
4434
4435 let offset = core.ui_state().scroll_offset(&scroll_id);
4437 assert!(
4438 offset > 30.0 && offset <= 50.0,
4439 "scroll offset should advance ~40px after a 40px finger drag, got {offset}",
4440 );
4441
4442 let up_events = core.pointer_up(Pointer::touch(
4445 cx,
4446 cy - 40.0,
4447 PointerButton::Primary,
4448 PointerId::PRIMARY,
4449 ));
4450 let up_kinds: Vec<UiEventKind> = up_events.iter().map(|e| e.kind).collect();
4451 assert!(
4452 !up_kinds.contains(&UiEventKind::Click),
4453 "scroll-committed gesture must not fire Click on release, got {up_kinds:?}",
4454 );
4455 }
4456
4457 #[test]
4458 fn touch_scroll_release_starts_momentum_after_fast_swipe_outside_viewport() {
4459 use crate::tree::*;
4460 let mut tree = crate::scroll((0..20).map(|i| {
4461 crate::widgets::button::button(format!("row {i}"))
4462 .key(format!("row{i}"))
4463 .height(Size::Fixed(50.0))
4464 }))
4465 .key("list")
4466 .height(Size::Fixed(120.0));
4467 let mut core = RunnerCore::new();
4468 crate::layout::layout(
4469 &mut tree,
4470 &mut core.ui_state,
4471 Rect::new(0.0, 0.0, 200.0, 120.0),
4472 );
4473 core.ui_state.sync_focus_order(&tree);
4474 let mut t = PrepareTimings::default();
4475 core.snapshot(&tree, &mut t);
4476 let scroll_id = core
4477 .last_tree
4478 .as_ref()
4479 .map(|t| t.computed_id.clone())
4480 .expect("scroll id");
4481
4482 let row1 = core.rect_of_key("row1").expect("row1");
4483 let cx = row1.x + row1.w * 0.5;
4484 let cy = row1.y + row1.h * 0.5;
4485
4486 core.pointer_down(Pointer::touch(
4487 cx,
4488 cy,
4489 PointerButton::Primary,
4490 PointerId::PRIMARY,
4491 ));
4492 let mut up_finger = Pointer::moving(cx, cy - 80.0);
4493 up_finger.kind = PointerKind::Touch;
4494 core.pointer_moved(up_finger);
4495 let before_release = core.ui_state().scroll_offset(&scroll_id);
4496 core.pointer_up(Pointer::touch(
4497 cx,
4498 cy - 80.0,
4499 PointerButton::Primary,
4500 PointerId::PRIMARY,
4501 ));
4502
4503 assert!(
4504 core.ui_state.has_scroll_momentum(),
4505 "quick touch scroll release should retain inertial velocity"
4506 );
4507 assert_eq!(
4508 core.next_input_deadline(Instant::now()),
4509 Some(Duration::ZERO),
4510 "active scroll momentum should request the next layout frame"
4511 );
4512
4513 let ticked = core
4514 .ui_state
4515 .tick_scroll_momentum(Instant::now() + Duration::from_millis(16));
4516 let after_tick = core.ui_state().scroll_offset(&scroll_id);
4517 assert!(ticked, "momentum tick should report visual work");
4518 assert!(
4519 after_tick > before_release,
4520 "momentum should continue in release direction: before={before_release}, after={after_tick}"
4521 );
4522 }
4523
4524 #[test]
4525 fn pointer_left_emits_leave_for_prior_hover() {
4526 let mut core = lay_out_input_tree(false);
4527 let btn = core.rect_of_key("btn").expect("btn rect");
4528 let _ = core.pointer_moved(Pointer::moving(btn.x + 4.0, btn.y + 4.0));
4529
4530 let events = core.pointer_left();
4531 assert_eq!(events.len(), 1);
4532 assert_eq!(events[0].kind, UiEventKind::PointerLeave);
4533 assert_eq!(events[0].key.as_deref(), Some("btn"));
4534 }
4535
4536 #[test]
4537 fn pointer_left_with_no_prior_hover_emits_nothing() {
4538 let mut core = lay_out_input_tree(false);
4539 let events = core.pointer_left();
4542 assert!(events.is_empty());
4543 }
4544
4545 #[test]
4546 fn poll_input_before_long_press_delay_emits_nothing() {
4547 let mut core = lay_out_input_tree(false);
4550 let btn = core.rect_of_key("btn").expect("btn rect");
4551 let cx = btn.x + btn.w * 0.5;
4552 let cy = btn.y + btn.h * 0.5;
4553 let _ = core.pointer_down(Pointer::touch(
4554 cx,
4555 cy,
4556 PointerButton::Primary,
4557 PointerId::PRIMARY,
4558 ));
4559 let polled = core.poll_input(Instant::now() + Duration::from_millis(100));
4561 assert!(polled.is_empty(), "should not fire before delay");
4562 }
4563
4564 #[test]
4565 fn poll_input_after_long_press_delay_fires_cancel_then_long_press() {
4566 let mut core = lay_out_input_tree(false);
4570 let btn = core.rect_of_key("btn").expect("btn rect");
4571 let cx = btn.x + btn.w * 0.5;
4572 let cy = btn.y + btn.h * 0.5;
4573 let _ = core.pointer_down(Pointer::touch(
4574 cx,
4575 cy,
4576 PointerButton::Primary,
4577 PointerId::PRIMARY,
4578 ));
4579 let polled = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
4580 let kinds: Vec<UiEventKind> = polled.iter().map(|e| e.kind).collect();
4581 assert!(
4582 kinds.contains(&UiEventKind::PointerCancel),
4583 "expected PointerCancel before LongPress, got {kinds:?}",
4584 );
4585 let long_press = polled
4586 .iter()
4587 .find(|e| matches!(e.kind, UiEventKind::LongPress))
4588 .expect("LongPress event missing");
4589 assert_eq!(
4590 long_press.key.as_deref(),
4591 Some("btn"),
4592 "LongPress should target the originally pressed node",
4593 );
4594 assert_eq!(
4595 long_press.pointer_kind,
4596 Some(PointerKind::Touch),
4597 "LongPress is touch-only",
4598 );
4599 }
4600
4601 #[test]
4602 fn touch_long_press_on_editable_preserves_drag_extension() {
4603 let mut core = lay_out_input_tree(true);
4604 let ti = core.rect_of_key("ti").expect("ti rect");
4605 let cx = ti.x + 4.0;
4606 let cy = ti.y + ti.h * 0.5;
4607 let _ = core.pointer_down(Pointer::touch(
4608 cx,
4609 cy,
4610 PointerButton::Primary,
4611 PointerId::PRIMARY,
4612 ));
4613
4614 let polled = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
4615 assert!(
4616 polled.iter().any(|e| e.kind == UiEventKind::LongPress),
4617 "editable long-press should emit LongPress"
4618 );
4619 assert!(
4620 !polled.iter().any(|e| e.kind == UiEventKind::PointerCancel),
4621 "editable long-press keeps the press captured so drag can extend selection"
4622 );
4623
4624 let mut moved = Pointer::moving(cx + 40.0, cy);
4625 moved.kind = PointerKind::Touch;
4626 let drag = core.pointer_moved(moved);
4627 assert!(
4628 drag.events.iter().any(|e| e.kind == UiEventKind::Drag),
4629 "held touch move after editable long-press should emit Drag"
4630 );
4631
4632 let up_events = core.pointer_up(Pointer::touch(
4633 cx + 40.0,
4634 cy,
4635 PointerButton::Primary,
4636 PointerId::PRIMARY,
4637 ));
4638 assert!(
4639 up_events.is_empty(),
4640 "long-press release should not synthesize click or pointer-up"
4641 );
4642 }
4643
4644 #[test]
4645 fn pointer_up_after_long_press_emits_no_click() {
4646 let mut core = lay_out_input_tree(false);
4650 let btn = core.rect_of_key("btn").expect("btn rect");
4651 let cx = btn.x + btn.w * 0.5;
4652 let cy = btn.y + btn.h * 0.5;
4653 let _ = core.pointer_down(Pointer::touch(
4654 cx,
4655 cy,
4656 PointerButton::Primary,
4657 PointerId::PRIMARY,
4658 ));
4659 let _ = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
4660 let up_events = core.pointer_up(Pointer::touch(
4661 cx,
4662 cy,
4663 PointerButton::Primary,
4664 PointerId::PRIMARY,
4665 ));
4666 assert!(
4667 up_events.is_empty(),
4668 "lift after long-press emits nothing, got {:?}",
4669 up_events.iter().map(|e| e.kind).collect::<Vec<_>>(),
4670 );
4671 }
4672
4673 #[test]
4674 fn moving_past_threshold_before_long_press_cancels_the_timer() {
4675 let mut core = lay_out_input_tree(false);
4680 let btn = core.rect_of_key("btn").expect("btn rect");
4681 let cx = btn.x + btn.w * 0.5;
4682 let cy = btn.y + btn.h * 0.5;
4683 let _ = core.pointer_down(Pointer::touch(
4684 cx,
4685 cy,
4686 PointerButton::Primary,
4687 PointerId::PRIMARY,
4688 ));
4689 let mut over = Pointer::moving(cx + 30.0, cy);
4691 over.kind = PointerKind::Touch;
4692 let _ = core.pointer_moved(over);
4693 let polled = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
4695 assert!(
4696 polled.is_empty(),
4697 "long-press should not fire after gesture committed",
4698 );
4699 }
4700
4701 #[test]
4702 fn ui_state_hovered_key_returns_leaf_key() {
4703 let mut core = lay_out_input_tree(false);
4704 assert_eq!(core.ui_state().hovered_key(), None);
4705
4706 let btn = core.rect_of_key("btn").expect("btn rect");
4707 core.pointer_moved(Pointer::moving(btn.x + 4.0, btn.y + 4.0));
4708 assert_eq!(core.ui_state().hovered_key(), Some("btn"));
4709
4710 core.pointer_moved(Pointer::moving(0.0, 0.0));
4712 assert_eq!(core.ui_state().hovered_key(), None);
4713 }
4714
4715 #[test]
4716 fn ui_state_is_hovering_within_walks_subtree() {
4717 use crate::tree::*;
4721 let mut tree = crate::column([crate::stack([
4722 crate::widgets::button::button("Inner").key("inner_btn")
4723 ])
4724 .key("card")
4725 .focusable()
4726 .width(Size::Fixed(120.0))
4727 .height(Size::Fixed(60.0))])
4728 .padding(20.0);
4729 let mut core = RunnerCore::new();
4730 crate::layout::layout(
4731 &mut tree,
4732 &mut core.ui_state,
4733 Rect::new(0.0, 0.0, 400.0, 200.0),
4734 );
4735 core.ui_state.sync_focus_order(&tree);
4736 let mut t = PrepareTimings::default();
4737 core.snapshot(&tree, &mut t);
4738
4739 assert!(!core.ui_state().is_hovering_within("card"));
4741 assert!(!core.ui_state().is_hovering_within("inner_btn"));
4742
4743 let inner = core.rect_of_key("inner_btn").expect("inner rect");
4746 core.pointer_moved(Pointer::moving(inner.x + 4.0, inner.y + 4.0));
4747 assert!(core.ui_state().is_hovering_within("card"));
4748 assert!(core.ui_state().is_hovering_within("inner_btn"));
4749
4750 assert!(!core.ui_state().is_hovering_within("not_a_key"));
4752
4753 core.pointer_moved(Pointer::moving(0.0, 0.0));
4755 assert!(!core.ui_state().is_hovering_within("card"));
4756 assert!(!core.ui_state().is_hovering_within("inner_btn"));
4757 }
4758
4759 #[test]
4760 fn hover_driven_scale_via_is_hovering_within_plus_animate() {
4761 use crate::Theme;
4768 use crate::anim::Timing;
4769 use crate::tree::*;
4770
4771 let build_card = |hovering: bool| -> El {
4774 let scale = if hovering { 1.05 } else { 1.0 };
4775 crate::column([crate::stack(
4776 [crate::widgets::button::button("Inner").key("inner_btn")],
4777 )
4778 .key("card")
4779 .focusable()
4780 .scale(scale)
4781 .animate(Timing::SPRING_QUICK)
4782 .width(Size::Fixed(120.0))
4783 .height(Size::Fixed(60.0))])
4784 .padding(20.0)
4785 };
4786
4787 let mut core = RunnerCore::new();
4788 core.ui_state
4791 .set_animation_mode(crate::state::AnimationMode::Settled);
4792
4793 let theme = Theme::default();
4795 let cx_pre = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
4796 assert!(!cx_pre.is_hovering_within("card"));
4797 let mut tree = build_card(cx_pre.is_hovering_within("card"));
4798 crate::layout::layout(
4799 &mut tree,
4800 &mut core.ui_state,
4801 Rect::new(0.0, 0.0, 400.0, 200.0),
4802 );
4803 core.ui_state.sync_focus_order(&tree);
4804 let mut t = PrepareTimings::default();
4805 core.snapshot(&tree, &mut t);
4806 core.ui_state
4807 .tick_visual_animations(&mut tree, web_time::Instant::now(), theme.palette());
4808 let card_at_rest = tree.children[0].clone();
4809 assert!((card_at_rest.scale - 1.0).abs() < 1e-3);
4810
4811 let card_rect = core.rect_of_key("card").expect("card rect");
4813 core.pointer_moved(Pointer::moving(card_rect.x + 4.0, card_rect.y + 4.0));
4814
4815 let cx_hot = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
4818 assert!(cx_hot.is_hovering_within("card"));
4819 let mut tree = build_card(cx_hot.is_hovering_within("card"));
4820 crate::layout::layout(
4821 &mut tree,
4822 &mut core.ui_state,
4823 Rect::new(0.0, 0.0, 400.0, 200.0),
4824 );
4825 core.ui_state.sync_focus_order(&tree);
4826 core.snapshot(&tree, &mut t);
4827 core.ui_state
4828 .tick_visual_animations(&mut tree, web_time::Instant::now(), theme.palette());
4829 let card_hot = tree.children[0].clone();
4830 assert!(
4831 (card_hot.scale - 1.05).abs() < 1e-3,
4832 "hover should drive card scale to 1.05 via animate; got {}",
4833 card_hot.scale,
4834 );
4835
4836 core.pointer_moved(Pointer::moving(0.0, 0.0));
4838 let cx_cold = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
4839 assert!(!cx_cold.is_hovering_within("card"));
4840 let mut tree = build_card(cx_cold.is_hovering_within("card"));
4841 crate::layout::layout(
4842 &mut tree,
4843 &mut core.ui_state,
4844 Rect::new(0.0, 0.0, 400.0, 200.0),
4845 );
4846 core.ui_state.sync_focus_order(&tree);
4847 core.snapshot(&tree, &mut t);
4848 core.ui_state
4849 .tick_visual_animations(&mut tree, web_time::Instant::now(), theme.palette());
4850 let card_after = tree.children[0].clone();
4851 assert!((card_after.scale - 1.0).abs() < 1e-3);
4852 }
4853
4854 #[test]
4855 fn file_dropped_routes_to_keyed_leaf_at_pointer() {
4856 let mut core = lay_out_input_tree(false);
4857 let btn = core.rect_of_key("btn").expect("btn rect");
4858 let path = std::path::PathBuf::from("/tmp/screenshot.png");
4859 let events = core.file_dropped(path.clone(), btn.x + 4.0, btn.y + 4.0);
4860 assert_eq!(events.len(), 1);
4861 let event = &events[0];
4862 assert_eq!(event.kind, UiEventKind::FileDropped);
4863 assert_eq!(event.key.as_deref(), Some("btn"));
4864 assert_eq!(event.path.as_deref(), Some(path.as_path()));
4865 assert_eq!(event.pointer, Some((btn.x + 4.0, btn.y + 4.0)));
4866 }
4867
4868 #[test]
4869 fn file_dropped_outside_keyed_surface_emits_window_level_event() {
4870 let mut core = lay_out_input_tree(false);
4871 let path = std::path::PathBuf::from("/tmp/screenshot.png");
4873 let events = core.file_dropped(path.clone(), 1.0, 1.0);
4874 assert_eq!(events.len(), 1);
4875 let event = &events[0];
4876 assert_eq!(event.kind, UiEventKind::FileDropped);
4877 assert!(
4878 event.target.is_none(),
4879 "drop outside any keyed surface routes window-level",
4880 );
4881 assert!(event.key.is_none());
4882 assert_eq!(event.path.as_deref(), Some(path.as_path()));
4884 }
4885
4886 #[test]
4887 fn file_hovered_then_cancelled_pair() {
4888 let mut core = lay_out_input_tree(false);
4889 let btn = core.rect_of_key("btn").expect("btn rect");
4890 let path = std::path::PathBuf::from("/tmp/a.png");
4891
4892 let hover = core.file_hovered(path.clone(), btn.x + 4.0, btn.y + 4.0);
4893 assert_eq!(hover.len(), 1);
4894 assert_eq!(hover[0].kind, UiEventKind::FileHovered);
4895 assert_eq!(hover[0].key.as_deref(), Some("btn"));
4896 assert_eq!(hover[0].path.as_deref(), Some(path.as_path()));
4897
4898 let cancel = core.file_hover_cancelled();
4899 assert_eq!(cancel.len(), 1);
4900 assert_eq!(cancel[0].kind, UiEventKind::FileHoverCancelled);
4901 assert!(cancel[0].target.is_none());
4902 assert!(cancel[0].path.is_none());
4903 }
4904
4905 #[test]
4906 fn build_cx_hover_accessors_default_off_without_state() {
4907 use crate::Theme;
4908 let theme = Theme::default();
4909 let cx = crate::BuildCx::new(&theme);
4910 assert_eq!(cx.hovered_key(), None);
4911 assert!(!cx.is_hovering_within("anything"));
4912 }
4913
4914 #[test]
4915 fn build_cx_hover_accessors_delegate_when_state_attached() {
4916 use crate::Theme;
4917 let mut core = lay_out_input_tree(false);
4918 let btn = core.rect_of_key("btn").expect("btn rect");
4919 core.pointer_moved(Pointer::moving(btn.x + 4.0, btn.y + 4.0));
4920
4921 let theme = Theme::default();
4922 let cx = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
4923 assert_eq!(cx.hovered_key(), Some("btn"));
4924 assert!(cx.is_hovering_within("btn"));
4925 assert!(!cx.is_hovering_within("ti"));
4926 }
4927
4928 #[test]
4929 fn build_cx_rect_of_key_reads_previous_layout() {
4930 use crate::Theme;
4931 let core = lay_out_input_tree(false);
4932 let theme = Theme::default();
4933
4934 let detached = crate::BuildCx::new(&theme);
4935 assert_eq!(detached.rect_of_key("btn"), None);
4936
4937 let cx = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
4938 assert_eq!(cx.rect_of_key("btn"), core.rect_of_key("btn"));
4939 assert_eq!(cx.rect_of_key("missing"), None);
4940 }
4941
4942 #[test]
4943 fn event_cx_rect_of_key_answers_from_laid_out_state() {
4944 let core = lay_out_input_tree(false);
4945
4946 let detached = crate::EventCx::new();
4947 assert_eq!(detached.rect_of_key("btn"), None);
4948
4949 let cx = crate::EventCx::new().with_ui_state(core.ui_state());
4950 let rect = cx.rect_of_key("btn").expect("btn rect via EventCx");
4951 assert_eq!(Some(rect), core.rect_of_key("btn"));
4952 assert_eq!(cx.rect_of_key("missing"), None);
4953 }
4954
4955 #[test]
4956 fn event_cx_viewport_and_diagnostics_mirror_build_cx() {
4957 let detached = crate::EventCx::new();
4960 assert_eq!(detached.viewport(), None);
4961 assert_eq!(detached.viewport_width(), None);
4962 assert_eq!(detached.viewport_height(), None);
4963 assert!(!detached.viewport_below(10_000.0));
4964 assert!(detached.diagnostics().is_none());
4965
4966 let diagnostics = crate::HostDiagnostics {
4967 backend: "Test",
4968 ..Default::default()
4969 };
4970 let cx = crate::EventCx::new()
4971 .with_viewport(800.0, 600.0)
4972 .with_diagnostics(&diagnostics);
4973 assert_eq!(cx.viewport(), Some((800.0, 600.0)));
4974 assert_eq!(cx.viewport_width(), Some(800.0));
4975 assert_eq!(cx.viewport_height(), Some(600.0));
4976 assert!(cx.viewport_below(900.0));
4977 assert!(!cx.viewport_below(800.0));
4978 assert_eq!(cx.diagnostics().map(|d| d.backend), Some("Test"));
4979 }
4980
4981 fn lay_out_paragraph_tree() -> RunnerCore {
4982 use crate::tree::*;
4983 let mut tree = crate::column([
4984 crate::widgets::text::text("First paragraph of text.")
4985 .key("p1")
4986 .selectable(),
4987 crate::widgets::text::text("Second paragraph of text.")
4988 .key("p2")
4989 .selectable(),
4990 ])
4991 .padding(20.0);
4992 let mut core = RunnerCore::new();
4993 crate::layout::layout(
4994 &mut tree,
4995 &mut core.ui_state,
4996 Rect::new(0.0, 0.0, 400.0, 300.0),
4997 );
4998 core.ui_state.sync_focus_order(&tree);
4999 core.ui_state.sync_selection_order(&tree);
5000 let mut t = PrepareTimings::default();
5001 core.snapshot(&tree, &mut t);
5002 core
5003 }
5004
5005 #[test]
5006 fn pointer_down_on_selectable_text_emits_selection_changed() {
5007 let mut core = lay_out_paragraph_tree();
5008 let p1 = core.rect_of_key("p1").expect("p1 rect");
5009 let cx = p1.x + 4.0;
5010 let cy = p1.y + p1.h * 0.5;
5011 let events = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5012 let sel_event = events
5013 .iter()
5014 .find(|e| e.kind == UiEventKind::SelectionChanged)
5015 .expect("SelectionChanged emitted");
5016 let new_sel = sel_event
5017 .selection
5018 .as_ref()
5019 .expect("SelectionChanged carries a selection");
5020 let range = new_sel.range.as_ref().expect("collapsed selection at hit");
5021 assert_eq!(range.anchor.key, "p1");
5022 assert_eq!(range.head.key, "p1");
5023 assert_eq!(range.anchor.byte, range.head.byte);
5024 assert!(core.ui_state.selection.drag.is_some());
5025 }
5026
5027 #[test]
5028 fn touch_long_press_on_selectable_text_selects_word_and_drags() {
5029 let mut core = lay_out_paragraph_tree();
5030 let p1 = core.rect_of_key("p1").expect("p1 rect");
5031 let p2 = core.rect_of_key("p2").expect("p2 rect");
5032 let x = p1.x + 4.0;
5033 let y = p1.y + p1.h * 0.5;
5034
5035 let _ = core.pointer_down(Pointer::touch(
5036 x,
5037 y,
5038 PointerButton::Primary,
5039 PointerId::PRIMARY,
5040 ));
5041 let polled = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
5042 let selection = polled
5043 .iter()
5044 .rev()
5045 .find(|e| e.kind == UiEventKind::SelectionChanged)
5046 .and_then(|e| e.selection.as_ref())
5047 .expect("touch long-press should select text");
5048 let range = selection.range.as_ref().expect("word selection");
5049 assert_eq!(range.anchor.key, "p1");
5050 assert_eq!(range.head.key, "p1");
5051 assert_eq!(range.anchor.byte, 0);
5052 assert_eq!(range.head.byte, 5);
5053 assert!(
5054 core.ui_state.selection.drag.is_some(),
5055 "long-pressed selectable text should stay ready for drag extension"
5056 );
5057
5058 let mut moved = Pointer::moving(p2.x + 8.0, p2.y + p2.h * 0.5);
5059 moved.kind = PointerKind::Touch;
5060 let events = core.pointer_moved(moved).events;
5061 let selection = events
5062 .iter()
5063 .find(|e| e.kind == UiEventKind::SelectionChanged)
5064 .and_then(|e| e.selection.as_ref())
5065 .unwrap_or(&core.ui_state.current_selection);
5066 let range = selection.range.as_ref().expect("extended selection");
5067 assert_eq!(range.anchor.key, "p1");
5068 assert_eq!(range.head.key, "p2");
5069
5070 let _ = core.pointer_up(Pointer::touch(
5071 p2.x + 8.0,
5072 p2.y + p2.h * 0.5,
5073 PointerButton::Primary,
5074 PointerId::PRIMARY,
5075 ));
5076 assert!(
5077 core.ui_state.selection.drag.is_none(),
5078 "selection drag should end on lift"
5079 );
5080 }
5081
5082 #[test]
5083 fn pointer_drag_on_selectable_text_extends_head() {
5084 let mut core = lay_out_paragraph_tree();
5085 let p1 = core.rect_of_key("p1").expect("p1 rect");
5086 let cx = p1.x + 4.0;
5087 let cy = p1.y + p1.h * 0.5;
5088 core.pointer_moved(Pointer::moving(cx, cy));
5089 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5090
5091 let events = core
5093 .pointer_moved(Pointer::moving(p1.x + p1.w - 10.0, cy))
5094 .events;
5095 let sel_event = events
5096 .iter()
5097 .find(|e| e.kind == UiEventKind::SelectionChanged)
5098 .expect("Drag emits SelectionChanged");
5099 let new_sel = sel_event.selection.as_ref().unwrap();
5100 let range = new_sel.range.as_ref().unwrap();
5101 assert_eq!(range.anchor.key, "p1");
5102 assert_eq!(range.head.key, "p1");
5103 assert!(
5104 range.head.byte > range.anchor.byte,
5105 "head should advance past anchor (anchor={}, head={})",
5106 range.anchor.byte,
5107 range.head.byte
5108 );
5109 }
5110
5111 #[test]
5112 fn double_click_hold_drag_inside_selectable_word_keeps_word_selected() {
5113 let mut core = lay_out_paragraph_tree();
5114 let p1 = core.rect_of_key("p1").expect("p1 rect");
5115 let cx = p1.x + 4.0;
5116 let cy = p1.y + p1.h * 0.5;
5117
5118 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5119 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5120 let down = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5121 let sel = down
5122 .iter()
5123 .find(|e| e.kind == UiEventKind::SelectionChanged)
5124 .and_then(|e| e.selection.as_ref())
5125 .and_then(|s| s.range.as_ref())
5126 .expect("double-click selects word");
5127 assert_eq!(sel.anchor.byte, 0);
5128 assert_eq!(sel.head.byte, 5);
5129
5130 let events = core.pointer_moved(Pointer::moving(cx + 1.0, cy)).events;
5131 assert!(
5132 !events
5133 .iter()
5134 .any(|e| e.kind == UiEventKind::SelectionChanged),
5135 "drag jitter within the double-clicked word should not collapse the selection"
5136 );
5137 let range = core
5138 .ui_state
5139 .current_selection
5140 .range
5141 .as_ref()
5142 .expect("selection persists");
5143 assert_eq!(range.anchor.byte, 0);
5144 assert_eq!(range.head.byte, 5);
5145 }
5146
5147 #[test]
5148 fn pointer_up_clears_drag_but_keeps_selection() {
5149 let mut core = lay_out_paragraph_tree();
5150 let p1 = core.rect_of_key("p1").expect("p1 rect");
5151 let cx = p1.x + 4.0;
5152 let cy = p1.y + p1.h * 0.5;
5153 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5154 core.pointer_moved(Pointer::moving(p1.x + p1.w - 10.0, cy));
5155 let _ = core.pointer_up(Pointer::mouse(
5156 p1.x + p1.w - 10.0,
5157 cy,
5158 PointerButton::Primary,
5159 ));
5160 assert!(
5161 core.ui_state.selection.drag.is_none(),
5162 "drag flag should clear on pointer_up"
5163 );
5164 assert!(
5165 !core.ui_state.current_selection.is_empty(),
5166 "selection itself should persist after pointer_up"
5167 );
5168 }
5169
5170 #[test]
5171 fn drag_past_a_leaf_bottom_keeps_head_in_that_leaf_not_anchor() {
5172 let mut core = lay_out_paragraph_tree();
5178 let p1 = core.rect_of_key("p1").expect("p1 rect");
5179 let p2 = core.rect_of_key("p2").expect("p2 rect");
5180 core.pointer_down(Pointer::mouse(
5182 p1.x + 4.0,
5183 p1.y + p1.h * 0.5,
5184 PointerButton::Primary,
5185 ));
5186 core.pointer_moved(Pointer::moving(p2.x + 8.0, p2.y + p2.h * 0.5));
5188 let events = core
5191 .pointer_moved(Pointer::moving(p2.x + 8.0, p2.y + p2.h + 200.0))
5192 .events;
5193 let sel = events
5194 .iter()
5195 .find(|e| e.kind == UiEventKind::SelectionChanged)
5196 .map(|e| e.selection.as_ref().unwrap().clone())
5197 .unwrap_or_else(|| core.ui_state.current_selection.clone());
5200 let r = sel.range.as_ref().expect("selection still active");
5201 assert_eq!(r.anchor.key, "p1", "anchor unchanged");
5202 assert_eq!(
5203 r.head.key, "p2",
5204 "head must stay in p2 even when pointer is below p2's rect"
5205 );
5206 }
5207
5208 #[test]
5209 fn drag_into_a_sibling_selectable_extends_head_into_that_leaf() {
5210 let mut core = lay_out_paragraph_tree();
5211 let p1 = core.rect_of_key("p1").expect("p1 rect");
5212 let p2 = core.rect_of_key("p2").expect("p2 rect");
5213 core.pointer_down(Pointer::mouse(
5215 p1.x + 4.0,
5216 p1.y + p1.h * 0.5,
5217 PointerButton::Primary,
5218 ));
5219 let events = core
5221 .pointer_moved(Pointer::moving(p2.x + 8.0, p2.y + p2.h * 0.5))
5222 .events;
5223 let sel_event = events
5224 .iter()
5225 .find(|e| e.kind == UiEventKind::SelectionChanged)
5226 .expect("Drag emits SelectionChanged");
5227 let new_sel = sel_event.selection.as_ref().unwrap();
5228 let range = new_sel.range.as_ref().unwrap();
5229 assert_eq!(range.anchor.key, "p1", "anchor stays in p1");
5230 assert_eq!(range.head.key, "p2", "head migrates into p2");
5231 }
5232
5233 #[test]
5234 fn pointer_down_on_focusable_owning_selection_does_not_clear_it() {
5235 let mut core = lay_out_input_tree(true);
5243 core.set_selection(crate::selection::Selection::caret("ti", 3));
5246 let ti = core.rect_of_key("ti").expect("ti rect");
5247 let cx = ti.x + ti.w * 0.5;
5248 let cy = ti.y + ti.h * 0.5;
5249
5250 let events = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5251 let cleared = events.iter().find(|e| {
5252 e.kind == UiEventKind::SelectionChanged
5253 && e.selection.as_ref().map(|s| s.is_empty()).unwrap_or(false)
5254 });
5255 assert!(
5256 cleared.is_none(),
5257 "click on the selection-owning input must not emit a clearing SelectionChanged"
5258 );
5259 assert_eq!(
5260 core.ui_state.current_selection,
5261 crate::selection::Selection::caret("ti", 3),
5262 "runtime mirror is preserved when the click owns the selection"
5263 );
5264 }
5265
5266 #[test]
5267 fn pointer_down_into_a_different_capture_keys_widget_does_not_clear_first() {
5268 let mut core = lay_out_input_tree(true);
5278 core.set_selection(crate::selection::Selection::caret("other", 4));
5280 let ti = core.rect_of_key("ti").expect("ti rect");
5281 let cx = ti.x + ti.w * 0.5;
5282 let cy = ti.y + ti.h * 0.5;
5283
5284 let events = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5285 let cleared = events.iter().any(|e| {
5286 e.kind == UiEventKind::SelectionChanged
5287 && e.selection.as_ref().map(|s| s.is_empty()).unwrap_or(false)
5288 });
5289 assert!(
5290 !cleared,
5291 "click on a different capture_keys widget must not race-clear the selection"
5292 );
5293 }
5294
5295 #[test]
5296 fn pointer_down_on_non_selectable_clears_existing_selection() {
5297 let mut core = lay_out_paragraph_tree();
5298 let p1 = core.rect_of_key("p1").expect("p1 rect");
5299 let cy = p1.y + p1.h * 0.5;
5300 core.pointer_down(Pointer::mouse(p1.x + 4.0, cy, PointerButton::Primary));
5302 core.pointer_up(Pointer::mouse(p1.x + 4.0, cy, PointerButton::Primary));
5303 assert!(!core.ui_state.current_selection.is_empty());
5304
5305 let events = core.pointer_down(Pointer::mouse(2.0, 2.0, PointerButton::Primary));
5307 let cleared = events
5308 .iter()
5309 .find(|e| e.kind == UiEventKind::SelectionChanged)
5310 .expect("clearing emits SelectionChanged");
5311 let new_sel = cleared.selection.as_ref().unwrap();
5312 assert!(new_sel.is_empty(), "new selection should be empty");
5313 assert!(core.ui_state.current_selection.is_empty());
5314 }
5315
5316 #[test]
5317 fn pointer_down_in_dead_space_clears_focus() {
5318 let mut core = lay_out_input_tree(false);
5319 let btn = core.rect_of_key("btn").expect("btn rect");
5320 let cx = btn.x + btn.w * 0.5;
5321 let cy = btn.y + btn.h * 0.5;
5322 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5323 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5324 assert_eq!(
5325 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5326 Some("btn")
5327 );
5328
5329 core.pointer_down(Pointer::mouse(2.0, 2.0, PointerButton::Primary));
5330
5331 assert_eq!(core.ui_state.focused.as_ref().map(|t| t.key.as_str()), None);
5332 }
5333
5334 #[test]
5335 fn key_down_bumps_caret_activity_when_focused_widget_captures_keys() {
5336 let mut core = lay_out_input_tree(true);
5341 let target = core
5342 .ui_state
5343 .focus
5344 .order
5345 .iter()
5346 .find(|t| t.key == "ti")
5347 .cloned();
5348 core.ui_state.set_focus(target); let after_focus = core.ui_state.caret.activity_at.expect("focus bump");
5350
5351 std::thread::sleep(std::time::Duration::from_millis(2));
5352 let _ = core.key_down(
5353 LogicalKey::Named(NamedKey::ArrowRight),
5354 PhysicalKey::Unidentified,
5355 KeyModifiers::default(),
5356 false,
5357 );
5358 let after_arrow = core
5359 .ui_state
5360 .caret
5361 .activity_at
5362 .expect("arrow key bumps even without app-side selection");
5363 assert!(
5364 after_arrow > after_focus,
5365 "ArrowRight to a capture_keys focused widget bumps caret activity"
5366 );
5367 }
5368
5369 #[test]
5370 fn text_input_bumps_caret_activity_when_focused() {
5371 let mut core = lay_out_input_tree(true);
5372 let target = core
5373 .ui_state
5374 .focus
5375 .order
5376 .iter()
5377 .find(|t| t.key == "ti")
5378 .cloned();
5379 core.ui_state.set_focus(target);
5380 let after_focus = core.ui_state.caret.activity_at.unwrap();
5381
5382 std::thread::sleep(std::time::Duration::from_millis(2));
5383 let _ = core.text_input("a".into());
5384 let after_text = core.ui_state.caret.activity_at.unwrap();
5385 assert!(
5386 after_text > after_focus,
5387 "TextInput to focused widget bumps caret activity"
5388 );
5389 }
5390
5391 #[test]
5392 fn pointer_down_inside_focused_input_bumps_caret_activity() {
5393 let mut core = lay_out_input_tree(true);
5398 let ti = core.rect_of_key("ti").expect("ti rect");
5399 let cx = ti.x + ti.w * 0.5;
5400 let cy = ti.y + ti.h * 0.5;
5401
5402 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5404 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5405 let after_first = core.ui_state.caret.activity_at.unwrap();
5406
5407 std::thread::sleep(std::time::Duration::from_millis(2));
5410 core.pointer_down(Pointer::mouse(cx + 1.0, cy, PointerButton::Primary));
5411 let after_second = core
5412 .ui_state
5413 .caret
5414 .activity_at
5415 .expect("second click bumps too");
5416 assert!(
5417 after_second > after_first,
5418 "click within already-focused capture_keys widget still bumps"
5419 );
5420 }
5421
5422 #[test]
5423 fn arrow_key_through_apply_event_mutates_selection_and_bumps_on_set() {
5424 use crate::widgets::text_input;
5430 let mut sel = crate::selection::Selection::caret("ti", 2);
5431 let mut value = String::from("hello");
5432
5433 let mut core = RunnerCore::new();
5434 core.set_selection(sel.clone());
5437 let baseline = core.ui_state.caret.activity_at;
5438
5439 let arrow_right = UiEvent {
5441 key: Some("ti".into()),
5442 target: None,
5443 pointer: None,
5444 key_press: Some(crate::event::KeyPress {
5445 logical: LogicalKey::Named(NamedKey::ArrowRight),
5446 physical: PhysicalKey::Unidentified,
5447 modifiers: KeyModifiers::default(),
5448 repeat: false,
5449 }),
5450 text: None,
5451 selection: None,
5452 modifiers: KeyModifiers::default(),
5453 click_count: 0,
5454 path: None,
5455 pointer_kind: None,
5456 wheel_delta: None,
5457 kind: UiEventKind::KeyDown,
5458 };
5459
5460 let mutated = text_input::apply_event(&mut value, &mut sel, &arrow_right, "ti");
5462 assert!(mutated, "ArrowRight should mutate selection");
5463 assert_eq!(
5464 sel.within("ti").unwrap().head,
5465 3,
5466 "head moved one char right (h-e-l-l-o, byte 2 → 3)"
5467 );
5468
5469 std::thread::sleep(std::time::Duration::from_millis(2));
5471 core.set_selection(sel);
5472 let after = core.ui_state.caret.activity_at.unwrap();
5473 if let Some(b) = baseline {
5477 assert!(after > b, "arrow-key flow should bump activity");
5478 }
5479 }
5480
5481 #[test]
5482 fn set_selection_bumps_caret_activity_only_when_value_changes() {
5483 let mut core = lay_out_paragraph_tree();
5484 core.set_selection(crate::selection::Selection::default());
5487 assert!(
5488 core.ui_state.caret.activity_at.is_none(),
5489 "no-op set_selection should not bump activity"
5490 );
5491
5492 let sel_a = crate::selection::Selection::caret("p1", 3);
5494 core.set_selection(sel_a.clone());
5495 let bumped_at = core
5496 .ui_state
5497 .caret
5498 .activity_at
5499 .expect("first real selection bumps");
5500
5501 core.set_selection(sel_a.clone());
5504 assert_eq!(
5505 core.ui_state.caret.activity_at,
5506 Some(bumped_at),
5507 "set_selection with same value is a no-op"
5508 );
5509
5510 std::thread::sleep(std::time::Duration::from_millis(2));
5513 let sel_b = crate::selection::Selection::caret("p1", 7);
5514 core.set_selection(sel_b);
5515 let new_bump = core.ui_state.caret.activity_at.expect("second bump");
5516 assert!(
5517 new_bump > bumped_at,
5518 "moving the caret bumps activity again",
5519 );
5520 }
5521
5522 #[test]
5523 fn escape_clears_active_selection_and_emits_selection_changed() {
5524 let mut core = lay_out_paragraph_tree();
5525 let p1 = core.rect_of_key("p1").expect("p1 rect");
5526 let cy = p1.y + p1.h * 0.5;
5527 core.pointer_down(Pointer::mouse(p1.x + 4.0, cy, PointerButton::Primary));
5529 core.pointer_moved(Pointer::moving(p1.x + p1.w - 10.0, cy));
5530 core.pointer_up(Pointer::mouse(
5531 p1.x + p1.w - 10.0,
5532 cy,
5533 PointerButton::Primary,
5534 ));
5535 assert!(!core.ui_state.current_selection.is_empty());
5536
5537 let events = core.key_down(
5538 LogicalKey::Named(NamedKey::Escape),
5539 PhysicalKey::Unidentified,
5540 KeyModifiers::default(),
5541 false,
5542 );
5543 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
5544 assert_eq!(
5545 kinds,
5546 vec![UiEventKind::Escape, UiEventKind::SelectionChanged],
5547 "Esc emits Escape (for popover dismiss) AND SelectionChanged"
5548 );
5549 let cleared = events
5550 .iter()
5551 .find(|e| e.kind == UiEventKind::SelectionChanged)
5552 .unwrap();
5553 assert!(cleared.selection.as_ref().unwrap().is_empty());
5554 assert!(core.ui_state.current_selection.is_empty());
5555 }
5556
5557 #[test]
5558 fn consecutive_clicks_on_same_target_extend_count() {
5559 let mut core = lay_out_input_tree(false);
5560 let btn = core.rect_of_key("btn").expect("btn rect");
5561 let cx = btn.x + btn.w * 0.5;
5562 let cy = btn.y + btn.h * 0.5;
5563
5564 let down1 = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5566 let pd1 = down1
5567 .iter()
5568 .find(|e| e.kind == UiEventKind::PointerDown)
5569 .expect("PointerDown emitted");
5570 assert_eq!(pd1.click_count, 1, "first press starts the sequence");
5571 let up1 = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5572 let click1 = up1
5573 .iter()
5574 .find(|e| e.kind == UiEventKind::Click)
5575 .expect("Click emitted");
5576 assert_eq!(
5577 click1.click_count, 1,
5578 "Click carries the same count as its PointerDown"
5579 );
5580
5581 let down2 = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5583 let pd2 = down2
5584 .iter()
5585 .find(|e| e.kind == UiEventKind::PointerDown)
5586 .unwrap();
5587 assert_eq!(pd2.click_count, 2, "second press extends the sequence");
5588 let up2 = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5589 assert_eq!(
5590 up2.iter()
5591 .find(|e| e.kind == UiEventKind::Click)
5592 .unwrap()
5593 .click_count,
5594 2
5595 );
5596
5597 let down3 = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5599 let pd3 = down3
5600 .iter()
5601 .find(|e| e.kind == UiEventKind::PointerDown)
5602 .unwrap();
5603 assert_eq!(pd3.click_count, 3, "third press → triple-click");
5604 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5605 }
5606
5607 #[test]
5608 fn click_count_resets_when_target_changes() {
5609 let mut core = lay_out_input_tree(false);
5610 let btn = core.rect_of_key("btn").expect("btn rect");
5611 let ti = core.rect_of_key("ti").expect("ti rect");
5612
5613 let down1 = core.pointer_down(Pointer::mouse(
5615 btn.x + btn.w * 0.5,
5616 btn.y + btn.h * 0.5,
5617 PointerButton::Primary,
5618 ));
5619 assert_eq!(
5620 down1
5621 .iter()
5622 .find(|e| e.kind == UiEventKind::PointerDown)
5623 .unwrap()
5624 .click_count,
5625 1
5626 );
5627 let _ = core.pointer_up(Pointer::mouse(
5628 btn.x + btn.w * 0.5,
5629 btn.y + btn.h * 0.5,
5630 PointerButton::Primary,
5631 ));
5632
5633 let down2 = core.pointer_down(Pointer::mouse(
5635 ti.x + ti.w * 0.5,
5636 ti.y + ti.h * 0.5,
5637 PointerButton::Primary,
5638 ));
5639 let pd2 = down2
5640 .iter()
5641 .find(|e| e.kind == UiEventKind::PointerDown)
5642 .unwrap();
5643 assert_eq!(
5644 pd2.click_count, 1,
5645 "press on a new target resets the multi-click sequence"
5646 );
5647 }
5648
5649 #[test]
5650 fn double_click_on_selectable_text_selects_word_at_hit() {
5651 let mut core = lay_out_paragraph_tree();
5652 let p1 = core.rect_of_key("p1").expect("p1 rect");
5653 let cy = p1.y + p1.h * 0.5;
5654 let cx = p1.x + 4.0;
5657 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5658 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5659 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5660 let sel = &core.ui_state.current_selection;
5662 let r = sel.range.as_ref().expect("selection set");
5663 assert_eq!(r.anchor.key, "p1");
5664 assert_eq!(r.head.key, "p1");
5665 assert_eq!(r.anchor.byte.min(r.head.byte), 0);
5667 assert_eq!(r.anchor.byte.max(r.head.byte), 5);
5668 }
5669
5670 #[test]
5671 fn triple_click_on_selectable_text_selects_whole_leaf() {
5672 let mut core = lay_out_paragraph_tree();
5673 let p1 = core.rect_of_key("p1").expect("p1 rect");
5674 let cy = p1.y + p1.h * 0.5;
5675 let cx = p1.x + 4.0;
5676 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5677 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5678 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5679 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5680 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5681 let sel = &core.ui_state.current_selection;
5682 let r = sel.range.as_ref().expect("selection set");
5683 assert_eq!(r.anchor.byte, 0);
5684 assert_eq!(r.head.byte, 24);
5686 }
5687
5688 #[test]
5689 fn click_count_resets_when_press_drifts_outside_distance_window() {
5690 let mut core = lay_out_input_tree(false);
5691 let btn = core.rect_of_key("btn").expect("btn rect");
5692 let cx = btn.x + btn.w * 0.5;
5693 let cy = btn.y + btn.h * 0.5;
5694
5695 let _ = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5696 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5697
5698 let down2 = core.pointer_down(Pointer::mouse(cx + 10.0, cy, PointerButton::Primary));
5701 let pd2 = down2
5702 .iter()
5703 .find(|e| e.kind == UiEventKind::PointerDown)
5704 .unwrap();
5705 assert_eq!(pd2.click_count, 1);
5706 }
5707
5708 #[test]
5709 fn escape_with_no_selection_emits_only_escape() {
5710 let mut core = lay_out_paragraph_tree();
5711 assert!(core.ui_state.current_selection.is_empty());
5712 let events = core.key_down(
5713 LogicalKey::Named(NamedKey::Escape),
5714 PhysicalKey::Unidentified,
5715 KeyModifiers::default(),
5716 false,
5717 );
5718 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
5719 assert_eq!(
5720 kinds,
5721 vec![UiEventKind::Escape],
5722 "no selection → no SelectionChanged side-effect"
5723 );
5724 }
5725
5726 fn lay_out_scroll_tree() -> (RunnerCore, String) {
5729 use crate::tree::*;
5730 let mut tree = crate::scroll(
5731 (0..6)
5732 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
5733 )
5734 .gap(12.0)
5735 .height(Size::Fixed(200.0));
5736 let mut core = RunnerCore::new();
5737 crate::layout::layout(
5738 &mut tree,
5739 &mut core.ui_state,
5740 Rect::new(0.0, 0.0, 300.0, 200.0),
5741 );
5742 let scroll_id = tree.computed_id.clone();
5743 let mut t = PrepareTimings::default();
5744 core.snapshot(&tree, &mut t);
5745 (core, scroll_id.to_string())
5746 }
5747
5748 #[test]
5749 fn thumb_pointer_down_captures_drag_and_suppresses_events() {
5750 let (mut core, scroll_id) = lay_out_scroll_tree();
5751 let thumb = core
5752 .ui_state
5753 .scroll
5754 .thumb_rects
5755 .get(&scroll_id)
5756 .copied()
5757 .expect("scrollable should have a thumb");
5758 let event = core.pointer_down(Pointer::mouse(
5759 thumb.x + thumb.w * 0.5,
5760 thumb.y + thumb.h * 0.5,
5761 PointerButton::Primary,
5762 ));
5763 assert!(
5764 event.is_empty(),
5765 "thumb press should not emit PointerDown to the app"
5766 );
5767 let drag = core
5768 .ui_state
5769 .scroll
5770 .thumb_drag
5771 .as_ref()
5772 .expect("scroll.thumb_drag should be set after pointer_down on thumb");
5773 assert_eq!(drag.scroll_id, scroll_id);
5774 }
5775
5776 #[test]
5777 fn track_click_above_thumb_pages_up_below_pages_down() {
5778 let (mut core, scroll_id) = lay_out_scroll_tree();
5779 let track = core
5780 .ui_state
5781 .scroll
5782 .thumb_tracks
5783 .get(&scroll_id)
5784 .copied()
5785 .expect("scrollable should have a track");
5786 let thumb = core
5787 .ui_state
5788 .scroll
5789 .thumb_rects
5790 .get(&scroll_id)
5791 .copied()
5792 .unwrap();
5793 let metrics = core
5794 .ui_state
5795 .scroll
5796 .metrics
5797 .get(&scroll_id)
5798 .copied()
5799 .unwrap();
5800
5801 let evt = core.pointer_down(Pointer::mouse(
5803 track.x + track.w * 0.5,
5804 thumb.y + thumb.h + 10.0,
5805 PointerButton::Primary,
5806 ));
5807 assert!(evt.is_empty(), "track press should not surface PointerDown");
5808 assert!(
5809 core.ui_state.scroll.thumb_drag.is_none(),
5810 "track click outside the thumb should not start a drag",
5811 );
5812 let after_down = core.ui_state.scroll_offset(&scroll_id);
5813 let expected_page = (metrics.viewport_h - SCROLL_PAGE_OVERLAP).max(0.0);
5814 assert!(
5815 (after_down - expected_page.min(metrics.max_offset)).abs() < 0.5,
5816 "page-down offset = {after_down} (expected ~{expected_page})",
5817 );
5818 let _ = core.pointer_up(Pointer::mouse(0.0, 0.0, PointerButton::Primary));
5820
5821 let mut tree = lay_out_scroll_tree_only();
5824 crate::layout::layout(
5825 &mut tree,
5826 &mut core.ui_state,
5827 Rect::new(0.0, 0.0, 300.0, 200.0),
5828 );
5829 let mut t = PrepareTimings::default();
5830 core.snapshot(&tree, &mut t);
5831 let track = core
5832 .ui_state
5833 .scroll
5834 .thumb_tracks
5835 .get(&*tree.computed_id)
5836 .copied()
5837 .unwrap();
5838 let thumb = core
5839 .ui_state
5840 .scroll
5841 .thumb_rects
5842 .get(&*tree.computed_id)
5843 .copied()
5844 .unwrap();
5845
5846 core.pointer_down(Pointer::mouse(
5847 track.x + track.w * 0.5,
5848 thumb.y - 4.0,
5849 PointerButton::Primary,
5850 ));
5851 let after_up = core.ui_state.scroll_offset(&tree.computed_id);
5852 assert!(
5853 after_up < after_down,
5854 "page-up should reduce offset: before={after_down} after={after_up}",
5855 );
5856 }
5857
5858 #[test]
5859 fn scrollbar_press_does_not_bypass_covering_scrim() {
5860 let (mut core, scroll_id) =
5861 lay_out_scroll_tree_with_layer(crate::widgets::overlay::scrim("modal:dismiss"));
5862 let thumb = core
5863 .ui_state
5864 .scroll
5865 .thumb_rects
5866 .get(&scroll_id)
5867 .copied()
5868 .expect("scrollable should have a thumb");
5869
5870 let events = core.pointer_down(Pointer::mouse(
5871 thumb.x + thumb.w * 0.5,
5872 thumb.y + thumb.h * 0.5,
5873 PointerButton::Primary,
5874 ));
5875
5876 assert_eq!(events.len(), 1);
5877 assert_eq!(events[0].kind, UiEventKind::PointerDown);
5878 assert_eq!(events[0].route(), Some("modal:dismiss"));
5879 assert!(
5880 core.ui_state.scroll.thumb_drag.is_none(),
5881 "covered scrollbar thumb must not capture drag",
5882 );
5883 }
5884
5885 #[test]
5886 fn scrollbar_press_does_not_bypass_block_pointer_layer() {
5887 use crate::tree::*;
5888
5889 let (mut core, scroll_id) =
5890 lay_out_scroll_tree_with_layer(El::new(Kind::Group).fill_size().block_pointer());
5891 let thumb = core
5892 .ui_state
5893 .scroll
5894 .thumb_rects
5895 .get(&scroll_id)
5896 .copied()
5897 .expect("scrollable should have a thumb");
5898
5899 let events = core.pointer_down(Pointer::mouse(
5900 thumb.x + thumb.w * 0.5,
5901 thumb.y + thumb.h * 0.5,
5902 PointerButton::Primary,
5903 ));
5904
5905 assert!(
5906 events.is_empty(),
5907 "block_pointer layer should swallow the press without scrolling",
5908 );
5909 assert!(
5910 core.ui_state.scroll.thumb_drag.is_none(),
5911 "covered scrollbar thumb must not capture drag",
5912 );
5913 }
5914
5915 fn lay_out_scroll_tree_only() -> El {
5920 use crate::tree::*;
5921 crate::scroll(
5922 (0..6)
5923 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
5924 )
5925 .gap(12.0)
5926 .height(Size::Fixed(200.0))
5927 }
5928
5929 fn lay_out_scroll_tree_with_layer(layer: El) -> (RunnerCore, String) {
5930 use crate::tree::*;
5931
5932 let scroll = lay_out_scroll_tree_only();
5933 let mut tree = crate::stack([scroll, layer]).fill_size();
5934 let mut core = RunnerCore::new();
5935 crate::layout::layout(
5936 &mut tree,
5937 &mut core.ui_state,
5938 Rect::new(0.0, 0.0, 300.0, 200.0),
5939 );
5940 let scroll_id = tree.children[0].computed_id.clone();
5941 let mut t = PrepareTimings::default();
5942 core.snapshot(&tree, &mut t);
5943 (core, scroll_id.to_string())
5944 }
5945
5946 fn lay_out_resizable_tree(layer: Option<El>) -> RunnerCore {
5950 use crate::tree::*;
5951 let row = crate::tree::row([
5952 column(Vec::<El>::new())
5953 .key("nav")
5954 .user_resizable()
5955 .width(Size::Fixed(200.0))
5956 .min_width(120.0)
5957 .max_width(420.0),
5958 column(Vec::<El>::new()).width(Size::Fill(1.0)),
5959 ])
5960 .height(Size::Fill(1.0));
5961 let mut tree = match layer {
5962 Some(layer) => crate::stack([row, layer]).fill_size(),
5963 None => row,
5964 };
5965 let mut core = RunnerCore::new();
5966 crate::layout::layout(
5967 &mut tree,
5968 &mut core.ui_state,
5969 Rect::new(0.0, 0.0, 800.0, 400.0),
5970 );
5971 let mut t = PrepareTimings::default();
5972 core.snapshot(&tree, &mut t);
5973 core
5974 }
5975
5976 #[test]
5977 fn resize_band_drag_updates_user_size_and_emits_resized_on_release() {
5978 let mut core = lay_out_resizable_tree(None);
5979
5980 let events = core.pointer_down(Pointer::mouse(200.0, 150.0, PointerButton::Primary));
5982 assert!(events.is_empty(), "band press must not reach the app");
5983 assert!(core.ui_state.resize.drag.is_some());
5984
5985 let mv = core.pointer_moved(Pointer::mouse(280.0, 150.0, PointerButton::Primary));
5987 assert!(mv.events.is_empty(), "drag is captured, no app events");
5988 assert!(mv.needs_redraw, "size change must redraw");
5989 assert_eq!(core.ui_state.user_size("nav"), Some(280.0));
5990
5991 let _ = core.pointer_moved(Pointer::mouse(700.0, 150.0, PointerButton::Primary));
5993 assert_eq!(core.ui_state.user_size("nav"), Some(420.0));
5994
5995 let up = core.pointer_up(Pointer::mouse(700.0, 150.0, PointerButton::Primary));
5997 assert_eq!(up.len(), 1);
5998 assert_eq!(up[0].kind, UiEventKind::Resized);
5999 assert_eq!(up[0].route(), Some("nav"));
6000 assert!(core.ui_state.resize.drag.is_none());
6001 }
6002
6003 #[test]
6004 fn resize_band_hover_flips_cursor_and_requests_redraw() {
6005 let mut core = lay_out_resizable_tree(None);
6006 let tree = core.last_tree.clone().expect("snapshot stored a tree");
6007
6008 let _ = core.pointer_moved(Pointer::mouse(400.0, 150.0, PointerButton::Primary));
6010 assert_eq!(core.ui_state.cursor(&tree), crate::cursor::Cursor::Default);
6011
6012 let mv = core.pointer_moved(Pointer::mouse(201.0, 150.0, PointerButton::Primary));
6016 assert!(mv.needs_redraw, "band entry must redraw for the cursor");
6017 assert_eq!(core.ui_state.cursor(&tree), crate::cursor::Cursor::EwResize);
6018
6019 let _ = core.pointer_down(Pointer::mouse(201.0, 150.0, PointerButton::Primary));
6022 let _ = core.pointer_moved(Pointer::mouse(390.0, 150.0, PointerButton::Primary));
6023 assert_eq!(core.ui_state.cursor(&tree), crate::cursor::Cursor::EwResize);
6024 }
6025
6026 #[test]
6027 fn resize_band_press_does_not_bypass_covering_scrim() {
6028 let mut core =
6029 lay_out_resizable_tree(Some(crate::widgets::overlay::scrim("modal:dismiss")));
6030
6031 let events = core.pointer_down(Pointer::mouse(200.0, 150.0, PointerButton::Primary));
6032 assert!(
6033 core.ui_state.resize.drag.is_none(),
6034 "covered band must not capture the press",
6035 );
6036 assert_eq!(events.len(), 1);
6037 assert_eq!(events[0].kind, UiEventKind::PointerDown);
6038 assert_eq!(events[0].route(), Some("modal:dismiss"));
6039 }
6040
6041 #[test]
6042 fn thumb_drag_translates_pointer_delta_into_scroll_offset() {
6043 let (mut core, scroll_id) = lay_out_scroll_tree();
6044 let thumb = core
6045 .ui_state
6046 .scroll
6047 .thumb_rects
6048 .get(&scroll_id)
6049 .copied()
6050 .unwrap();
6051 let metrics = core
6052 .ui_state
6053 .scroll
6054 .metrics
6055 .get(&scroll_id)
6056 .copied()
6057 .unwrap();
6058 let track_remaining = (metrics.viewport_h - thumb.h).max(0.0);
6059
6060 let press_y = thumb.y + thumb.h * 0.5;
6061 core.pointer_down(Pointer::mouse(
6062 thumb.x + thumb.w * 0.5,
6063 press_y,
6064 PointerButton::Primary,
6065 ));
6066 let evt = core.pointer_moved(Pointer::moving(thumb.x + thumb.w * 0.5, press_y + 20.0));
6068 assert!(
6069 evt.events.is_empty(),
6070 "thumb-drag move should suppress Drag event",
6071 );
6072 let offset = core.ui_state.scroll_offset(&scroll_id);
6073 let expected = 20.0 * (metrics.max_offset / track_remaining);
6074 assert!(
6075 (offset - expected).abs() < 0.5,
6076 "offset {offset} (expected {expected})",
6077 );
6078 core.pointer_moved(Pointer::moving(thumb.x + thumb.w * 0.5, press_y + 9999.0));
6080 let offset = core.ui_state.scroll_offset(&scroll_id);
6081 assert!(
6082 (offset - metrics.max_offset).abs() < 0.5,
6083 "overshoot offset {offset} (expected {})",
6084 metrics.max_offset
6085 );
6086 let events = core.pointer_up(Pointer::mouse(thumb.x, press_y, PointerButton::Primary));
6088 assert!(events.is_empty(), "thumb release shouldn't emit events");
6089 assert!(core.ui_state.scroll.thumb_drag.is_none());
6090 }
6091
6092 #[test]
6093 fn secondary_click_does_not_steal_focus_or_press() {
6094 let mut core = lay_out_input_tree(false);
6095 let btn_rect = core.rect_of_key("btn").expect("btn rect");
6096 let cx = btn_rect.x + btn_rect.w * 0.5;
6097 let cy = btn_rect.y + btn_rect.h * 0.5;
6098 let ti_rect = core.rect_of_key("ti").expect("ti rect");
6100 let tx = ti_rect.x + ti_rect.w * 0.5;
6101 let ty = ti_rect.y + ti_rect.h * 0.5;
6102 core.pointer_down(Pointer::mouse(tx, ty, PointerButton::Primary));
6103 let _ = core.pointer_up(Pointer::mouse(tx, ty, PointerButton::Primary));
6104 let focused_before = core.ui_state.focused.as_ref().map(|t| t.key.clone());
6105 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Secondary));
6107 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Secondary));
6108 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
6109 assert_eq!(kinds, vec![UiEventKind::SecondaryClick]);
6110 let focused_after = core.ui_state.focused.as_ref().map(|t| t.key.clone());
6111 assert_eq!(
6112 focused_before, focused_after,
6113 "right-click must not steal focus"
6114 );
6115 assert!(
6116 core.ui_state.pressed.is_none(),
6117 "right-click must not set primary press"
6118 );
6119 }
6120
6121 #[test]
6122 fn text_input_routes_to_focused_only() {
6123 let mut core = lay_out_input_tree(false);
6124 assert!(core.text_input("a".into()).is_none());
6126 let btn_rect = core.rect_of_key("btn").expect("btn rect");
6128 let cx = btn_rect.x + btn_rect.w * 0.5;
6129 let cy = btn_rect.y + btn_rect.h * 0.5;
6130 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6131 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
6132 let event = core.text_input("hi".into()).expect("focused → event");
6133 assert_eq!(event.kind, UiEventKind::TextInput);
6134 assert_eq!(event.text.as_deref(), Some("hi"));
6135 assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("btn"));
6136 assert!(core.text_input(String::new()).is_none());
6138 }
6139
6140 #[test]
6141 fn capture_keys_bypasses_tab_traversal_for_focused_node() {
6142 let mut core = lay_out_input_tree(true);
6145 let ti_rect = core.rect_of_key("ti").expect("ti rect");
6146 let tx = ti_rect.x + ti_rect.w * 0.5;
6147 let ty = ti_rect.y + ti_rect.h * 0.5;
6148 core.pointer_down(Pointer::mouse(tx, ty, PointerButton::Primary));
6149 let _ = core.pointer_up(Pointer::mouse(tx, ty, PointerButton::Primary));
6150 assert_eq!(
6151 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6152 Some("ti"),
6153 "primary click on capture_keys node still focuses it"
6154 );
6155
6156 let events = core.key_down(
6157 LogicalKey::Named(NamedKey::Tab),
6158 PhysicalKey::Unidentified,
6159 KeyModifiers::default(),
6160 false,
6161 );
6162 assert_eq!(events.len(), 1, "Tab → exactly one KeyDown");
6163 let event = &events[0];
6164 assert_eq!(event.kind, UiEventKind::KeyDown);
6165 assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("ti"));
6166 assert_eq!(
6167 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6168 Some("ti"),
6169 "Tab inside capture_keys must NOT move focus"
6170 );
6171 }
6172
6173 #[test]
6174 fn escape_blurs_capture_keys_after_delivering_raw_keydown() {
6175 let mut core = lay_out_input_tree(true);
6176 let ti_rect = core.rect_of_key("ti").expect("ti rect");
6177 let tx = ti_rect.x + ti_rect.w * 0.5;
6178 let ty = ti_rect.y + ti_rect.h * 0.5;
6179 core.pointer_down(Pointer::mouse(tx, ty, PointerButton::Primary));
6180 let _ = core.pointer_up(Pointer::mouse(tx, ty, PointerButton::Primary));
6181 assert_eq!(
6182 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6183 Some("ti")
6184 );
6185
6186 let events = core.key_down(
6187 LogicalKey::Named(NamedKey::Escape),
6188 PhysicalKey::Unidentified,
6189 KeyModifiers::default(),
6190 false,
6191 );
6192
6193 assert_eq!(events.len(), 1);
6194 let event = &events[0];
6195 assert_eq!(event.kind, UiEventKind::KeyDown);
6196 assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("ti"));
6197 assert!(matches!(
6198 event.key_press.as_ref().map(|p| &p.logical),
6199 Some(LogicalKey::Named(NamedKey::Escape))
6200 ));
6201 assert_eq!(core.ui_state.focused.as_ref().map(|t| t.key.as_str()), None);
6202 }
6203
6204 #[test]
6205 fn pointer_down_focus_does_not_raise_focus_visible() {
6206 let mut core = lay_out_input_tree(false);
6209 let btn_rect = core.rect_of_key("btn").expect("btn rect");
6210 let cx = btn_rect.x + btn_rect.w * 0.5;
6211 let cy = btn_rect.y + btn_rect.h * 0.5;
6212 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6213 assert_eq!(
6214 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6215 Some("btn"),
6216 "primary click focuses the button",
6217 );
6218 assert!(
6219 !core.ui_state.focus_visible,
6220 "click focus must not raise focus_visible — ring stays off",
6221 );
6222 }
6223
6224 #[test]
6225 fn tab_key_raises_focus_visible_so_ring_appears() {
6226 let mut core = lay_out_input_tree(false);
6227 let btn_rect = core.rect_of_key("btn").expect("btn rect");
6229 let cx = btn_rect.x + btn_rect.w * 0.5;
6230 let cy = btn_rect.y + btn_rect.h * 0.5;
6231 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6232 assert!(!core.ui_state.focus_visible);
6233 let _ = core.key_down(
6235 LogicalKey::Named(NamedKey::Tab),
6236 PhysicalKey::Unidentified,
6237 KeyModifiers::default(),
6238 false,
6239 );
6240 assert!(
6241 core.ui_state.focus_visible,
6242 "Tab must raise focus_visible so the ring paints on the new target",
6243 );
6244 }
6245
6246 #[test]
6247 fn click_after_tab_clears_focus_visible_again() {
6248 let mut core = lay_out_input_tree(false);
6251 let _ = core.key_down(
6252 LogicalKey::Named(NamedKey::Tab),
6253 PhysicalKey::Unidentified,
6254 KeyModifiers::default(),
6255 false,
6256 );
6257 assert!(core.ui_state.focus_visible, "Tab raises ring");
6258 let btn_rect = core.rect_of_key("btn").expect("btn rect");
6259 let cx = btn_rect.x + btn_rect.w * 0.5;
6260 let cy = btn_rect.y + btn_rect.h * 0.5;
6261 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6262 assert!(
6263 !core.ui_state.focus_visible,
6264 "pointer-down clears focus_visible — ring fades back out",
6265 );
6266 }
6267
6268 #[test]
6269 fn keypress_on_focused_widget_raises_focus_visible_after_click() {
6270 let mut core = lay_out_input_tree(false);
6274 let btn_rect = core.rect_of_key("btn").expect("btn rect");
6275 let cx = btn_rect.x + btn_rect.w * 0.5;
6276 let cy = btn_rect.y + btn_rect.h * 0.5;
6277 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6278 assert!(!core.ui_state.focus_visible);
6279 let _ = core.key_down(
6280 LogicalKey::Named(NamedKey::ArrowRight),
6281 PhysicalKey::Unidentified,
6282 KeyModifiers::default(),
6283 false,
6284 );
6285 assert!(
6286 core.ui_state.focus_visible,
6287 "non-Tab key on focused widget raises focus_visible",
6288 );
6289 }
6290
6291 #[test]
6292 fn selected_text_resolves_a_selection_inside_a_virtual_list() {
6293 use crate::selection::{Selection, SelectionPoint, SelectionRange};
6300 use crate::tree::*;
6301
6302 let mut tree = virtual_list_dyn(
6306 20,
6307 50.0,
6308 |i| format!("row-{i}"),
6309 |i| {
6310 crate::widgets::text::text(format!("row {i} text"))
6311 .key(format!("row-{i}"))
6312 .selectable()
6313 .height(Size::Fixed(50.0))
6314 },
6315 );
6316 let mut core = RunnerCore::new();
6317 crate::layout::layout(
6318 &mut tree,
6319 &mut core.ui_state,
6320 Rect::new(0.0, 0.0, 200.0, 200.0),
6321 );
6322 let mut t = PrepareTimings::default();
6323 core.snapshot(&tree, &mut t);
6324
6325 let selection = Selection {
6327 range: Some(SelectionRange {
6328 anchor: SelectionPoint::new("row-1", 0),
6329 head: SelectionPoint::new("row-1", 9),
6330 }),
6331 };
6332 core.set_selection(selection);
6333
6334 assert_eq!(
6335 core.selected_text().as_deref(),
6336 Some("row 1 tex"),
6337 "runtime.selected_text() must walk last_tree (realized rows) — \
6338 a build-only path would miss virtual_list children entirely",
6339 );
6340 }
6341
6342 #[test]
6343 fn shortcut_chord_does_not_raise_focus_visible() {
6344 let mut core = lay_out_input_tree(false);
6351 let btn_rect = core.rect_of_key("btn").expect("btn rect");
6352 let cx = btn_rect.x + btn_rect.w * 0.5;
6353 let cy = btn_rect.y + btn_rect.h * 0.5;
6354 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6355 assert!(!core.ui_state.focus_visible);
6356
6357 let ctrl = KeyModifiers {
6358 ctrl: true,
6359 ..Default::default()
6360 };
6361 let _ = core.key_down(
6362 LogicalKey::Named(NamedKey::Control),
6363 PhysicalKey::Unidentified,
6364 ctrl,
6365 false,
6366 );
6367 assert!(
6368 !core.ui_state.focus_visible,
6369 "bare Ctrl press must not raise focus_visible on a pointer-focused widget",
6370 );
6371 let _ = core.key_down(
6372 LogicalKey::Character("c".into()),
6373 PhysicalKey::Unidentified,
6374 ctrl,
6375 false,
6376 );
6377 assert!(
6378 !core.ui_state.focus_visible,
6379 "Ctrl+C is a shortcut, not interaction with the focused widget",
6380 );
6381
6382 let _ = core.key_down(
6383 LogicalKey::Named(NamedKey::Shift),
6384 PhysicalKey::Unidentified,
6385 KeyModifiers::default(),
6386 false,
6387 );
6388 assert!(
6389 !core.ui_state.focus_visible,
6390 "bare Shift press must not raise focus_visible",
6391 );
6392 let _ = core.key_down(
6393 LogicalKey::Character("a".into()),
6394 PhysicalKey::Unidentified,
6395 KeyModifiers::default(),
6396 false,
6397 );
6398 assert!(
6399 !core.ui_state.focus_visible,
6400 "bare character keys are typing/activation guesses, not navigation",
6401 );
6402 let _ = core.key_down(
6403 LogicalKey::Named(NamedKey::Escape),
6404 PhysicalKey::Unidentified,
6405 KeyModifiers::default(),
6406 false,
6407 );
6408 assert!(
6409 !core.ui_state.focus_visible,
6410 "Escape is dismissal, not navigation — no ring",
6411 );
6412 }
6413
6414 #[test]
6415 fn arrow_nav_in_sibling_group_raises_focus_visible() {
6416 let mut core = lay_out_arrow_nav_tree();
6417 core.ui_state.set_focus_visible(false);
6420 let _ = core.key_down(
6421 LogicalKey::Named(NamedKey::ArrowDown),
6422 PhysicalKey::Unidentified,
6423 KeyModifiers::default(),
6424 false,
6425 );
6426 assert!(
6427 core.ui_state.focus_visible,
6428 "arrow-nav within an arrow_nav_siblings group is keyboard navigation",
6429 );
6430 }
6431
6432 #[test]
6433 fn capture_keys_falls_back_to_default_when_focus_off_capturing_node() {
6434 let mut core = lay_out_input_tree(true);
6438 let btn_rect = core.rect_of_key("btn").expect("btn rect");
6439 let cx = btn_rect.x + btn_rect.w * 0.5;
6440 let cy = btn_rect.y + btn_rect.h * 0.5;
6441 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6442 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
6443 assert_eq!(
6444 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6445 Some("btn"),
6446 "primary click focuses button"
6447 );
6448 let _ = core.key_down(
6450 LogicalKey::Named(NamedKey::Tab),
6451 PhysicalKey::Unidentified,
6452 KeyModifiers::default(),
6453 false,
6454 );
6455 assert_eq!(
6456 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6457 Some("ti"),
6458 "Tab from non-capturing focused does library-default traversal"
6459 );
6460 }
6461
6462 fn lay_out_arrow_nav_tree() -> RunnerCore {
6467 use crate::tree::*;
6468 let mut tree = crate::column([
6469 crate::widgets::button::button("Red").key("opt-red"),
6470 crate::widgets::button::button("Green").key("opt-green"),
6471 crate::widgets::button::button("Blue").key("opt-blue"),
6472 ])
6473 .arrow_nav_siblings()
6474 .padding(10.0);
6475 let mut core = RunnerCore::new();
6476 crate::layout::layout(
6477 &mut tree,
6478 &mut core.ui_state,
6479 Rect::new(0.0, 0.0, 200.0, 300.0),
6480 );
6481 core.ui_state.sync_focus_order(&tree);
6482 let mut t = PrepareTimings::default();
6483 core.snapshot(&tree, &mut t);
6484 let target = core
6487 .ui_state
6488 .focus
6489 .order
6490 .iter()
6491 .find(|t| t.key == "opt-green")
6492 .cloned();
6493 core.ui_state.set_focus(target);
6494 core
6495 }
6496
6497 #[test]
6498 fn arrow_nav_moves_focus_among_siblings() {
6499 let mut core = lay_out_arrow_nav_tree();
6500
6501 let down = core.key_down(
6504 LogicalKey::Named(NamedKey::ArrowDown),
6505 PhysicalKey::Unidentified,
6506 KeyModifiers::default(),
6507 false,
6508 );
6509 assert!(down.is_empty(), "arrow-nav consumes the key event");
6510 assert_eq!(
6511 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6512 Some("opt-blue"),
6513 );
6514
6515 core.key_down(
6517 LogicalKey::Named(NamedKey::ArrowUp),
6518 PhysicalKey::Unidentified,
6519 KeyModifiers::default(),
6520 false,
6521 );
6522 assert_eq!(
6523 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6524 Some("opt-green"),
6525 );
6526
6527 core.key_down(
6529 LogicalKey::Named(NamedKey::Home),
6530 PhysicalKey::Unidentified,
6531 KeyModifiers::default(),
6532 false,
6533 );
6534 assert_eq!(
6535 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6536 Some("opt-red"),
6537 );
6538
6539 core.key_down(
6541 LogicalKey::Named(NamedKey::End),
6542 PhysicalKey::Unidentified,
6543 KeyModifiers::default(),
6544 false,
6545 );
6546 assert_eq!(
6547 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6548 Some("opt-blue"),
6549 );
6550 }
6551
6552 #[test]
6553 fn arrow_nav_saturates_at_ends() {
6554 let mut core = lay_out_arrow_nav_tree();
6555 core.key_down(
6557 LogicalKey::Named(NamedKey::Home),
6558 PhysicalKey::Unidentified,
6559 KeyModifiers::default(),
6560 false,
6561 );
6562 core.key_down(
6563 LogicalKey::Named(NamedKey::ArrowUp),
6564 PhysicalKey::Unidentified,
6565 KeyModifiers::default(),
6566 false,
6567 );
6568 assert_eq!(
6569 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6570 Some("opt-red"),
6571 "ArrowUp at top stays at top — no wrap",
6572 );
6573 core.key_down(
6575 LogicalKey::Named(NamedKey::End),
6576 PhysicalKey::Unidentified,
6577 KeyModifiers::default(),
6578 false,
6579 );
6580 core.key_down(
6581 LogicalKey::Named(NamedKey::ArrowDown),
6582 PhysicalKey::Unidentified,
6583 KeyModifiers::default(),
6584 false,
6585 );
6586 assert_eq!(
6587 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6588 Some("opt-blue"),
6589 "ArrowDown at bottom stays at bottom — no wrap",
6590 );
6591 }
6592
6593 #[test]
6594 fn arrow_nav_vertical_lets_horizontal_arrows_fall_through() {
6595 let mut core = lay_out_arrow_nav_tree();
6599 let out = core.key_down(
6600 LogicalKey::Named(NamedKey::ArrowRight),
6601 PhysicalKey::Unidentified,
6602 KeyModifiers::default(),
6603 false,
6604 );
6605 assert_eq!(
6606 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6607 Some("opt-green"),
6608 "ArrowRight must not move focus in a Vertical group",
6609 );
6610 assert!(
6611 !out.is_empty(),
6612 "ArrowRight falls through to normal KeyDown routing",
6613 );
6614 }
6615
6616 fn lay_out_horizontal_group() -> RunnerCore {
6619 let mut tree = crate::widgets::toggle::toggle_group(
6620 "view",
6621 &"list",
6622 [("list", "List"), ("grid", "Grid"), ("map", "Map")],
6623 )
6624 .padding(10.0);
6625 let mut core = RunnerCore::new();
6626 crate::layout::layout(
6627 &mut tree,
6628 &mut core.ui_state,
6629 Rect::new(0.0, 0.0, 400.0, 100.0),
6630 );
6631 core.ui_state.sync_focus_order(&tree);
6632 let mut t = PrepareTimings::default();
6633 core.snapshot(&tree, &mut t);
6634 let target = core
6635 .ui_state
6636 .focus
6637 .order
6638 .iter()
6639 .find(|t| t.key == "view:toggle:list")
6640 .cloned();
6641 core.ui_state.set_focus(target);
6642 core
6643 }
6644
6645 #[test]
6646 fn arrow_nav_horizontal_group_steps_on_left_right() {
6647 let mut core = lay_out_horizontal_group();
6650
6651 let right = core.key_down(
6652 LogicalKey::Named(NamedKey::ArrowRight),
6653 PhysicalKey::Unidentified,
6654 KeyModifiers::default(),
6655 false,
6656 );
6657 assert!(right.is_empty(), "ArrowRight is consumed by the group");
6658 assert_eq!(
6659 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6660 Some("view:toggle:grid"),
6661 );
6662
6663 core.key_down(
6664 LogicalKey::Named(NamedKey::ArrowLeft),
6665 PhysicalKey::Unidentified,
6666 KeyModifiers::default(),
6667 false,
6668 );
6669 assert_eq!(
6670 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6671 Some("view:toggle:list"),
6672 );
6673
6674 core.key_down(
6675 LogicalKey::Named(NamedKey::End),
6676 PhysicalKey::Unidentified,
6677 KeyModifiers::default(),
6678 false,
6679 );
6680 assert_eq!(
6681 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6682 Some("view:toggle:map"),
6683 );
6684
6685 let down = core.key_down(
6686 LogicalKey::Named(NamedKey::ArrowDown),
6687 PhysicalKey::Unidentified,
6688 KeyModifiers::default(),
6689 false,
6690 );
6691 assert_eq!(
6692 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6693 Some("view:toggle:map"),
6694 "ArrowDown must not move focus in a Horizontal group",
6695 );
6696 assert!(
6697 !down.is_empty(),
6698 "ArrowDown falls through to normal KeyDown routing",
6699 );
6700 }
6701
6702 #[test]
6703 fn arrow_nav_radio_group_steps_on_both_axes() {
6704 let mut tree = crate::widgets::radio::radio_group(
6707 "theme",
6708 &"light",
6709 [("light", "Light"), ("dark", "Dark"), ("auto", "Auto")],
6710 )
6711 .padding(10.0);
6712 let mut core = RunnerCore::new();
6713 crate::layout::layout(
6714 &mut tree,
6715 &mut core.ui_state,
6716 Rect::new(0.0, 0.0, 300.0, 300.0),
6717 );
6718 core.ui_state.sync_focus_order(&tree);
6719 let mut t = PrepareTimings::default();
6720 core.snapshot(&tree, &mut t);
6721 let target = core
6722 .ui_state
6723 .focus
6724 .order
6725 .iter()
6726 .find(|t| t.key == "theme:radio:light")
6727 .cloned();
6728 core.ui_state.set_focus(target);
6729
6730 core.key_down(
6731 LogicalKey::Named(NamedKey::ArrowDown),
6732 PhysicalKey::Unidentified,
6733 KeyModifiers::default(),
6734 false,
6735 );
6736 assert_eq!(
6737 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6738 Some("theme:radio:dark"),
6739 "ArrowDown steps to the next radio",
6740 );
6741 core.key_down(
6742 LogicalKey::Named(NamedKey::ArrowRight),
6743 PhysicalKey::Unidentified,
6744 KeyModifiers::default(),
6745 false,
6746 );
6747 assert_eq!(
6748 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6749 Some("theme:radio:auto"),
6750 "ArrowRight also steps in a Both group",
6751 );
6752 core.key_down(
6753 LogicalKey::Named(NamedKey::ArrowLeft),
6754 PhysicalKey::Unidentified,
6755 KeyModifiers::default(),
6756 false,
6757 );
6758 assert_eq!(
6759 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6760 Some("theme:radio:dark"),
6761 "ArrowLeft steps back in a Both group",
6762 );
6763 }
6764
6765 fn lay_out_calendar(focus_day: u32, disabled_day: Option<u32>) -> RunnerCore {
6768 use crate::widgets::calendar::{CalendarDay, calendar_month};
6769 let days = (1..=14).map(|d| {
6770 let day = CalendarDay::new(format!("2026-06-{d:02}"), d.to_string());
6771 if Some(d) == disabled_day {
6772 day.disabled()
6773 } else {
6774 day
6775 }
6776 });
6777 let mut tree = calendar_month("cal", "June 2026", days);
6778 let mut core = RunnerCore::new();
6779 crate::layout::layout(
6780 &mut tree,
6781 &mut core.ui_state,
6782 Rect::new(0.0, 0.0, 600.0, 400.0),
6783 );
6784 core.ui_state.sync_focus_order(&tree);
6785 let mut t = PrepareTimings::default();
6786 core.snapshot(&tree, &mut t);
6787 let key = format!("cal:day:2026-06-{focus_day:02}");
6788 let target = core
6789 .ui_state
6790 .focus
6791 .order
6792 .iter()
6793 .find(|t| t.key == key)
6794 .cloned();
6795 assert!(target.is_some(), "day {focus_day} should be focusable");
6796 core.ui_state.set_focus(target);
6797 core
6798 }
6799
6800 #[test]
6801 fn arrow_nav_calendar_grid_moves_two_dimensionally() {
6802 let mut core = lay_out_calendar(3, None);
6805
6806 let down = core.key_down(
6807 LogicalKey::Named(NamedKey::ArrowDown),
6808 PhysicalKey::Unidentified,
6809 KeyModifiers::default(),
6810 false,
6811 );
6812 assert!(down.is_empty(), "ArrowDown is consumed by the grid");
6813 assert_eq!(
6814 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6815 Some("cal:day:2026-06-10"),
6816 "ArrowDown lands on the same weekday one week later",
6817 );
6818
6819 core.key_down(
6820 LogicalKey::Named(NamedKey::ArrowRight),
6821 PhysicalKey::Unidentified,
6822 KeyModifiers::default(),
6823 false,
6824 );
6825 assert_eq!(
6826 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6827 Some("cal:day:2026-06-11"),
6828 );
6829
6830 core.key_down(
6831 LogicalKey::Named(NamedKey::ArrowUp),
6832 PhysicalKey::Unidentified,
6833 KeyModifiers::default(),
6834 false,
6835 );
6836 assert_eq!(
6837 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6838 Some("cal:day:2026-06-04"),
6839 "ArrowUp lands on the same weekday one week earlier",
6840 );
6841
6842 core.key_down(
6844 LogicalKey::Named(NamedKey::ArrowUp),
6845 PhysicalKey::Unidentified,
6846 KeyModifiers::default(),
6847 false,
6848 );
6849 assert_eq!(
6850 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6851 Some("cal:day:2026-06-04"),
6852 "ArrowUp at the top edge saturates",
6853 );
6854 }
6855
6856 #[test]
6857 fn arrow_nav_calendar_grid_skips_disabled_days() {
6858 let mut core = lay_out_calendar(3, Some(10));
6862 core.key_down(
6863 LogicalKey::Named(NamedKey::ArrowDown),
6864 PhysicalKey::Unidentified,
6865 KeyModifiers::default(),
6866 false,
6867 );
6868 let focused = core
6869 .ui_state
6870 .focused
6871 .as_ref()
6872 .map(|t| t.key.clone())
6873 .expect("focus survives the move");
6874 assert!(
6875 focused == "cal:day:2026-06-09" || focused == "cal:day:2026-06-11",
6876 "ArrowDown over a disabled day lands on a neighbor in the \
6877 same week, got {focused}",
6878 );
6879 }
6880
6881 fn build_popover_tree(open: bool) -> El {
6885 use crate::widgets::button::button;
6886 use crate::widgets::overlay::overlay;
6887 use crate::widgets::popover::{dropdown, menu_item};
6888 let mut layers: Vec<El> = vec![button("Trigger").key("trigger")];
6889 if open {
6890 layers.push(dropdown(
6891 "menu",
6892 "trigger",
6893 [
6894 menu_item("A").key("item-a"),
6895 menu_item("B").key("item-b"),
6896 menu_item("C").key("item-c"),
6897 ],
6898 ));
6899 }
6900 overlay(layers).padding(20.0)
6901 }
6902
6903 fn run_frame(core: &mut RunnerCore, tree: &mut El) {
6907 let mut t = PrepareTimings::default();
6908 core.prepare_layout(
6909 tree,
6910 Rect::new(0.0, 0.0, 400.0, 300.0),
6911 1.0,
6912 &mut t,
6913 RunnerCore::no_time_shaders,
6914 );
6915 core.snapshot(tree, &mut t);
6916 }
6917
6918 #[test]
6919 fn popover_open_pushes_focus_and_auto_focuses_first_item() {
6920 let mut core = RunnerCore::new();
6921 let mut closed = build_popover_tree(false);
6922 run_frame(&mut core, &mut closed);
6923 let trigger = core
6926 .ui_state
6927 .focus
6928 .order
6929 .iter()
6930 .find(|t| t.key == "trigger")
6931 .cloned();
6932 core.ui_state.set_focus(trigger);
6933 assert_eq!(
6934 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6935 Some("trigger"),
6936 );
6937
6938 let mut open = build_popover_tree(true);
6941 run_frame(&mut core, &mut open);
6942 assert_eq!(
6943 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6944 Some("item-a"),
6945 "popover open should auto-focus the first menu item",
6946 );
6947 assert_eq!(
6948 core.ui_state.popover_focus.focus_stack.len(),
6949 1,
6950 "trigger should be saved on the focus stack",
6951 );
6952 assert_eq!(
6953 core.ui_state.popover_focus.focus_stack[0].key.as_str(),
6954 "trigger",
6955 "saved focus should be the pre-open target",
6956 );
6957 }
6958
6959 #[test]
6960 fn popover_close_restores_focus_to_trigger() {
6961 let mut core = RunnerCore::new();
6962 let mut closed = build_popover_tree(false);
6963 run_frame(&mut core, &mut closed);
6964 let trigger = core
6965 .ui_state
6966 .focus
6967 .order
6968 .iter()
6969 .find(|t| t.key == "trigger")
6970 .cloned();
6971 core.ui_state.set_focus(trigger);
6972
6973 let mut open = build_popover_tree(true);
6975 run_frame(&mut core, &mut open);
6976 assert_eq!(
6977 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6978 Some("item-a"),
6979 );
6980
6981 let mut closed_again = build_popover_tree(false);
6983 run_frame(&mut core, &mut closed_again);
6984 assert_eq!(
6985 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6986 Some("trigger"),
6987 "closing the popover should pop the saved focus",
6988 );
6989 assert!(
6990 core.ui_state.popover_focus.focus_stack.is_empty(),
6991 "focus stack should be drained after restore",
6992 );
6993 }
6994
6995 #[test]
6996 fn popover_close_does_not_override_intentional_focus_move() {
6997 let mut core = RunnerCore::new();
6998 let build = |open: bool| -> El {
7001 use crate::widgets::button::button;
7002 use crate::widgets::overlay::overlay;
7003 use crate::widgets::popover::{dropdown, menu_item};
7004 let main = crate::row([
7005 button("Trigger").key("trigger"),
7006 button("Other").key("other"),
7007 ]);
7008 let mut layers: Vec<El> = vec![main];
7009 if open {
7010 layers.push(dropdown("menu", "trigger", [menu_item("A").key("item-a")]));
7011 }
7012 overlay(layers).padding(20.0)
7013 };
7014
7015 let mut closed = build(false);
7016 run_frame(&mut core, &mut closed);
7017 let trigger = core
7018 .ui_state
7019 .focus
7020 .order
7021 .iter()
7022 .find(|t| t.key == "trigger")
7023 .cloned();
7024 core.ui_state.set_focus(trigger);
7025
7026 let mut open = build(true);
7027 run_frame(&mut core, &mut open);
7028 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
7029
7030 let other = core
7035 .ui_state
7036 .focus
7037 .order
7038 .iter()
7039 .find(|t| t.key == "other")
7040 .cloned();
7041 core.ui_state.set_focus(other);
7042
7043 let mut closed_again = build(false);
7044 run_frame(&mut core, &mut closed_again);
7045 assert_eq!(
7046 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7047 Some("other"),
7048 "focus moved before close should not be overridden by restore",
7049 );
7050 assert!(core.ui_state.popover_focus.focus_stack.is_empty());
7051 }
7052
7053 #[test]
7054 fn nested_popovers_stack_and_unwind_focus_correctly() {
7055 let mut core = RunnerCore::new();
7056 let build = |outer: bool, inner: bool| -> El {
7061 use crate::widgets::button::button;
7062 use crate::widgets::overlay::overlay;
7063 use crate::widgets::popover::{Anchor, popover, popover_panel};
7064 let main = button("Trigger").key("trigger");
7065 let mut layers: Vec<El> = vec![main];
7066 if outer {
7067 layers.push(popover(
7068 "outer",
7069 Anchor::below_key("trigger"),
7070 popover_panel([button("Open inner").key("inner-trigger")]),
7071 ));
7072 }
7073 if inner {
7074 layers.push(popover(
7075 "inner",
7076 Anchor::below_key("inner-trigger"),
7077 popover_panel([button("X").key("inner-a"), button("Y").key("inner-b")]),
7078 ));
7079 }
7080 overlay(layers).padding(20.0)
7081 };
7082
7083 let mut closed = build(false, false);
7085 run_frame(&mut core, &mut closed);
7086 let trigger = core
7087 .ui_state
7088 .focus
7089 .order
7090 .iter()
7091 .find(|t| t.key == "trigger")
7092 .cloned();
7093 core.ui_state.set_focus(trigger);
7094
7095 let mut outer = build(true, false);
7097 run_frame(&mut core, &mut outer);
7098 assert_eq!(
7099 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7100 Some("inner-trigger"),
7101 );
7102 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
7103
7104 let mut both = build(true, true);
7106 run_frame(&mut core, &mut both);
7107 assert_eq!(
7108 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7109 Some("inner-a"),
7110 );
7111 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 2);
7112
7113 let mut outer_only = build(true, false);
7115 run_frame(&mut core, &mut outer_only);
7116 assert_eq!(
7117 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7118 Some("inner-trigger"),
7119 );
7120 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
7121
7122 let mut none = build(false, false);
7124 run_frame(&mut core, &mut none);
7125 assert_eq!(
7126 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7127 Some("trigger"),
7128 );
7129 assert!(core.ui_state.popover_focus.focus_stack.is_empty());
7130 }
7131
7132 #[test]
7133 fn arrow_nav_does_not_intercept_outside_navigable_groups() {
7134 let mut core = lay_out_input_tree(false);
7138 let target = core
7139 .ui_state
7140 .focus
7141 .order
7142 .iter()
7143 .find(|t| t.key == "btn")
7144 .cloned();
7145 core.ui_state.set_focus(target);
7146 let events = core.key_down(
7147 LogicalKey::Named(NamedKey::ArrowDown),
7148 PhysicalKey::Unidentified,
7149 KeyModifiers::default(),
7150 false,
7151 );
7152 assert_eq!(
7153 events.len(),
7154 1,
7155 "ArrowDown without navigable parent → event"
7156 );
7157 assert_eq!(events[0].kind, UiEventKind::KeyDown);
7158 }
7159
7160 fn quad(shader: ShaderHandle) -> DrawOp {
7161 DrawOp::Quad {
7162 id: "q".into(),
7163 rect: Rect::new(0.0, 0.0, 10.0, 10.0),
7164 scissor: None,
7165 shader,
7166 uniforms: UniformBlock::new(),
7167 }
7168 }
7169
7170 #[test]
7171 fn prepare_paint_skips_ops_outside_viewport() {
7172 let mut core = RunnerCore::new();
7173 core.set_surface_size(100, 100);
7174 core.viewport_px = (100, 100);
7175 let ops = vec![
7176 DrawOp::Quad {
7177 id: "offscreen".into(),
7178 rect: Rect::new(0.0, 150.0, 10.0, 10.0),
7179 scissor: None,
7180 shader: ShaderHandle::Stock(StockShader::RoundedRect),
7181 uniforms: UniformBlock::new(),
7182 },
7183 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
7184 ];
7185 let mut timings = PrepareTimings::default();
7186 core.prepare_paint(&ops, |_| true, |_| false, &mut NoText, 1.0, &mut timings);
7187
7188 assert_eq!(timings.paint_culled_ops, 1);
7189 assert_eq!(
7190 core.runs.len(),
7191 1,
7192 "only the visible quad should become a paint run"
7193 );
7194 }
7195
7196 #[test]
7197 fn prepare_paint_does_not_shape_text_outside_clip() {
7198 let mut core = RunnerCore::new();
7199 core.set_surface_size(100, 100);
7200 core.viewport_px = (100, 100);
7201 let ops = vec![
7202 DrawOp::GlyphRun {
7203 id: "offscreen-text".into(),
7204 rect: Rect::new(0.0, 150.0, 80.0, 20.0),
7205 scissor: Some(Rect::new(0.0, 0.0, 100.0, 100.0)),
7206 shader: ShaderHandle::Stock(StockShader::Text),
7207 color: Color::srgb_u8a(255, 255, 255, 255),
7208 text: "offscreen".into(),
7209 size: 14.0,
7210 line_height: 20.0,
7211 family: Default::default(),
7212 mono_family: Default::default(),
7213 weight: FontWeight::Regular,
7214 mono: false,
7215 wrap: TextWrap::NoWrap,
7216 anchor: TextAnchor::Start,
7217 layout: empty_text_layout(20.0),
7218 underline: false,
7219 strikethrough: false,
7220 link: None,
7221 tabular_numerals: false,
7222 },
7223 DrawOp::GlyphRun {
7224 id: "visible-text".into(),
7225 rect: Rect::new(0.0, 10.0, 80.0, 20.0),
7226 scissor: Some(Rect::new(0.0, 0.0, 100.0, 100.0)),
7227 shader: ShaderHandle::Stock(StockShader::Text),
7228 color: Color::srgb_u8a(255, 255, 255, 255),
7229 text: "visible".into(),
7230 size: 14.0,
7231 line_height: 20.0,
7232 family: Default::default(),
7233 mono_family: Default::default(),
7234 weight: FontWeight::Regular,
7235 mono: false,
7236 wrap: TextWrap::NoWrap,
7237 anchor: TextAnchor::Start,
7238 layout: empty_text_layout(20.0),
7239 underline: false,
7240 strikethrough: false,
7241 link: None,
7242 tabular_numerals: false,
7243 },
7244 ];
7245 let mut text = CountingText::default();
7246 let mut timings = PrepareTimings::default();
7247 core.prepare_paint(&ops, |_| true, |_| false, &mut text, 1.0, &mut timings);
7248
7249 assert_eq!(timings.paint_culled_ops, 1);
7250 assert_eq!(text.records, 1, "offscreen text must not be shaped");
7251 }
7252
7253 #[test]
7254 fn samples_backdrop_inserts_snapshot_before_first_glass_quad() {
7255 let mut core = RunnerCore::new();
7256 core.set_surface_size(100, 100);
7257 let ops = vec![
7258 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
7259 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
7260 quad(ShaderHandle::Custom("liquid_glass")),
7261 quad(ShaderHandle::Custom("liquid_glass")),
7262 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
7263 ];
7264 let mut timings = PrepareTimings::default();
7265 core.prepare_paint(
7266 &ops,
7267 |_| true,
7268 |s| matches!(s, ShaderHandle::Custom(name) if *name == "liquid_glass"),
7269 &mut NoText,
7270 1.0,
7271 &mut timings,
7272 );
7273
7274 let kinds: Vec<&'static str> = core
7275 .paint_items
7276 .iter()
7277 .map(|p| match p {
7278 PaintItem::QuadRun(_) => "Q",
7279 PaintItem::IconRun(_) => "I",
7280 PaintItem::Text(_) => "T",
7281 PaintItem::Image(_) => "M",
7282 PaintItem::AppTexture(_) => "A",
7283 PaintItem::Vector(_) => "V",
7284 PaintItem::Scene3D(_) => "3",
7285 PaintItem::BackdropSnapshot => "S",
7286 })
7287 .collect();
7288 assert_eq!(
7289 kinds,
7290 vec!["Q", "S", "Q", "Q"],
7291 "expected one stock run, snapshot, then a glass run, then a foreground stock run"
7292 );
7293 }
7294
7295 #[test]
7296 fn no_snapshot_when_no_glass_drawn() {
7297 let mut core = RunnerCore::new();
7298 core.set_surface_size(100, 100);
7299 let ops = vec![
7300 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
7301 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
7302 ];
7303 let mut timings = PrepareTimings::default();
7304 core.prepare_paint(&ops, |_| true, |_| false, &mut NoText, 1.0, &mut timings);
7305 assert!(
7306 !core
7307 .paint_items
7308 .iter()
7309 .any(|p| matches!(p, PaintItem::BackdropSnapshot)),
7310 "no glass shader registered → no snapshot"
7311 );
7312 }
7313
7314 #[test]
7319 fn scene3d_op_emits_paint_item_only_when_recorder_records() {
7320 use crate::scene::{Aabb, CameraState, LightRig, Scene3DData, SceneStyle};
7321
7322 fn scene_op() -> DrawOp {
7323 let scene = Scene3DData {
7324 meshes: Vec::new(),
7325 points: Vec::new(),
7326 lines: Vec::new(),
7327 camera: CameraState::default().resolve(Aabb::EMPTY),
7328 lights: LightRig::default(),
7329 style: SceneStyle::default(),
7330 capture_depth: false,
7331 };
7332 DrawOp::Scene3D {
7333 id: "scene".into(),
7334 rect: Rect::new(0.0, 0.0, 40.0, 40.0),
7335 scissor: None,
7336 scene: std::sync::Arc::new(scene),
7337 }
7338 }
7339
7340 let mut core = RunnerCore::new();
7342 core.set_surface_size(100, 100);
7343 let mut timings = PrepareTimings::default();
7344 core.prepare_paint(
7345 &[scene_op()],
7346 |_| true,
7347 |_| false,
7348 &mut NoText,
7349 1.0,
7350 &mut timings,
7351 );
7352 assert!(
7353 !core
7354 .paint_items
7355 .iter()
7356 .any(|p| matches!(p, PaintItem::Scene3D(_))),
7357 "default no-op recorder must not emit a Scene3D paint item",
7358 );
7359
7360 struct SceneRecorder {
7362 calls: usize,
7363 }
7364 impl TextRecorder for SceneRecorder {
7365 fn record(
7366 &mut self,
7367 _: Rect,
7368 _: Option<PhysicalScissor>,
7369 _: &RunStyle,
7370 _: &str,
7371 _: f32,
7372 _: f32,
7373 _: TextWrap,
7374 _: TextAnchor,
7375 _: f32,
7376 ) -> Range<usize> {
7377 0..0
7378 }
7379 fn record_runs(
7380 &mut self,
7381 _: Rect,
7382 _: Option<PhysicalScissor>,
7383 _: &[(String, RunStyle)],
7384 _: f32,
7385 _: f32,
7386 _: TextWrap,
7387 _: TextAnchor,
7388 _: f32,
7389 ) -> Range<usize> {
7390 0..0
7391 }
7392 fn record_scene3d(
7393 &mut self,
7394 _: Rect,
7395 _: Option<PhysicalScissor>,
7396 id: &str,
7397 _: &std::sync::Arc<Scene3DData>,
7398 _: f32,
7399 ) -> Range<usize> {
7400 assert_eq!(id, "scene", "node id threads through to the recorder");
7401 let start = self.calls;
7402 self.calls += 1;
7403 start..self.calls
7404 }
7405 }
7406
7407 let mut core = RunnerCore::new();
7408 core.set_surface_size(100, 100);
7409 let mut rec = SceneRecorder { calls: 0 };
7410 let mut timings = PrepareTimings::default();
7411 core.prepare_paint(
7412 &[scene_op()],
7413 |_| true,
7414 |_| false,
7415 &mut rec,
7416 1.0,
7417 &mut timings,
7418 );
7419 let scenes = core
7420 .paint_items
7421 .iter()
7422 .filter(|p| matches!(p, PaintItem::Scene3D(_)))
7423 .count();
7424 assert_eq!(
7425 scenes, 1,
7426 "recorded scene must emit exactly one Scene3D item"
7427 );
7428 }
7429
7430 #[test]
7437 fn keyed_scene_still_begins_camera_drag() {
7438 use crate::scene::glam::Vec3;
7439 use crate::scene::{PointData, PointsHandle, ScenePoint, SceneSpec};
7440 use crate::tree::chart3d;
7441
7442 let spec = || {
7443 SceneSpec::new().points(PointsHandle::new(PointData {
7444 points: vec![
7445 ScenePoint {
7446 position: Vec3::splat(-1.0),
7447 color: [1.0; 4],
7448 },
7449 ScenePoint {
7450 position: Vec3::splat(1.0),
7451 color: [1.0; 4],
7452 },
7453 ],
7454 }))
7455 };
7456
7457 let drag_active_after_press = |mut tree: crate::tree::El| {
7460 let mut core = RunnerCore::new();
7461 crate::layout::layout(
7462 &mut tree,
7463 &mut core.ui_state,
7464 Rect::new(0.0, 0.0, 200.0, 200.0),
7465 );
7466 core.ui_state.tick_scene_cameras(&tree, Instant::now());
7467 let mut t = PrepareTimings::default();
7468 core.snapshot(&tree, &mut t);
7469 core.pointer_down(Pointer::mouse(100.0, 100.0, PointerButton::Primary));
7470 core.ui_state.camera_drag_active()
7471 };
7472
7473 assert!(
7475 drag_active_after_press(chart3d(spec())),
7476 "unkeyed scene should begin a camera drag"
7477 );
7478 assert!(
7480 drag_active_after_press(chart3d(spec()).key("scene")),
7481 "keyed scene must still begin a camera drag (its own node hit must not suppress it)"
7482 );
7483 }
7484
7485 #[test]
7489 fn plot_control_scheme_routes_primary_drag() {
7490 use crate::event::KeyModifiers;
7491 use crate::plot::{PlotControls, PlotSpec, Sample, Scale, SeriesHandle, line};
7492 use crate::tree::plot;
7493
7494 let make = |controls| {
7495 let h = SeriesHandle::new(vec![Sample::new(0.0, 0.0), Sample::new(10.0, 10.0)]);
7496 plot(
7497 PlotSpec::new()
7498 .x(Scale::linear())
7499 .y(Scale::linear())
7500 .add_mark(line(&h))
7501 .controls(controls),
7502 )
7503 .key("p")
7504 };
7505
7506 let press = |mut tree: crate::tree::El, shift: bool| {
7508 let mut core = RunnerCore::new();
7509 crate::layout::layout(
7510 &mut tree,
7511 &mut core.ui_state,
7512 Rect::new(0.0, 0.0, 200.0, 200.0),
7513 );
7514 core.ui_state.prepare_plots(&tree);
7515 let mut t = PrepareTimings::default();
7516 core.snapshot(&tree, &mut t);
7517 core.ui_state.set_modifiers(KeyModifiers {
7518 shift,
7519 ..Default::default()
7520 });
7521 core.pointer_down(Pointer::mouse(100.0, 100.0, PointerButton::Primary));
7522 (
7523 core.ui_state.plot_zoom_active(),
7524 core.ui_state.plot_pan_active(),
7525 )
7526 };
7527
7528 assert_eq!(press(make(PlotControls::ZoomDrag), false), (true, false));
7530 assert_eq!(press(make(PlotControls::ZoomDrag), true), (false, true));
7531 assert_eq!(press(make(PlotControls::PanDrag), false), (false, true));
7533 assert_eq!(press(make(PlotControls::PanDrag), true), (true, false));
7534 }
7535
7536 #[test]
7537 fn at_most_one_snapshot_per_frame() {
7538 let mut core = RunnerCore::new();
7539 core.set_surface_size(100, 100);
7540 let ops = vec![
7541 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
7542 quad(ShaderHandle::Custom("g")),
7543 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
7544 quad(ShaderHandle::Custom("g")),
7545 ];
7546 let mut timings = PrepareTimings::default();
7547 core.prepare_paint(
7548 &ops,
7549 |_| true,
7550 |s| matches!(s, ShaderHandle::Custom("g")),
7551 &mut NoText,
7552 1.0,
7553 &mut timings,
7554 );
7555 let snapshots = core
7556 .paint_items
7557 .iter()
7558 .filter(|p| matches!(p, PaintItem::BackdropSnapshot))
7559 .count();
7560 assert_eq!(snapshots, 1, "backdrop depth is capped at 1");
7561 }
7562}