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, LatentViewportPan, MOUSE_DRAG_SLOP, SelectionDragGranularity,
73 TOUCH_DRAG_THRESHOLD, 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 if let Some(latent) = self.ui_state.viewport.latent_pan.clone() {
485 let (dx, dy) = (x - latent.press.0, y - latent.press.1);
486 if (dx * dx + dy * dy).sqrt() >= MOUSE_DRAG_SLOP {
487 self.ui_state.viewport.latent_pan = None;
488 let modifiers = self.ui_state.modifiers;
489 let mut out = Vec::new();
490 self.cancel_press_for_scroll(&mut out, x, y, kind, modifiers);
491 self.ui_state.click.last = None;
496 self.ui_state.begin_viewport_pan(
497 latent.viewport_id,
498 PointerButton::Primary,
499 latent.press.0,
500 latent.press.1,
501 );
502 self.ui_state.drag_viewport_to(x, y);
503 return PointerMove {
506 events: out,
507 needs_redraw: true,
508 };
509 }
510 }
511
512 let hit = self
513 .last_tree
514 .as_ref()
515 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
516 let prev_hover = self.ui_state.hovered.clone();
520 let hover_changed = self.ui_state.set_hovered(hit, Instant::now());
521 let prev_hovered_link = self.ui_state.hovered_link.clone();
527 let new_hovered_link = self
528 .linkable_tree()
529 .and_then(|t| hit_test::link_at(t, (x, y)));
530 let link_hover_changed = new_hovered_link != prev_hovered_link;
531 self.ui_state.hovered_link = new_hovered_link;
532 let prev_hovered_band = self.ui_state.resize.hovered_band.clone();
538 let new_hovered_band = self.ui_state.resize_band_at(x, y).map(|b| b.id.clone());
539 let band_hover_changed = new_hovered_band != prev_hovered_band;
540 self.ui_state.resize.hovered_band = new_hovered_band;
541 let modifiers = self.ui_state.modifiers;
542
543 let mut out = Vec::new();
544
545 let touch_no_press = matches!(kind, PointerKind::Touch) && self.ui_state.pressed.is_none();
561 if hover_changed && !touch_no_press {
562 if let Some(prev) = prev_hover {
563 out.push(UiEvent {
564 key: Some(prev.key.clone()),
565 target: Some(prev),
566 pointer: Some((x, y)),
567 key_press: None,
568 text: None,
569 selection: None,
570 modifiers,
571 click_count: 0,
572 path: None,
573 pointer_kind: Some(kind),
574 wheel_delta: None,
575 kind: UiEventKind::PointerLeave,
576 });
577 }
578 if let Some(new) = self.ui_state.hovered.clone() {
579 out.push(UiEvent {
580 key: Some(new.key.clone()),
581 target: Some(new),
582 pointer: Some((x, y)),
583 key_press: None,
584 text: None,
585 selection: None,
586 modifiers,
587 click_count: 0,
588 path: None,
589 pointer_kind: Some(kind),
590 wheel_delta: None,
591 kind: UiEventKind::PointerEnter,
592 });
593 }
594 }
595
596 if matches!(kind, PointerKind::Touch) {
601 match self.ui_state.touch_gesture.clone() {
602 TouchGestureState::Pending {
603 initial,
604 consumes_drag,
605 started_at,
606 } => {
607 let dx = x - initial.0;
608 let dy = y - initial.1;
609 if (dx * dx + dy * dy).sqrt() < TOUCH_DRAG_THRESHOLD {
610 let needs_redraw = hover_changed
615 || link_hover_changed
616 || band_hover_changed
617 || !out.is_empty();
618 return PointerMove {
619 events: out,
620 needs_redraw,
621 };
622 }
623 if consumes_drag {
624 self.ui_state.touch_gesture = TouchGestureState::None;
629 } else {
630 let now = Instant::now();
636 let press_target = self.ui_state.pressed.clone();
641 self.cancel_press_for_scroll(&mut out, x, y, kind, modifiers);
642 let scroll_dy = initial.1 - y;
648 let pan_vp =
665 self.ui_state
666 .viewport_at(initial.0, initial.1)
667 .filter(|(id, cfg)| {
668 cfg.pan_trigger.matches(PointerButton::Primary, modifiers)
669 && self.last_tree.as_ref().is_none_or(|t| {
670 !hit_test::occluded_by_overlay(t, initial, id)
671 })
672 && press_target.as_ref().is_none_or(|p| {
673 self.last_tree.as_ref().is_some_and(|t| {
674 find_inside_subtree(t, id, &p.node_id, false)
675 .unwrap_or(false)
676 })
677 })
678 });
679 if let Some((vp_id, _)) = pan_vp {
680 let vertical_dominant = dy.abs() >= dx.abs();
696 let mut inner_step = None;
700 if vertical_dominant && let Some(tree) = self.last_tree.as_ref() {
701 for id in
702 hit_test::scroll_targets_at(tree, initial).into_iter().rev()
703 {
704 if find_inside_subtree(tree, &vp_id, &id, false)
705 .unwrap_or(false)
706 && let Some(step) =
707 self.ui_state.scroll_by_id(&id, scroll_dy)
708 {
709 inner_step = Some(step);
710 break;
711 }
712 }
713 }
714 if let Some(step) = inner_step {
715 let dt = now
716 .duration_since(started_at)
717 .as_secs_f32()
718 .max(1.0 / 120.0);
719 self.ui_state.touch_gesture = TouchGestureState::Scrolling {
720 last_pos: (x, y),
721 last_time: now,
722 velocity: step.applied_delta / dt,
723 scroll_id: Some(step.scroll_id),
724 };
725 } else {
726 self.ui_state.touch_gesture = TouchGestureState::None;
733 self.ui_state.begin_viewport_pan(
734 vp_id,
735 PointerButton::Primary,
736 initial.0,
737 initial.1,
738 );
739 self.ui_state.drag_viewport_to(x, y);
740 }
741 return PointerMove {
742 events: out,
743 needs_redraw: true,
744 };
745 }
746 let step = self.last_tree.as_ref().and_then(|tree| {
747 self.ui_state.scroll_by_pointer(tree, initial, scroll_dy)
748 });
749 let dt = now
750 .duration_since(started_at)
751 .as_secs_f32()
752 .max(1.0 / 120.0);
753 let velocity = step.as_ref().map(|s| s.applied_delta / dt).unwrap_or(0.0);
754 self.ui_state.touch_gesture = TouchGestureState::Scrolling {
755 last_pos: (x, y),
756 last_time: now,
757 velocity,
758 scroll_id: step.map(|s| s.scroll_id),
759 };
760 return PointerMove {
761 events: out,
762 needs_redraw: true,
763 };
764 }
765 }
766 TouchGestureState::Scrolling {
767 last_pos,
768 last_time,
769 velocity,
770 scroll_id,
771 } => {
772 let now = Instant::now();
773 let scroll_dy = last_pos.1 - y;
774 let step = scroll_id
775 .as_ref()
776 .and_then(|id| self.ui_state.scroll_by_id(id, scroll_dy))
777 .or_else(|| {
778 self.last_tree.as_ref().and_then(|tree| {
779 self.ui_state.scroll_by_pointer(tree, (x, y), scroll_dy)
780 })
781 });
782 let dt = now.duration_since(last_time).as_secs_f32().max(1.0 / 240.0);
783 let sample_velocity =
784 step.as_ref().map(|s| s.applied_delta / dt).unwrap_or(0.0);
785 let velocity = sample_velocity * 0.65 + velocity * 0.35;
786 self.ui_state.touch_gesture = TouchGestureState::Scrolling {
787 last_pos: (x, y),
788 last_time: now,
789 velocity,
790 scroll_id: step.map(|s| s.scroll_id).or(scroll_id),
791 };
792 return PointerMove {
793 events: out,
794 needs_redraw: true,
795 };
796 }
797 TouchGestureState::None => {
798 }
801 TouchGestureState::LongPressed => {
802 self.extend_selection_drag_at(x, y, kind, modifiers, &mut out);
807 if self.ui_state.pressed.is_none() {
808 let needs_redraw = hover_changed
809 || link_hover_changed
810 || band_hover_changed
811 || !out.is_empty();
812 return PointerMove {
813 events: out,
814 needs_redraw,
815 };
816 }
817 }
818 }
819 }
820
821 self.extend_selection_drag_at(x, y, kind, modifiers, &mut out);
828
829 if let Some(p) = self.ui_state.pressed.clone() {
835 if self.focused_captures_keys() {
839 self.ui_state.bump_caret_activity(Instant::now());
840 }
841 out.push(UiEvent {
842 key: Some(p.key.clone()),
843 target: Some(p),
844 pointer: Some((x, y)),
845 key_press: None,
846 text: None,
847 selection: None,
848 modifiers,
849 click_count: self.ui_state.current_click_count(),
850 path: None,
851 pointer_kind: Some(kind),
852 wheel_delta: None,
853 kind: UiEventKind::Drag,
854 });
855 }
856
857 let over_hover_scene = self.ui_state.pointer_over_hover_scene(x, y)
861 || prev_pos.is_some_and(|(px, py)| self.ui_state.pointer_over_hover_scene(px, py));
862 let over_crosshair_plot = self.ui_state.pointer_over_crosshair_plot(x, y)
865 || prev_pos.is_some_and(|(px, py)| self.ui_state.pointer_over_crosshair_plot(px, py));
866 let needs_redraw = hover_changed
867 || link_hover_changed
868 || band_hover_changed
869 || !out.is_empty()
870 || over_hover_scene
871 || over_crosshair_plot;
872 PointerMove {
873 events: out,
874 needs_redraw,
875 }
876 }
877
878 pub fn pointer_left(&mut self) -> Vec<UiEvent> {
886 let last_pos = self.ui_state.pointer_pos;
887 let prev_hover = self.ui_state.hovered.clone();
888 let modifiers = self.ui_state.modifiers;
889 let kind = self.ui_state.pointer_kind;
894 self.ui_state.pointer_pos = None;
895 self.ui_state.set_hovered(None, Instant::now());
896 self.ui_state.pressed = None;
897 self.ui_state.pressed_secondary = None;
898 self.ui_state.touch_gesture = TouchGestureState::None;
899 self.ui_state.viewport.latent_pan = None;
901 self.ui_state.cancel_scroll_momentum();
902 self.ui_state.hovered_link = None;
908 self.ui_state.pressed_link = None;
909 self.ui_state.resize.hovered_band = None;
913 self.ui_state.resize.drag = None;
914
915 let mut out = Vec::new();
916 if let Some(prev) = prev_hover {
917 out.push(UiEvent {
918 key: Some(prev.key.clone()),
919 target: Some(prev),
920 pointer: last_pos,
921 key_press: None,
922 text: None,
923 selection: None,
924 modifiers,
925 click_count: 0,
926 path: None,
927 pointer_kind: Some(kind),
928 wheel_delta: None,
929 kind: UiEventKind::PointerLeave,
930 });
931 }
932 out
933 }
934
935 pub fn pointer_cancelled(&mut self) -> Vec<UiEvent> {
950 let (x, y) = self.ui_state.pointer_pos.unwrap_or_default();
951 let kind = self.ui_state.pointer_kind;
952 let modifiers = self.ui_state.modifiers;
953 let mut out = Vec::new();
954 self.cancel_press_for_scroll(&mut out, x, y, kind, modifiers);
957 self.ui_state.viewport.latent_pan = None;
958 self.ui_state.viewport.pan_drag = None;
959 self.ui_state.cancel_camera_drag();
960 self.ui_state.cancel_plot_gestures();
961 self.ui_state.scroll.thumb_drag = None;
962 self.ui_state.resize.drag = None;
963 self.ui_state.resize.hovered_band = None;
964 self.ui_state.touch_gesture = TouchGestureState::None;
965 self.ui_state.cancel_scroll_momentum();
966 self.ui_state.pointer_pos = None;
967 out
968 }
969
970 pub fn file_hovered(&mut self, path: std::path::PathBuf, x: f32, y: f32) -> Vec<UiEvent> {
984 self.ui_state.pointer_pos = Some((x, y));
985 let target = self
986 .last_tree
987 .as_ref()
988 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
989 let key = target.as_ref().map(|t| t.key.clone());
990 vec![UiEvent {
991 key,
992 target,
993 pointer: Some((x, y)),
994 key_press: None,
995 text: None,
996 selection: None,
997 modifiers: self.ui_state.modifiers,
998 click_count: 0,
999 path: Some(path),
1000 pointer_kind: None,
1001 wheel_delta: None,
1002 kind: UiEventKind::FileHovered,
1003 }]
1004 }
1005
1006 pub fn file_hover_cancelled(&mut self) -> Vec<UiEvent> {
1011 vec![UiEvent {
1012 key: None,
1013 target: None,
1014 pointer: self.ui_state.pointer_pos,
1015 key_press: None,
1016 text: None,
1017 selection: None,
1018 modifiers: self.ui_state.modifiers,
1019 click_count: 0,
1020 path: None,
1021 pointer_kind: None,
1022 wheel_delta: None,
1023 kind: UiEventKind::FileHoverCancelled,
1024 }]
1025 }
1026
1027 pub fn file_dropped(&mut self, path: std::path::PathBuf, x: f32, y: f32) -> Vec<UiEvent> {
1032 self.ui_state.pointer_pos = Some((x, y));
1033 let target = self
1034 .last_tree
1035 .as_ref()
1036 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
1037 let key = target.as_ref().map(|t| t.key.clone());
1038 vec![UiEvent {
1039 key,
1040 target,
1041 pointer: Some((x, y)),
1042 key_press: None,
1043 text: None,
1044 selection: None,
1045 modifiers: self.ui_state.modifiers,
1046 click_count: 0,
1047 path: Some(path),
1048 pointer_kind: None,
1049 wheel_delta: None,
1050 kind: UiEventKind::FileDropped,
1051 }]
1052 }
1053
1054 pub fn pointer_down(&mut self, p: Pointer) -> Vec<UiEvent> {
1067 let Pointer {
1068 x, y, button, kind, ..
1069 } = p;
1070 self.ui_state.pointer_kind = kind;
1071 self.ui_state.cancel_scroll_momentum();
1072 self.ui_state.viewport.latent_pan = None;
1075 if matches!(button, PointerButton::Primary)
1084 && let Some((scroll_id, _track, thumb_rect)) = self
1085 .ui_state
1086 .thumb_at(x, y)
1087 .filter(|(scroll_id, _, _)| self.scrollbar_can_capture(scroll_id, x, y))
1088 {
1089 let metrics = self
1090 .ui_state
1091 .scroll
1092 .metrics
1093 .get(&scroll_id)
1094 .copied()
1095 .unwrap_or_default();
1096 let start_offset = self
1097 .ui_state
1098 .scroll
1099 .offsets
1100 .get(&scroll_id)
1101 .copied()
1102 .unwrap_or(0.0);
1103
1104 let grabbed = y >= thumb_rect.y && y <= thumb_rect.y + thumb_rect.h;
1108 if grabbed {
1109 let track_remaining = (metrics.viewport_h - thumb_rect.h).max(0.0);
1110 self.ui_state.scroll.thumb_drag = Some(crate::state::ThumbDrag {
1111 scroll_id,
1112 start_pointer_y: y,
1113 start_offset,
1114 track_remaining,
1115 max_offset: metrics.max_offset,
1116 });
1117 } else {
1118 let page = (metrics.viewport_h - SCROLL_PAGE_OVERLAP).max(0.0);
1124 let delta = if y < thumb_rect.y { -page } else { page };
1125 let new_offset = (start_offset + delta).clamp(0.0, metrics.max_offset);
1126 self.ui_state.scroll.offsets.insert(scroll_id, new_offset);
1127 }
1128 return Vec::new();
1129 }
1130
1131 if matches!(button, PointerButton::Primary)
1138 && let Some(band) = self
1139 .ui_state
1140 .resize_band_at(x, y)
1141 .filter(|b| self.resize_band_can_capture(b, x, y))
1142 .cloned()
1143 {
1144 let anchor = match band.axis {
1145 crate::tree::Axis::Column => y,
1146 _ => x,
1147 };
1148 self.ui_state.resize.drag = Some(crate::state::resize::EdgeResizeDrag {
1149 id: band.id,
1150 key: band.key,
1151 axis: band.axis,
1152 sign: band.sign,
1153 anchor,
1154 initial: band.current,
1155 min: band.min,
1156 max: band.max,
1157 });
1158 return Vec::new();
1159 }
1160
1161 let hit = self
1162 .last_tree
1163 .as_ref()
1164 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
1165
1166 if let Some(id) = self.ui_state.scene_at(x, y)
1181 && hit.as_ref().is_none_or(|h| *h.node_id == *id)
1182 && let Some(mode) = self
1183 .ui_state
1184 .scene_drag_mode(&id, button, self.ui_state.modifiers)
1185 && self
1186 .last_tree
1187 .as_ref()
1188 .is_none_or(|t| !hit_test::occluded_by_overlay(t, (x, y), &id))
1189 {
1190 self.ui_state.begin_camera_drag(id, mode, button, x, y);
1191 return Vec::new();
1192 }
1193
1194 if let Some((vp_id, cfg)) = self.ui_state.viewport_at(x, y)
1215 && cfg.pan_trigger.matches(button, self.ui_state.modifiers)
1216 && self
1217 .last_tree
1218 .as_ref()
1219 .is_none_or(|t| !hit_test::occluded_by_overlay(t, (x, y), &vp_id))
1220 {
1221 let dedicated = cfg.pan_trigger.button != PointerButton::Primary
1222 || cfg.pan_trigger.modifiers != crate::event::KeyModifiers::default();
1223 let on_background = hit.as_ref().is_none_or(|h| *h.node_id == *vp_id);
1224 if dedicated || on_background {
1225 self.ui_state.begin_viewport_pan(vp_id, button, x, y);
1226 return Vec::new();
1227 }
1228 }
1229
1230 if matches!(button, PointerButton::Primary)
1239 && let Some((plot_id, metrics)) = self.ui_state.plot_at(x, y)
1240 && hit.as_ref().is_none_or(|h| *h.node_id == *plot_id)
1241 && self
1242 .last_tree
1243 .as_ref()
1244 .is_none_or(|t| !hit_test::occluded_by_overlay(t, (x, y), &plot_id))
1245 {
1246 let clicks =
1249 self.ui_state
1250 .next_click_count(Instant::now(), (x, y), Some(plot_id.as_str()));
1251 if clicks >= 2 {
1252 self.ui_state.reset_plot_view(&plot_id);
1253 } else {
1254 let pan = match metrics.controls {
1256 crate::plot::PlotControls::ZoomDrag => self.ui_state.modifiers.shift,
1257 crate::plot::PlotControls::PanDrag => !self.ui_state.modifiers.shift,
1258 };
1259 if pan {
1260 self.ui_state.begin_plot_pan(plot_id, x, y);
1261 } else {
1262 self.ui_state.begin_plot_zoom(plot_id, x, y);
1263 }
1264 }
1265 return Vec::new();
1266 }
1267
1268 if !matches!(button, PointerButton::Primary) {
1273 self.ui_state.pressed_secondary = hit.map(|h| (h, button));
1276 return Vec::new();
1277 }
1278
1279 self.ui_state.pressed_link = self
1287 .linkable_tree()
1288 .and_then(|t| hit_test::link_at(t, (x, y)));
1289 self.ui_state.set_focus_from_pointer(hit.clone());
1290 self.ui_state.set_focus_visible(false);
1294 self.ui_state.pressed = hit.clone();
1295 self.ui_state.tooltip.dismissed_for_hover = true;
1298 let modifiers = self.ui_state.modifiers;
1299
1300 let now = Instant::now();
1303 let click_count =
1304 self.ui_state
1305 .next_click_count(now, (x, y), hit.as_ref().map(|t| t.node_id.as_ref()));
1306
1307 let mut out = Vec::new();
1308
1309 if matches!(kind, PointerKind::Touch) {
1317 let prev_hover = self.ui_state.hovered.clone();
1318 let hover_changed = self.ui_state.set_hovered(hit.clone(), now);
1319 if hover_changed {
1320 if let Some(prev) = prev_hover {
1321 out.push(UiEvent {
1322 key: Some(prev.key.clone()),
1323 target: Some(prev),
1324 pointer: Some((x, y)),
1325 key_press: None,
1326 text: None,
1327 selection: None,
1328 modifiers,
1329 click_count: 0,
1330 path: None,
1331 pointer_kind: Some(kind),
1332 wheel_delta: None,
1333 kind: UiEventKind::PointerLeave,
1334 });
1335 }
1336 if let Some(new) = hit.clone() {
1337 out.push(UiEvent {
1338 key: Some(new.key.clone()),
1339 target: Some(new),
1340 pointer: Some((x, y)),
1341 key_press: None,
1342 text: None,
1343 selection: None,
1344 modifiers,
1345 click_count: 0,
1346 path: None,
1347 pointer_kind: Some(kind),
1348 wheel_delta: None,
1349 kind: UiEventKind::PointerEnter,
1350 });
1351 }
1352 }
1353 let consumes_drag = hit
1361 .as_ref()
1362 .and_then(|t| {
1363 self.last_tree
1364 .as_ref()
1365 .and_then(|tree| find_consumes_touch_drag(tree, &t.node_id, false))
1366 })
1367 .unwrap_or(false);
1368 self.ui_state.touch_gesture = TouchGestureState::Pending {
1369 initial: (x, y),
1370 consumes_drag,
1371 started_at: now,
1372 };
1373 }
1374
1375 if let Some(p) = hit.clone() {
1376 if self.focused_captures_keys() {
1383 self.ui_state.bump_caret_activity(now);
1384 }
1385 out.push(UiEvent {
1386 key: Some(p.key.clone()),
1387 target: Some(p),
1388 pointer: Some((x, y)),
1389 key_press: None,
1390 text: None,
1391 selection: None,
1392 modifiers,
1393 click_count,
1394 path: None,
1395 pointer_kind: Some(kind),
1396 wheel_delta: None,
1397 kind: UiEventKind::PointerDown,
1398 });
1399 }
1400
1401 if let Some(point) = self
1409 .last_tree
1410 .as_ref()
1411 .and_then(|t| hit_test::selection_point_at(t, (x, y)))
1412 {
1413 self.start_selection_drag(point, &mut out, modifiers, (x, y), click_count, kind);
1414 } else if !self.ui_state.current_selection.is_empty() {
1415 let click_handles_selection = match (&hit, &self.ui_state.current_selection.range) {
1437 (Some(h), Some(range)) => {
1438 h.key == range.anchor.key
1439 || h.key == range.head.key
1440 || self
1441 .last_tree
1442 .as_ref()
1443 .and_then(|t| find_capture_keys(t, &h.node_id))
1444 .unwrap_or(false)
1445 }
1446 _ => false,
1447 };
1448 if !click_handles_selection {
1449 out.push(selection_event(
1450 crate::selection::Selection::default(),
1451 modifiers,
1452 Some((x, y)),
1453 Some(kind),
1454 ));
1455 self.ui_state.current_selection = crate::selection::Selection::default();
1456 self.ui_state.selection.drag = None;
1457 }
1458 }
1459
1460 if !matches!(kind, PointerKind::Touch)
1478 && self.ui_state.selection.drag.is_none()
1479 && let Some(h) = self.ui_state.pressed.as_ref()
1480 && let Some((vp_id, cfg)) = self.ui_state.viewport_at(x, y)
1481 && cfg.pan_trigger.matches(button, modifiers)
1482 && let Some(tree) = self.last_tree.as_ref()
1483 && find_inside_subtree(tree, &vp_id, &h.node_id, false).unwrap_or(false)
1484 && !find_consumes_touch_drag(tree, &h.node_id, false).unwrap_or(false)
1485 && !find_capture_keys(tree, &h.node_id).unwrap_or(false)
1486 {
1487 self.ui_state.viewport.latent_pan = Some(LatentViewportPan {
1488 viewport_id: vp_id,
1489 press: (x, y),
1490 });
1491 }
1492
1493 out
1494 }
1495
1496 fn start_selection_drag(
1504 &mut self,
1505 point: crate::selection::SelectionPoint,
1506 out: &mut Vec<UiEvent>,
1507 modifiers: KeyModifiers,
1508 pointer: (f32, f32),
1509 click_count: u8,
1510 kind: PointerKind,
1511 ) {
1512 let leaf_text = self
1513 .last_tree
1514 .as_ref()
1515 .and_then(|t| crate::selection::find_keyed_text(t, &point.key))
1516 .unwrap_or_default();
1517 let (anchor_byte, head_byte) = match click_count {
1518 2 => crate::selection::word_range_at(&leaf_text, point.byte),
1519 n if n >= 3 => (0, leaf_text.len()),
1520 _ => (point.byte, point.byte),
1521 };
1522 let granularity = match click_count {
1523 2 => SelectionDragGranularity::Word,
1524 n if n >= 3 => SelectionDragGranularity::Leaf,
1525 _ => SelectionDragGranularity::Character,
1526 };
1527 let anchor = crate::selection::SelectionPoint::new(point.key.clone(), anchor_byte);
1528 let head = crate::selection::SelectionPoint::new(point.key.clone(), head_byte);
1529 let new_sel = crate::selection::Selection {
1530 range: Some(crate::selection::SelectionRange {
1531 anchor: anchor.clone(),
1532 head: head.clone(),
1533 }),
1534 };
1535 self.ui_state.current_selection = new_sel.clone();
1536 self.ui_state.selection.drag = Some(crate::state::SelectionDrag {
1537 anchor,
1538 head,
1539 granularity,
1540 });
1541 out.push(selection_event(
1542 new_sel,
1543 modifiers,
1544 Some(pointer),
1545 Some(kind),
1546 ));
1547 }
1548
1549 fn extend_selection_drag_at(
1550 &mut self,
1551 x: f32,
1552 y: f32,
1553 kind: PointerKind,
1554 modifiers: KeyModifiers,
1555 out: &mut Vec<UiEvent>,
1556 ) {
1557 let Some(drag) = self.ui_state.selection.drag.clone() else {
1558 return;
1559 };
1560 let Some(tree) = self.last_tree.as_ref() else {
1561 return;
1562 };
1563 let raw_head =
1564 head_for_drag(tree, &self.ui_state, (x, y)).unwrap_or_else(|| drag.anchor.clone());
1565 let (anchor, head) = selection_range_for_drag(tree, &self.ui_state, &drag, raw_head);
1566 let new_sel = crate::selection::Selection {
1567 range: Some(crate::selection::SelectionRange { anchor, head }),
1568 };
1569 if new_sel != self.ui_state.current_selection {
1570 self.ui_state.current_selection = new_sel.clone();
1571 out.push(selection_event(
1572 new_sel,
1573 modifiers,
1574 Some((x, y)),
1575 Some(kind),
1576 ));
1577 }
1578 }
1579
1580 fn resize_band_can_capture(
1588 &self,
1589 band: &crate::state::resize::ResizeBand,
1590 x: f32,
1591 y: f32,
1592 ) -> bool {
1593 let Some(tree) = self.last_tree.as_ref() else {
1594 return false;
1595 };
1596 hit_test::hit_test_target(tree, &self.ui_state, (x, y))
1597 .is_none_or(|target| target_id_in_subtree(&band.container_id, &target.node_id))
1598 }
1599
1600 fn scrollbar_can_capture(&self, scroll_id: &str, x: f32, y: f32) -> bool {
1601 let Some(tree) = self.last_tree.as_ref() else {
1602 return false;
1603 };
1604 let target_allows_capture = hit_test::hit_test_target(tree, &self.ui_state, (x, y))
1605 .is_none_or(|target| target_id_in_subtree(scroll_id, &target.node_id));
1606 if !target_allows_capture {
1607 return false;
1608 }
1609 hit_test::scroll_targets_at(tree, (x, y))
1610 .iter()
1611 .any(|id| id == scroll_id)
1612 }
1613
1614 fn cancel_press_for_scroll(
1624 &mut self,
1625 out: &mut Vec<UiEvent>,
1626 x: f32,
1627 y: f32,
1628 kind: PointerKind,
1629 modifiers: KeyModifiers,
1630 ) {
1631 let pressed = self.ui_state.pressed.take();
1632 let hovered = self.ui_state.hovered.clone();
1633 self.ui_state.set_hovered(None, Instant::now());
1634 self.ui_state.pressed_secondary = None;
1635 self.ui_state.pressed_link = None;
1636 self.ui_state.hovered_link = None;
1640 self.ui_state.selection.drag = None;
1641 if let Some(p) = pressed {
1642 out.push(UiEvent {
1643 key: Some(p.key.clone()),
1644 target: Some(p),
1645 pointer: Some((x, y)),
1646 key_press: None,
1647 text: None,
1648 selection: None,
1649 modifiers,
1650 click_count: 0,
1651 path: None,
1652 pointer_kind: Some(kind),
1653 wheel_delta: None,
1654 kind: UiEventKind::PointerCancel,
1655 });
1656 }
1657 if let Some(h) = hovered {
1658 out.push(UiEvent {
1659 key: Some(h.key.clone()),
1660 target: Some(h),
1661 pointer: Some((x, y)),
1662 key_press: None,
1663 text: None,
1664 selection: None,
1665 modifiers,
1666 click_count: 0,
1667 path: None,
1668 pointer_kind: Some(kind),
1669 wheel_delta: None,
1670 kind: UiEventKind::PointerLeave,
1671 });
1672 }
1673 }
1674
1675 pub fn pointer_up(&mut self, p: Pointer) -> Vec<UiEvent> {
1683 let Pointer {
1684 x, y, button, kind, ..
1685 } = p;
1686 self.ui_state.pointer_kind = kind;
1687 if matches!(button, PointerButton::Primary) {
1695 self.ui_state.viewport.latent_pan = None;
1696 }
1697 if matches!(button, PointerButton::Primary) && self.ui_state.scroll.thumb_drag.is_some() {
1702 self.ui_state.scroll.thumb_drag = None;
1703 self.ui_state.touch_gesture = TouchGestureState::None;
1704 return Vec::new();
1705 }
1706
1707 if matches!(button, PointerButton::Primary)
1713 && let Some(drag) = self.ui_state.resize.drag.take()
1714 {
1715 self.ui_state.touch_gesture = TouchGestureState::None;
1716 let Some(key) = drag.key else {
1717 return Vec::new();
1718 };
1719 return vec![UiEvent {
1720 key: Some(key),
1721 target: None,
1722 pointer: Some((x, y)),
1723 key_press: None,
1724 text: None,
1725 selection: None,
1726 modifiers: self.ui_state.modifiers,
1727 click_count: 0,
1728 path: None,
1729 pointer_kind: Some(kind),
1730 wheel_delta: None,
1731 kind: UiEventKind::Resized,
1732 }];
1733 }
1734
1735 if self.ui_state.end_camera_drag(button) {
1742 self.ui_state.touch_gesture = TouchGestureState::None;
1743 return Vec::new();
1744 }
1745
1746 if self.ui_state.end_viewport_pan(button) {
1750 self.ui_state.touch_gesture = TouchGestureState::None;
1751 return Vec::new();
1752 }
1753
1754 if matches!(button, PointerButton::Primary) && self.ui_state.end_plot_pan() {
1757 self.ui_state.touch_gesture = TouchGestureState::None;
1758 return Vec::new();
1759 }
1760
1761 if matches!(button, PointerButton::Primary) && self.ui_state.end_plot_zoom() {
1763 self.ui_state.touch_gesture = TouchGestureState::None;
1764 return Vec::new();
1765 }
1766
1767 let was_long_pressed =
1773 matches!(self.ui_state.touch_gesture, TouchGestureState::LongPressed);
1774 let momentum = match &self.ui_state.touch_gesture {
1775 TouchGestureState::Scrolling {
1776 velocity,
1777 scroll_id,
1778 ..
1779 } if matches!(kind, PointerKind::Touch) => {
1780 Some((scroll_id.clone(), *velocity, Instant::now()))
1781 }
1782 _ => None,
1783 };
1784 let was_scrolling_or_long = matches!(
1785 self.ui_state.touch_gesture,
1786 TouchGestureState::Scrolling { .. } | TouchGestureState::LongPressed
1787 );
1788 self.ui_state.touch_gesture = TouchGestureState::None;
1789 if was_scrolling_or_long {
1790 if let Some((scroll_id, velocity, now)) = momentum {
1791 self.ui_state
1792 .start_scroll_momentum(scroll_id, velocity, now);
1793 }
1794 if was_long_pressed {
1795 self.ui_state.pressed = None;
1796 self.ui_state.pressed_secondary = None;
1797 self.ui_state.pressed_link = None;
1798 self.ui_state.selection.drag = None;
1799 self.ui_state.set_hovered(None, Instant::now());
1800 }
1801 return Vec::new();
1802 }
1803
1804 if matches!(button, PointerButton::Primary) {
1807 self.ui_state.selection.drag = None;
1808 }
1809
1810 let hit = self
1811 .last_tree
1812 .as_ref()
1813 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
1814 let modifiers = self.ui_state.modifiers;
1815 let mut out = Vec::new();
1816 match button {
1817 PointerButton::Primary => {
1818 let pressed = self.ui_state.pressed.take();
1819 let click_count = self.ui_state.current_click_count();
1820 if let Some(p) = pressed.clone() {
1821 out.push(UiEvent {
1822 key: Some(p.key.clone()),
1823 target: Some(p),
1824 pointer: Some((x, y)),
1825 key_press: None,
1826 text: None,
1827 selection: None,
1828 modifiers,
1829 click_count,
1830 path: None,
1831 pointer_kind: Some(kind),
1832 wheel_delta: None,
1833 kind: UiEventKind::PointerUp,
1834 });
1835 }
1836 if let (Some(p), Some(h)) = (pressed, hit)
1837 && p.node_id == h.node_id
1838 {
1839 if let Some(id) = toast::parse_dismiss_key(&p.key) {
1845 self.ui_state.dismiss_toast(id);
1846 } else {
1847 out.push(UiEvent {
1848 key: Some(p.key.clone()),
1849 target: Some(p),
1850 pointer: Some((x, y)),
1851 key_press: None,
1852 text: None,
1853 selection: None,
1854 modifiers,
1855 click_count,
1856 path: None,
1857 pointer_kind: Some(kind),
1858 wheel_delta: None,
1859 kind: UiEventKind::Click,
1860 });
1861 }
1862 }
1863 if let Some(pressed_url) = self.ui_state.pressed_link.take() {
1869 let up_link = self
1870 .linkable_tree()
1871 .and_then(|t| hit_test::link_at(t, (x, y)));
1872 if up_link.as_ref() == Some(&pressed_url) {
1873 out.push(UiEvent {
1874 key: Some(pressed_url),
1875 target: None,
1876 pointer: Some((x, y)),
1877 key_press: None,
1878 text: None,
1879 selection: None,
1880 modifiers,
1881 click_count: 1,
1882 path: None,
1883 pointer_kind: Some(kind),
1884 wheel_delta: None,
1885 kind: UiEventKind::LinkActivated,
1886 });
1887 }
1888 }
1889 }
1890 PointerButton::Secondary | PointerButton::Middle => {
1891 let pressed = self.ui_state.pressed_secondary.take();
1892 if let (Some((p, b)), Some(h)) = (pressed, hit)
1893 && b == button
1894 && p.node_id == h.node_id
1895 {
1896 let event_kind = match button {
1897 PointerButton::Secondary => UiEventKind::SecondaryClick,
1898 PointerButton::Middle => UiEventKind::MiddleClick,
1899 PointerButton::Primary => unreachable!(),
1900 };
1901 out.push(UiEvent {
1902 key: Some(p.key.clone()),
1903 target: Some(p),
1904 pointer: Some((x, y)),
1905 key_press: None,
1906 text: None,
1907 selection: None,
1908 modifiers,
1909 click_count: 1,
1910 path: None,
1911 pointer_kind: Some(kind),
1912 wheel_delta: None,
1913 kind: event_kind,
1914 });
1915 }
1916 }
1917 }
1918
1919 if matches!(kind, PointerKind::Touch)
1925 && let Some(prev) = self.ui_state.hovered.clone()
1926 {
1927 self.ui_state.set_hovered(None, Instant::now());
1928 out.push(UiEvent {
1929 key: Some(prev.key.clone()),
1930 target: Some(prev),
1931 pointer: Some((x, y)),
1932 key_press: None,
1933 text: None,
1934 selection: None,
1935 modifiers,
1936 click_count: 0,
1937 path: None,
1938 pointer_kind: Some(kind),
1939 wheel_delta: None,
1940 kind: UiEventKind::PointerLeave,
1941 });
1942 }
1943
1944 out
1945 }
1946
1947 pub fn key_down(
1948 &mut self,
1949 logical: LogicalKey,
1950 physical: PhysicalKey,
1951 modifiers: KeyModifiers,
1952 repeat: bool,
1953 ) -> Vec<UiEvent> {
1954 if self.focused_captures_keys() {
1962 if let Some(event) = self
1963 .ui_state
1964 .try_hotkey(&logical, physical, modifiers, repeat)
1965 {
1966 return vec![event];
1967 }
1968 self.ui_state.bump_caret_activity(Instant::now());
1975 self.ui_state.set_focus_visible(true);
1976 let blur_after = logical.named() == Some(NamedKey::Escape);
1977 let out = self
1978 .ui_state
1979 .key_down_raw(logical, physical, modifiers, repeat)
1980 .into_iter()
1981 .collect();
1982 if blur_after {
1983 self.ui_state.set_focus(None);
1984 self.ui_state.set_focus_visible(false);
1985 }
1986 return out;
1987 }
1988
1989 if matches!(
1999 logical.named(),
2000 Some(
2001 NamedKey::ArrowUp
2002 | NamedKey::ArrowDown
2003 | NamedKey::ArrowLeft
2004 | NamedKey::ArrowRight
2005 | NamedKey::Home
2006 | NamedKey::End
2007 )
2008 ) && let Some((mode, members)) = self.focused_arrow_nav_group()
2009 && mode.handles(&logical)
2010 {
2011 if let Some(event) = self
2012 .ui_state
2013 .try_hotkey(&logical, physical, modifiers, repeat)
2014 {
2015 return vec![event];
2016 }
2017 self.move_focus_in_group(&logical, mode, &members);
2018 return Vec::new();
2019 }
2020
2021 let mut out: Vec<UiEvent> = self
2022 .ui_state
2023 .key_down(logical, physical, modifiers, repeat)
2024 .into_iter()
2025 .collect();
2026
2027 if matches!(out.first().map(|e| e.kind), Some(UiEventKind::Escape))
2035 && !self.ui_state.current_selection.is_empty()
2036 {
2037 self.ui_state.current_selection = crate::selection::Selection::default();
2038 self.ui_state.selection.drag = None;
2039 out.push(selection_event(
2040 crate::selection::Selection::default(),
2041 modifiers,
2042 None,
2043 None,
2044 ));
2045 }
2046
2047 out
2048 }
2049
2050 fn focused_arrow_nav_group(&self) -> Option<(crate::tree::ArrowNav, Vec<UiTarget>)> {
2056 let focused = self.ui_state.focused.as_ref()?;
2057 let tree = self.last_tree.as_ref()?;
2058 focus::arrow_nav_group(tree, &focused.node_id)
2059 }
2060
2061 fn move_focus_in_group(
2070 &mut self,
2071 logical: &LogicalKey,
2072 mode: crate::tree::ArrowNav,
2073 members: &[UiTarget],
2074 ) {
2075 if members.is_empty() {
2076 return;
2077 }
2078 let focused_id = match self.ui_state.focused.as_ref() {
2079 Some(t) => t.node_id.clone(),
2080 None => return,
2081 };
2082 let idx = members.iter().position(|t| t.node_id == focused_id);
2083 let grid = mode == crate::tree::ArrowNav::Grid;
2084 let next_idx = match (logical.named(), idx) {
2085 (Some(NamedKey::ArrowUp), Some(i)) if grid => {
2086 match grid_vertical_step(members, i, -1.0) {
2087 Some(j) => j,
2088 None => return,
2089 }
2090 }
2091 (Some(NamedKey::ArrowDown), Some(i)) if grid => {
2092 match grid_vertical_step(members, i, 1.0) {
2093 Some(j) => j,
2094 None => return,
2095 }
2096 }
2097 (Some(NamedKey::ArrowUp | NamedKey::ArrowLeft), Some(i)) => i.saturating_sub(1),
2098 (Some(NamedKey::ArrowDown | NamedKey::ArrowRight), Some(i)) => {
2099 (i + 1).min(members.len() - 1)
2100 }
2101 (Some(NamedKey::Home), _) => 0,
2102 (Some(NamedKey::End), _) => members.len() - 1,
2103 _ => return,
2104 };
2105 if Some(next_idx) != idx {
2106 self.ui_state.set_focus(Some(members[next_idx].clone()));
2107 self.ui_state.set_focus_visible(true);
2108 }
2109 }
2110
2111 pub fn focused_captures_keys(&self) -> bool {
2118 let Some(focused) = self.ui_state.focused.as_ref() else {
2119 return false;
2120 };
2121 let Some(tree) = self.last_tree.as_ref() else {
2122 return false;
2123 };
2124 find_capture_keys(tree, &focused.node_id).unwrap_or(false)
2125 }
2126
2127 pub fn text_input(&mut self, text: String) -> Option<UiEvent> {
2133 if text.is_empty() {
2134 return None;
2135 }
2136 let target = self.ui_state.focused.clone()?;
2137 let modifiers = self.ui_state.modifiers;
2138 self.ui_state.bump_caret_activity(Instant::now());
2141 Some(UiEvent {
2142 key: Some(target.key.clone()),
2143 target: Some(target),
2144 pointer: None,
2145 key_press: None,
2146 text: Some(text),
2147 selection: None,
2148 modifiers,
2149 click_count: 0,
2150 path: None,
2151 pointer_kind: None,
2152 wheel_delta: None,
2153 kind: UiEventKind::TextInput,
2154 })
2155 }
2156
2157 pub fn set_hotkeys(&mut self, hotkeys: Vec<(KeyChord, String)>) {
2158 self.ui_state.set_hotkeys(hotkeys);
2159 }
2160
2161 pub fn set_selection(&mut self, selection: crate::selection::Selection) {
2166 if self.ui_state.current_selection != selection {
2167 self.ui_state.bump_caret_activity(Instant::now());
2168 }
2169 self.ui_state.current_selection = selection;
2170 }
2171
2172 pub fn selected_text(&self) -> Option<String> {
2186 self.selected_text_for(&self.ui_state.current_selection)
2187 }
2188
2189 pub fn selected_text_for(&self, selection: &crate::selection::Selection) -> Option<String> {
2195 let tree = self.last_tree.as_ref()?;
2196 crate::selection::selected_text(tree, selection)
2197 }
2198
2199 pub fn push_toasts(&mut self, specs: Vec<crate::toast::ToastSpec>) {
2205 let now = Instant::now();
2206 for spec in specs {
2207 self.ui_state.push_toast(spec, now);
2208 }
2209 }
2210
2211 pub fn dismiss_toast(&mut self, id: u64) {
2215 self.ui_state.dismiss_toast(id);
2216 }
2217
2218 pub fn push_focus_requests(&mut self, keys: Vec<String>) {
2224 self.ui_state.push_focus_requests(keys);
2225 }
2226
2227 pub fn push_scroll_requests(&mut self, requests: Vec<crate::scroll::ScrollRequest>) {
2233 self.ui_state.push_scroll_requests(requests);
2234 }
2235
2236 pub fn push_viewport_requests(&mut self, requests: Vec<crate::viewport::ViewportRequest>) {
2243 self.ui_state.push_viewport_requests(requests);
2244 }
2245
2246 pub fn push_plot_requests(&mut self, requests: Vec<crate::plot::PlotRequest>) {
2250 self.ui_state.push_plot_requests(requests);
2251 }
2252
2253 pub fn set_animation_mode(&mut self, mode: AnimationMode) {
2254 self.ui_state.set_animation_mode(mode);
2255 }
2256
2257 pub fn pointer_wheel(&mut self, x: f32, y: f32, dy: f32) -> bool {
2258 let Some(tree) = self.last_tree.as_ref() else {
2259 return false;
2260 };
2261 self.ui_state.cancel_scroll_momentum();
2262 if self.ui_state.camera_wheel_zoom(tree, x, y, dy) {
2265 return true;
2266 }
2267 if self.ui_state.viewport_wheel_zoom(tree, x, y, dy) {
2271 return true;
2272 }
2273 if self.ui_state.plot_wheel_zoom(tree, x, y, dy) {
2276 return true;
2277 }
2278 self.ui_state.pointer_wheel(tree, (x, y), dy)
2279 }
2280
2281 pub fn pointer_wheel_event(&mut self, x: f32, y: f32, dx: f32, dy: f32) -> Option<UiEvent> {
2288 if dx.abs() <= f32::EPSILON && dy.abs() <= f32::EPSILON {
2289 return None;
2290 }
2291 let tree = self.last_tree.as_ref()?;
2292 let target = hit_test::hit_test_target(tree, &self.ui_state, (x, y))?;
2293 self.ui_state.cancel_scroll_momentum();
2294 Some(UiEvent {
2295 key: Some(target.key.clone()),
2296 target: Some(target),
2297 pointer: Some((x, y)),
2298 key_press: None,
2299 text: None,
2300 selection: None,
2301 modifiers: self.ui_state.modifiers,
2302 click_count: 0,
2303 path: None,
2304 pointer_kind: Some(self.ui_state.pointer_kind),
2305 wheel_delta: Some((dx, dy)),
2306 kind: UiEventKind::PointerWheel,
2307 })
2308 }
2309
2310 pub fn poll_input(&mut self, now: Instant) -> Vec<UiEvent> {
2325 let TouchGestureState::Pending {
2326 initial,
2327 started_at,
2328 ..
2329 } = self.ui_state.touch_gesture.clone()
2330 else {
2331 return Vec::new();
2332 };
2333 if now.duration_since(started_at) < LONG_PRESS_DELAY {
2334 return Vec::new();
2335 }
2336 let mut out = Vec::new();
2337 let modifiers = self.ui_state.modifiers;
2338 let kind = PointerKind::Touch;
2339 let (x, y) = initial;
2340 let press_target = self.ui_state.pressed.clone();
2341 let preserves_press_for_drag = press_target.as_ref().is_some_and(|t| {
2342 self.last_tree
2343 .as_ref()
2344 .and_then(|tree| find_capture_keys(tree, &t.node_id))
2345 .unwrap_or(false)
2346 });
2347 if preserves_press_for_drag {
2348 self.ui_state.pressed_secondary = None;
2349 self.ui_state.pressed_link = None;
2350 self.ui_state.selection.drag = None;
2351 } else {
2352 self.cancel_press_for_scroll(&mut out, x, y, kind, modifiers);
2359 }
2360 if let Some(t) = press_target {
2361 out.push(UiEvent {
2362 key: Some(t.key.clone()),
2363 target: Some(t),
2364 pointer: Some((x, y)),
2365 key_press: None,
2366 text: None,
2367 selection: None,
2368 modifiers,
2369 click_count: 0,
2370 path: None,
2371 pointer_kind: Some(kind),
2372 wheel_delta: None,
2373 kind: UiEventKind::LongPress,
2374 });
2375 } else {
2376 out.push(UiEvent {
2380 key: None,
2381 target: None,
2382 pointer: Some((x, y)),
2383 key_press: None,
2384 text: None,
2385 selection: None,
2386 modifiers,
2387 click_count: 0,
2388 path: None,
2389 pointer_kind: Some(kind),
2390 wheel_delta: None,
2391 kind: UiEventKind::LongPress,
2392 });
2393 }
2394 if !preserves_press_for_drag
2395 && let Some(point) = self
2396 .last_tree
2397 .as_ref()
2398 .and_then(|t| hit_test::selection_point_at(t, (x, y)))
2399 {
2400 self.start_selection_drag(point, &mut out, modifiers, (x, y), 2, kind);
2401 }
2402 self.ui_state.touch_gesture = TouchGestureState::LongPressed;
2403 out
2404 }
2405
2406 pub fn next_input_deadline(&self, now: Instant) -> Option<std::time::Duration> {
2416 if self.ui_state.has_scroll_momentum() {
2417 return Some(std::time::Duration::ZERO);
2418 }
2419 let TouchGestureState::Pending { started_at, .. } = self.ui_state.touch_gesture.clone()
2420 else {
2421 return None;
2422 };
2423 let elapsed = now.duration_since(started_at);
2424 Some(LONG_PRESS_DELAY.saturating_sub(elapsed))
2425 }
2426
2427 pub fn prepare_layout<F>(
2444 &mut self,
2445 root: &mut El,
2446 viewport: Rect,
2447 scale_factor: f32,
2448 timings: &mut PrepareTimings,
2449 samples_time: F,
2450 ) -> LayoutPrepared
2451 where
2452 F: Fn(&ShaderHandle) -> bool,
2453 {
2454 let t0 = Instant::now();
2455 let scroll_momentum_pending = self.ui_state.tick_scroll_momentum(t0);
2456 let mut needs_redraw = {
2463 crate::profile_span!("prepare::layout");
2464 {
2465 crate::profile_span!("prepare::layout::assign_ids");
2466 layout::assign_ids(root);
2467 }
2468 let tooltip_pending = {
2469 crate::profile_span!("prepare::layout::tooltip");
2470 tooltip::synthesize_tooltip(root, &self.ui_state, t0)
2471 };
2472 let toast_pending = {
2473 crate::profile_span!("prepare::layout::toast");
2474 toast::synthesize_toasts(root, &mut self.ui_state, t0)
2475 };
2476 {
2477 crate::profile_span!("prepare::layout::apply_metrics");
2478 self.theme.apply_metrics(root);
2479 }
2480 {
2481 crate::profile_span!("prepare::layout::layout");
2482 layout::layout_post_assign(root, &mut self.ui_state, viewport);
2489 self.ui_state.clear_pending_scroll_requests();
2494 self.ui_state.clear_pending_viewport_requests();
2496 self.ui_state.gc_side_maps(root);
2501 self.ui_state.prepare_plots(root);
2505 self.ui_state.clear_pending_plot_requests();
2508 }
2509 {
2510 crate::profile_span!("prepare::layout::sync_orders");
2511 self.ui_state.sync_focus_and_selection_order(root);
2512 }
2513 {
2514 crate::profile_span!("prepare::layout::sync_popover_focus");
2515 focus::sync_popover_focus(root, &mut self.ui_state);
2516 }
2517 {
2518 crate::profile_span!("prepare::layout::drain_focus_requests");
2523 self.ui_state.drain_focus_requests();
2524 }
2525 {
2526 crate::profile_span!("prepare::layout::apply_state");
2527 self.ui_state.apply_to_state();
2528 }
2529 self.viewport_px = self.surface_size_override.unwrap_or_else(|| {
2530 (
2531 (viewport.w * scale_factor).ceil().max(1.0) as u32,
2532 (viewport.h * scale_factor).ceil().max(1.0) as u32,
2533 )
2534 });
2535 let animations = {
2536 crate::profile_span!("prepare::layout::tick_animations");
2537 self.ui_state
2538 .tick_visual_animations(root, Instant::now(), self.theme.palette())
2539 };
2540 let cameras_animating = {
2545 crate::profile_span!("prepare::layout::tick_cameras");
2546 self.ui_state.tick_scene_cameras(root, Instant::now())
2547 };
2548 let viewport_flights = self.ui_state.viewport.any_flight_animating(Instant::now());
2554 animations
2555 || cameras_animating
2556 || viewport_flights
2557 || tooltip_pending
2558 || toast_pending
2559 || scroll_momentum_pending
2560 };
2561 let t_after_layout = Instant::now();
2562 timings.layout_intrinsic_cache = layout::take_intrinsic_cache_stats();
2563 timings.layout_prune = layout::take_prune_stats();
2564 let (ops, draw_ops_stats) = {
2565 crate::profile_span!("prepare::draw_ops");
2566 let mut stats = DrawOpsStats::default();
2567 let ops = draw_ops::draw_ops_with_theme_and_stats(
2568 root,
2569 &self.ui_state,
2570 &self.theme,
2571 &mut stats,
2572 );
2573 (ops, stats)
2574 };
2575 let t_after_draw_ops = Instant::now();
2576 timings.layout = t_after_layout - t0;
2577 timings.draw_ops = t_after_draw_ops - t_after_layout;
2578 timings.draw_ops_culled_text_ops = draw_ops_stats.culled_text_ops;
2579 self.ui_state
2582 .set_hovered_scene_point(draw_ops_stats.hovered_scene_point);
2583 timings.text_layout_cache = crate::text::metrics::take_shape_cache_stats();
2584
2585 let shader_needs_redraw = ops.iter().any(|op| op_is_continuous(op, &samples_time));
2602 let widget_redraw = draw_ops_stats.redraw_within;
2603 let input_deadline = self.next_input_deadline(Instant::now());
2609 let widget_redraw = match (widget_redraw, input_deadline) {
2610 (Some(a), Some(b)) => Some(a.min(b)),
2611 (a, b) => a.or(b),
2612 };
2613
2614 let next_layout_redraw_in = match (needs_redraw, widget_redraw) {
2615 (true, _) => Some(std::time::Duration::ZERO),
2618 (false, d) => d,
2619 };
2620 let next_paint_redraw_in = if shader_needs_redraw {
2621 Some(std::time::Duration::ZERO)
2622 } else {
2623 None
2624 };
2625 if next_layout_redraw_in.is_some() || next_paint_redraw_in.is_some() {
2626 needs_redraw = true;
2627 }
2628
2629 LayoutPrepared {
2634 ops,
2635 needs_redraw,
2636 next_layout_redraw_in,
2637 next_paint_redraw_in,
2638 }
2639 }
2640
2641 pub fn prepare_paint_cached<F1, F2>(
2654 &mut self,
2655 is_registered: F1,
2656 samples_backdrop: F2,
2657 text: &mut dyn TextRecorder,
2658 scale_factor: f32,
2659 timings: &mut PrepareTimings,
2660 ) where
2661 F1: Fn(&ShaderHandle) -> bool,
2662 F2: Fn(&ShaderHandle) -> bool,
2663 {
2664 let ops = std::mem::take(&mut self.last_ops);
2668 self.prepare_paint(
2669 &ops,
2670 is_registered,
2671 samples_backdrop,
2672 text,
2673 scale_factor,
2674 timings,
2675 );
2676 self.last_ops = ops;
2677 }
2678
2679 pub fn no_time_shaders(_shader: &ShaderHandle) -> bool {
2684 false
2685 }
2686
2687 pub fn scan_continuous_shaders<F>(&self, samples_time: F) -> Option<std::time::Duration>
2694 where
2695 F: Fn(&ShaderHandle) -> bool,
2696 {
2697 let any = self
2698 .last_ops
2699 .iter()
2700 .any(|op| op_is_continuous(op, &samples_time));
2701 if any {
2702 Some(std::time::Duration::ZERO)
2703 } else {
2704 None
2705 }
2706 }
2707
2708 pub fn prepare_paint<F1, F2>(
2719 &mut self,
2720 ops: &[DrawOp],
2721 is_registered: F1,
2722 samples_backdrop: F2,
2723 text: &mut dyn TextRecorder,
2724 scale_factor: f32,
2725 timings: &mut PrepareTimings,
2726 ) where
2727 F1: Fn(&ShaderHandle) -> bool,
2728 F2: Fn(&ShaderHandle) -> bool,
2729 {
2730 crate::profile_span!("prepare::paint");
2731 let t0 = Instant::now();
2732 self.quad_scratch.clear();
2733 self.runs.clear();
2734 self.paint_items.clear();
2735
2736 let mut current: Option<(ShaderHandle, Option<PhysicalScissor>)> = None;
2737 let mut run_first: u32 = 0;
2738 let mut snapshot_emitted = false;
2741
2742 for op in ops {
2743 match op {
2744 DrawOp::Quad {
2745 rect,
2746 scissor,
2747 shader,
2748 uniforms,
2749 ..
2750 } => {
2751 if !is_registered(shader) {
2752 continue;
2753 }
2754 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2755 timings.paint_culled_ops += 1;
2756 continue;
2757 }
2758 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2759 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2760 timings.paint_culled_ops += 1;
2761 continue;
2762 }
2763 if !snapshot_emitted && samples_backdrop(shader) {
2764 close_run(
2765 &mut self.runs,
2766 &mut self.paint_items,
2767 current,
2768 run_first,
2769 self.quad_scratch.len() as u32,
2770 );
2771 current = None;
2772 run_first = self.quad_scratch.len() as u32;
2773 self.paint_items.push(PaintItem::BackdropSnapshot);
2774 snapshot_emitted = true;
2775 }
2776 let inst = match shader {
2781 ShaderHandle::Custom(name) => match self.shader_slot_maps.get(name) {
2782 Some(slot_map) => {
2783 let mut unknown = Vec::new();
2784 let inst = crate::paint::pack_instance_named(
2785 *rect,
2786 uniforms,
2787 self.working_color_space,
2788 slot_map,
2789 &mut unknown,
2790 );
2791 for key in unknown {
2792 if self.warned_shader_uniforms.insert((name, key)) {
2793 let declared: Vec<&str> =
2794 slot_map.declared().map(|(n, _)| n).collect();
2795 log::warn!(
2796 "damascene: shader `{name}` declares no \
2797 instance attribute named `{key}` — the \
2798 value lands nowhere. Declared names: \
2799 {declared:?} (plus the positional aliases \
2800 vec_a..vec_e). Rename one side so the Rust \
2801 packing and WGSL unpacking agree."
2802 );
2803 }
2804 }
2805 inst
2806 }
2807 None => {
2808 pack_instance_in(*rect, *shader, uniforms, self.working_color_space)
2809 }
2810 },
2811 _ => pack_instance_in(*rect, *shader, uniforms, self.working_color_space),
2812 };
2813
2814 let key = (*shader, phys);
2815 if current != Some(key) {
2816 close_run(
2817 &mut self.runs,
2818 &mut self.paint_items,
2819 current,
2820 run_first,
2821 self.quad_scratch.len() as u32,
2822 );
2823 current = Some(key);
2824 run_first = self.quad_scratch.len() as u32;
2825 }
2826 self.quad_scratch.push(inst);
2827 }
2828 DrawOp::GlyphRun {
2829 rect,
2830 scissor,
2831 color,
2832 text: glyph_text,
2833 size,
2834 line_height,
2835 family,
2836 mono_family,
2837 weight,
2838 mono,
2839 wrap,
2840 anchor,
2841 underline,
2842 strikethrough,
2843 link,
2844 tabular_numerals,
2845 letter_spacing,
2846 ..
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(
2858 &mut self.runs,
2859 &mut self.paint_items,
2860 current,
2861 run_first,
2862 self.quad_scratch.len() as u32,
2863 );
2864 current = None;
2865 run_first = self.quad_scratch.len() as u32;
2866
2867 let mut style = crate::text::atlas::RunStyle::new(*weight, *color)
2868 .family(*family)
2869 .mono_family(*mono_family);
2870 if *mono {
2871 style = style.mono();
2872 }
2873 if *tabular_numerals {
2874 style = style.tabular_numerals();
2875 }
2876 if *letter_spacing != 0.0 {
2877 style = style.with_letter_spacing(*letter_spacing);
2878 }
2879 if *underline {
2880 style = style.underline();
2881 }
2882 if *strikethrough {
2883 style = style.strikethrough();
2884 }
2885 if let Some(url) = link {
2886 style = style.with_link(url.clone());
2887 }
2888 let layers = text.record(
2889 *rect,
2890 phys,
2891 &style,
2892 glyph_text,
2893 *size,
2894 *line_height,
2895 *wrap,
2896 *anchor,
2897 scale_factor,
2898 );
2899 for index in layers {
2900 self.paint_items.push(PaintItem::Text(index));
2901 }
2902 }
2903 DrawOp::AttributedText {
2904 rect,
2905 scissor,
2906 runs,
2907 size,
2908 line_height,
2909 wrap,
2910 anchor,
2911 ..
2912 } => {
2913 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2914 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2915 timings.paint_culled_ops += 1;
2916 continue;
2917 }
2918 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2919 timings.paint_culled_ops += 1;
2920 continue;
2921 }
2922 close_run(
2923 &mut self.runs,
2924 &mut self.paint_items,
2925 current,
2926 run_first,
2927 self.quad_scratch.len() as u32,
2928 );
2929 current = None;
2930 run_first = self.quad_scratch.len() as u32;
2931
2932 let layers = text.record_runs(
2933 *rect,
2934 phys,
2935 runs,
2936 *size,
2937 *line_height,
2938 *wrap,
2939 *anchor,
2940 scale_factor,
2941 );
2942 for index in layers {
2943 self.paint_items.push(PaintItem::Text(index));
2944 }
2945 }
2946 DrawOp::Icon {
2947 rect,
2948 scissor,
2949 source,
2950 color,
2951 size,
2952 stroke_width,
2953 ..
2954 } => {
2955 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2956 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2957 timings.paint_culled_ops += 1;
2958 continue;
2959 }
2960 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2961 timings.paint_culled_ops += 1;
2962 continue;
2963 }
2964 close_run(
2965 &mut self.runs,
2966 &mut self.paint_items,
2967 current,
2968 run_first,
2969 self.quad_scratch.len() as u32,
2970 );
2971 current = None;
2972 run_first = self.quad_scratch.len() as u32;
2973
2974 let recorded = text.record_icon(
2975 *rect,
2976 phys,
2977 source,
2978 *color,
2979 *size,
2980 *stroke_width,
2981 scale_factor,
2982 );
2983 match recorded {
2984 RecordedPaint::Text(layers) => {
2985 for index in layers {
2986 self.paint_items.push(PaintItem::Text(index));
2987 }
2988 }
2989 RecordedPaint::Icon(runs) => {
2990 for index in runs {
2991 self.paint_items.push(PaintItem::IconRun(index));
2992 }
2993 }
2994 }
2995 }
2996 DrawOp::Image {
2997 rect,
2998 scissor,
2999 image,
3000 tint,
3001 radius,
3002 fit,
3003 range_limit,
3004 ..
3005 } => {
3006 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
3007 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
3008 timings.paint_culled_ops += 1;
3009 continue;
3010 }
3011 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
3012 timings.paint_culled_ops += 1;
3013 continue;
3014 }
3015 close_run(
3016 &mut self.runs,
3017 &mut self.paint_items,
3018 current,
3019 run_first,
3020 self.quad_scratch.len() as u32,
3021 );
3022 current = None;
3023 run_first = self.quad_scratch.len() as u32;
3024
3025 let recorded = text.record_image(
3026 *rect,
3027 phys,
3028 image,
3029 *tint,
3030 *radius,
3031 *fit,
3032 *range_limit,
3033 scale_factor,
3034 );
3035 for index in recorded {
3036 self.paint_items.push(PaintItem::Image(index));
3037 }
3038 }
3039 DrawOp::AppTexture {
3040 rect,
3041 scissor,
3042 texture,
3043 alpha,
3044 transform,
3045 ..
3046 } => {
3047 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
3048 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
3049 timings.paint_culled_ops += 1;
3050 continue;
3051 }
3052 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
3053 timings.paint_culled_ops += 1;
3054 continue;
3055 }
3056 close_run(
3057 &mut self.runs,
3058 &mut self.paint_items,
3059 current,
3060 run_first,
3061 self.quad_scratch.len() as u32,
3062 );
3063 current = None;
3064 run_first = self.quad_scratch.len() as u32;
3065
3066 let recorded = text.record_app_texture(
3067 *rect,
3068 phys,
3069 texture,
3070 *alpha,
3071 *transform,
3072 scale_factor,
3073 );
3074 for index in recorded {
3075 self.paint_items.push(PaintItem::AppTexture(index));
3076 }
3077 }
3078 DrawOp::Vector {
3079 rect,
3080 scissor,
3081 asset,
3082 render_mode,
3083 ..
3084 } => {
3085 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
3086 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
3087 timings.paint_culled_ops += 1;
3088 continue;
3089 }
3090 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
3091 timings.paint_culled_ops += 1;
3092 continue;
3093 }
3094 close_run(
3095 &mut self.runs,
3096 &mut self.paint_items,
3097 current,
3098 run_first,
3099 self.quad_scratch.len() as u32,
3100 );
3101 current = None;
3102 run_first = self.quad_scratch.len() as u32;
3103
3104 let recorded =
3105 text.record_vector(*rect, phys, asset, *render_mode, scale_factor);
3106 for index in recorded {
3107 self.paint_items.push(PaintItem::Vector(index));
3108 }
3109 }
3110 DrawOp::Scene3D {
3111 id,
3112 rect,
3113 scissor,
3114 scene,
3115 } => {
3116 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
3117 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
3118 timings.paint_culled_ops += 1;
3119 continue;
3120 }
3121 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
3122 timings.paint_culled_ops += 1;
3123 continue;
3124 }
3125 close_run(
3131 &mut self.runs,
3132 &mut self.paint_items,
3133 current,
3134 run_first,
3135 self.quad_scratch.len() as u32,
3136 );
3137 current = None;
3138 run_first = self.quad_scratch.len() as u32;
3139
3140 let recorded = text.record_scene3d(*rect, phys, id, scene, scale_factor);
3141 for index in recorded {
3142 self.paint_items.push(PaintItem::Scene3D(index));
3143 }
3144 }
3145 DrawOp::BackdropSnapshot => {
3146 close_run(
3147 &mut self.runs,
3148 &mut self.paint_items,
3149 current,
3150 run_first,
3151 self.quad_scratch.len() as u32,
3152 );
3153 current = None;
3154 run_first = self.quad_scratch.len() as u32;
3155 if !snapshot_emitted {
3158 self.paint_items.push(PaintItem::BackdropSnapshot);
3159 snapshot_emitted = true;
3160 }
3161 }
3162 }
3163 }
3164 close_run(
3165 &mut self.runs,
3166 &mut self.paint_items,
3167 current,
3168 run_first,
3169 self.quad_scratch.len() as u32,
3170 );
3171 timings.paint = Instant::now() - t0;
3172 }
3173
3174 pub fn snapshot(&mut self, root: &El, timings: &mut PrepareTimings) {
3182 self.snapshot_owned(root.clone(), timings);
3183 }
3184
3185 pub fn snapshot_owned(&mut self, root: El, timings: &mut PrepareTimings) {
3190 crate::profile_span!("prepare::snapshot");
3191 let t0 = Instant::now();
3192 self.last_tree_has_links = tree_has_links(&root);
3193 self.last_tree = Some(root);
3194 timings.snapshot = Instant::now() - t0;
3195 }
3196
3197 pub fn snapshot_cursor(&self) -> crate::cursor::Cursor {
3201 self.last_tree
3202 .as_ref()
3203 .map(|tree| self.ui_state.cursor(tree))
3204 .unwrap_or_default()
3205 }
3206
3207 fn linkable_tree(&self) -> Option<&El> {
3212 self.last_tree.as_ref().filter(|_| self.last_tree_has_links)
3213 }
3214}
3215
3216fn paint_rect_visible(
3217 rect: Rect,
3218 scissor: Option<Rect>,
3219 viewport_px: (u32, u32),
3220 scale_factor: f32,
3221) -> bool {
3222 if rect.w <= 0.0 || rect.h <= 0.0 {
3223 return false;
3224 }
3225 let scale = scale_factor.max(f32::EPSILON);
3226 let viewport = Rect::new(
3227 0.0,
3228 0.0,
3229 viewport_px.0 as f32 / scale,
3230 viewport_px.1 as f32 / scale,
3231 );
3232 let Some(clip) = scissor.map_or(Some(viewport), |s| s.intersect(viewport)) else {
3233 return false;
3234 };
3235 rect.intersect(clip).is_some()
3236}
3237
3238fn target_id_in_subtree(root_id: &str, target_id: &str) -> bool {
3239 target_id == root_id
3240 || target_id
3241 .strip_prefix(root_id)
3242 .is_some_and(|rest| rest.starts_with('.'))
3243}
3244
3245fn op_is_continuous<F>(op: &DrawOp, samples_time: &F) -> bool
3252where
3253 F: Fn(&ShaderHandle) -> bool,
3254{
3255 match op.shader() {
3256 Some(handle @ ShaderHandle::Stock(s)) => s.is_continuous() || samples_time(handle),
3257 Some(handle @ ShaderHandle::Custom(_)) => samples_time(handle),
3258 None => false,
3259 }
3260}
3261
3262fn grid_vertical_step(members: &[UiTarget], from: usize, dir: f32) -> Option<usize> {
3272 let cur = &members[from].rect;
3273 let (cx, cy) = (cur.x + cur.w / 2.0, cur.y + cur.h / 2.0);
3274 members
3275 .iter()
3276 .enumerate()
3277 .filter(|(i, t)| {
3278 let ty = t.rect.y + t.rect.h / 2.0;
3279 *i != from && (ty - cy) * dir > cur.h / 2.0
3283 })
3284 .min_by(|(_, a), (_, b)| {
3285 let key = |t: &UiTarget| {
3286 let tx = t.rect.x + t.rect.w / 2.0;
3287 let ty = t.rect.y + t.rect.h / 2.0;
3288 ((ty - cy).abs(), (tx - cx).abs())
3289 };
3290 key(a)
3291 .partial_cmp(&key(b))
3292 .unwrap_or(std::cmp::Ordering::Equal)
3293 })
3294 .map(|(i, _)| i)
3295}
3296
3297fn tree_has_links(node: &El) -> bool {
3305 node.text_link.is_some() || node.children.iter().any(tree_has_links)
3306}
3307
3308pub(crate) fn find_capture_keys(node: &El, id: &str) -> Option<bool> {
3309 if node.computed_id == id.into() {
3310 return Some(node.capture_keys);
3311 }
3312 node.children.iter().find_map(|c| find_capture_keys(c, id))
3313}
3314
3315fn find_consumes_touch_drag(node: &El, id: &str, ancestor_consumes: bool) -> Option<bool> {
3325 let consumes = ancestor_consumes || node.consumes_touch_drag;
3326 if node.computed_id == id.into() {
3327 return Some(consumes);
3328 }
3329 node.children
3330 .iter()
3331 .find_map(|c| find_consumes_touch_drag(c, id, consumes))
3332}
3333
3334fn find_inside_subtree(
3341 node: &El,
3342 ancestor_id: &str,
3343 id: &str,
3344 inside_ancestor: bool,
3345) -> Option<bool> {
3346 let inside = inside_ancestor || node.computed_id == ancestor_id.into();
3347 if node.computed_id == id.into() {
3348 return Some(inside);
3349 }
3350 node.children
3351 .iter()
3352 .find_map(|c| find_inside_subtree(c, ancestor_id, id, inside))
3353}
3354
3355fn selection_event(
3357 new_sel: crate::selection::Selection,
3358 modifiers: KeyModifiers,
3359 pointer: Option<(f32, f32)>,
3360 pointer_kind: Option<PointerKind>,
3361) -> UiEvent {
3362 UiEvent {
3363 kind: UiEventKind::SelectionChanged,
3364 key: None,
3365 target: None,
3366 pointer,
3367 key_press: None,
3368 text: None,
3369 selection: Some(new_sel),
3370 modifiers,
3371 click_count: 0,
3372 path: None,
3373 pointer_kind,
3374 wheel_delta: None,
3375 }
3376}
3377
3378fn head_for_drag(
3390 root: &El,
3391 ui_state: &UiState,
3392 point: (f32, f32),
3393) -> Option<crate::selection::SelectionPoint> {
3394 if let Some(p) = hit_test::selection_point_at(root, point) {
3395 return Some(p);
3396 }
3397
3398 let order = &ui_state.selection.order;
3399 if order.is_empty() {
3400 return None;
3401 }
3402 let target = order
3407 .iter()
3408 .find(|t| point.1 >= t.rect.y && point.1 < t.rect.y + t.rect.h)
3409 .or_else(|| {
3410 order.iter().min_by(|a, b| {
3411 let da = y_distance(a.rect, point.1);
3412 let db = y_distance(b.rect, point.1);
3413 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
3414 })
3415 })?;
3416 let target_rect = target.rect;
3417 let cy = point
3418 .1
3419 .clamp(target_rect.y, target_rect.y + target_rect.h - 1.0);
3420 if let Some(p) = hit_test::selection_point_at(root, (point.0, cy)) {
3421 return Some(p);
3422 }
3423 let leaf_len = find_text_len(root, &target.node_id).unwrap_or(0);
3426 let byte = if point.0 < target_rect.x { 0 } else { leaf_len };
3427 Some(crate::selection::SelectionPoint {
3428 key: target.key.clone(),
3429 byte,
3430 })
3431}
3432
3433fn selection_range_for_drag(
3434 root: &El,
3435 ui_state: &UiState,
3436 drag: &crate::state::SelectionDrag,
3437 raw_head: crate::selection::SelectionPoint,
3438) -> (
3439 crate::selection::SelectionPoint,
3440 crate::selection::SelectionPoint,
3441) {
3442 match drag.granularity {
3443 SelectionDragGranularity::Character => (drag.anchor.clone(), raw_head),
3444 SelectionDragGranularity::Word => {
3445 let text = crate::selection::find_keyed_text(root, &raw_head.key).unwrap_or_default();
3446 let (lo, hi) = crate::selection::word_range_at(&text, raw_head.byte);
3447 if point_cmp(ui_state, &raw_head, &drag.anchor) == Ordering::Less {
3448 (
3449 drag.head.clone(),
3450 crate::selection::SelectionPoint::new(raw_head.key, lo),
3451 )
3452 } else {
3453 (
3454 drag.anchor.clone(),
3455 crate::selection::SelectionPoint::new(raw_head.key, hi),
3456 )
3457 }
3458 }
3459 SelectionDragGranularity::Leaf => {
3460 let len = crate::selection::find_keyed_text(root, &raw_head.key)
3461 .map(|text| text.len())
3462 .unwrap_or(raw_head.byte);
3463 if point_cmp(ui_state, &raw_head, &drag.anchor) == Ordering::Less {
3464 (
3465 drag.head.clone(),
3466 crate::selection::SelectionPoint::new(raw_head.key, 0),
3467 )
3468 } else {
3469 (
3470 drag.anchor.clone(),
3471 crate::selection::SelectionPoint::new(raw_head.key, len),
3472 )
3473 }
3474 }
3475 }
3476}
3477
3478fn point_cmp(
3479 ui_state: &UiState,
3480 a: &crate::selection::SelectionPoint,
3481 b: &crate::selection::SelectionPoint,
3482) -> Ordering {
3483 let order_index = |key: &str| {
3484 ui_state
3485 .selection
3486 .order
3487 .iter()
3488 .position(|target| target.key == key)
3489 .unwrap_or(usize::MAX)
3490 };
3491 order_index(&a.key)
3492 .cmp(&order_index(&b.key))
3493 .then_with(|| a.byte.cmp(&b.byte))
3494}
3495
3496fn y_distance(rect: Rect, y: f32) -> f32 {
3497 if y < rect.y {
3498 rect.y - y
3499 } else if y > rect.y + rect.h {
3500 y - (rect.y + rect.h)
3501 } else {
3502 0.0
3503 }
3504}
3505
3506fn find_text_len(node: &El, id: &str) -> Option<usize> {
3507 if node.computed_id == id.into() {
3508 if let Some(source) = &node.selection_source {
3509 return Some(source.visible_len());
3510 }
3511 return node.text.as_ref().map(|t| t.len());
3512 }
3513 node.children.iter().find_map(|c| find_text_len(c, id))
3514}
3515
3516pub enum RecordedPaint {
3519 Text(Range<usize>),
3520 Icon(Range<usize>),
3521}
3522
3523pub trait TextRecorder {
3527 #[allow(clippy::too_many_arguments)]
3535 fn record(
3536 &mut self,
3537 rect: Rect,
3538 scissor: Option<PhysicalScissor>,
3539 style: &RunStyle,
3540 text: &str,
3541 size: f32,
3542 line_height: f32,
3543 wrap: TextWrap,
3544 anchor: TextAnchor,
3545 scale_factor: f32,
3546 ) -> Range<usize>;
3547
3548 #[allow(clippy::too_many_arguments)]
3553 fn record_runs(
3554 &mut self,
3555 rect: Rect,
3556 scissor: Option<PhysicalScissor>,
3557 runs: &[(String, RunStyle)],
3558 size: f32,
3559 line_height: f32,
3560 wrap: TextWrap,
3561 anchor: TextAnchor,
3562 scale_factor: f32,
3563 ) -> Range<usize>;
3564
3565 #[allow(clippy::too_many_arguments)]
3571 fn record_icon(
3572 &mut self,
3573 rect: Rect,
3574 scissor: Option<PhysicalScissor>,
3575 source: &crate::icons::svg::IconSource,
3576 color: Color,
3577 size: f32,
3578 _stroke_width: f32,
3579 scale_factor: f32,
3580 ) -> RecordedPaint {
3581 let glyph = match source {
3582 crate::icons::svg::IconSource::Builtin(name) => name.fallback_glyph(),
3583 crate::icons::svg::IconSource::Custom(_) => "?",
3584 crate::icons::svg::IconSource::UnknownName(_) => {
3585 crate::tree::IconName::AlertCircle.fallback_glyph()
3586 }
3587 };
3588 RecordedPaint::Text(self.record(
3589 rect,
3590 scissor,
3591 &RunStyle::new(FontWeight::Regular, color),
3592 glyph,
3593 size,
3594 crate::text::metrics::line_height(size),
3595 TextWrap::NoWrap,
3596 TextAnchor::Middle,
3597 scale_factor,
3598 ))
3599 }
3600
3601 #[allow(clippy::too_many_arguments)]
3608 fn record_image(
3609 &mut self,
3610 _rect: Rect,
3611 _scissor: Option<PhysicalScissor>,
3612 _image: &crate::image::Image,
3613 _tint: Option<Color>,
3614 _radius: crate::tree::Corners,
3615 _fit: crate::image::ImageFit,
3616 _range_limit: crate::image::DynamicRangeLimit,
3617 _scale_factor: f32,
3618 ) -> Range<usize> {
3619 0..0
3620 }
3621
3622 fn record_app_texture(
3628 &mut self,
3629 _rect: Rect,
3630 _scissor: Option<PhysicalScissor>,
3631 _texture: &crate::surface::AppTexture,
3632 _alpha: crate::surface::SurfaceAlpha,
3633 _transform: crate::affine::Affine2,
3634 _scale_factor: f32,
3635 ) -> Range<usize> {
3636 0..0
3637 }
3638
3639 fn record_vector(
3645 &mut self,
3646 _rect: Rect,
3647 _scissor: Option<PhysicalScissor>,
3648 _asset: &crate::vector::VectorAsset,
3649 _render_mode: crate::vector::VectorRenderMode,
3650 _scale_factor: f32,
3651 ) -> Range<usize> {
3652 0..0
3653 }
3654
3655 fn record_scene3d(
3666 &mut self,
3667 _rect: Rect,
3668 _scissor: Option<PhysicalScissor>,
3669 _id: &str,
3670 _scene: &std::sync::Arc<crate::scene::Scene3DData>,
3671 _scale_factor: f32,
3672 ) -> Range<usize> {
3673 0..0
3674 }
3675}
3676
3677#[cfg(test)]
3678mod tests {
3679 use super::*;
3680 use crate::event::PointerId;
3681 use crate::shader::{ShaderHandle, StockShader, UniformBlock};
3682
3683 struct NoText;
3685 impl TextRecorder for NoText {
3686 fn record(
3687 &mut self,
3688 _rect: Rect,
3689 _scissor: Option<PhysicalScissor>,
3690 _style: &RunStyle,
3691 _text: &str,
3692 _size: f32,
3693 _line_height: f32,
3694 _wrap: TextWrap,
3695 _anchor: TextAnchor,
3696 _scale_factor: f32,
3697 ) -> Range<usize> {
3698 0..0
3699 }
3700 fn record_runs(
3701 &mut self,
3702 _rect: Rect,
3703 _scissor: Option<PhysicalScissor>,
3704 _runs: &[(String, RunStyle)],
3705 _size: f32,
3706 _line_height: f32,
3707 _wrap: TextWrap,
3708 _anchor: TextAnchor,
3709 _scale_factor: f32,
3710 ) -> Range<usize> {
3711 0..0
3712 }
3713 }
3714
3715 #[derive(Default)]
3716 struct CountingText {
3717 records: usize,
3718 }
3719
3720 impl TextRecorder for CountingText {
3721 fn record(
3722 &mut self,
3723 _rect: Rect,
3724 _scissor: Option<PhysicalScissor>,
3725 _style: &RunStyle,
3726 _text: &str,
3727 _size: f32,
3728 _line_height: f32,
3729 _wrap: TextWrap,
3730 _anchor: TextAnchor,
3731 _scale_factor: f32,
3732 ) -> Range<usize> {
3733 self.records += 1;
3734 0..0
3735 }
3736
3737 fn record_runs(
3738 &mut self,
3739 _rect: Rect,
3740 _scissor: Option<PhysicalScissor>,
3741 _runs: &[(String, RunStyle)],
3742 _size: f32,
3743 _line_height: f32,
3744 _wrap: TextWrap,
3745 _anchor: TextAnchor,
3746 _scale_factor: f32,
3747 ) -> Range<usize> {
3748 self.records += 1;
3749 0..0
3750 }
3751 }
3752
3753 fn empty_text_layout(line_height: f32) -> crate::text::metrics::TextLayout {
3754 crate::text::metrics::TextLayout {
3755 lines: Vec::new(),
3756 width: 0.0,
3757 height: 0.0,
3758 line_height,
3759 }
3760 }
3761
3762 fn lay_out_input_tree(capture: bool) -> RunnerCore {
3769 use crate::tree::*;
3770 let ti = if capture {
3771 crate::widgets::text::text("input").key("ti").capture_keys()
3772 } else {
3773 crate::widgets::text::text("noop").key("ti").focusable()
3774 };
3775 let mut tree =
3776 crate::column([crate::widgets::button::button("Btn").key("btn"), ti]).padding(10.0);
3777 let mut core = RunnerCore::new();
3778 crate::layout::layout(
3779 &mut tree,
3780 &mut core.ui_state,
3781 Rect::new(0.0, 0.0, 200.0, 200.0),
3782 );
3783 core.ui_state.sync_focus_order(&tree);
3784 let mut t = PrepareTimings::default();
3785 core.snapshot(&tree, &mut t);
3786 core
3787 }
3788
3789 #[test]
3790 fn pointer_up_emits_pointer_up_then_click() {
3791 let mut core = lay_out_input_tree(false);
3792 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3793 let cx = btn_rect.x + btn_rect.w * 0.5;
3794 let cy = btn_rect.y + btn_rect.h * 0.5;
3795 core.pointer_moved(Pointer::moving(cx, cy));
3796 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3797 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
3798 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3799 assert_eq!(kinds, vec![UiEventKind::PointerUp, UiEventKind::Click]);
3800 }
3801
3802 #[test]
3803 fn pointer_wheel_event_routes_to_keyed_target() {
3804 let mut core = lay_out_input_tree(false);
3805 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3806 let cx = btn_rect.center_x();
3807 let cy = btn_rect.center_y();
3808
3809 let event = core
3810 .pointer_wheel_event(cx, cy, 0.0, 40.0)
3811 .expect("wheel over keyed button");
3812
3813 assert_eq!(event.kind, UiEventKind::PointerWheel);
3814 assert_eq!(event.route(), Some("btn"));
3815 assert_eq!(event.pointer_pos(), Some((cx, cy)));
3816 assert_eq!(event.wheel_delta(), Some((0.0, 40.0)));
3817 assert_eq!(event.wheel_dy(), Some(40.0));
3818 assert_eq!(event.target_rect(), Some(btn_rect));
3819 assert_eq!(event.pointer_kind, Some(PointerKind::Mouse));
3820 }
3821
3822 fn lay_out_link_tree() -> (RunnerCore, Rect, &'static str) {
3828 use crate::tree::*;
3829 const URL: &str = "https://github.com/computer-whisperer/damascene";
3830 let mut tree = crate::column([crate::text_runs([
3831 crate::text("Visit "),
3832 crate::text("github.com/computer-whisperer/damascene").link(URL),
3833 crate::text("."),
3834 ])])
3835 .padding(10.0);
3836 let mut core = RunnerCore::new();
3837 crate::layout::layout(
3838 &mut tree,
3839 &mut core.ui_state,
3840 Rect::new(0.0, 0.0, 600.0, 200.0),
3841 );
3842 core.ui_state.sync_focus_order(&tree);
3843 let mut t = PrepareTimings::default();
3844 core.snapshot(&tree, &mut t);
3845 let para = core
3846 .last_tree
3847 .as_ref()
3848 .and_then(|t| t.children.first())
3849 .map(|p| p.computed_rect)
3850 .expect("paragraph rect");
3851 (core, para, URL)
3852 }
3853
3854 #[test]
3855 fn snapshot_computes_the_links_flag_that_gates_link_walks() {
3856 let core = lay_out_input_tree(false);
3859 assert!(
3860 !core.last_tree_has_links,
3861 "input tree has no links; the flag must stay off"
3862 );
3863 let (core, ..) = lay_out_link_tree();
3864 assert!(
3865 core.last_tree_has_links,
3866 "linked paragraph must flip the flag on"
3867 );
3868 }
3869
3870 #[test]
3871 fn pointer_up_on_link_emits_link_activated_with_url() {
3872 let (mut core, para, url) = lay_out_link_tree();
3873 let cx = para.x + 100.0;
3877 let cy = para.y + para.h * 0.5;
3878 core.pointer_moved(Pointer::moving(cx, cy));
3879 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3880 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
3881 let link = events
3882 .iter()
3883 .find(|e| e.kind == UiEventKind::LinkActivated)
3884 .expect("LinkActivated event");
3885 assert_eq!(link.key.as_deref(), Some(url));
3886 }
3887
3888 #[test]
3889 fn pointer_up_after_drag_off_link_does_not_activate() {
3890 let (mut core, para, _url) = lay_out_link_tree();
3891 let press_x = para.x + 100.0;
3892 let cy = para.y + para.h * 0.5;
3893 core.pointer_moved(Pointer::moving(press_x, cy));
3894 core.pointer_down(Pointer::mouse(press_x, cy, PointerButton::Primary));
3895 let events = core.pointer_up(Pointer::mouse(press_x, 180.0, PointerButton::Primary));
3899 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3900 assert!(
3901 !kinds.contains(&UiEventKind::LinkActivated),
3902 "drag-off-link should cancel the link activation; got {kinds:?}",
3903 );
3904 }
3905
3906 #[test]
3907 fn disabled_control_resolves_not_allowed_cursor_on_hover() {
3908 use crate::cursor::Cursor;
3913 let mut tree = crate::widgets::button::button("Save")
3914 .key("save")
3915 .disabled();
3916 let mut core = RunnerCore::new();
3917 crate::layout::layout(
3918 &mut tree,
3919 &mut core.ui_state,
3920 Rect::new(0.0, 0.0, 200.0, 100.0),
3921 );
3922 let rect = tree.computed_rect;
3923 let mut t = PrepareTimings::default();
3924 core.snapshot(&tree, &mut t);
3925 core.pointer_moved(Pointer::moving(
3926 rect.x + rect.w * 0.5,
3927 rect.y + rect.h * 0.5,
3928 ));
3929 let snap = core.last_tree.as_ref().expect("tree").clone();
3930 assert_eq!(core.ui_state.cursor(&snap), Cursor::NotAllowed);
3931 }
3932
3933 #[test]
3934 fn pointer_moved_over_link_resolves_cursor_to_pointer_and_requests_redraw() {
3935 use crate::cursor::Cursor;
3936 let (mut core, para, _url) = lay_out_link_tree();
3937 let cx = para.x + 100.0;
3938 let cy = para.y + para.h * 0.5;
3939 let initial = core.pointer_moved(Pointer::moving(para.x - 50.0, cy));
3941 assert!(
3942 !initial.needs_redraw,
3943 "moving in empty space shouldn't request a redraw"
3944 );
3945 let tree = core.last_tree.as_ref().expect("tree").clone();
3946 assert_eq!(
3947 core.ui_state.cursor(&tree),
3948 Cursor::Default,
3949 "no link under pointer → default cursor"
3950 );
3951 let onto = core.pointer_moved(Pointer::moving(cx, cy));
3954 assert!(
3955 onto.needs_redraw,
3956 "entering a link region should flag a redraw so the cursor refresh isn't stale"
3957 );
3958 assert_eq!(
3959 core.ui_state.cursor(&tree),
3960 Cursor::Pointer,
3961 "pointer over a link → Pointer cursor"
3962 );
3963 let off = core.pointer_moved(Pointer::moving(para.x - 50.0, cy));
3966 assert!(
3967 off.needs_redraw,
3968 "leaving a link region should flag a redraw"
3969 );
3970 assert_eq!(core.ui_state.cursor(&tree), Cursor::Default);
3971 }
3972
3973 #[test]
3974 fn pointer_up_on_unlinked_text_does_not_emit_link_activated() {
3975 let (mut core, para, _url) = lay_out_link_tree();
3976 let cx = para.x + 1.0;
3979 let cy = para.y + para.h * 0.5;
3980 core.pointer_moved(Pointer::moving(cx, cy));
3981 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3982 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
3983 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3984 assert!(
3985 !kinds.contains(&UiEventKind::LinkActivated),
3986 "click on the unlinked prefix should not surface a link event; got {kinds:?}",
3987 );
3988 }
3989
3990 #[test]
3991 fn pointer_up_off_target_emits_only_pointer_up() {
3992 let mut core = lay_out_input_tree(false);
3993 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3994 let cx = btn_rect.x + btn_rect.w * 0.5;
3995 let cy = btn_rect.y + btn_rect.h * 0.5;
3996 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3997 let events = core.pointer_up(Pointer::mouse(180.0, 180.0, PointerButton::Primary));
3999 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
4000 assert_eq!(
4001 kinds,
4002 vec![UiEventKind::PointerUp],
4003 "drag-off-target should still surface PointerUp so widgets see drag-end"
4004 );
4005 }
4006
4007 #[test]
4008 fn pointer_moved_while_pressed_emits_drag() {
4009 let mut core = lay_out_input_tree(false);
4010 let btn_rect = core.rect_of_key("btn").expect("btn rect");
4011 let cx = btn_rect.x + btn_rect.w * 0.5;
4012 let cy = btn_rect.y + btn_rect.h * 0.5;
4013 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4014 let drag = core
4015 .pointer_moved(Pointer::moving(cx + 30.0, cy))
4016 .events
4017 .into_iter()
4018 .find(|e| e.kind == UiEventKind::Drag)
4019 .expect("drag while pressed");
4020 assert_eq!(drag.target.as_ref().map(|t| t.key.as_str()), Some("btn"));
4021 assert_eq!(drag.pointer, Some((cx + 30.0, cy)));
4022 }
4023
4024 #[test]
4025 fn toast_dismiss_click_removes_toast_and_suppresses_click_event() {
4026 use crate::toast::ToastSpec;
4027 use crate::tree::Size;
4028 let mut core = RunnerCore::new();
4032 core.ui_state
4033 .push_toast(ToastSpec::success("hi"), Instant::now());
4034 let toast_id = core.ui_state.toasts()[0].id;
4035
4036 let mut tree: El = crate::stack(std::iter::empty::<El>())
4040 .width(Size::Fill(1.0))
4041 .height(Size::Fill(1.0));
4042 crate::layout::assign_ids(&mut tree);
4043 let _ = crate::toast::synthesize_toasts(&mut tree, &mut core.ui_state, Instant::now());
4044 crate::layout::layout(
4045 &mut tree,
4046 &mut core.ui_state,
4047 Rect::new(0.0, 0.0, 800.0, 600.0),
4048 );
4049 core.ui_state.sync_focus_order(&tree);
4050 let mut t = PrepareTimings::default();
4051 core.snapshot(&tree, &mut t);
4052
4053 let dismiss_key = format!("toast-dismiss-{toast_id}");
4054 let dismiss_rect = core.rect_of_key(&dismiss_key).expect("dismiss button");
4055 let cx = dismiss_rect.x + dismiss_rect.w * 0.5;
4056 let cy = dismiss_rect.y + dismiss_rect.h * 0.5;
4057
4058 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4059 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
4060 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
4061 assert!(
4065 !kinds.contains(&UiEventKind::Click),
4066 "Click on toast-dismiss should not be surfaced: {kinds:?}",
4067 );
4068 let toast = core
4072 .ui_state
4073 .toasts()
4074 .iter()
4075 .find(|t| t.id == toast_id)
4076 .expect("dismissed toast stays queued through its exit window");
4077 assert!(toast.dismissed, "dismiss-click marks the toast dismissed");
4078
4079 let mut tree2: El = crate::stack(std::iter::empty::<El>())
4080 .width(Size::Fill(1.0))
4081 .height(Size::Fill(1.0));
4082 crate::layout::assign_ids(&mut tree2);
4083 let exit_open = Instant::now();
4084 let _ = crate::toast::synthesize_toasts(&mut tree2, &mut core.ui_state, exit_open);
4085 let after = exit_open + crate::toast::TOAST_EXIT_WINDOW + Duration::from_millis(10);
4086 let mut tree3: El = crate::stack(std::iter::empty::<El>())
4087 .width(Size::Fill(1.0))
4088 .height(Size::Fill(1.0));
4089 crate::layout::assign_ids(&mut tree3);
4090 let _ = crate::toast::synthesize_toasts(&mut tree3, &mut core.ui_state, after);
4091 assert!(
4092 core.ui_state.toasts().iter().all(|t| t.id != toast_id),
4093 "toast {toast_id} should leave the queue after its exit window",
4094 );
4095 }
4096
4097 #[test]
4098 fn pointer_moved_without_press_emits_no_drag() {
4099 let mut core = lay_out_input_tree(false);
4100 let events = core.pointer_moved(Pointer::moving(50.0, 50.0)).events;
4101 assert!(!events.iter().any(|e| e.kind == UiEventKind::Drag));
4105 }
4106
4107 #[test]
4108 fn spinner_in_tree_keeps_needs_redraw_set() {
4109 use crate::widgets::spinner::spinner;
4114 let mut tree = crate::column([spinner()]);
4115 let mut core = RunnerCore::new();
4116 let mut t = PrepareTimings::default();
4117 let LayoutPrepared { needs_redraw, .. } = core.prepare_layout(
4118 &mut tree,
4119 Rect::new(0.0, 0.0, 200.0, 200.0),
4120 1.0,
4121 &mut t,
4122 RunnerCore::no_time_shaders,
4123 );
4124 assert!(
4125 needs_redraw,
4126 "tree with a spinner must request continuous redraw",
4127 );
4128
4129 let mut bare = crate::column([crate::widgets::text::text("idle")]);
4133 let mut core2 = RunnerCore::new();
4134 let mut t2 = PrepareTimings::default();
4135 let LayoutPrepared {
4136 needs_redraw: needs_redraw2,
4137 ..
4138 } = core2.prepare_layout(
4139 &mut bare,
4140 Rect::new(0.0, 0.0, 200.0, 200.0),
4141 1.0,
4142 &mut t2,
4143 RunnerCore::no_time_shaders,
4144 );
4145 assert!(
4146 !needs_redraw2,
4147 "tree without time-driven shaders should idle: got needs_redraw={needs_redraw2}",
4148 );
4149 }
4150
4151 #[test]
4152 fn custom_samples_time_shader_keeps_needs_redraw_set() {
4153 let mut tree = crate::column([crate::tree::El::new(crate::tree::Kind::Custom("anim"))
4157 .shader(crate::shader::ShaderBinding::custom("my_animated_glow"))
4158 .width(crate::tree::Size::Fixed(32.0))
4159 .height(crate::tree::Size::Fixed(32.0))]);
4160 let mut core = RunnerCore::new();
4161 let mut t = PrepareTimings::default();
4162
4163 let LayoutPrepared {
4164 needs_redraw: idle, ..
4165 } = core.prepare_layout(
4166 &mut tree,
4167 Rect::new(0.0, 0.0, 200.0, 200.0),
4168 1.0,
4169 &mut t,
4170 RunnerCore::no_time_shaders,
4171 );
4172 assert!(
4173 !idle,
4174 "without a samples_time registration the host should idle",
4175 );
4176
4177 let mut t2 = PrepareTimings::default();
4178 let LayoutPrepared {
4179 needs_redraw: animated,
4180 ..
4181 } = core.prepare_layout(
4182 &mut tree,
4183 Rect::new(0.0, 0.0, 200.0, 200.0),
4184 1.0,
4185 &mut t2,
4186 |handle| matches!(handle, ShaderHandle::Custom("my_animated_glow")),
4187 );
4188 assert!(
4189 animated,
4190 "custom shader registered as samples_time=true must request continuous redraw",
4191 );
4192 }
4193
4194 #[test]
4195 fn redraw_within_aggregates_to_minimum_visible_deadline() {
4196 use std::time::Duration;
4197 let mut tree = crate::column([
4198 crate::widgets::text::text("a")
4200 .redraw_within(Duration::from_millis(16))
4201 .width(crate::tree::Size::Fixed(20.0))
4202 .height(crate::tree::Size::Fixed(20.0)),
4203 crate::widgets::text::text("b")
4205 .redraw_within(Duration::from_millis(50))
4206 .width(crate::tree::Size::Fixed(20.0))
4207 .height(crate::tree::Size::Fixed(20.0)),
4208 ]);
4209 let mut core = RunnerCore::new();
4210 let mut t = PrepareTimings::default();
4211 let LayoutPrepared {
4212 needs_redraw,
4213 next_layout_redraw_in,
4214 ..
4215 } = core.prepare_layout(
4216 &mut tree,
4217 Rect::new(0.0, 0.0, 200.0, 200.0),
4218 1.0,
4219 &mut t,
4220 RunnerCore::no_time_shaders,
4221 );
4222 assert!(needs_redraw, "redraw_within must lift the legacy bool");
4223 assert_eq!(
4224 next_layout_redraw_in,
4225 Some(Duration::from_millis(16)),
4226 "tightest visible deadline wins, on the layout lane",
4227 );
4228 }
4229
4230 #[test]
4231 fn redraw_within_off_screen_widget_is_ignored() {
4232 use std::time::Duration;
4233 let mut tree = crate::column([
4239 crate::tree::spacer().height(crate::tree::Size::Fixed(150.0)),
4240 crate::widgets::text::text("offscreen")
4241 .redraw_within(Duration::from_millis(16))
4242 .width(crate::tree::Size::Fixed(10.0))
4243 .height(crate::tree::Size::Fixed(10.0)),
4244 ]);
4245 let mut core = RunnerCore::new();
4246 let mut t = PrepareTimings::default();
4247 let LayoutPrepared {
4248 next_layout_redraw_in,
4249 ..
4250 } = core.prepare_layout(
4251 &mut tree,
4252 Rect::new(0.0, 0.0, 100.0, 100.0),
4253 1.0,
4254 &mut t,
4255 RunnerCore::no_time_shaders,
4256 );
4257 assert_eq!(
4258 next_layout_redraw_in, None,
4259 "off-screen redraw_within must not contribute to the aggregate",
4260 );
4261 }
4262
4263 #[test]
4264 fn redraw_within_clipped_out_widget_is_ignored() {
4265 use std::time::Duration;
4266
4267 let clipped = crate::column([crate::widgets::text::text("clipped")
4268 .redraw_within(Duration::from_millis(16))
4269 .width(crate::tree::Size::Fixed(10.0))
4270 .height(crate::tree::Size::Fixed(10.0))])
4271 .clip()
4272 .width(crate::tree::Size::Fixed(100.0))
4273 .height(crate::tree::Size::Fixed(20.0))
4274 .layout(|ctx| {
4275 vec![Rect::new(
4276 ctx.container.x,
4277 ctx.container.y + 30.0,
4278 10.0,
4279 10.0,
4280 )]
4281 });
4282 let mut tree = crate::column([clipped]);
4283
4284 let mut core = RunnerCore::new();
4285 let mut t = PrepareTimings::default();
4286 let LayoutPrepared {
4287 next_layout_redraw_in,
4288 ..
4289 } = core.prepare_layout(
4290 &mut tree,
4291 Rect::new(0.0, 0.0, 100.0, 100.0),
4292 1.0,
4293 &mut t,
4294 RunnerCore::no_time_shaders,
4295 );
4296 assert_eq!(
4297 next_layout_redraw_in, None,
4298 "redraw_within inside an inherited clip but outside the clip rect must not contribute",
4299 );
4300 }
4301
4302 #[test]
4303 fn pointer_moved_within_same_hovered_node_does_not_request_redraw() {
4304 let mut core = lay_out_input_tree(false);
4310 let btn = core.rect_of_key("btn").expect("btn rect");
4311 let (cx, cy) = (btn.x + btn.w * 0.5, btn.y + btn.h * 0.5);
4312
4313 let first = core.pointer_moved(Pointer::moving(cx, cy));
4317 assert_eq!(first.events.len(), 1);
4318 assert_eq!(first.events[0].kind, UiEventKind::PointerEnter);
4319 assert_eq!(first.events[0].key.as_deref(), Some("btn"));
4320 assert!(
4321 first.needs_redraw,
4322 "entering a focusable should warrant a redraw",
4323 );
4324
4325 let second = core.pointer_moved(Pointer::moving(cx + 1.0, cy));
4329 assert!(second.events.is_empty());
4330 assert!(
4331 !second.needs_redraw,
4332 "identical hover, no drag → host should idle",
4333 );
4334
4335 let off = core.pointer_moved(Pointer::moving(0.0, 0.0));
4339 assert_eq!(off.events.len(), 1);
4340 assert_eq!(off.events[0].kind, UiEventKind::PointerLeave);
4341 assert_eq!(off.events[0].key.as_deref(), Some("btn"));
4342 assert!(
4343 off.needs_redraw,
4344 "leaving a hovered node still warrants a redraw",
4345 );
4346 }
4347
4348 #[test]
4349 fn pointer_move_within_hover_label_scene_keeps_redrawing() {
4350 use crate::scene::glam::Vec3;
4355 use crate::scene::{
4356 PointData, PointLabels, PointStyle, PointsHandle, ScenePoint, SceneSpec,
4357 };
4358 let pts = PointsHandle::new(PointData {
4359 points: vec![ScenePoint {
4360 position: Vec3::ZERO,
4361 color: [1.0; 4],
4362 }],
4363 });
4364 let spec = SceneSpec::new().points_labeled(
4365 pts,
4366 PointStyle::default(),
4367 PointLabels::new(["a"]).on_hover(),
4368 );
4369 let mut tree = crate::tree::chart3d(spec).key("scene");
4370 let mut core = RunnerCore::new();
4371 crate::layout::layout(
4372 &mut tree,
4373 &mut core.ui_state,
4374 Rect::new(0.0, 0.0, 200.0, 200.0),
4375 );
4376 let mut t = PrepareTimings::default();
4377 core.snapshot(&tree, &mut t);
4378 core.ui_state.tick_scene_cameras(&tree, Instant::now());
4381
4382 let scene = core.rect_of_key("scene").expect("scene rect");
4383 let (cx, cy) = (scene.center_x(), scene.center_y());
4384
4385 let enter = core.pointer_moved(Pointer::moving(cx, cy));
4386 assert!(enter.needs_redraw, "entering a hover-label scene redraws");
4387 let within = core.pointer_moved(Pointer::moving(cx + 3.0, cy - 2.0));
4390 assert!(
4391 within.needs_redraw,
4392 "moving within a hover-label scene keeps redrawing the tooltip",
4393 );
4394 let leave = core.pointer_moved(Pointer::moving(scene.x + scene.w + 20.0, cy));
4396 assert!(leave.needs_redraw, "leaving clears the tooltip");
4397 }
4398
4399 #[test]
4400 fn pointer_moved_between_keyed_targets_emits_leave_then_enter() {
4401 let mut core = lay_out_input_tree(false);
4408 let btn = core.rect_of_key("btn").expect("btn rect");
4409 let ti = core.rect_of_key("ti").expect("ti rect");
4410
4411 let _ = core.pointer_moved(Pointer::moving(btn.x + 4.0, btn.y + 4.0));
4413
4414 let cross = core.pointer_moved(Pointer::moving(ti.x + 4.0, ti.y + 4.0));
4416 let kinds: Vec<UiEventKind> = cross.events.iter().map(|e| e.kind).collect();
4417 assert_eq!(
4418 kinds,
4419 vec![UiEventKind::PointerLeave, UiEventKind::PointerEnter],
4420 "paired Leave-then-Enter on cross-target hover transition",
4421 );
4422 assert_eq!(cross.events[0].key.as_deref(), Some("btn"));
4423 assert_eq!(cross.events[1].key.as_deref(), Some("ti"));
4424 assert!(cross.needs_redraw);
4425 }
4426
4427 #[test]
4428 fn touch_pointer_down_emits_pointer_enter_then_pointer_down() {
4429 let mut core = lay_out_input_tree(false);
4435 let btn = core.rect_of_key("btn").expect("btn rect");
4436 let cx = btn.x + btn.w * 0.5;
4437 let cy = btn.y + btn.h * 0.5;
4438 let events = core.pointer_down(Pointer::touch(
4439 cx,
4440 cy,
4441 PointerButton::Primary,
4442 PointerId::PRIMARY,
4443 ));
4444 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
4445 assert_eq!(
4446 kinds,
4447 vec![UiEventKind::PointerEnter, UiEventKind::PointerDown],
4448 );
4449 for e in &events {
4450 assert_eq!(e.pointer_kind, Some(PointerKind::Touch));
4451 }
4452 assert_eq!(core.ui_state().hovered_key(), Some("btn"));
4453 }
4454
4455 #[test]
4456 fn touch_pointer_up_emits_pointer_leave_after_click() {
4457 let mut core = lay_out_input_tree(false);
4462 let btn = core.rect_of_key("btn").expect("btn rect");
4463 let cx = btn.x + btn.w * 0.5;
4464 let cy = btn.y + btn.h * 0.5;
4465 let _ = core.pointer_down(Pointer::touch(
4466 cx,
4467 cy,
4468 PointerButton::Primary,
4469 PointerId::PRIMARY,
4470 ));
4471 let events = core.pointer_up(Pointer::touch(
4472 cx,
4473 cy,
4474 PointerButton::Primary,
4475 PointerId::PRIMARY,
4476 ));
4477 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
4478 assert_eq!(
4479 kinds,
4480 vec![
4481 UiEventKind::PointerUp,
4482 UiEventKind::Click,
4483 UiEventKind::PointerLeave,
4484 ],
4485 );
4486 assert_eq!(core.ui_state().hovered_key(), None);
4487 }
4488
4489 #[test]
4490 fn touch_pointer_moved_without_press_does_not_emit_hover_transitions() {
4491 let mut core = lay_out_input_tree(false);
4499 let btn = core.rect_of_key("btn").expect("btn rect");
4500 let mut p = Pointer::moving(btn.x + 4.0, btn.y + 4.0);
4501 p.kind = PointerKind::Touch;
4502 let moved = core.pointer_moved(p);
4503 assert!(
4504 moved.events.is_empty(),
4505 "touch move without press should not emit hover events, got {:?}",
4506 moved.events.iter().map(|e| e.kind).collect::<Vec<_>>(),
4507 );
4508 }
4509
4510 #[test]
4511 fn touch_drag_between_targets_still_emits_hover_transitions() {
4512 use crate::tree::*;
4523 let mut tree = crate::column([
4524 crate::widgets::button::button("Btn")
4525 .key("btn")
4526 .consumes_touch_drag(),
4527 crate::widgets::button::button("Other").key("other"),
4528 ])
4529 .padding(10.0);
4530 let mut core = RunnerCore::new();
4531 crate::layout::layout(
4532 &mut tree,
4533 &mut core.ui_state,
4534 Rect::new(0.0, 0.0, 200.0, 200.0),
4535 );
4536 core.ui_state.sync_focus_order(&tree);
4537 let mut t = PrepareTimings::default();
4538 core.snapshot(&tree, &mut t);
4539
4540 let btn = core.rect_of_key("btn").expect("btn rect");
4541 let other = core.rect_of_key("other").expect("other rect");
4542 let _ = core.pointer_down(Pointer::touch(
4543 btn.x + 4.0,
4544 btn.y + 4.0,
4545 PointerButton::Primary,
4546 PointerId::PRIMARY,
4547 ));
4548 let mut move_p = Pointer::moving(other.x + 4.0, other.y + 4.0);
4549 move_p.kind = PointerKind::Touch;
4550 let cross = core.pointer_moved(move_p);
4551 let kinds: Vec<UiEventKind> = cross.events.iter().map(|e| e.kind).collect();
4552 assert!(
4553 kinds.contains(&UiEventKind::PointerLeave)
4554 && kinds.contains(&UiEventKind::PointerEnter),
4555 "touch drag across targets should emit Leave + Enter, got {kinds:?}",
4556 );
4557 assert!(kinds.contains(&UiEventKind::Drag));
4561 }
4562
4563 #[test]
4564 fn would_press_focus_text_input_distinguishes_capture_keys() {
4565 let core = lay_out_input_tree(true);
4570 let ti = core.rect_of_key("ti").expect("ti rect");
4571 let btn = core.rect_of_key("btn").expect("btn rect");
4572
4573 assert!(
4574 core.would_press_focus_text_input(ti.center_x(), ti.center_y()),
4575 "press on capture_keys widget should report true",
4576 );
4577 assert!(
4578 !core.would_press_focus_text_input(btn.center_x(), btn.center_y()),
4579 "press on plain focusable should report false",
4580 );
4581 assert!(!core.would_press_focus_text_input(0.0, 0.0));
4583 }
4584
4585 #[test]
4586 fn touch_jiggle_below_threshold_still_taps() {
4587 let mut core = lay_out_input_tree(false);
4593 let btn = core.rect_of_key("btn").expect("btn rect");
4594 let cx = btn.x + btn.w * 0.5;
4595 let cy = btn.y + btn.h * 0.5;
4596 let _ = core.pointer_down(Pointer::touch(
4597 cx,
4598 cy,
4599 PointerButton::Primary,
4600 PointerId::PRIMARY,
4601 ));
4602 let mut jiggle = Pointer::moving(cx + 3.0, cy + 2.0);
4604 jiggle.kind = PointerKind::Touch;
4605 let _ = core.pointer_moved(jiggle);
4606 let events = core.pointer_up(Pointer::touch(
4607 cx + 3.0,
4608 cy + 2.0,
4609 PointerButton::Primary,
4610 PointerId::PRIMARY,
4611 ));
4612 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
4613 assert!(
4614 kinds.contains(&UiEventKind::Click),
4615 "small jiggle should not commit to scroll, expected Click in {kinds:?}",
4616 );
4617 }
4618
4619 #[test]
4620 fn touch_drag_on_consuming_widget_emits_drag_not_cancel() {
4621 use crate::tree::*;
4626 let mut tree = crate::column([crate::widgets::button::button("Drag me")
4627 .key("draggable")
4628 .consumes_touch_drag()])
4629 .padding(10.0);
4630 let mut core = RunnerCore::new();
4631 crate::layout::layout(
4632 &mut tree,
4633 &mut core.ui_state,
4634 Rect::new(0.0, 0.0, 200.0, 200.0),
4635 );
4636 core.ui_state.sync_focus_order(&tree);
4637 let mut t = PrepareTimings::default();
4638 core.snapshot(&tree, &mut t);
4639
4640 let r = core.rect_of_key("draggable").expect("rect");
4641 let cx = r.x + r.w * 0.5;
4642 let cy = r.y + r.h * 0.5;
4643 let _ = core.pointer_down(Pointer::touch(
4644 cx,
4645 cy,
4646 PointerButton::Primary,
4647 PointerId::PRIMARY,
4648 ));
4649 let mut over = Pointer::moving(cx + 30.0, cy);
4652 over.kind = PointerKind::Touch;
4653 let moved = core.pointer_moved(over);
4654 let kinds: Vec<UiEventKind> = moved.events.iter().map(|e| e.kind).collect();
4655 assert!(
4656 kinds.contains(&UiEventKind::Drag),
4657 "drag-consuming widget should receive Drag past threshold, got {kinds:?}",
4658 );
4659 assert!(
4660 !kinds.contains(&UiEventKind::PointerCancel),
4661 "drag-consuming widget should not see PointerCancel, got {kinds:?}",
4662 );
4663 }
4664
4665 #[test]
4666 fn touch_drag_in_scrollable_cancels_press_and_scrolls() {
4667 use crate::tree::*;
4674 let mut tree = crate::scroll([
4675 crate::widgets::button::button("row 0")
4676 .key("row0")
4677 .height(Size::Fixed(50.0)),
4678 crate::widgets::button::button("row 1")
4679 .key("row1")
4680 .height(Size::Fixed(50.0)),
4681 crate::widgets::button::button("row 2")
4682 .key("row2")
4683 .height(Size::Fixed(50.0)),
4684 crate::widgets::button::button("row 3")
4685 .key("row3")
4686 .height(Size::Fixed(50.0)),
4687 crate::widgets::button::button("row 4")
4688 .key("row4")
4689 .height(Size::Fixed(50.0)),
4690 ])
4691 .key("list")
4692 .height(Size::Fixed(120.0));
4693 let mut core = RunnerCore::new();
4694 crate::layout::layout(
4695 &mut tree,
4696 &mut core.ui_state,
4697 Rect::new(0.0, 0.0, 200.0, 120.0),
4698 );
4699 core.ui_state.sync_focus_order(&tree);
4700 let mut t = PrepareTimings::default();
4701 core.snapshot(&tree, &mut t);
4702 let scroll_id = core
4703 .last_tree
4704 .as_ref()
4705 .map(|t| t.computed_id.clone())
4706 .expect("scroll id");
4707
4708 let row1 = core.rect_of_key("row1").expect("row1");
4713 let cx = row1.x + row1.w * 0.5;
4714 let cy = row1.y + row1.h * 0.5;
4715
4716 let down_events = core.pointer_down(Pointer::touch(
4718 cx,
4719 cy,
4720 PointerButton::Primary,
4721 PointerId::PRIMARY,
4722 ));
4723 assert!(
4725 down_events
4726 .iter()
4727 .any(|e| matches!(e.kind, UiEventKind::PointerDown)),
4728 "expected PointerDown on press",
4729 );
4730
4731 let mut up_finger = Pointer::moving(cx, cy - 40.0);
4735 up_finger.kind = PointerKind::Touch;
4736 let move_events = core.pointer_moved(up_finger);
4737 let kinds: Vec<UiEventKind> = move_events.events.iter().map(|e| e.kind).collect();
4738 assert!(
4739 kinds.contains(&UiEventKind::PointerCancel),
4740 "scroll commit should fire PointerCancel, got {kinds:?}",
4741 );
4742 assert!(
4743 !kinds.contains(&UiEventKind::Drag),
4744 "scroll commit should NOT emit Drag, got {kinds:?}",
4745 );
4746
4747 let offset = core.ui_state().scroll_offset(&scroll_id);
4749 assert!(
4750 offset > 30.0 && offset <= 50.0,
4751 "scroll offset should advance ~40px after a 40px finger drag, got {offset}",
4752 );
4753
4754 let up_events = core.pointer_up(Pointer::touch(
4757 cx,
4758 cy - 40.0,
4759 PointerButton::Primary,
4760 PointerId::PRIMARY,
4761 ));
4762 let up_kinds: Vec<UiEventKind> = up_events.iter().map(|e| e.kind).collect();
4763 assert!(
4764 !up_kinds.contains(&UiEventKind::Click),
4765 "scroll-committed gesture must not fire Click on release, got {up_kinds:?}",
4766 );
4767 }
4768
4769 #[test]
4770 fn touch_scroll_release_starts_momentum_after_fast_swipe_outside_viewport() {
4771 use crate::tree::*;
4772 let mut tree = crate::scroll((0..20).map(|i| {
4773 crate::widgets::button::button(format!("row {i}"))
4774 .key(format!("row{i}"))
4775 .height(Size::Fixed(50.0))
4776 }))
4777 .key("list")
4778 .height(Size::Fixed(120.0));
4779 let mut core = RunnerCore::new();
4780 crate::layout::layout(
4781 &mut tree,
4782 &mut core.ui_state,
4783 Rect::new(0.0, 0.0, 200.0, 120.0),
4784 );
4785 core.ui_state.sync_focus_order(&tree);
4786 let mut t = PrepareTimings::default();
4787 core.snapshot(&tree, &mut t);
4788 let scroll_id = core
4789 .last_tree
4790 .as_ref()
4791 .map(|t| t.computed_id.clone())
4792 .expect("scroll id");
4793
4794 let row1 = core.rect_of_key("row1").expect("row1");
4795 let cx = row1.x + row1.w * 0.5;
4796 let cy = row1.y + row1.h * 0.5;
4797
4798 core.pointer_down(Pointer::touch(
4799 cx,
4800 cy,
4801 PointerButton::Primary,
4802 PointerId::PRIMARY,
4803 ));
4804 let mut up_finger = Pointer::moving(cx, cy - 80.0);
4805 up_finger.kind = PointerKind::Touch;
4806 core.pointer_moved(up_finger);
4807 let before_release = core.ui_state().scroll_offset(&scroll_id);
4808 core.pointer_up(Pointer::touch(
4809 cx,
4810 cy - 80.0,
4811 PointerButton::Primary,
4812 PointerId::PRIMARY,
4813 ));
4814
4815 assert!(
4816 core.ui_state.has_scroll_momentum(),
4817 "quick touch scroll release should retain inertial velocity"
4818 );
4819 assert_eq!(
4820 core.next_input_deadline(Instant::now()),
4821 Some(Duration::ZERO),
4822 "active scroll momentum should request the next layout frame"
4823 );
4824
4825 let ticked = core
4826 .ui_state
4827 .tick_scroll_momentum(Instant::now() + Duration::from_millis(16));
4828 let after_tick = core.ui_state().scroll_offset(&scroll_id);
4829 assert!(ticked, "momentum tick should report visual work");
4830 assert!(
4831 after_tick > before_release,
4832 "momentum should continue in release direction: before={before_release}, after={after_tick}"
4833 );
4834 }
4835
4836 #[test]
4837 fn pointer_left_emits_leave_for_prior_hover() {
4838 let mut core = lay_out_input_tree(false);
4839 let btn = core.rect_of_key("btn").expect("btn rect");
4840 let _ = core.pointer_moved(Pointer::moving(btn.x + 4.0, btn.y + 4.0));
4841
4842 let events = core.pointer_left();
4843 assert_eq!(events.len(), 1);
4844 assert_eq!(events[0].kind, UiEventKind::PointerLeave);
4845 assert_eq!(events[0].key.as_deref(), Some("btn"));
4846 }
4847
4848 #[test]
4849 fn pointer_left_with_no_prior_hover_emits_nothing() {
4850 let mut core = lay_out_input_tree(false);
4851 let events = core.pointer_left();
4854 assert!(events.is_empty());
4855 }
4856
4857 #[test]
4858 fn poll_input_before_long_press_delay_emits_nothing() {
4859 let mut core = lay_out_input_tree(false);
4862 let btn = core.rect_of_key("btn").expect("btn rect");
4863 let cx = btn.x + btn.w * 0.5;
4864 let cy = btn.y + btn.h * 0.5;
4865 let _ = core.pointer_down(Pointer::touch(
4866 cx,
4867 cy,
4868 PointerButton::Primary,
4869 PointerId::PRIMARY,
4870 ));
4871 let polled = core.poll_input(Instant::now() + Duration::from_millis(100));
4873 assert!(polled.is_empty(), "should not fire before delay");
4874 }
4875
4876 #[test]
4877 fn poll_input_after_long_press_delay_fires_cancel_then_long_press() {
4878 let mut core = lay_out_input_tree(false);
4882 let btn = core.rect_of_key("btn").expect("btn rect");
4883 let cx = btn.x + btn.w * 0.5;
4884 let cy = btn.y + btn.h * 0.5;
4885 let _ = core.pointer_down(Pointer::touch(
4886 cx,
4887 cy,
4888 PointerButton::Primary,
4889 PointerId::PRIMARY,
4890 ));
4891 let polled = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
4892 let kinds: Vec<UiEventKind> = polled.iter().map(|e| e.kind).collect();
4893 assert!(
4894 kinds.contains(&UiEventKind::PointerCancel),
4895 "expected PointerCancel before LongPress, got {kinds:?}",
4896 );
4897 let long_press = polled
4898 .iter()
4899 .find(|e| matches!(e.kind, UiEventKind::LongPress))
4900 .expect("LongPress event missing");
4901 assert_eq!(
4902 long_press.key.as_deref(),
4903 Some("btn"),
4904 "LongPress should target the originally pressed node",
4905 );
4906 assert_eq!(
4907 long_press.pointer_kind,
4908 Some(PointerKind::Touch),
4909 "LongPress is touch-only",
4910 );
4911 }
4912
4913 #[test]
4914 fn touch_long_press_on_editable_preserves_drag_extension() {
4915 let mut core = lay_out_input_tree(true);
4916 let ti = core.rect_of_key("ti").expect("ti rect");
4917 let cx = ti.x + 4.0;
4918 let cy = ti.y + ti.h * 0.5;
4919 let _ = core.pointer_down(Pointer::touch(
4920 cx,
4921 cy,
4922 PointerButton::Primary,
4923 PointerId::PRIMARY,
4924 ));
4925
4926 let polled = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
4927 assert!(
4928 polled.iter().any(|e| e.kind == UiEventKind::LongPress),
4929 "editable long-press should emit LongPress"
4930 );
4931 assert!(
4932 !polled.iter().any(|e| e.kind == UiEventKind::PointerCancel),
4933 "editable long-press keeps the press captured so drag can extend selection"
4934 );
4935
4936 let mut moved = Pointer::moving(cx + 40.0, cy);
4937 moved.kind = PointerKind::Touch;
4938 let drag = core.pointer_moved(moved);
4939 assert!(
4940 drag.events.iter().any(|e| e.kind == UiEventKind::Drag),
4941 "held touch move after editable long-press should emit Drag"
4942 );
4943
4944 let up_events = core.pointer_up(Pointer::touch(
4945 cx + 40.0,
4946 cy,
4947 PointerButton::Primary,
4948 PointerId::PRIMARY,
4949 ));
4950 assert!(
4951 up_events.is_empty(),
4952 "long-press release should not synthesize click or pointer-up"
4953 );
4954 }
4955
4956 #[test]
4957 fn pointer_up_after_long_press_emits_no_click() {
4958 let mut core = lay_out_input_tree(false);
4962 let btn = core.rect_of_key("btn").expect("btn rect");
4963 let cx = btn.x + btn.w * 0.5;
4964 let cy = btn.y + btn.h * 0.5;
4965 let _ = core.pointer_down(Pointer::touch(
4966 cx,
4967 cy,
4968 PointerButton::Primary,
4969 PointerId::PRIMARY,
4970 ));
4971 let _ = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
4972 let up_events = core.pointer_up(Pointer::touch(
4973 cx,
4974 cy,
4975 PointerButton::Primary,
4976 PointerId::PRIMARY,
4977 ));
4978 assert!(
4979 up_events.is_empty(),
4980 "lift after long-press emits nothing, got {:?}",
4981 up_events.iter().map(|e| e.kind).collect::<Vec<_>>(),
4982 );
4983 }
4984
4985 #[test]
4986 fn moving_past_threshold_before_long_press_cancels_the_timer() {
4987 let mut core = lay_out_input_tree(false);
4992 let btn = core.rect_of_key("btn").expect("btn rect");
4993 let cx = btn.x + btn.w * 0.5;
4994 let cy = btn.y + btn.h * 0.5;
4995 let _ = core.pointer_down(Pointer::touch(
4996 cx,
4997 cy,
4998 PointerButton::Primary,
4999 PointerId::PRIMARY,
5000 ));
5001 let mut over = Pointer::moving(cx + 30.0, cy);
5003 over.kind = PointerKind::Touch;
5004 let _ = core.pointer_moved(over);
5005 let polled = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
5007 assert!(
5008 polled.is_empty(),
5009 "long-press should not fire after gesture committed",
5010 );
5011 }
5012
5013 fn lay_out_viewport_tree(mut tree: El) -> RunnerCore {
5017 let mut core = RunnerCore::new();
5018 crate::layout::layout(
5019 &mut tree,
5020 &mut core.ui_state,
5021 Rect::new(0.0, 0.0, 400.0, 300.0),
5022 );
5023 core.ui_state.sync_focus_order(&tree);
5024 core.ui_state.sync_selection_order(&tree);
5025 let mut t = PrepareTimings::default();
5026 core.snapshot(&tree, &mut t);
5027 core
5028 }
5029
5030 fn free_viewport(child: El) -> El {
5034 crate::tree::viewport([child])
5035 .key("vp")
5036 .pan_bounds(crate::viewport::PanBounds::Free)
5037 }
5038
5039 fn vp_card() -> El {
5041 use crate::tree::*;
5042 crate::widgets::button::button("Card")
5043 .key("card")
5044 .width(Size::Fixed(120.0))
5045 .height(Size::Fixed(60.0))
5046 }
5047
5048 #[test]
5049 fn latent_pan_sub_slop_release_still_clicks() {
5050 let mut core = lay_out_viewport_tree(free_viewport(vp_card()));
5051 let r = core.rect_of_key("card").expect("card rect");
5052 let (cx, cy) = (r.x + r.w * 0.5, r.y + r.h * 0.5);
5053 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5054 core.pointer_moved(Pointer::moving(cx + 2.0, cy + 1.0));
5056 assert!(
5057 !core.ui_state.viewport_pan_active(),
5058 "sub-slop motion must not begin a pan"
5059 );
5060 let events = core.pointer_up(Pointer::mouse(cx + 2.0, cy + 1.0, PointerButton::Primary));
5061 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
5062 assert!(
5063 kinds.contains(&UiEventKind::Click),
5064 "sub-slop release must still click, got {kinds:?}"
5065 );
5066 let view = core.ui_state.viewport_view_by_key("vp").expect("vp view");
5067 assert_eq!(
5068 view.pan,
5069 (0.0, 0.0),
5070 "sub-slop jiggle must not move the pan"
5071 );
5072 }
5073
5074 #[test]
5075 fn latent_pan_converts_past_slop_cancels_click_and_anchors_at_press() {
5076 let mut core = lay_out_viewport_tree(free_viewport(vp_card()));
5077 let r = core.rect_of_key("card").expect("card rect");
5078 let (cx, cy) = (r.x + r.w * 0.5, r.y + r.h * 0.5);
5079 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5080 let moved = core.pointer_moved(Pointer::moving(cx + 20.0, cy));
5081 let kinds: Vec<UiEventKind> = moved.events.iter().map(|e| e.kind).collect();
5082 assert!(
5083 kinds.contains(&UiEventKind::PointerCancel),
5084 "conversion must cancel the pending click, got {kinds:?}"
5085 );
5086 assert!(moved.needs_redraw, "conversion clears press styling");
5087 assert!(core.ui_state.viewport_pan_active());
5088 let view = core.ui_state.viewport_view_by_key("vp").expect("vp view");
5089 assert!(
5092 (view.pan.0 - 20.0).abs() < 0.01 && view.pan.1.abs() < 0.01,
5093 "pan should track the full press-to-cursor delta, got {:?}",
5094 view.pan
5095 );
5096 core.pointer_moved(Pointer::moving(cx + 30.0, cy + 5.0));
5098 let view = core.ui_state.viewport_view_by_key("vp").expect("vp view");
5099 assert!(
5100 (view.pan.0 - 30.0).abs() < 0.01 && (view.pan.1 - 5.0).abs() < 0.01,
5101 "pan should keep tracking, got {:?}",
5102 view.pan
5103 );
5104 let up = core.pointer_up(Pointer::mouse(cx + 30.0, cy + 5.0, PointerButton::Primary));
5105 assert!(
5106 up.is_empty(),
5107 "a converted pan releases without click, got {:?}",
5108 up.iter().map(|e| e.kind).collect::<Vec<_>>()
5109 );
5110 assert!(!core.ui_state.viewport_pan_active());
5111 }
5112
5113 #[test]
5114 fn viewport_background_press_still_pans_immediately() {
5115 let mut core = lay_out_viewport_tree(free_viewport(vp_card()));
5118 core.pointer_down(Pointer::mouse(300.0, 250.0, PointerButton::Primary));
5119 assert!(
5120 core.ui_state.viewport_pan_active(),
5121 "background press must begin the pan at press time"
5122 );
5123 }
5124
5125 #[test]
5126 fn dedicated_trigger_still_pans_over_child_at_press() {
5127 let mut core =
5130 lay_out_viewport_tree(free_viewport(vp_card()).pan_button(PointerButton::Secondary));
5131 let r = core.rect_of_key("card").expect("card rect");
5132 core.pointer_down(Pointer::mouse(
5133 r.x + r.w * 0.5,
5134 r.y + r.h * 0.5,
5135 PointerButton::Secondary,
5136 ));
5137 assert!(
5138 core.ui_state.viewport_pan_active(),
5139 "dedicated trigger must pan at press time even over a child"
5140 );
5141 }
5142
5143 #[test]
5144 fn latent_pan_yields_to_drag_consuming_child() {
5145 let mut core = lay_out_viewport_tree(free_viewport(vp_card().consumes_touch_drag()));
5148 let r = core.rect_of_key("card").expect("card rect");
5149 let (cx, cy) = (r.x + r.w * 0.5, r.y + r.h * 0.5);
5150 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5151 let moved = core.pointer_moved(Pointer::moving(cx + 20.0, cy));
5152 assert!(
5153 !core.ui_state.viewport_pan_active(),
5154 "drag-consumer keeps its drag"
5155 );
5156 let kinds: Vec<UiEventKind> = moved.events.iter().map(|e| e.kind).collect();
5157 assert!(
5158 kinds.contains(&UiEventKind::Drag) && !kinds.contains(&UiEventKind::PointerCancel),
5159 "child should keep receiving Drag, got {kinds:?}"
5160 );
5161 }
5162
5163 #[test]
5164 fn latent_pan_yields_to_capture_keys_child() {
5165 let ti = crate::widgets::text::text("input").key("ti").capture_keys();
5168 let mut core = lay_out_viewport_tree(free_viewport(ti));
5169 let r = core.rect_of_key("ti").expect("ti rect");
5170 let (cx, cy) = (r.x + r.w * 0.5, r.y + r.h * 0.5);
5171 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5172 let moved = core.pointer_moved(Pointer::moving(cx + 20.0, cy));
5173 assert!(
5174 !core.ui_state.viewport_pan_active(),
5175 "editable keeps its drag"
5176 );
5177 let kinds: Vec<UiEventKind> = moved.events.iter().map(|e| e.kind).collect();
5178 assert!(
5179 kinds.contains(&UiEventKind::Drag) && !kinds.contains(&UiEventKind::PointerCancel),
5180 "input should keep receiving Drag, got {kinds:?}"
5181 );
5182 }
5183
5184 #[test]
5185 fn latent_pan_yields_to_text_selection_drag() {
5186 let p = crate::widgets::text::text("Some selectable paragraph text.")
5190 .key("p")
5191 .selectable();
5192 let mut core = lay_out_viewport_tree(free_viewport(p));
5193 let r = core.rect_of_key("p").expect("p rect");
5194 let (cx, cy) = (r.x + 4.0, r.y + r.h * 0.5);
5195 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5196 assert!(
5197 core.ui_state.selection.drag.is_some(),
5198 "press starts selection"
5199 );
5200 core.pointer_moved(Pointer::moving(cx + 20.0, cy));
5201 assert!(
5202 !core.ui_state.viewport_pan_active(),
5203 "selection drag must keep priority over the pan"
5204 );
5205 }
5206
5207 #[test]
5208 fn interleaved_pan_release_does_not_strand_the_latent_pan() {
5209 use crate::tree::*;
5218 let vp_a = crate::tree::viewport([crate::widgets::button::button("A")
5219 .key("card_a")
5220 .width(Size::Fixed(80.0))
5221 .height(Size::Fixed(40.0))])
5222 .key("vpa")
5223 .pan_button(PointerButton::Secondary)
5224 .pan_bounds(crate::viewport::PanBounds::Free);
5225 let mut core = lay_out_viewport_tree(crate::row([vp_a, free_viewport(vp_card())]));
5226 core.pointer_down(Pointer::mouse(100.0, 250.0, PointerButton::Secondary));
5228 assert!(core.ui_state.viewport_pan_active(), "dedicated pan on A");
5229 let r = core.rect_of_key("card").expect("card rect");
5231 let (cx, cy) = (r.x + r.w * 0.5, r.y + r.h * 0.5);
5232 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5233 let up = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5236 let up_kinds: Vec<UiEventKind> = up.iter().map(|e| e.kind).collect();
5237 assert!(
5238 up_kinds.contains(&UiEventKind::Click),
5239 "primary release clicks the card despite A's pan, got {up_kinds:?}"
5240 );
5241 assert!(
5242 core.ui_state.viewport_pan_active(),
5243 "A's pan survives the unrelated primary release"
5244 );
5245 core.pointer_up(Pointer::mouse(100.0, 250.0, PointerButton::Secondary));
5246 assert!(
5247 !core.ui_state.viewport_pan_active(),
5248 "A's own release ends it"
5249 );
5250 let moved = core.pointer_moved(Pointer::moving(cx + 20.0, cy));
5252 assert!(
5253 !core.ui_state.viewport_pan_active(),
5254 "no button held — a stale latent pan must not begin a drag"
5255 );
5256 let kinds: Vec<UiEventKind> = moved.events.iter().map(|e| e.kind).collect();
5257 assert!(
5258 !kinds.contains(&UiEventKind::PointerCancel),
5259 "no phantom press-cancel on a button-less move, got {kinds:?}"
5260 );
5261 assert!(
5262 !kinds.contains(&UiEventKind::Drag),
5263 "no stale press keeps emitting Drag after release, got {kinds:?}"
5264 );
5265 let view = core.ui_state.viewport_view_by_key("vp").expect("vp view");
5266 assert_eq!(view.pan, (0.0, 0.0), "B must not have panned");
5267 }
5268
5269 #[test]
5270 fn unrelated_release_does_not_end_a_viewport_pan() {
5271 let mut core = lay_out_viewport_tree(free_viewport(vp_card()));
5274 core.pointer_down(Pointer::mouse(300.0, 250.0, PointerButton::Primary));
5275 assert!(core.ui_state.viewport_pan_active(), "background pan begins");
5276 core.pointer_down(Pointer::mouse(300.0, 250.0, PointerButton::Secondary));
5277 core.pointer_up(Pointer::mouse(300.0, 250.0, PointerButton::Secondary));
5278 assert!(
5279 core.ui_state.viewport_pan_active(),
5280 "pan survives an unrelated button's release"
5281 );
5282 core.pointer_moved(Pointer::moving(320.0, 250.0));
5284 let view = core.ui_state.viewport_view_by_key("vp").expect("vp view");
5285 assert!(
5286 (view.pan.0 - 20.0).abs() < 0.01,
5287 "still panning: {:?}",
5288 view.pan
5289 );
5290 core.pointer_up(Pointer::mouse(320.0, 250.0, PointerButton::Primary));
5292 assert!(!core.ui_state.viewport_pan_active());
5293 }
5294
5295 #[test]
5296 fn latent_pan_conversion_resets_the_multi_click_chain() {
5297 let mut core = lay_out_viewport_tree(free_viewport(vp_card()));
5301 let r = core.rect_of_key("card").expect("card rect");
5302 let (cx, cy) = (r.x + r.w * 0.5, r.y + r.h * 0.5);
5303 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5304 core.pointer_moved(Pointer::moving(cx + 20.0, cy));
5305 assert!(core.ui_state.viewport_pan_active(), "converted");
5306 core.pointer_up(Pointer::mouse(cx + 20.0, cy, PointerButton::Primary));
5307 let events = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5309 let down = events
5310 .iter()
5311 .find(|e| e.kind == UiEventKind::PointerDown)
5312 .expect("PointerDown on re-press");
5313 assert_eq!(
5314 down.click_count, 1,
5315 "a press that became a pan must not seed a double-click"
5316 );
5317 }
5318
5319 fn touch_move(x: f32, y: f32) -> Pointer {
5321 let mut p = Pointer::moving(x, y);
5322 p.kind = PointerKind::Touch;
5323 p
5324 }
5325
5326 #[test]
5327 fn touch_drag_on_viewport_child_pans_past_threshold() {
5328 let mut core = lay_out_viewport_tree(free_viewport(vp_card()));
5333 let r = core.rect_of_key("card").expect("card rect");
5334 let (cx, cy) = (r.x + r.w * 0.5, r.y + r.h * 0.5);
5335 core.pointer_down(Pointer::touch(
5336 cx,
5337 cy,
5338 PointerButton::Primary,
5339 PointerId::PRIMARY,
5340 ));
5341 let moved = core.pointer_moved(touch_move(cx + 30.0, cy));
5342 let kinds: Vec<UiEventKind> = moved.events.iter().map(|e| e.kind).collect();
5343 assert!(
5344 kinds.contains(&UiEventKind::PointerCancel),
5345 "commit cancels the pending tap, got {kinds:?}"
5346 );
5347 assert!(core.ui_state.viewport_pan_active(), "drag commits to a pan");
5348 let view = core.ui_state.viewport_view_by_key("vp").expect("vp view");
5349 assert!(
5350 (view.pan.0 - 30.0).abs() < 0.01 && view.pan.1.abs() < 0.01,
5351 "anchored at the initial contact — full delta lands, got {:?}",
5352 view.pan
5353 );
5354 let up = core.pointer_up(Pointer::touch(
5355 cx + 30.0,
5356 cy,
5357 PointerButton::Primary,
5358 PointerId::PRIMARY,
5359 ));
5360 assert!(
5361 up.is_empty(),
5362 "a committed pan releases without click, got {:?}",
5363 up.iter().map(|e| e.kind).collect::<Vec<_>>()
5364 );
5365 assert!(!core.ui_state.viewport_pan_active());
5366 }
5367
5368 #[test]
5369 fn touch_tap_on_viewport_child_still_clicks() {
5370 let mut core = lay_out_viewport_tree(free_viewport(vp_card()));
5373 let r = core.rect_of_key("card").expect("card rect");
5374 let (cx, cy) = (r.x + r.w * 0.5, r.y + r.h * 0.5);
5375 core.pointer_down(Pointer::touch(
5376 cx,
5377 cy,
5378 PointerButton::Primary,
5379 PointerId::PRIMARY,
5380 ));
5381 core.pointer_moved(touch_move(cx + 3.0, cy));
5382 let up = core.pointer_up(Pointer::touch(
5383 cx + 3.0,
5384 cy,
5385 PointerButton::Primary,
5386 PointerId::PRIMARY,
5387 ));
5388 let kinds: Vec<UiEventKind> = up.iter().map(|e| e.kind).collect();
5389 assert!(
5390 kinds.contains(&UiEventKind::Click),
5391 "tap clicks, got {kinds:?}"
5392 );
5393 let view = core.ui_state.viewport_view_by_key("vp").expect("vp view");
5394 assert_eq!(view.pan, (0.0, 0.0), "tap must not pan");
5395 }
5396
5397 #[test]
5398 fn touch_drag_over_scrollable_inside_viewport_scrolls_it() {
5399 use crate::tree::*;
5403 let rows: Vec<El> = (0..20)
5404 .map(|i| {
5405 crate::widgets::button::button("Row")
5406 .key(format!("row{i}"))
5407 .width(Size::Fixed(160.0))
5408 .height(Size::Fixed(40.0))
5409 })
5410 .collect();
5411 let list = crate::scroll(rows)
5412 .key("list")
5413 .width(Size::Fixed(180.0))
5414 .height(Size::Fixed(120.0));
5415 let mut core = lay_out_viewport_tree(free_viewport(list));
5416 let list_id = core
5417 .ui_state
5418 .layout
5419 .key_index
5420 .get("list")
5421 .expect("list id")
5422 .to_string();
5423 core.pointer_down(Pointer::touch(
5425 90.0,
5426 80.0,
5427 PointerButton::Primary,
5428 PointerId::PRIMARY,
5429 ));
5430 core.pointer_moved(touch_move(90.0, 40.0));
5431 assert!(
5432 !core.ui_state.viewport_pan_active(),
5433 "the inner scrollable wins the vertical drag"
5434 );
5435 assert!(
5436 matches!(
5437 core.ui_state.touch_gesture,
5438 TouchGestureState::Scrolling { .. }
5439 ),
5440 "gesture commits to scrolling the list"
5441 );
5442 let offset = core
5443 .ui_state
5444 .scroll
5445 .offsets
5446 .get(list_id.as_str())
5447 .copied()
5448 .unwrap_or(0.0);
5449 assert!(offset > 1.0, "the list scrolled, offset {offset}");
5450 let view = core.ui_state.viewport_view_by_key("vp").expect("vp view");
5451 assert_eq!(view.pan, (0.0, 0.0), "viewport must not pan");
5452 }
5453
5454 #[test]
5455 fn touch_horizontal_drag_over_scrollable_inside_viewport_pans() {
5456 use crate::tree::*;
5462 let rows: Vec<El> = (0..20)
5463 .map(|i| {
5464 crate::widgets::button::button("Row")
5465 .key(format!("row{i}"))
5466 .width(Size::Fixed(160.0))
5467 .height(Size::Fixed(40.0))
5468 })
5469 .collect();
5470 let list = crate::scroll(rows)
5471 .key("list")
5472 .width(Size::Fixed(180.0))
5473 .height(Size::Fixed(120.0));
5474 let mut core = lay_out_viewport_tree(free_viewport(list));
5475 let list_id = core
5476 .ui_state
5477 .layout
5478 .key_index
5479 .get("list")
5480 .expect("list id")
5481 .to_string();
5482 core.pointer_down(Pointer::touch(
5488 90.0,
5489 80.0,
5490 PointerButton::Primary,
5491 PointerId::PRIMARY,
5492 ));
5493 core.pointer_moved(touch_move(120.0, 77.0));
5494 assert!(
5495 core.ui_state.viewport_pan_active(),
5496 "horizontal-dominant drag pans the viewport"
5497 );
5498 let offset = core
5499 .ui_state
5500 .scroll
5501 .offsets
5502 .get(list_id.as_str())
5503 .copied()
5504 .unwrap_or(0.0);
5505 assert!(
5506 offset.abs() < 0.01,
5507 "the list must not consume the vertical drift, offset {offset}"
5508 );
5509 let view = core.ui_state.viewport_view_by_key("vp").expect("vp view");
5510 assert!(
5511 (view.pan.0 - 30.0).abs() < 0.01 && (view.pan.1 + 3.0).abs() < 0.01,
5512 "pan anchored at the initial contact, got {:?}",
5513 view.pan
5514 );
5515 }
5516
5517 #[test]
5518 fn touch_vertical_drag_with_drift_still_scrolls_inner_list() {
5519 use crate::tree::*;
5523 let rows: Vec<El> = (0..20)
5524 .map(|i| {
5525 crate::widgets::button::button("Row")
5526 .key(format!("row{i}"))
5527 .width(Size::Fixed(160.0))
5528 .height(Size::Fixed(40.0))
5529 })
5530 .collect();
5531 let list = crate::scroll(rows)
5532 .key("list")
5533 .width(Size::Fixed(180.0))
5534 .height(Size::Fixed(120.0));
5535 let mut core = lay_out_viewport_tree(free_viewport(list));
5536 let list_id = core
5537 .ui_state
5538 .layout
5539 .key_index
5540 .get("list")
5541 .expect("list id")
5542 .to_string();
5543 core.pointer_down(Pointer::touch(
5545 90.0,
5546 80.0,
5547 PointerButton::Primary,
5548 PointerId::PRIMARY,
5549 ));
5550 core.pointer_moved(touch_move(93.0, 40.0));
5551 assert!(
5552 !core.ui_state.viewport_pan_active(),
5553 "vertical-dominant drag stays with the inner scrollable"
5554 );
5555 let offset = core
5556 .ui_state
5557 .scroll
5558 .offsets
5559 .get(list_id.as_str())
5560 .copied()
5561 .unwrap_or(0.0);
5562 assert!(offset > 1.0, "the list scrolled, offset {offset}");
5563 }
5564
5565 #[test]
5566 fn touch_drag_on_scrollable_floating_over_viewport_scrolls_it() {
5567 use crate::tree::*;
5572 let rows: Vec<El> = (0..20)
5573 .map(|i| {
5574 crate::widgets::button::button("Row")
5575 .key(format!("row{i}"))
5576 .width(Size::Fixed(160.0))
5577 .height(Size::Fixed(40.0))
5578 })
5579 .collect();
5580 let list = crate::scroll(rows)
5581 .key("list")
5582 .width(Size::Fixed(180.0))
5583 .height(Size::Fixed(120.0));
5584 let mut core = lay_out_viewport_tree(crate::stack([free_viewport(vp_card()), list]));
5585 let list_id = core
5586 .ui_state
5587 .layout
5588 .key_index
5589 .get("list")
5590 .expect("list id")
5591 .to_string();
5592 let r = core.rect_of_key("row1").expect("row rect");
5593 let (cx, cy) = (r.x + r.w * 0.5, r.y + r.h * 0.5);
5594 core.pointer_down(Pointer::touch(
5595 cx,
5596 cy,
5597 PointerButton::Primary,
5598 PointerId::PRIMARY,
5599 ));
5600 core.pointer_moved(touch_move(cx, cy - 40.0));
5601 assert!(
5602 !core.ui_state.viewport_pan_active(),
5603 "the floating list keeps its drag — no pan underneath"
5604 );
5605 let offset = core
5606 .ui_state
5607 .scroll
5608 .offsets
5609 .get(list_id.as_str())
5610 .copied()
5611 .unwrap_or(0.0);
5612 assert!(offset > 1.0, "the list scrolled, offset {offset}");
5613 let view = core.ui_state.viewport_view_by_key("vp").expect("vp view");
5614 assert_eq!(view.pan, (0.0, 0.0), "viewport must not pan");
5615 }
5616
5617 #[test]
5618 fn touch_drag_on_child_of_dedicated_trigger_viewport_does_not_pan() {
5619 let mut core =
5623 lay_out_viewport_tree(free_viewport(vp_card()).pan_button(PointerButton::Secondary));
5624 let r = core.rect_of_key("card").expect("card rect");
5625 let (cx, cy) = (r.x + r.w * 0.5, r.y + r.h * 0.5);
5626 core.pointer_down(Pointer::touch(
5627 cx,
5628 cy,
5629 PointerButton::Primary,
5630 PointerId::PRIMARY,
5631 ));
5632 core.pointer_moved(touch_move(cx + 30.0, cy));
5633 assert!(
5634 !core.ui_state.viewport_pan_active(),
5635 "dedicated trigger must not pan on a touch drag"
5636 );
5637 }
5638
5639 #[test]
5640 fn touch_drag_over_viewport_child_beats_the_outer_page_scroll() {
5641 use crate::tree::*;
5644 let vp = free_viewport(vp_card())
5645 .width(Size::Fixed(300.0))
5646 .height(Size::Fixed(150.0));
5647 let mut children: Vec<El> = vec![vp];
5648 children.extend((0..10).map(|i| {
5649 crate::widgets::button::button("Filler")
5650 .key(format!("f{i}"))
5651 .height(Size::Fixed(100.0))
5652 }));
5653 let mut core = lay_out_viewport_tree(crate::scroll(children).key("page"));
5654 let r = core.rect_of_key("card").expect("card rect");
5655 let (cx, cy) = (r.x + r.w * 0.5, r.y + r.h * 0.5);
5656 core.pointer_down(Pointer::touch(
5657 cx,
5658 cy,
5659 PointerButton::Primary,
5660 PointerId::PRIMARY,
5661 ));
5662 core.pointer_moved(touch_move(cx, cy + 30.0));
5663 assert!(
5664 core.ui_state.viewport_pan_active(),
5665 "the viewport wins over the enclosing page scroll"
5666 );
5667 let view = core.ui_state.viewport_view_by_key("vp").expect("vp view");
5668 assert!(
5669 (view.pan.1 - 30.0).abs() < 0.01,
5670 "pan tracks the drag, got {:?}",
5671 view.pan
5672 );
5673 }
5674
5675 #[test]
5676 fn pointer_cancel_abandons_captures_without_release_effects() {
5677 let mut core = lay_out_viewport_tree(free_viewport(vp_card()));
5682 let r = core.rect_of_key("card").expect("card rect");
5683 let (cx, cy) = (r.x + r.w * 0.5, r.y + r.h * 0.5);
5684 core.pointer_down(Pointer::touch(
5685 cx,
5686 cy,
5687 PointerButton::Primary,
5688 PointerId::PRIMARY,
5689 ));
5690 core.pointer_moved(touch_move(cx + 30.0, cy));
5691 assert!(core.ui_state.viewport_pan_active(), "pan in flight");
5692 core.pointer_cancelled();
5693 assert!(
5694 !core.ui_state.viewport_pan_active(),
5695 "cancel abandons the pan capture"
5696 );
5697 let view = core.ui_state.viewport_view_by_key("vp").expect("vp view");
5699 assert!((view.pan.0 - 30.0).abs() < 0.01);
5700 core.pointer_down(Pointer::touch(
5702 cx,
5703 cy,
5704 PointerButton::Primary,
5705 PointerId::PRIMARY,
5706 ));
5707 let up = core.pointer_up(Pointer::touch(
5708 cx,
5709 cy,
5710 PointerButton::Primary,
5711 PointerId::PRIMARY,
5712 ));
5713 let kinds: Vec<UiEventKind> = up.iter().map(|e| e.kind).collect();
5714 assert!(
5715 kinds.contains(&UiEventKind::Click),
5716 "next tap clicks, got {kinds:?}"
5717 );
5718 }
5719
5720 #[test]
5721 fn pointer_cancel_discards_plot_box_zoom() {
5722 let mut core = plot_under_modal(false);
5725 let before = core.ui_state.plot_view_by_key("p").expect("plot view");
5726 core.pointer_down(Pointer::mouse(120.0, 100.0, PointerButton::Primary));
5727 assert!(core.ui_state.plot_zoom_active(), "box-zoom in flight");
5728 core.pointer_moved(Pointer::moving(280.0, 200.0));
5729 core.pointer_cancelled();
5730 assert!(!core.ui_state.plot_zoom_active(), "cancel clears the drag");
5731 let after = core.ui_state.plot_view_by_key("p").expect("plot view");
5732 assert_eq!(
5733 (before.x.min, before.x.max),
5734 (after.x.min, after.x.max),
5735 "the swept span must not be applied"
5736 );
5737 }
5738
5739 fn vp_under_modal(with_modal: bool) -> RunnerCore {
5745 use crate::tree::*;
5746 let modal = with_modal.then(|| {
5747 crate::widgets::overlay::modal(
5748 "detail",
5749 "Detail",
5750 [crate::widgets::text::text("plain detail text")
5751 .width(Size::Fixed(200.0))
5752 .height(Size::Fixed(120.0))],
5753 )
5754 });
5755 lay_out_viewport_tree(crate::widgets::overlay::overlays(
5756 free_viewport(vp_card()),
5757 [modal],
5758 ))
5759 }
5760
5761 #[test]
5762 fn press_on_modal_dead_space_does_not_pan_the_viewport_underneath() {
5763 let mut core = vp_under_modal(true);
5768 core.pointer_down(Pointer::mouse(200.0, 150.0, PointerButton::Primary));
5770 assert!(
5771 !core.ui_state.viewport_pan_active(),
5772 "press on the modal must not pan the canvas under it"
5773 );
5774 let mut core = vp_under_modal(false);
5777 core.pointer_down(Pointer::mouse(200.0, 150.0, PointerButton::Primary));
5778 assert!(
5779 core.ui_state.viewport_pan_active(),
5780 "background press without the modal still pans"
5781 );
5782 }
5783
5784 fn scene_under_modal(with_modal: bool) -> RunnerCore {
5787 use crate::scene::{PointData, PointsHandle, ScenePoint, SceneSpec};
5788 let handle = PointsHandle::new(PointData {
5789 points: vec![
5790 ScenePoint {
5791 position: crate::scene::glam::Vec3::splat(-1.0),
5792 color: [1.0; 4],
5793 },
5794 ScenePoint {
5795 position: crate::scene::glam::Vec3::splat(1.0),
5796 color: [1.0; 4],
5797 },
5798 ],
5799 });
5800 let modal = with_modal.then(|| {
5801 crate::widgets::overlay::modal(
5802 "detail",
5803 "Detail",
5804 [crate::widgets::text::text("plain detail text")
5805 .width(crate::tree::Size::Fixed(200.0))
5806 .height(crate::tree::Size::Fixed(120.0))],
5807 )
5808 });
5809 let mut tree = crate::widgets::overlay::overlays(
5810 crate::tree::chart3d(SceneSpec::new().points(handle)),
5811 [modal],
5812 );
5813 let mut core = RunnerCore::new();
5814 crate::layout::layout(
5815 &mut tree,
5816 &mut core.ui_state,
5817 Rect::new(0.0, 0.0, 400.0, 300.0),
5818 );
5819 core.ui_state.sync_focus_order(&tree);
5820 core.ui_state.tick_scene_cameras(&tree, Instant::now());
5821 let mut t = PrepareTimings::default();
5822 core.snapshot(&tree, &mut t);
5823 core
5824 }
5825
5826 fn plot_under_modal(with_modal: bool) -> RunnerCore {
5829 use crate::plot::{PlotSpec, Sample, Scale, SeriesHandle, line};
5830 let h = SeriesHandle::new(vec![Sample::new(0.0, 0.0), Sample::new(10.0, 10.0)]);
5831 let p = crate::tree::plot(
5832 PlotSpec::new()
5833 .x(Scale::linear())
5834 .y(Scale::linear())
5835 .add_mark(line(&h)),
5836 )
5837 .key("p");
5838 let modal = with_modal.then(|| {
5839 crate::widgets::overlay::modal(
5840 "detail",
5841 "Detail",
5842 [crate::widgets::text::text("plain detail text")
5843 .width(crate::tree::Size::Fixed(200.0))
5844 .height(crate::tree::Size::Fixed(120.0))],
5845 )
5846 });
5847 let mut tree = crate::widgets::overlay::overlays(p, [modal]);
5848 let mut core = RunnerCore::new();
5849 crate::layout::layout(
5850 &mut tree,
5851 &mut core.ui_state,
5852 Rect::new(0.0, 0.0, 400.0, 300.0),
5853 );
5854 core.ui_state.prepare_plots(&tree);
5855 let mut t = PrepareTimings::default();
5856 core.snapshot(&tree, &mut t);
5857 core
5858 }
5859
5860 #[test]
5861 fn press_on_modal_dead_space_does_not_zoom_the_plot_underneath() {
5862 let mut core = plot_under_modal(true);
5866 core.pointer_down(Pointer::mouse(200.0, 150.0, PointerButton::Primary));
5867 assert!(
5868 !core.ui_state.plot_zoom_active() && !core.ui_state.plot_pan_active(),
5869 "press on the modal must not start a plot gesture under it"
5870 );
5871 let mut core = plot_under_modal(false);
5874 core.pointer_down(Pointer::mouse(200.0, 150.0, PointerButton::Primary));
5875 assert!(
5876 core.ui_state.plot_zoom_active() || core.ui_state.plot_pan_active(),
5877 "bare plot press still starts the drag without the modal"
5878 );
5879 }
5880
5881 #[test]
5882 fn press_on_modal_dead_space_does_not_orbit_the_scene_underneath() {
5883 let mut core = scene_under_modal(true);
5885 assert!(
5886 core.ui_state.scene_at(200.0, 150.0).is_some(),
5887 "scene covers the center"
5888 );
5889 core.pointer_down(Pointer::mouse(200.0, 150.0, PointerButton::Primary));
5891 assert!(
5892 !core.ui_state.camera_drag_active(),
5893 "press on the modal must not orbit the scene under it"
5894 );
5895 let mut core = scene_under_modal(false);
5898 core.pointer_down(Pointer::mouse(200.0, 150.0, PointerButton::Primary));
5899 assert!(
5900 core.ui_state.camera_drag_active(),
5901 "bare scene press still orbits without the modal"
5902 );
5903 }
5904
5905 #[test]
5906 fn latent_pan_ignores_widget_floating_over_viewport() {
5907 use crate::tree::*;
5910 let tree = crate::stack([
5911 free_viewport(vp_card()),
5912 crate::widgets::button::button("Hud")
5913 .key("hud")
5914 .width(Size::Fixed(60.0))
5915 .height(Size::Fixed(30.0)),
5916 ]);
5917 let mut core = lay_out_viewport_tree(tree);
5918 let r = core.rect_of_key("hud").expect("hud rect");
5919 let (cx, cy) = (r.x + r.w * 0.5, r.y + r.h * 0.5);
5920 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5921 core.pointer_moved(Pointer::moving(cx + 20.0, cy));
5922 assert!(
5923 !core.ui_state.viewport_pan_active(),
5924 "overlay press must not convert into a viewport pan"
5925 );
5926 }
5927
5928 #[test]
5929 fn ui_state_hovered_key_returns_leaf_key() {
5930 let mut core = lay_out_input_tree(false);
5931 assert_eq!(core.ui_state().hovered_key(), None);
5932
5933 let btn = core.rect_of_key("btn").expect("btn rect");
5934 core.pointer_moved(Pointer::moving(btn.x + 4.0, btn.y + 4.0));
5935 assert_eq!(core.ui_state().hovered_key(), Some("btn"));
5936
5937 core.pointer_moved(Pointer::moving(0.0, 0.0));
5939 assert_eq!(core.ui_state().hovered_key(), None);
5940 }
5941
5942 #[test]
5943 fn ui_state_is_hovering_within_walks_subtree() {
5944 use crate::tree::*;
5948 let mut tree = crate::column([crate::stack([
5949 crate::widgets::button::button("Inner").key("inner_btn")
5950 ])
5951 .key("card")
5952 .focusable()
5953 .width(Size::Fixed(120.0))
5954 .height(Size::Fixed(60.0))])
5955 .padding(20.0);
5956 let mut core = RunnerCore::new();
5957 crate::layout::layout(
5958 &mut tree,
5959 &mut core.ui_state,
5960 Rect::new(0.0, 0.0, 400.0, 200.0),
5961 );
5962 core.ui_state.sync_focus_order(&tree);
5963 let mut t = PrepareTimings::default();
5964 core.snapshot(&tree, &mut t);
5965
5966 assert!(!core.ui_state().is_hovering_within("card"));
5968 assert!(!core.ui_state().is_hovering_within("inner_btn"));
5969
5970 let inner = core.rect_of_key("inner_btn").expect("inner rect");
5973 core.pointer_moved(Pointer::moving(inner.x + 4.0, inner.y + 4.0));
5974 assert!(core.ui_state().is_hovering_within("card"));
5975 assert!(core.ui_state().is_hovering_within("inner_btn"));
5976
5977 assert!(!core.ui_state().is_hovering_within("not_a_key"));
5979
5980 core.pointer_moved(Pointer::moving(0.0, 0.0));
5982 assert!(!core.ui_state().is_hovering_within("card"));
5983 assert!(!core.ui_state().is_hovering_within("inner_btn"));
5984 }
5985
5986 #[test]
5987 fn hover_driven_scale_via_is_hovering_within_plus_animate() {
5988 use crate::Theme;
5995 use crate::anim::Timing;
5996 use crate::tree::*;
5997
5998 let build_card = |hovering: bool| -> El {
6001 let scale = if hovering { 1.05 } else { 1.0 };
6002 crate::column([crate::stack(
6003 [crate::widgets::button::button("Inner").key("inner_btn")],
6004 )
6005 .key("card")
6006 .focusable()
6007 .scale(scale)
6008 .animate(Timing::SPRING_QUICK)
6009 .width(Size::Fixed(120.0))
6010 .height(Size::Fixed(60.0))])
6011 .padding(20.0)
6012 };
6013
6014 let mut core = RunnerCore::new();
6015 core.ui_state
6018 .set_animation_mode(crate::state::AnimationMode::Settled);
6019
6020 let theme = Theme::default();
6022 let cx_pre = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
6023 assert!(!cx_pre.is_hovering_within("card"));
6024 let mut tree = build_card(cx_pre.is_hovering_within("card"));
6025 crate::layout::layout(
6026 &mut tree,
6027 &mut core.ui_state,
6028 Rect::new(0.0, 0.0, 400.0, 200.0),
6029 );
6030 core.ui_state.sync_focus_order(&tree);
6031 let mut t = PrepareTimings::default();
6032 core.snapshot(&tree, &mut t);
6033 core.ui_state
6034 .tick_visual_animations(&mut tree, web_time::Instant::now(), theme.palette());
6035 let card_at_rest = tree.children[0].clone();
6036 assert!((card_at_rest.scale - 1.0).abs() < 1e-3);
6037
6038 let card_rect = core.rect_of_key("card").expect("card rect");
6040 core.pointer_moved(Pointer::moving(card_rect.x + 4.0, card_rect.y + 4.0));
6041
6042 let cx_hot = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
6045 assert!(cx_hot.is_hovering_within("card"));
6046 let mut tree = build_card(cx_hot.is_hovering_within("card"));
6047 crate::layout::layout(
6048 &mut tree,
6049 &mut core.ui_state,
6050 Rect::new(0.0, 0.0, 400.0, 200.0),
6051 );
6052 core.ui_state.sync_focus_order(&tree);
6053 core.snapshot(&tree, &mut t);
6054 core.ui_state
6055 .tick_visual_animations(&mut tree, web_time::Instant::now(), theme.palette());
6056 let card_hot = tree.children[0].clone();
6057 assert!(
6058 (card_hot.scale - 1.05).abs() < 1e-3,
6059 "hover should drive card scale to 1.05 via animate; got {}",
6060 card_hot.scale,
6061 );
6062
6063 core.pointer_moved(Pointer::moving(0.0, 0.0));
6065 let cx_cold = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
6066 assert!(!cx_cold.is_hovering_within("card"));
6067 let mut tree = build_card(cx_cold.is_hovering_within("card"));
6068 crate::layout::layout(
6069 &mut tree,
6070 &mut core.ui_state,
6071 Rect::new(0.0, 0.0, 400.0, 200.0),
6072 );
6073 core.ui_state.sync_focus_order(&tree);
6074 core.snapshot(&tree, &mut t);
6075 core.ui_state
6076 .tick_visual_animations(&mut tree, web_time::Instant::now(), theme.palette());
6077 let card_after = tree.children[0].clone();
6078 assert!((card_after.scale - 1.0).abs() < 1e-3);
6079 }
6080
6081 #[test]
6082 fn file_dropped_routes_to_keyed_leaf_at_pointer() {
6083 let mut core = lay_out_input_tree(false);
6084 let btn = core.rect_of_key("btn").expect("btn rect");
6085 let path = std::path::PathBuf::from("/tmp/screenshot.png");
6086 let events = core.file_dropped(path.clone(), btn.x + 4.0, btn.y + 4.0);
6087 assert_eq!(events.len(), 1);
6088 let event = &events[0];
6089 assert_eq!(event.kind, UiEventKind::FileDropped);
6090 assert_eq!(event.key.as_deref(), Some("btn"));
6091 assert_eq!(event.path.as_deref(), Some(path.as_path()));
6092 assert_eq!(event.pointer, Some((btn.x + 4.0, btn.y + 4.0)));
6093 }
6094
6095 #[test]
6096 fn file_dropped_outside_keyed_surface_emits_window_level_event() {
6097 let mut core = lay_out_input_tree(false);
6098 let path = std::path::PathBuf::from("/tmp/screenshot.png");
6100 let events = core.file_dropped(path.clone(), 1.0, 1.0);
6101 assert_eq!(events.len(), 1);
6102 let event = &events[0];
6103 assert_eq!(event.kind, UiEventKind::FileDropped);
6104 assert!(
6105 event.target.is_none(),
6106 "drop outside any keyed surface routes window-level",
6107 );
6108 assert!(event.key.is_none());
6109 assert_eq!(event.path.as_deref(), Some(path.as_path()));
6111 }
6112
6113 #[test]
6114 fn file_hovered_then_cancelled_pair() {
6115 let mut core = lay_out_input_tree(false);
6116 let btn = core.rect_of_key("btn").expect("btn rect");
6117 let path = std::path::PathBuf::from("/tmp/a.png");
6118
6119 let hover = core.file_hovered(path.clone(), btn.x + 4.0, btn.y + 4.0);
6120 assert_eq!(hover.len(), 1);
6121 assert_eq!(hover[0].kind, UiEventKind::FileHovered);
6122 assert_eq!(hover[0].key.as_deref(), Some("btn"));
6123 assert_eq!(hover[0].path.as_deref(), Some(path.as_path()));
6124
6125 let cancel = core.file_hover_cancelled();
6126 assert_eq!(cancel.len(), 1);
6127 assert_eq!(cancel[0].kind, UiEventKind::FileHoverCancelled);
6128 assert!(cancel[0].target.is_none());
6129 assert!(cancel[0].path.is_none());
6130 }
6131
6132 #[test]
6133 fn build_cx_hover_accessors_default_off_without_state() {
6134 use crate::Theme;
6135 let theme = Theme::default();
6136 let cx = crate::BuildCx::new(&theme);
6137 assert_eq!(cx.hovered_key(), None);
6138 assert!(!cx.is_hovering_within("anything"));
6139 }
6140
6141 #[test]
6142 fn build_cx_hover_accessors_delegate_when_state_attached() {
6143 use crate::Theme;
6144 let mut core = lay_out_input_tree(false);
6145 let btn = core.rect_of_key("btn").expect("btn rect");
6146 core.pointer_moved(Pointer::moving(btn.x + 4.0, btn.y + 4.0));
6147
6148 let theme = Theme::default();
6149 let cx = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
6150 assert_eq!(cx.hovered_key(), Some("btn"));
6151 assert!(cx.is_hovering_within("btn"));
6152 assert!(!cx.is_hovering_within("ti"));
6153 }
6154
6155 #[test]
6156 fn build_cx_rect_of_key_reads_previous_layout() {
6157 use crate::Theme;
6158 let core = lay_out_input_tree(false);
6159 let theme = Theme::default();
6160
6161 let detached = crate::BuildCx::new(&theme);
6162 assert_eq!(detached.rect_of_key("btn"), None);
6163
6164 let cx = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
6165 assert_eq!(cx.rect_of_key("btn"), core.rect_of_key("btn"));
6166 assert_eq!(cx.rect_of_key("missing"), None);
6167 }
6168
6169 #[test]
6170 fn event_cx_rect_of_key_answers_from_laid_out_state() {
6171 let core = lay_out_input_tree(false);
6172
6173 let detached = crate::EventCx::new();
6174 assert_eq!(detached.rect_of_key("btn"), None);
6175
6176 let cx = crate::EventCx::new().with_ui_state(core.ui_state());
6177 let rect = cx.rect_of_key("btn").expect("btn rect via EventCx");
6178 assert_eq!(Some(rect), core.rect_of_key("btn"));
6179 assert_eq!(cx.rect_of_key("missing"), None);
6180 }
6181
6182 #[test]
6183 fn event_cx_viewport_and_diagnostics_mirror_build_cx() {
6184 let detached = crate::EventCx::new();
6187 assert_eq!(detached.viewport(), None);
6188 assert_eq!(detached.viewport_width(), None);
6189 assert_eq!(detached.viewport_height(), None);
6190 assert!(!detached.viewport_below(10_000.0));
6191 assert!(detached.diagnostics().is_none());
6192
6193 let diagnostics = crate::HostDiagnostics {
6194 backend: "Test",
6195 ..Default::default()
6196 };
6197 let cx = crate::EventCx::new()
6198 .with_viewport(800.0, 600.0)
6199 .with_diagnostics(&diagnostics);
6200 assert_eq!(cx.viewport(), Some((800.0, 600.0)));
6201 assert_eq!(cx.viewport_width(), Some(800.0));
6202 assert_eq!(cx.viewport_height(), Some(600.0));
6203 assert!(cx.viewport_below(900.0));
6204 assert!(!cx.viewport_below(800.0));
6205 assert_eq!(cx.diagnostics().map(|d| d.backend), Some("Test"));
6206 }
6207
6208 fn lay_out_paragraph_tree() -> RunnerCore {
6209 use crate::tree::*;
6210 let mut tree = crate::column([
6211 crate::widgets::text::text("First paragraph of text.")
6212 .key("p1")
6213 .selectable(),
6214 crate::widgets::text::text("Second paragraph of text.")
6215 .key("p2")
6216 .selectable(),
6217 ])
6218 .padding(20.0);
6219 let mut core = RunnerCore::new();
6220 crate::layout::layout(
6221 &mut tree,
6222 &mut core.ui_state,
6223 Rect::new(0.0, 0.0, 400.0, 300.0),
6224 );
6225 core.ui_state.sync_focus_order(&tree);
6226 core.ui_state.sync_selection_order(&tree);
6227 let mut t = PrepareTimings::default();
6228 core.snapshot(&tree, &mut t);
6229 core
6230 }
6231
6232 #[test]
6233 fn pointer_down_on_selectable_text_emits_selection_changed() {
6234 let mut core = lay_out_paragraph_tree();
6235 let p1 = core.rect_of_key("p1").expect("p1 rect");
6236 let cx = p1.x + 4.0;
6237 let cy = p1.y + p1.h * 0.5;
6238 let events = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6239 let sel_event = events
6240 .iter()
6241 .find(|e| e.kind == UiEventKind::SelectionChanged)
6242 .expect("SelectionChanged emitted");
6243 let new_sel = sel_event
6244 .selection
6245 .as_ref()
6246 .expect("SelectionChanged carries a selection");
6247 let range = new_sel.range.as_ref().expect("collapsed selection at hit");
6248 assert_eq!(range.anchor.key, "p1");
6249 assert_eq!(range.head.key, "p1");
6250 assert_eq!(range.anchor.byte, range.head.byte);
6251 assert!(core.ui_state.selection.drag.is_some());
6252 }
6253
6254 #[test]
6255 fn touch_long_press_on_selectable_text_selects_word_and_drags() {
6256 let mut core = lay_out_paragraph_tree();
6257 let p1 = core.rect_of_key("p1").expect("p1 rect");
6258 let p2 = core.rect_of_key("p2").expect("p2 rect");
6259 let x = p1.x + 4.0;
6260 let y = p1.y + p1.h * 0.5;
6261
6262 let _ = core.pointer_down(Pointer::touch(
6263 x,
6264 y,
6265 PointerButton::Primary,
6266 PointerId::PRIMARY,
6267 ));
6268 let polled = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
6269 let selection = polled
6270 .iter()
6271 .rev()
6272 .find(|e| e.kind == UiEventKind::SelectionChanged)
6273 .and_then(|e| e.selection.as_ref())
6274 .expect("touch long-press should select text");
6275 let range = selection.range.as_ref().expect("word selection");
6276 assert_eq!(range.anchor.key, "p1");
6277 assert_eq!(range.head.key, "p1");
6278 assert_eq!(range.anchor.byte, 0);
6279 assert_eq!(range.head.byte, 5);
6280 assert!(
6281 core.ui_state.selection.drag.is_some(),
6282 "long-pressed selectable text should stay ready for drag extension"
6283 );
6284
6285 let mut moved = Pointer::moving(p2.x + 8.0, p2.y + p2.h * 0.5);
6286 moved.kind = PointerKind::Touch;
6287 let events = core.pointer_moved(moved).events;
6288 let selection = events
6289 .iter()
6290 .find(|e| e.kind == UiEventKind::SelectionChanged)
6291 .and_then(|e| e.selection.as_ref())
6292 .unwrap_or(&core.ui_state.current_selection);
6293 let range = selection.range.as_ref().expect("extended selection");
6294 assert_eq!(range.anchor.key, "p1");
6295 assert_eq!(range.head.key, "p2");
6296
6297 let _ = core.pointer_up(Pointer::touch(
6298 p2.x + 8.0,
6299 p2.y + p2.h * 0.5,
6300 PointerButton::Primary,
6301 PointerId::PRIMARY,
6302 ));
6303 assert!(
6304 core.ui_state.selection.drag.is_none(),
6305 "selection drag should end on lift"
6306 );
6307 }
6308
6309 #[test]
6310 fn pointer_drag_on_selectable_text_extends_head() {
6311 let mut core = lay_out_paragraph_tree();
6312 let p1 = core.rect_of_key("p1").expect("p1 rect");
6313 let cx = p1.x + 4.0;
6314 let cy = p1.y + p1.h * 0.5;
6315 core.pointer_moved(Pointer::moving(cx, cy));
6316 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6317
6318 let events = core
6320 .pointer_moved(Pointer::moving(p1.x + p1.w - 10.0, cy))
6321 .events;
6322 let sel_event = events
6323 .iter()
6324 .find(|e| e.kind == UiEventKind::SelectionChanged)
6325 .expect("Drag emits SelectionChanged");
6326 let new_sel = sel_event.selection.as_ref().unwrap();
6327 let range = new_sel.range.as_ref().unwrap();
6328 assert_eq!(range.anchor.key, "p1");
6329 assert_eq!(range.head.key, "p1");
6330 assert!(
6331 range.head.byte > range.anchor.byte,
6332 "head should advance past anchor (anchor={}, head={})",
6333 range.anchor.byte,
6334 range.head.byte
6335 );
6336 }
6337
6338 #[test]
6339 fn double_click_hold_drag_inside_selectable_word_keeps_word_selected() {
6340 let mut core = lay_out_paragraph_tree();
6341 let p1 = core.rect_of_key("p1").expect("p1 rect");
6342 let cx = p1.x + 4.0;
6343 let cy = p1.y + p1.h * 0.5;
6344
6345 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6346 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
6347 let down = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6348 let sel = down
6349 .iter()
6350 .find(|e| e.kind == UiEventKind::SelectionChanged)
6351 .and_then(|e| e.selection.as_ref())
6352 .and_then(|s| s.range.as_ref())
6353 .expect("double-click selects word");
6354 assert_eq!(sel.anchor.byte, 0);
6355 assert_eq!(sel.head.byte, 5);
6356
6357 let events = core.pointer_moved(Pointer::moving(cx + 1.0, cy)).events;
6358 assert!(
6359 !events
6360 .iter()
6361 .any(|e| e.kind == UiEventKind::SelectionChanged),
6362 "drag jitter within the double-clicked word should not collapse the selection"
6363 );
6364 let range = core
6365 .ui_state
6366 .current_selection
6367 .range
6368 .as_ref()
6369 .expect("selection persists");
6370 assert_eq!(range.anchor.byte, 0);
6371 assert_eq!(range.head.byte, 5);
6372 }
6373
6374 #[test]
6375 fn pointer_up_clears_drag_but_keeps_selection() {
6376 let mut core = lay_out_paragraph_tree();
6377 let p1 = core.rect_of_key("p1").expect("p1 rect");
6378 let cx = p1.x + 4.0;
6379 let cy = p1.y + p1.h * 0.5;
6380 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6381 core.pointer_moved(Pointer::moving(p1.x + p1.w - 10.0, cy));
6382 let _ = core.pointer_up(Pointer::mouse(
6383 p1.x + p1.w - 10.0,
6384 cy,
6385 PointerButton::Primary,
6386 ));
6387 assert!(
6388 core.ui_state.selection.drag.is_none(),
6389 "drag flag should clear on pointer_up"
6390 );
6391 assert!(
6392 !core.ui_state.current_selection.is_empty(),
6393 "selection itself should persist after pointer_up"
6394 );
6395 }
6396
6397 #[test]
6398 fn drag_past_a_leaf_bottom_keeps_head_in_that_leaf_not_anchor() {
6399 let mut core = lay_out_paragraph_tree();
6405 let p1 = core.rect_of_key("p1").expect("p1 rect");
6406 let p2 = core.rect_of_key("p2").expect("p2 rect");
6407 core.pointer_down(Pointer::mouse(
6409 p1.x + 4.0,
6410 p1.y + p1.h * 0.5,
6411 PointerButton::Primary,
6412 ));
6413 core.pointer_moved(Pointer::moving(p2.x + 8.0, p2.y + p2.h * 0.5));
6415 let events = core
6418 .pointer_moved(Pointer::moving(p2.x + 8.0, p2.y + p2.h + 200.0))
6419 .events;
6420 let sel = events
6421 .iter()
6422 .find(|e| e.kind == UiEventKind::SelectionChanged)
6423 .map(|e| e.selection.as_ref().unwrap().clone())
6424 .unwrap_or_else(|| core.ui_state.current_selection.clone());
6427 let r = sel.range.as_ref().expect("selection still active");
6428 assert_eq!(r.anchor.key, "p1", "anchor unchanged");
6429 assert_eq!(
6430 r.head.key, "p2",
6431 "head must stay in p2 even when pointer is below p2's rect"
6432 );
6433 }
6434
6435 #[test]
6436 fn drag_into_a_sibling_selectable_extends_head_into_that_leaf() {
6437 let mut core = lay_out_paragraph_tree();
6438 let p1 = core.rect_of_key("p1").expect("p1 rect");
6439 let p2 = core.rect_of_key("p2").expect("p2 rect");
6440 core.pointer_down(Pointer::mouse(
6442 p1.x + 4.0,
6443 p1.y + p1.h * 0.5,
6444 PointerButton::Primary,
6445 ));
6446 let events = core
6448 .pointer_moved(Pointer::moving(p2.x + 8.0, p2.y + p2.h * 0.5))
6449 .events;
6450 let sel_event = events
6451 .iter()
6452 .find(|e| e.kind == UiEventKind::SelectionChanged)
6453 .expect("Drag emits SelectionChanged");
6454 let new_sel = sel_event.selection.as_ref().unwrap();
6455 let range = new_sel.range.as_ref().unwrap();
6456 assert_eq!(range.anchor.key, "p1", "anchor stays in p1");
6457 assert_eq!(range.head.key, "p2", "head migrates into p2");
6458 }
6459
6460 #[test]
6461 fn pointer_down_on_focusable_owning_selection_does_not_clear_it() {
6462 let mut core = lay_out_input_tree(true);
6470 core.set_selection(crate::selection::Selection::caret("ti", 3));
6473 let ti = core.rect_of_key("ti").expect("ti rect");
6474 let cx = ti.x + ti.w * 0.5;
6475 let cy = ti.y + ti.h * 0.5;
6476
6477 let events = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6478 let cleared = events.iter().find(|e| {
6479 e.kind == UiEventKind::SelectionChanged
6480 && e.selection.as_ref().map(|s| s.is_empty()).unwrap_or(false)
6481 });
6482 assert!(
6483 cleared.is_none(),
6484 "click on the selection-owning input must not emit a clearing SelectionChanged"
6485 );
6486 assert_eq!(
6487 core.ui_state.current_selection,
6488 crate::selection::Selection::caret("ti", 3),
6489 "runtime mirror is preserved when the click owns the selection"
6490 );
6491 }
6492
6493 #[test]
6494 fn pointer_down_into_a_different_capture_keys_widget_does_not_clear_first() {
6495 let mut core = lay_out_input_tree(true);
6505 core.set_selection(crate::selection::Selection::caret("other", 4));
6507 let ti = core.rect_of_key("ti").expect("ti rect");
6508 let cx = ti.x + ti.w * 0.5;
6509 let cy = ti.y + ti.h * 0.5;
6510
6511 let events = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6512 let cleared = events.iter().any(|e| {
6513 e.kind == UiEventKind::SelectionChanged
6514 && e.selection.as_ref().map(|s| s.is_empty()).unwrap_or(false)
6515 });
6516 assert!(
6517 !cleared,
6518 "click on a different capture_keys widget must not race-clear the selection"
6519 );
6520 }
6521
6522 #[test]
6523 fn pointer_down_on_non_selectable_clears_existing_selection() {
6524 let mut core = lay_out_paragraph_tree();
6525 let p1 = core.rect_of_key("p1").expect("p1 rect");
6526 let cy = p1.y + p1.h * 0.5;
6527 core.pointer_down(Pointer::mouse(p1.x + 4.0, cy, PointerButton::Primary));
6529 core.pointer_up(Pointer::mouse(p1.x + 4.0, cy, PointerButton::Primary));
6530 assert!(!core.ui_state.current_selection.is_empty());
6531
6532 let events = core.pointer_down(Pointer::mouse(2.0, 2.0, PointerButton::Primary));
6534 let cleared = events
6535 .iter()
6536 .find(|e| e.kind == UiEventKind::SelectionChanged)
6537 .expect("clearing emits SelectionChanged");
6538 let new_sel = cleared.selection.as_ref().unwrap();
6539 assert!(new_sel.is_empty(), "new selection should be empty");
6540 assert!(core.ui_state.current_selection.is_empty());
6541 }
6542
6543 #[test]
6544 fn pointer_down_in_dead_space_clears_focus() {
6545 let mut core = lay_out_input_tree(false);
6546 let btn = core.rect_of_key("btn").expect("btn rect");
6547 let cx = btn.x + btn.w * 0.5;
6548 let cy = btn.y + btn.h * 0.5;
6549 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6550 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
6551 assert_eq!(
6552 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
6553 Some("btn")
6554 );
6555
6556 core.pointer_down(Pointer::mouse(2.0, 2.0, PointerButton::Primary));
6557
6558 assert_eq!(core.ui_state.focused.as_ref().map(|t| t.key.as_str()), None);
6559 }
6560
6561 #[test]
6562 fn key_down_bumps_caret_activity_when_focused_widget_captures_keys() {
6563 let mut core = lay_out_input_tree(true);
6568 let target = core
6569 .ui_state
6570 .focus
6571 .order
6572 .iter()
6573 .find(|t| t.key == "ti")
6574 .cloned();
6575 core.ui_state.set_focus(target); let after_focus = core.ui_state.caret.activity_at.expect("focus bump");
6577
6578 std::thread::sleep(std::time::Duration::from_millis(2));
6579 let _ = core.key_down(
6580 LogicalKey::Named(NamedKey::ArrowRight),
6581 PhysicalKey::Unidentified,
6582 KeyModifiers::default(),
6583 false,
6584 );
6585 let after_arrow = core
6586 .ui_state
6587 .caret
6588 .activity_at
6589 .expect("arrow key bumps even without app-side selection");
6590 assert!(
6591 after_arrow > after_focus,
6592 "ArrowRight to a capture_keys focused widget bumps caret activity"
6593 );
6594 }
6595
6596 #[test]
6597 fn text_input_bumps_caret_activity_when_focused() {
6598 let mut core = lay_out_input_tree(true);
6599 let target = core
6600 .ui_state
6601 .focus
6602 .order
6603 .iter()
6604 .find(|t| t.key == "ti")
6605 .cloned();
6606 core.ui_state.set_focus(target);
6607 let after_focus = core.ui_state.caret.activity_at.unwrap();
6608
6609 std::thread::sleep(std::time::Duration::from_millis(2));
6610 let _ = core.text_input("a".into());
6611 let after_text = core.ui_state.caret.activity_at.unwrap();
6612 assert!(
6613 after_text > after_focus,
6614 "TextInput to focused widget bumps caret activity"
6615 );
6616 }
6617
6618 #[test]
6619 fn pointer_down_inside_focused_input_bumps_caret_activity() {
6620 let mut core = lay_out_input_tree(true);
6625 let ti = core.rect_of_key("ti").expect("ti rect");
6626 let cx = ti.x + ti.w * 0.5;
6627 let cy = ti.y + ti.h * 0.5;
6628
6629 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6631 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
6632 let after_first = core.ui_state.caret.activity_at.unwrap();
6633
6634 std::thread::sleep(std::time::Duration::from_millis(2));
6637 core.pointer_down(Pointer::mouse(cx + 1.0, cy, PointerButton::Primary));
6638 let after_second = core
6639 .ui_state
6640 .caret
6641 .activity_at
6642 .expect("second click bumps too");
6643 assert!(
6644 after_second > after_first,
6645 "click within already-focused capture_keys widget still bumps"
6646 );
6647 }
6648
6649 #[test]
6650 fn arrow_key_through_apply_event_mutates_selection_and_bumps_on_set() {
6651 use crate::widgets::text_input;
6657 let mut sel = crate::selection::Selection::caret("ti", 2);
6658 let mut value = String::from("hello");
6659
6660 let mut core = RunnerCore::new();
6661 core.set_selection(sel.clone());
6664 let baseline = core.ui_state.caret.activity_at;
6665
6666 let arrow_right = UiEvent {
6668 key: Some("ti".into()),
6669 target: None,
6670 pointer: None,
6671 key_press: Some(crate::event::KeyPress {
6672 logical: LogicalKey::Named(NamedKey::ArrowRight),
6673 physical: PhysicalKey::Unidentified,
6674 modifiers: KeyModifiers::default(),
6675 repeat: false,
6676 }),
6677 text: None,
6678 selection: None,
6679 modifiers: KeyModifiers::default(),
6680 click_count: 0,
6681 path: None,
6682 pointer_kind: None,
6683 wheel_delta: None,
6684 kind: UiEventKind::KeyDown,
6685 };
6686
6687 let mutated = text_input::apply_event(&mut value, &mut sel, &arrow_right, "ti");
6689 assert!(mutated, "ArrowRight should mutate selection");
6690 assert_eq!(
6691 sel.within("ti").unwrap().head,
6692 3,
6693 "head moved one char right (h-e-l-l-o, byte 2 → 3)"
6694 );
6695
6696 std::thread::sleep(std::time::Duration::from_millis(2));
6698 core.set_selection(sel);
6699 let after = core.ui_state.caret.activity_at.unwrap();
6700 if let Some(b) = baseline {
6704 assert!(after > b, "arrow-key flow should bump activity");
6705 }
6706 }
6707
6708 #[test]
6709 fn set_selection_bumps_caret_activity_only_when_value_changes() {
6710 let mut core = lay_out_paragraph_tree();
6711 core.set_selection(crate::selection::Selection::default());
6714 assert!(
6715 core.ui_state.caret.activity_at.is_none(),
6716 "no-op set_selection should not bump activity"
6717 );
6718
6719 let sel_a = crate::selection::Selection::caret("p1", 3);
6721 core.set_selection(sel_a.clone());
6722 let bumped_at = core
6723 .ui_state
6724 .caret
6725 .activity_at
6726 .expect("first real selection bumps");
6727
6728 core.set_selection(sel_a.clone());
6731 assert_eq!(
6732 core.ui_state.caret.activity_at,
6733 Some(bumped_at),
6734 "set_selection with same value is a no-op"
6735 );
6736
6737 std::thread::sleep(std::time::Duration::from_millis(2));
6740 let sel_b = crate::selection::Selection::caret("p1", 7);
6741 core.set_selection(sel_b);
6742 let new_bump = core.ui_state.caret.activity_at.expect("second bump");
6743 assert!(
6744 new_bump > bumped_at,
6745 "moving the caret bumps activity again",
6746 );
6747 }
6748
6749 #[test]
6750 fn escape_clears_active_selection_and_emits_selection_changed() {
6751 let mut core = lay_out_paragraph_tree();
6752 let p1 = core.rect_of_key("p1").expect("p1 rect");
6753 let cy = p1.y + p1.h * 0.5;
6754 core.pointer_down(Pointer::mouse(p1.x + 4.0, cy, PointerButton::Primary));
6756 core.pointer_moved(Pointer::moving(p1.x + p1.w - 10.0, cy));
6757 core.pointer_up(Pointer::mouse(
6758 p1.x + p1.w - 10.0,
6759 cy,
6760 PointerButton::Primary,
6761 ));
6762 assert!(!core.ui_state.current_selection.is_empty());
6763
6764 let events = core.key_down(
6765 LogicalKey::Named(NamedKey::Escape),
6766 PhysicalKey::Unidentified,
6767 KeyModifiers::default(),
6768 false,
6769 );
6770 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
6771 assert_eq!(
6772 kinds,
6773 vec![UiEventKind::Escape, UiEventKind::SelectionChanged],
6774 "Esc emits Escape (for popover dismiss) AND SelectionChanged"
6775 );
6776 let cleared = events
6777 .iter()
6778 .find(|e| e.kind == UiEventKind::SelectionChanged)
6779 .unwrap();
6780 assert!(cleared.selection.as_ref().unwrap().is_empty());
6781 assert!(core.ui_state.current_selection.is_empty());
6782 }
6783
6784 #[test]
6785 fn consecutive_clicks_on_same_target_extend_count() {
6786 let mut core = lay_out_input_tree(false);
6787 let btn = core.rect_of_key("btn").expect("btn rect");
6788 let cx = btn.x + btn.w * 0.5;
6789 let cy = btn.y + btn.h * 0.5;
6790
6791 let down1 = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6793 let pd1 = down1
6794 .iter()
6795 .find(|e| e.kind == UiEventKind::PointerDown)
6796 .expect("PointerDown emitted");
6797 assert_eq!(pd1.click_count, 1, "first press starts the sequence");
6798 let up1 = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
6799 let click1 = up1
6800 .iter()
6801 .find(|e| e.kind == UiEventKind::Click)
6802 .expect("Click emitted");
6803 assert_eq!(
6804 click1.click_count, 1,
6805 "Click carries the same count as its PointerDown"
6806 );
6807
6808 let down2 = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6810 let pd2 = down2
6811 .iter()
6812 .find(|e| e.kind == UiEventKind::PointerDown)
6813 .unwrap();
6814 assert_eq!(pd2.click_count, 2, "second press extends the sequence");
6815 let up2 = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
6816 assert_eq!(
6817 up2.iter()
6818 .find(|e| e.kind == UiEventKind::Click)
6819 .unwrap()
6820 .click_count,
6821 2
6822 );
6823
6824 let down3 = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6826 let pd3 = down3
6827 .iter()
6828 .find(|e| e.kind == UiEventKind::PointerDown)
6829 .unwrap();
6830 assert_eq!(pd3.click_count, 3, "third press → triple-click");
6831 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
6832 }
6833
6834 #[test]
6835 fn click_count_resets_when_target_changes() {
6836 let mut core = lay_out_input_tree(false);
6837 let btn = core.rect_of_key("btn").expect("btn rect");
6838 let ti = core.rect_of_key("ti").expect("ti rect");
6839
6840 let down1 = core.pointer_down(Pointer::mouse(
6842 btn.x + btn.w * 0.5,
6843 btn.y + btn.h * 0.5,
6844 PointerButton::Primary,
6845 ));
6846 assert_eq!(
6847 down1
6848 .iter()
6849 .find(|e| e.kind == UiEventKind::PointerDown)
6850 .unwrap()
6851 .click_count,
6852 1
6853 );
6854 let _ = core.pointer_up(Pointer::mouse(
6855 btn.x + btn.w * 0.5,
6856 btn.y + btn.h * 0.5,
6857 PointerButton::Primary,
6858 ));
6859
6860 let down2 = core.pointer_down(Pointer::mouse(
6862 ti.x + ti.w * 0.5,
6863 ti.y + ti.h * 0.5,
6864 PointerButton::Primary,
6865 ));
6866 let pd2 = down2
6867 .iter()
6868 .find(|e| e.kind == UiEventKind::PointerDown)
6869 .unwrap();
6870 assert_eq!(
6871 pd2.click_count, 1,
6872 "press on a new target resets the multi-click sequence"
6873 );
6874 }
6875
6876 #[test]
6877 fn double_click_on_selectable_text_selects_word_at_hit() {
6878 let mut core = lay_out_paragraph_tree();
6879 let p1 = core.rect_of_key("p1").expect("p1 rect");
6880 let cy = p1.y + p1.h * 0.5;
6881 let cx = p1.x + 4.0;
6884 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6885 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
6886 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6887 let sel = &core.ui_state.current_selection;
6889 let r = sel.range.as_ref().expect("selection set");
6890 assert_eq!(r.anchor.key, "p1");
6891 assert_eq!(r.head.key, "p1");
6892 assert_eq!(r.anchor.byte.min(r.head.byte), 0);
6894 assert_eq!(r.anchor.byte.max(r.head.byte), 5);
6895 }
6896
6897 #[test]
6898 fn triple_click_on_selectable_text_selects_whole_leaf() {
6899 let mut core = lay_out_paragraph_tree();
6900 let p1 = core.rect_of_key("p1").expect("p1 rect");
6901 let cy = p1.y + p1.h * 0.5;
6902 let cx = p1.x + 4.0;
6903 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6904 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
6905 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6906 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
6907 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6908 let sel = &core.ui_state.current_selection;
6909 let r = sel.range.as_ref().expect("selection set");
6910 assert_eq!(r.anchor.byte, 0);
6911 assert_eq!(r.head.byte, 24);
6913 }
6914
6915 #[test]
6916 fn click_count_resets_when_press_drifts_outside_distance_window() {
6917 let mut core = lay_out_input_tree(false);
6918 let btn = core.rect_of_key("btn").expect("btn rect");
6919 let cx = btn.x + btn.w * 0.5;
6920 let cy = btn.y + btn.h * 0.5;
6921
6922 let _ = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
6923 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
6924
6925 let down2 = core.pointer_down(Pointer::mouse(cx + 10.0, cy, PointerButton::Primary));
6928 let pd2 = down2
6929 .iter()
6930 .find(|e| e.kind == UiEventKind::PointerDown)
6931 .unwrap();
6932 assert_eq!(pd2.click_count, 1);
6933 }
6934
6935 #[test]
6936 fn escape_with_no_selection_emits_only_escape() {
6937 let mut core = lay_out_paragraph_tree();
6938 assert!(core.ui_state.current_selection.is_empty());
6939 let events = core.key_down(
6940 LogicalKey::Named(NamedKey::Escape),
6941 PhysicalKey::Unidentified,
6942 KeyModifiers::default(),
6943 false,
6944 );
6945 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
6946 assert_eq!(
6947 kinds,
6948 vec![UiEventKind::Escape],
6949 "no selection → no SelectionChanged side-effect"
6950 );
6951 }
6952
6953 fn lay_out_scroll_tree() -> (RunnerCore, String) {
6956 use crate::tree::*;
6957 let mut tree = crate::scroll(
6958 (0..6)
6959 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
6960 )
6961 .gap(12.0)
6962 .height(Size::Fixed(200.0));
6963 let mut core = RunnerCore::new();
6964 crate::layout::layout(
6965 &mut tree,
6966 &mut core.ui_state,
6967 Rect::new(0.0, 0.0, 300.0, 200.0),
6968 );
6969 let scroll_id = tree.computed_id.clone();
6970 let mut t = PrepareTimings::default();
6971 core.snapshot(&tree, &mut t);
6972 (core, scroll_id.to_string())
6973 }
6974
6975 #[test]
6976 fn thumb_pointer_down_captures_drag_and_suppresses_events() {
6977 let (mut core, scroll_id) = lay_out_scroll_tree();
6978 let thumb = core
6979 .ui_state
6980 .scroll
6981 .thumb_rects
6982 .get(&scroll_id)
6983 .copied()
6984 .expect("scrollable should have a thumb");
6985 let event = core.pointer_down(Pointer::mouse(
6986 thumb.x + thumb.w * 0.5,
6987 thumb.y + thumb.h * 0.5,
6988 PointerButton::Primary,
6989 ));
6990 assert!(
6991 event.is_empty(),
6992 "thumb press should not emit PointerDown to the app"
6993 );
6994 let drag = core
6995 .ui_state
6996 .scroll
6997 .thumb_drag
6998 .as_ref()
6999 .expect("scroll.thumb_drag should be set after pointer_down on thumb");
7000 assert_eq!(drag.scroll_id, scroll_id);
7001 }
7002
7003 #[test]
7004 fn track_click_above_thumb_pages_up_below_pages_down() {
7005 let (mut core, scroll_id) = lay_out_scroll_tree();
7006 let track = core
7007 .ui_state
7008 .scroll
7009 .thumb_tracks
7010 .get(&scroll_id)
7011 .copied()
7012 .expect("scrollable should have a track");
7013 let thumb = core
7014 .ui_state
7015 .scroll
7016 .thumb_rects
7017 .get(&scroll_id)
7018 .copied()
7019 .unwrap();
7020 let metrics = core
7021 .ui_state
7022 .scroll
7023 .metrics
7024 .get(&scroll_id)
7025 .copied()
7026 .unwrap();
7027
7028 let evt = core.pointer_down(Pointer::mouse(
7030 track.x + track.w * 0.5,
7031 thumb.y + thumb.h + 10.0,
7032 PointerButton::Primary,
7033 ));
7034 assert!(evt.is_empty(), "track press should not surface PointerDown");
7035 assert!(
7036 core.ui_state.scroll.thumb_drag.is_none(),
7037 "track click outside the thumb should not start a drag",
7038 );
7039 let after_down = core.ui_state.scroll_offset(&scroll_id);
7040 let expected_page = (metrics.viewport_h - SCROLL_PAGE_OVERLAP).max(0.0);
7041 assert!(
7042 (after_down - expected_page.min(metrics.max_offset)).abs() < 0.5,
7043 "page-down offset = {after_down} (expected ~{expected_page})",
7044 );
7045 let _ = core.pointer_up(Pointer::mouse(0.0, 0.0, PointerButton::Primary));
7047
7048 let mut tree = lay_out_scroll_tree_only();
7051 crate::layout::layout(
7052 &mut tree,
7053 &mut core.ui_state,
7054 Rect::new(0.0, 0.0, 300.0, 200.0),
7055 );
7056 let mut t = PrepareTimings::default();
7057 core.snapshot(&tree, &mut t);
7058 let track = core
7059 .ui_state
7060 .scroll
7061 .thumb_tracks
7062 .get(&*tree.computed_id)
7063 .copied()
7064 .unwrap();
7065 let thumb = core
7066 .ui_state
7067 .scroll
7068 .thumb_rects
7069 .get(&*tree.computed_id)
7070 .copied()
7071 .unwrap();
7072
7073 core.pointer_down(Pointer::mouse(
7074 track.x + track.w * 0.5,
7075 thumb.y - 4.0,
7076 PointerButton::Primary,
7077 ));
7078 let after_up = core.ui_state.scroll_offset(&tree.computed_id);
7079 assert!(
7080 after_up < after_down,
7081 "page-up should reduce offset: before={after_down} after={after_up}",
7082 );
7083 }
7084
7085 #[test]
7086 fn scrollbar_press_does_not_bypass_covering_scrim() {
7087 let (mut core, scroll_id) =
7088 lay_out_scroll_tree_with_layer(crate::widgets::overlay::scrim("modal:dismiss"));
7089 let thumb = core
7090 .ui_state
7091 .scroll
7092 .thumb_rects
7093 .get(&scroll_id)
7094 .copied()
7095 .expect("scrollable should have a thumb");
7096
7097 let events = core.pointer_down(Pointer::mouse(
7098 thumb.x + thumb.w * 0.5,
7099 thumb.y + thumb.h * 0.5,
7100 PointerButton::Primary,
7101 ));
7102
7103 assert_eq!(events.len(), 1);
7104 assert_eq!(events[0].kind, UiEventKind::PointerDown);
7105 assert_eq!(events[0].route(), Some("modal:dismiss"));
7106 assert!(
7107 core.ui_state.scroll.thumb_drag.is_none(),
7108 "covered scrollbar thumb must not capture drag",
7109 );
7110 }
7111
7112 #[test]
7113 fn scrollbar_press_does_not_bypass_block_pointer_layer() {
7114 use crate::tree::*;
7115
7116 let (mut core, scroll_id) =
7117 lay_out_scroll_tree_with_layer(El::new(Kind::Group).fill_size().block_pointer());
7118 let thumb = core
7119 .ui_state
7120 .scroll
7121 .thumb_rects
7122 .get(&scroll_id)
7123 .copied()
7124 .expect("scrollable should have a thumb");
7125
7126 let events = core.pointer_down(Pointer::mouse(
7127 thumb.x + thumb.w * 0.5,
7128 thumb.y + thumb.h * 0.5,
7129 PointerButton::Primary,
7130 ));
7131
7132 assert!(
7133 events.is_empty(),
7134 "block_pointer layer should swallow the press without scrolling",
7135 );
7136 assert!(
7137 core.ui_state.scroll.thumb_drag.is_none(),
7138 "covered scrollbar thumb must not capture drag",
7139 );
7140 }
7141
7142 fn lay_out_scroll_tree_only() -> El {
7147 use crate::tree::*;
7148 crate::scroll(
7149 (0..6)
7150 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
7151 )
7152 .gap(12.0)
7153 .height(Size::Fixed(200.0))
7154 }
7155
7156 fn lay_out_scroll_tree_with_layer(layer: El) -> (RunnerCore, String) {
7157 use crate::tree::*;
7158
7159 let scroll = lay_out_scroll_tree_only();
7160 let mut tree = crate::stack([scroll, layer]).fill_size();
7161 let mut core = RunnerCore::new();
7162 crate::layout::layout(
7163 &mut tree,
7164 &mut core.ui_state,
7165 Rect::new(0.0, 0.0, 300.0, 200.0),
7166 );
7167 let scroll_id = tree.children[0].computed_id.clone();
7168 let mut t = PrepareTimings::default();
7169 core.snapshot(&tree, &mut t);
7170 (core, scroll_id.to_string())
7171 }
7172
7173 fn lay_out_resizable_tree(layer: Option<El>) -> RunnerCore {
7177 use crate::tree::*;
7178 let row = crate::tree::row([
7179 column(Vec::<El>::new())
7180 .key("nav")
7181 .user_resizable()
7182 .width(Size::Fixed(200.0))
7183 .min_width(120.0)
7184 .max_width(420.0),
7185 column(Vec::<El>::new()).width(Size::Fill(1.0)),
7186 ])
7187 .height(Size::Fill(1.0));
7188 let mut tree = match layer {
7189 Some(layer) => crate::stack([row, layer]).fill_size(),
7190 None => row,
7191 };
7192 let mut core = RunnerCore::new();
7193 crate::layout::layout(
7194 &mut tree,
7195 &mut core.ui_state,
7196 Rect::new(0.0, 0.0, 800.0, 400.0),
7197 );
7198 let mut t = PrepareTimings::default();
7199 core.snapshot(&tree, &mut t);
7200 core
7201 }
7202
7203 #[test]
7204 fn resize_band_drag_updates_user_size_and_emits_resized_on_release() {
7205 let mut core = lay_out_resizable_tree(None);
7206
7207 let events = core.pointer_down(Pointer::mouse(200.0, 150.0, PointerButton::Primary));
7209 assert!(events.is_empty(), "band press must not reach the app");
7210 assert!(core.ui_state.resize.drag.is_some());
7211
7212 let mv = core.pointer_moved(Pointer::mouse(280.0, 150.0, PointerButton::Primary));
7214 assert!(mv.events.is_empty(), "drag is captured, no app events");
7215 assert!(mv.needs_redraw, "size change must redraw");
7216 assert_eq!(core.ui_state.user_size("nav"), Some(280.0));
7217
7218 let _ = core.pointer_moved(Pointer::mouse(700.0, 150.0, PointerButton::Primary));
7220 assert_eq!(core.ui_state.user_size("nav"), Some(420.0));
7221
7222 let up = core.pointer_up(Pointer::mouse(700.0, 150.0, PointerButton::Primary));
7224 assert_eq!(up.len(), 1);
7225 assert_eq!(up[0].kind, UiEventKind::Resized);
7226 assert_eq!(up[0].route(), Some("nav"));
7227 assert!(core.ui_state.resize.drag.is_none());
7228 }
7229
7230 #[test]
7231 fn resize_band_hover_flips_cursor_and_requests_redraw() {
7232 let mut core = lay_out_resizable_tree(None);
7233 let tree = core.last_tree.clone().expect("snapshot stored a tree");
7234
7235 let _ = core.pointer_moved(Pointer::mouse(400.0, 150.0, PointerButton::Primary));
7237 assert_eq!(core.ui_state.cursor(&tree), crate::cursor::Cursor::Default);
7238
7239 let mv = core.pointer_moved(Pointer::mouse(201.0, 150.0, PointerButton::Primary));
7243 assert!(mv.needs_redraw, "band entry must redraw for the cursor");
7244 assert_eq!(core.ui_state.cursor(&tree), crate::cursor::Cursor::EwResize);
7245
7246 let _ = core.pointer_down(Pointer::mouse(201.0, 150.0, PointerButton::Primary));
7249 let _ = core.pointer_moved(Pointer::mouse(390.0, 150.0, PointerButton::Primary));
7250 assert_eq!(core.ui_state.cursor(&tree), crate::cursor::Cursor::EwResize);
7251 }
7252
7253 #[test]
7254 fn resize_band_press_does_not_bypass_covering_scrim() {
7255 let mut core =
7256 lay_out_resizable_tree(Some(crate::widgets::overlay::scrim("modal:dismiss")));
7257
7258 let events = core.pointer_down(Pointer::mouse(200.0, 150.0, PointerButton::Primary));
7259 assert!(
7260 core.ui_state.resize.drag.is_none(),
7261 "covered band must not capture the press",
7262 );
7263 assert_eq!(events.len(), 1);
7264 assert_eq!(events[0].kind, UiEventKind::PointerDown);
7265 assert_eq!(events[0].route(), Some("modal:dismiss"));
7266 }
7267
7268 #[test]
7269 fn thumb_drag_translates_pointer_delta_into_scroll_offset() {
7270 let (mut core, scroll_id) = lay_out_scroll_tree();
7271 let thumb = core
7272 .ui_state
7273 .scroll
7274 .thumb_rects
7275 .get(&scroll_id)
7276 .copied()
7277 .unwrap();
7278 let metrics = core
7279 .ui_state
7280 .scroll
7281 .metrics
7282 .get(&scroll_id)
7283 .copied()
7284 .unwrap();
7285 let track_remaining = (metrics.viewport_h - thumb.h).max(0.0);
7286
7287 let press_y = thumb.y + thumb.h * 0.5;
7288 core.pointer_down(Pointer::mouse(
7289 thumb.x + thumb.w * 0.5,
7290 press_y,
7291 PointerButton::Primary,
7292 ));
7293 let evt = core.pointer_moved(Pointer::moving(thumb.x + thumb.w * 0.5, press_y + 20.0));
7295 assert!(
7296 evt.events.is_empty(),
7297 "thumb-drag move should suppress Drag event",
7298 );
7299 let offset = core.ui_state.scroll_offset(&scroll_id);
7300 let expected = 20.0 * (metrics.max_offset / track_remaining);
7301 assert!(
7302 (offset - expected).abs() < 0.5,
7303 "offset {offset} (expected {expected})",
7304 );
7305 core.pointer_moved(Pointer::moving(thumb.x + thumb.w * 0.5, press_y + 9999.0));
7307 let offset = core.ui_state.scroll_offset(&scroll_id);
7308 assert!(
7309 (offset - metrics.max_offset).abs() < 0.5,
7310 "overshoot offset {offset} (expected {})",
7311 metrics.max_offset
7312 );
7313 let events = core.pointer_up(Pointer::mouse(thumb.x, press_y, PointerButton::Primary));
7315 assert!(events.is_empty(), "thumb release shouldn't emit events");
7316 assert!(core.ui_state.scroll.thumb_drag.is_none());
7317 }
7318
7319 #[test]
7320 fn secondary_click_does_not_steal_focus_or_press() {
7321 let mut core = lay_out_input_tree(false);
7322 let btn_rect = core.rect_of_key("btn").expect("btn rect");
7323 let cx = btn_rect.x + btn_rect.w * 0.5;
7324 let cy = btn_rect.y + btn_rect.h * 0.5;
7325 let ti_rect = core.rect_of_key("ti").expect("ti rect");
7327 let tx = ti_rect.x + ti_rect.w * 0.5;
7328 let ty = ti_rect.y + ti_rect.h * 0.5;
7329 core.pointer_down(Pointer::mouse(tx, ty, PointerButton::Primary));
7330 let _ = core.pointer_up(Pointer::mouse(tx, ty, PointerButton::Primary));
7331 let focused_before = core.ui_state.focused.as_ref().map(|t| t.key.clone());
7332 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Secondary));
7334 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Secondary));
7335 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
7336 assert_eq!(kinds, vec![UiEventKind::SecondaryClick]);
7337 let focused_after = core.ui_state.focused.as_ref().map(|t| t.key.clone());
7338 assert_eq!(
7339 focused_before, focused_after,
7340 "right-click must not steal focus"
7341 );
7342 assert!(
7343 core.ui_state.pressed.is_none(),
7344 "right-click must not set primary press"
7345 );
7346 }
7347
7348 #[test]
7349 fn text_input_routes_to_focused_only() {
7350 let mut core = lay_out_input_tree(false);
7351 assert!(core.text_input("a".into()).is_none());
7353 let btn_rect = core.rect_of_key("btn").expect("btn rect");
7355 let cx = btn_rect.x + btn_rect.w * 0.5;
7356 let cy = btn_rect.y + btn_rect.h * 0.5;
7357 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
7358 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
7359 let event = core.text_input("hi".into()).expect("focused → event");
7360 assert_eq!(event.kind, UiEventKind::TextInput);
7361 assert_eq!(event.text.as_deref(), Some("hi"));
7362 assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("btn"));
7363 assert!(core.text_input(String::new()).is_none());
7365 }
7366
7367 #[test]
7368 fn capture_keys_bypasses_tab_traversal_for_focused_node() {
7369 let mut core = lay_out_input_tree(true);
7372 let ti_rect = core.rect_of_key("ti").expect("ti rect");
7373 let tx = ti_rect.x + ti_rect.w * 0.5;
7374 let ty = ti_rect.y + ti_rect.h * 0.5;
7375 core.pointer_down(Pointer::mouse(tx, ty, PointerButton::Primary));
7376 let _ = core.pointer_up(Pointer::mouse(tx, ty, PointerButton::Primary));
7377 assert_eq!(
7378 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7379 Some("ti"),
7380 "primary click on capture_keys node still focuses it"
7381 );
7382
7383 let events = core.key_down(
7384 LogicalKey::Named(NamedKey::Tab),
7385 PhysicalKey::Unidentified,
7386 KeyModifiers::default(),
7387 false,
7388 );
7389 assert_eq!(events.len(), 1, "Tab → exactly one KeyDown");
7390 let event = &events[0];
7391 assert_eq!(event.kind, UiEventKind::KeyDown);
7392 assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("ti"));
7393 assert_eq!(
7394 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7395 Some("ti"),
7396 "Tab inside capture_keys must NOT move focus"
7397 );
7398 }
7399
7400 #[test]
7401 fn escape_blurs_capture_keys_after_delivering_raw_keydown() {
7402 let mut core = lay_out_input_tree(true);
7403 let ti_rect = core.rect_of_key("ti").expect("ti rect");
7404 let tx = ti_rect.x + ti_rect.w * 0.5;
7405 let ty = ti_rect.y + ti_rect.h * 0.5;
7406 core.pointer_down(Pointer::mouse(tx, ty, PointerButton::Primary));
7407 let _ = core.pointer_up(Pointer::mouse(tx, ty, PointerButton::Primary));
7408 assert_eq!(
7409 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7410 Some("ti")
7411 );
7412
7413 let events = core.key_down(
7414 LogicalKey::Named(NamedKey::Escape),
7415 PhysicalKey::Unidentified,
7416 KeyModifiers::default(),
7417 false,
7418 );
7419
7420 assert_eq!(events.len(), 1);
7421 let event = &events[0];
7422 assert_eq!(event.kind, UiEventKind::KeyDown);
7423 assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("ti"));
7424 assert!(matches!(
7425 event.key_press.as_ref().map(|p| &p.logical),
7426 Some(LogicalKey::Named(NamedKey::Escape))
7427 ));
7428 assert_eq!(core.ui_state.focused.as_ref().map(|t| t.key.as_str()), None);
7429 }
7430
7431 #[test]
7432 fn pointer_down_focus_does_not_raise_focus_visible() {
7433 let mut core = lay_out_input_tree(false);
7436 let btn_rect = core.rect_of_key("btn").expect("btn rect");
7437 let cx = btn_rect.x + btn_rect.w * 0.5;
7438 let cy = btn_rect.y + btn_rect.h * 0.5;
7439 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
7440 assert_eq!(
7441 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7442 Some("btn"),
7443 "primary click focuses the button",
7444 );
7445 assert!(
7446 !core.ui_state.focus_visible,
7447 "click focus must not raise focus_visible — ring stays off",
7448 );
7449 }
7450
7451 #[test]
7452 fn tab_key_raises_focus_visible_so_ring_appears() {
7453 let mut core = lay_out_input_tree(false);
7454 let btn_rect = core.rect_of_key("btn").expect("btn rect");
7456 let cx = btn_rect.x + btn_rect.w * 0.5;
7457 let cy = btn_rect.y + btn_rect.h * 0.5;
7458 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
7459 assert!(!core.ui_state.focus_visible);
7460 let _ = core.key_down(
7462 LogicalKey::Named(NamedKey::Tab),
7463 PhysicalKey::Unidentified,
7464 KeyModifiers::default(),
7465 false,
7466 );
7467 assert!(
7468 core.ui_state.focus_visible,
7469 "Tab must raise focus_visible so the ring paints on the new target",
7470 );
7471 }
7472
7473 #[test]
7474 fn click_after_tab_clears_focus_visible_again() {
7475 let mut core = lay_out_input_tree(false);
7478 let _ = core.key_down(
7479 LogicalKey::Named(NamedKey::Tab),
7480 PhysicalKey::Unidentified,
7481 KeyModifiers::default(),
7482 false,
7483 );
7484 assert!(core.ui_state.focus_visible, "Tab raises ring");
7485 let btn_rect = core.rect_of_key("btn").expect("btn rect");
7486 let cx = btn_rect.x + btn_rect.w * 0.5;
7487 let cy = btn_rect.y + btn_rect.h * 0.5;
7488 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
7489 assert!(
7490 !core.ui_state.focus_visible,
7491 "pointer-down clears focus_visible — ring fades back out",
7492 );
7493 }
7494
7495 #[test]
7496 fn keypress_on_focused_widget_raises_focus_visible_after_click() {
7497 let mut core = lay_out_input_tree(false);
7501 let btn_rect = core.rect_of_key("btn").expect("btn rect");
7502 let cx = btn_rect.x + btn_rect.w * 0.5;
7503 let cy = btn_rect.y + btn_rect.h * 0.5;
7504 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
7505 assert!(!core.ui_state.focus_visible);
7506 let _ = core.key_down(
7507 LogicalKey::Named(NamedKey::ArrowRight),
7508 PhysicalKey::Unidentified,
7509 KeyModifiers::default(),
7510 false,
7511 );
7512 assert!(
7513 core.ui_state.focus_visible,
7514 "non-Tab key on focused widget raises focus_visible",
7515 );
7516 }
7517
7518 #[test]
7519 fn selected_text_resolves_a_selection_inside_a_virtual_list() {
7520 use crate::selection::{Selection, SelectionPoint, SelectionRange};
7527 use crate::tree::*;
7528
7529 let mut tree = virtual_list_dyn(
7533 20,
7534 50.0,
7535 |i| format!("row-{i}"),
7536 |i| {
7537 crate::widgets::text::text(format!("row {i} text"))
7538 .key(format!("row-{i}"))
7539 .selectable()
7540 .height(Size::Fixed(50.0))
7541 },
7542 );
7543 let mut core = RunnerCore::new();
7544 crate::layout::layout(
7545 &mut tree,
7546 &mut core.ui_state,
7547 Rect::new(0.0, 0.0, 200.0, 200.0),
7548 );
7549 let mut t = PrepareTimings::default();
7550 core.snapshot(&tree, &mut t);
7551
7552 let selection = Selection {
7554 range: Some(SelectionRange {
7555 anchor: SelectionPoint::new("row-1", 0),
7556 head: SelectionPoint::new("row-1", 9),
7557 }),
7558 };
7559 core.set_selection(selection);
7560
7561 assert_eq!(
7562 core.selected_text().as_deref(),
7563 Some("row 1 tex"),
7564 "runtime.selected_text() must walk last_tree (realized rows) — \
7565 a build-only path would miss virtual_list children entirely",
7566 );
7567 }
7568
7569 #[test]
7570 fn shortcut_chord_does_not_raise_focus_visible() {
7571 let mut core = lay_out_input_tree(false);
7578 let btn_rect = core.rect_of_key("btn").expect("btn rect");
7579 let cx = btn_rect.x + btn_rect.w * 0.5;
7580 let cy = btn_rect.y + btn_rect.h * 0.5;
7581 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
7582 assert!(!core.ui_state.focus_visible);
7583
7584 let ctrl = KeyModifiers {
7585 ctrl: true,
7586 ..Default::default()
7587 };
7588 let _ = core.key_down(
7589 LogicalKey::Named(NamedKey::Control),
7590 PhysicalKey::Unidentified,
7591 ctrl,
7592 false,
7593 );
7594 assert!(
7595 !core.ui_state.focus_visible,
7596 "bare Ctrl press must not raise focus_visible on a pointer-focused widget",
7597 );
7598 let _ = core.key_down(
7599 LogicalKey::Character("c".into()),
7600 PhysicalKey::Unidentified,
7601 ctrl,
7602 false,
7603 );
7604 assert!(
7605 !core.ui_state.focus_visible,
7606 "Ctrl+C is a shortcut, not interaction with the focused widget",
7607 );
7608
7609 let _ = core.key_down(
7610 LogicalKey::Named(NamedKey::Shift),
7611 PhysicalKey::Unidentified,
7612 KeyModifiers::default(),
7613 false,
7614 );
7615 assert!(
7616 !core.ui_state.focus_visible,
7617 "bare Shift press must not raise focus_visible",
7618 );
7619 let _ = core.key_down(
7620 LogicalKey::Character("a".into()),
7621 PhysicalKey::Unidentified,
7622 KeyModifiers::default(),
7623 false,
7624 );
7625 assert!(
7626 !core.ui_state.focus_visible,
7627 "bare character keys are typing/activation guesses, not navigation",
7628 );
7629 let _ = core.key_down(
7630 LogicalKey::Named(NamedKey::Escape),
7631 PhysicalKey::Unidentified,
7632 KeyModifiers::default(),
7633 false,
7634 );
7635 assert!(
7636 !core.ui_state.focus_visible,
7637 "Escape is dismissal, not navigation — no ring",
7638 );
7639 }
7640
7641 #[test]
7642 fn arrow_nav_in_sibling_group_raises_focus_visible() {
7643 let mut core = lay_out_arrow_nav_tree();
7644 core.ui_state.set_focus_visible(false);
7647 let _ = core.key_down(
7648 LogicalKey::Named(NamedKey::ArrowDown),
7649 PhysicalKey::Unidentified,
7650 KeyModifiers::default(),
7651 false,
7652 );
7653 assert!(
7654 core.ui_state.focus_visible,
7655 "arrow-nav within an arrow_nav_siblings group is keyboard navigation",
7656 );
7657 }
7658
7659 #[test]
7660 fn capture_keys_falls_back_to_default_when_focus_off_capturing_node() {
7661 let mut core = lay_out_input_tree(true);
7665 let btn_rect = core.rect_of_key("btn").expect("btn rect");
7666 let cx = btn_rect.x + btn_rect.w * 0.5;
7667 let cy = btn_rect.y + btn_rect.h * 0.5;
7668 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
7669 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
7670 assert_eq!(
7671 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7672 Some("btn"),
7673 "primary click focuses button"
7674 );
7675 let _ = core.key_down(
7677 LogicalKey::Named(NamedKey::Tab),
7678 PhysicalKey::Unidentified,
7679 KeyModifiers::default(),
7680 false,
7681 );
7682 assert_eq!(
7683 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7684 Some("ti"),
7685 "Tab from non-capturing focused does library-default traversal"
7686 );
7687 }
7688
7689 fn lay_out_arrow_nav_tree() -> RunnerCore {
7694 use crate::tree::*;
7695 let mut tree = crate::column([
7696 crate::widgets::button::button("Red").key("opt-red"),
7697 crate::widgets::button::button("Green").key("opt-green"),
7698 crate::widgets::button::button("Blue").key("opt-blue"),
7699 ])
7700 .arrow_nav_siblings()
7701 .padding(10.0);
7702 let mut core = RunnerCore::new();
7703 crate::layout::layout(
7704 &mut tree,
7705 &mut core.ui_state,
7706 Rect::new(0.0, 0.0, 200.0, 300.0),
7707 );
7708 core.ui_state.sync_focus_order(&tree);
7709 let mut t = PrepareTimings::default();
7710 core.snapshot(&tree, &mut t);
7711 let target = core
7714 .ui_state
7715 .focus
7716 .order
7717 .iter()
7718 .find(|t| t.key == "opt-green")
7719 .cloned();
7720 core.ui_state.set_focus(target);
7721 core
7722 }
7723
7724 #[test]
7725 fn arrow_nav_moves_focus_among_siblings() {
7726 let mut core = lay_out_arrow_nav_tree();
7727
7728 let down = core.key_down(
7731 LogicalKey::Named(NamedKey::ArrowDown),
7732 PhysicalKey::Unidentified,
7733 KeyModifiers::default(),
7734 false,
7735 );
7736 assert!(down.is_empty(), "arrow-nav consumes the key event");
7737 assert_eq!(
7738 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7739 Some("opt-blue"),
7740 );
7741
7742 core.key_down(
7744 LogicalKey::Named(NamedKey::ArrowUp),
7745 PhysicalKey::Unidentified,
7746 KeyModifiers::default(),
7747 false,
7748 );
7749 assert_eq!(
7750 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7751 Some("opt-green"),
7752 );
7753
7754 core.key_down(
7756 LogicalKey::Named(NamedKey::Home),
7757 PhysicalKey::Unidentified,
7758 KeyModifiers::default(),
7759 false,
7760 );
7761 assert_eq!(
7762 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7763 Some("opt-red"),
7764 );
7765
7766 core.key_down(
7768 LogicalKey::Named(NamedKey::End),
7769 PhysicalKey::Unidentified,
7770 KeyModifiers::default(),
7771 false,
7772 );
7773 assert_eq!(
7774 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7775 Some("opt-blue"),
7776 );
7777 }
7778
7779 #[test]
7780 fn arrow_nav_saturates_at_ends() {
7781 let mut core = lay_out_arrow_nav_tree();
7782 core.key_down(
7784 LogicalKey::Named(NamedKey::Home),
7785 PhysicalKey::Unidentified,
7786 KeyModifiers::default(),
7787 false,
7788 );
7789 core.key_down(
7790 LogicalKey::Named(NamedKey::ArrowUp),
7791 PhysicalKey::Unidentified,
7792 KeyModifiers::default(),
7793 false,
7794 );
7795 assert_eq!(
7796 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7797 Some("opt-red"),
7798 "ArrowUp at top stays at top — no wrap",
7799 );
7800 core.key_down(
7802 LogicalKey::Named(NamedKey::End),
7803 PhysicalKey::Unidentified,
7804 KeyModifiers::default(),
7805 false,
7806 );
7807 core.key_down(
7808 LogicalKey::Named(NamedKey::ArrowDown),
7809 PhysicalKey::Unidentified,
7810 KeyModifiers::default(),
7811 false,
7812 );
7813 assert_eq!(
7814 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7815 Some("opt-blue"),
7816 "ArrowDown at bottom stays at bottom — no wrap",
7817 );
7818 }
7819
7820 #[test]
7821 fn arrow_nav_vertical_lets_horizontal_arrows_fall_through() {
7822 let mut core = lay_out_arrow_nav_tree();
7826 let out = core.key_down(
7827 LogicalKey::Named(NamedKey::ArrowRight),
7828 PhysicalKey::Unidentified,
7829 KeyModifiers::default(),
7830 false,
7831 );
7832 assert_eq!(
7833 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7834 Some("opt-green"),
7835 "ArrowRight must not move focus in a Vertical group",
7836 );
7837 assert!(
7838 !out.is_empty(),
7839 "ArrowRight falls through to normal KeyDown routing",
7840 );
7841 }
7842
7843 fn lay_out_horizontal_group() -> RunnerCore {
7846 let mut tree = crate::widgets::toggle::toggle_group(
7847 "view",
7848 &"list",
7849 [("list", "List"), ("grid", "Grid"), ("map", "Map")],
7850 )
7851 .padding(10.0);
7852 let mut core = RunnerCore::new();
7853 crate::layout::layout(
7854 &mut tree,
7855 &mut core.ui_state,
7856 Rect::new(0.0, 0.0, 400.0, 100.0),
7857 );
7858 core.ui_state.sync_focus_order(&tree);
7859 let mut t = PrepareTimings::default();
7860 core.snapshot(&tree, &mut t);
7861 let target = core
7862 .ui_state
7863 .focus
7864 .order
7865 .iter()
7866 .find(|t| t.key == "view:toggle:list")
7867 .cloned();
7868 core.ui_state.set_focus(target);
7869 core
7870 }
7871
7872 #[test]
7873 fn arrow_nav_horizontal_group_steps_on_left_right() {
7874 let mut core = lay_out_horizontal_group();
7877
7878 let right = core.key_down(
7879 LogicalKey::Named(NamedKey::ArrowRight),
7880 PhysicalKey::Unidentified,
7881 KeyModifiers::default(),
7882 false,
7883 );
7884 assert!(right.is_empty(), "ArrowRight is consumed by the group");
7885 assert_eq!(
7886 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7887 Some("view:toggle:grid"),
7888 );
7889
7890 core.key_down(
7891 LogicalKey::Named(NamedKey::ArrowLeft),
7892 PhysicalKey::Unidentified,
7893 KeyModifiers::default(),
7894 false,
7895 );
7896 assert_eq!(
7897 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7898 Some("view:toggle:list"),
7899 );
7900
7901 core.key_down(
7902 LogicalKey::Named(NamedKey::End),
7903 PhysicalKey::Unidentified,
7904 KeyModifiers::default(),
7905 false,
7906 );
7907 assert_eq!(
7908 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7909 Some("view:toggle:map"),
7910 );
7911
7912 let down = core.key_down(
7913 LogicalKey::Named(NamedKey::ArrowDown),
7914 PhysicalKey::Unidentified,
7915 KeyModifiers::default(),
7916 false,
7917 );
7918 assert_eq!(
7919 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7920 Some("view:toggle:map"),
7921 "ArrowDown must not move focus in a Horizontal group",
7922 );
7923 assert!(
7924 !down.is_empty(),
7925 "ArrowDown falls through to normal KeyDown routing",
7926 );
7927 }
7928
7929 #[test]
7930 fn arrow_nav_radio_group_steps_on_both_axes() {
7931 let mut tree = crate::widgets::radio::radio_group(
7934 "theme",
7935 &"light",
7936 [("light", "Light"), ("dark", "Dark"), ("auto", "Auto")],
7937 )
7938 .padding(10.0);
7939 let mut core = RunnerCore::new();
7940 crate::layout::layout(
7941 &mut tree,
7942 &mut core.ui_state,
7943 Rect::new(0.0, 0.0, 300.0, 300.0),
7944 );
7945 core.ui_state.sync_focus_order(&tree);
7946 let mut t = PrepareTimings::default();
7947 core.snapshot(&tree, &mut t);
7948 let target = core
7949 .ui_state
7950 .focus
7951 .order
7952 .iter()
7953 .find(|t| t.key == "theme:radio:light")
7954 .cloned();
7955 core.ui_state.set_focus(target);
7956
7957 core.key_down(
7958 LogicalKey::Named(NamedKey::ArrowDown),
7959 PhysicalKey::Unidentified,
7960 KeyModifiers::default(),
7961 false,
7962 );
7963 assert_eq!(
7964 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7965 Some("theme:radio:dark"),
7966 "ArrowDown steps to the next radio",
7967 );
7968 core.key_down(
7969 LogicalKey::Named(NamedKey::ArrowRight),
7970 PhysicalKey::Unidentified,
7971 KeyModifiers::default(),
7972 false,
7973 );
7974 assert_eq!(
7975 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7976 Some("theme:radio:auto"),
7977 "ArrowRight also steps in a Both group",
7978 );
7979 core.key_down(
7980 LogicalKey::Named(NamedKey::ArrowLeft),
7981 PhysicalKey::Unidentified,
7982 KeyModifiers::default(),
7983 false,
7984 );
7985 assert_eq!(
7986 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
7987 Some("theme:radio:dark"),
7988 "ArrowLeft steps back in a Both group",
7989 );
7990 }
7991
7992 fn lay_out_calendar(focus_day: u32, disabled_day: Option<u32>) -> RunnerCore {
7995 use crate::widgets::calendar::{CalendarDay, calendar_month};
7996 let days = (1..=14).map(|d| {
7997 let day = CalendarDay::new(format!("2026-06-{d:02}"), d.to_string());
7998 if Some(d) == disabled_day {
7999 day.disabled()
8000 } else {
8001 day
8002 }
8003 });
8004 let mut tree = calendar_month("cal", "June 2026", days);
8005 let mut core = RunnerCore::new();
8006 crate::layout::layout(
8007 &mut tree,
8008 &mut core.ui_state,
8009 Rect::new(0.0, 0.0, 600.0, 400.0),
8010 );
8011 core.ui_state.sync_focus_order(&tree);
8012 let mut t = PrepareTimings::default();
8013 core.snapshot(&tree, &mut t);
8014 let key = format!("cal:day:2026-06-{focus_day:02}");
8015 let target = core
8016 .ui_state
8017 .focus
8018 .order
8019 .iter()
8020 .find(|t| t.key == key)
8021 .cloned();
8022 assert!(target.is_some(), "day {focus_day} should be focusable");
8023 core.ui_state.set_focus(target);
8024 core
8025 }
8026
8027 #[test]
8028 fn arrow_nav_calendar_grid_moves_two_dimensionally() {
8029 let mut core = lay_out_calendar(3, None);
8032
8033 let down = core.key_down(
8034 LogicalKey::Named(NamedKey::ArrowDown),
8035 PhysicalKey::Unidentified,
8036 KeyModifiers::default(),
8037 false,
8038 );
8039 assert!(down.is_empty(), "ArrowDown is consumed by the grid");
8040 assert_eq!(
8041 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
8042 Some("cal:day:2026-06-10"),
8043 "ArrowDown lands on the same weekday one week later",
8044 );
8045
8046 core.key_down(
8047 LogicalKey::Named(NamedKey::ArrowRight),
8048 PhysicalKey::Unidentified,
8049 KeyModifiers::default(),
8050 false,
8051 );
8052 assert_eq!(
8053 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
8054 Some("cal:day:2026-06-11"),
8055 );
8056
8057 core.key_down(
8058 LogicalKey::Named(NamedKey::ArrowUp),
8059 PhysicalKey::Unidentified,
8060 KeyModifiers::default(),
8061 false,
8062 );
8063 assert_eq!(
8064 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
8065 Some("cal:day:2026-06-04"),
8066 "ArrowUp lands on the same weekday one week earlier",
8067 );
8068
8069 core.key_down(
8071 LogicalKey::Named(NamedKey::ArrowUp),
8072 PhysicalKey::Unidentified,
8073 KeyModifiers::default(),
8074 false,
8075 );
8076 assert_eq!(
8077 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
8078 Some("cal:day:2026-06-04"),
8079 "ArrowUp at the top edge saturates",
8080 );
8081 }
8082
8083 #[test]
8084 fn arrow_nav_calendar_grid_skips_disabled_days() {
8085 let mut core = lay_out_calendar(3, Some(10));
8089 core.key_down(
8090 LogicalKey::Named(NamedKey::ArrowDown),
8091 PhysicalKey::Unidentified,
8092 KeyModifiers::default(),
8093 false,
8094 );
8095 let focused = core
8096 .ui_state
8097 .focused
8098 .as_ref()
8099 .map(|t| t.key.clone())
8100 .expect("focus survives the move");
8101 assert!(
8102 focused == "cal:day:2026-06-09" || focused == "cal:day:2026-06-11",
8103 "ArrowDown over a disabled day lands on a neighbor in the \
8104 same week, got {focused}",
8105 );
8106 }
8107
8108 fn build_popover_tree(open: bool) -> El {
8112 use crate::widgets::button::button;
8113 use crate::widgets::overlay::overlay;
8114 use crate::widgets::popover::{dropdown, menu_item};
8115 let mut layers: Vec<El> = vec![button("Trigger").key("trigger")];
8116 if open {
8117 layers.push(dropdown(
8118 "menu",
8119 "trigger",
8120 [
8121 menu_item("A").key("item-a"),
8122 menu_item("B").key("item-b"),
8123 menu_item("C").key("item-c"),
8124 ],
8125 ));
8126 }
8127 overlay(layers).padding(20.0)
8128 }
8129
8130 fn run_frame(core: &mut RunnerCore, tree: &mut El) {
8134 let mut t = PrepareTimings::default();
8135 core.prepare_layout(
8136 tree,
8137 Rect::new(0.0, 0.0, 400.0, 300.0),
8138 1.0,
8139 &mut t,
8140 RunnerCore::no_time_shaders,
8141 );
8142 core.snapshot(tree, &mut t);
8143 }
8144
8145 #[test]
8146 fn popover_open_pushes_focus_and_auto_focuses_first_item() {
8147 let mut core = RunnerCore::new();
8148 let mut closed = build_popover_tree(false);
8149 run_frame(&mut core, &mut closed);
8150 let trigger = core
8153 .ui_state
8154 .focus
8155 .order
8156 .iter()
8157 .find(|t| t.key == "trigger")
8158 .cloned();
8159 core.ui_state.set_focus(trigger);
8160 assert_eq!(
8161 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
8162 Some("trigger"),
8163 );
8164
8165 let mut open = build_popover_tree(true);
8168 run_frame(&mut core, &mut open);
8169 assert_eq!(
8170 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
8171 Some("item-a"),
8172 "popover open should auto-focus the first menu item",
8173 );
8174 assert_eq!(
8175 core.ui_state.popover_focus.focus_stack.len(),
8176 1,
8177 "trigger should be saved on the focus stack",
8178 );
8179 assert_eq!(
8180 core.ui_state.popover_focus.focus_stack[0].key.as_str(),
8181 "trigger",
8182 "saved focus should be the pre-open target",
8183 );
8184 }
8185
8186 #[test]
8187 fn popover_close_restores_focus_to_trigger() {
8188 let mut core = RunnerCore::new();
8189 let mut closed = build_popover_tree(false);
8190 run_frame(&mut core, &mut closed);
8191 let trigger = core
8192 .ui_state
8193 .focus
8194 .order
8195 .iter()
8196 .find(|t| t.key == "trigger")
8197 .cloned();
8198 core.ui_state.set_focus(trigger);
8199
8200 let mut open = build_popover_tree(true);
8202 run_frame(&mut core, &mut open);
8203 assert_eq!(
8204 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
8205 Some("item-a"),
8206 );
8207
8208 let mut closed_again = build_popover_tree(false);
8210 run_frame(&mut core, &mut closed_again);
8211 assert_eq!(
8212 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
8213 Some("trigger"),
8214 "closing the popover should pop the saved focus",
8215 );
8216 assert!(
8217 core.ui_state.popover_focus.focus_stack.is_empty(),
8218 "focus stack should be drained after restore",
8219 );
8220 }
8221
8222 #[test]
8223 fn popover_close_does_not_override_intentional_focus_move() {
8224 let mut core = RunnerCore::new();
8225 let build = |open: bool| -> El {
8228 use crate::widgets::button::button;
8229 use crate::widgets::overlay::overlay;
8230 use crate::widgets::popover::{dropdown, menu_item};
8231 let main = crate::row([
8232 button("Trigger").key("trigger"),
8233 button("Other").key("other"),
8234 ]);
8235 let mut layers: Vec<El> = vec![main];
8236 if open {
8237 layers.push(dropdown("menu", "trigger", [menu_item("A").key("item-a")]));
8238 }
8239 overlay(layers).padding(20.0)
8240 };
8241
8242 let mut closed = build(false);
8243 run_frame(&mut core, &mut closed);
8244 let trigger = core
8245 .ui_state
8246 .focus
8247 .order
8248 .iter()
8249 .find(|t| t.key == "trigger")
8250 .cloned();
8251 core.ui_state.set_focus(trigger);
8252
8253 let mut open = build(true);
8254 run_frame(&mut core, &mut open);
8255 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
8256
8257 let other = core
8262 .ui_state
8263 .focus
8264 .order
8265 .iter()
8266 .find(|t| t.key == "other")
8267 .cloned();
8268 core.ui_state.set_focus(other);
8269
8270 let mut closed_again = build(false);
8271 run_frame(&mut core, &mut closed_again);
8272 assert_eq!(
8273 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
8274 Some("other"),
8275 "focus moved before close should not be overridden by restore",
8276 );
8277 assert!(core.ui_state.popover_focus.focus_stack.is_empty());
8278 }
8279
8280 #[test]
8281 fn nested_popovers_stack_and_unwind_focus_correctly() {
8282 let mut core = RunnerCore::new();
8283 let build = |outer: bool, inner: bool| -> El {
8288 use crate::widgets::button::button;
8289 use crate::widgets::overlay::overlay;
8290 use crate::widgets::popover::{Anchor, popover, popover_panel};
8291 let main = button("Trigger").key("trigger");
8292 let mut layers: Vec<El> = vec![main];
8293 if outer {
8294 layers.push(popover(
8295 "outer",
8296 Anchor::below_key("trigger"),
8297 popover_panel([button("Open inner").key("inner-trigger")]),
8298 ));
8299 }
8300 if inner {
8301 layers.push(popover(
8302 "inner",
8303 Anchor::below_key("inner-trigger"),
8304 popover_panel([button("X").key("inner-a"), button("Y").key("inner-b")]),
8305 ));
8306 }
8307 overlay(layers).padding(20.0)
8308 };
8309
8310 let mut closed = build(false, false);
8312 run_frame(&mut core, &mut closed);
8313 let trigger = core
8314 .ui_state
8315 .focus
8316 .order
8317 .iter()
8318 .find(|t| t.key == "trigger")
8319 .cloned();
8320 core.ui_state.set_focus(trigger);
8321
8322 let mut outer = build(true, false);
8324 run_frame(&mut core, &mut outer);
8325 assert_eq!(
8326 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
8327 Some("inner-trigger"),
8328 );
8329 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
8330
8331 let mut both = build(true, true);
8333 run_frame(&mut core, &mut both);
8334 assert_eq!(
8335 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
8336 Some("inner-a"),
8337 );
8338 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 2);
8339
8340 let mut outer_only = build(true, false);
8342 run_frame(&mut core, &mut outer_only);
8343 assert_eq!(
8344 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
8345 Some("inner-trigger"),
8346 );
8347 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
8348
8349 let mut none = build(false, false);
8351 run_frame(&mut core, &mut none);
8352 assert_eq!(
8353 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
8354 Some("trigger"),
8355 );
8356 assert!(core.ui_state.popover_focus.focus_stack.is_empty());
8357 }
8358
8359 #[test]
8360 fn arrow_nav_does_not_intercept_outside_navigable_groups() {
8361 let mut core = lay_out_input_tree(false);
8365 let target = core
8366 .ui_state
8367 .focus
8368 .order
8369 .iter()
8370 .find(|t| t.key == "btn")
8371 .cloned();
8372 core.ui_state.set_focus(target);
8373 let events = core.key_down(
8374 LogicalKey::Named(NamedKey::ArrowDown),
8375 PhysicalKey::Unidentified,
8376 KeyModifiers::default(),
8377 false,
8378 );
8379 assert_eq!(
8380 events.len(),
8381 1,
8382 "ArrowDown without navigable parent → event"
8383 );
8384 assert_eq!(events[0].kind, UiEventKind::KeyDown);
8385 }
8386
8387 fn quad(shader: ShaderHandle) -> DrawOp {
8388 DrawOp::Quad {
8389 id: "q".into(),
8390 rect: Rect::new(0.0, 0.0, 10.0, 10.0),
8391 scissor: None,
8392 shader,
8393 uniforms: UniformBlock::new(),
8394 }
8395 }
8396
8397 #[test]
8398 fn prepare_paint_skips_ops_outside_viewport() {
8399 let mut core = RunnerCore::new();
8400 core.set_surface_size(100, 100);
8401 core.viewport_px = (100, 100);
8402 let ops = vec![
8403 DrawOp::Quad {
8404 id: "offscreen".into(),
8405 rect: Rect::new(0.0, 150.0, 10.0, 10.0),
8406 scissor: None,
8407 shader: ShaderHandle::Stock(StockShader::RoundedRect),
8408 uniforms: UniformBlock::new(),
8409 },
8410 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
8411 ];
8412 let mut timings = PrepareTimings::default();
8413 core.prepare_paint(&ops, |_| true, |_| false, &mut NoText, 1.0, &mut timings);
8414
8415 assert_eq!(timings.paint_culled_ops, 1);
8416 assert_eq!(
8417 core.runs.len(),
8418 1,
8419 "only the visible quad should become a paint run"
8420 );
8421 }
8422
8423 #[test]
8424 fn prepare_paint_does_not_shape_text_outside_clip() {
8425 let mut core = RunnerCore::new();
8426 core.set_surface_size(100, 100);
8427 core.viewport_px = (100, 100);
8428 let ops = vec![
8429 DrawOp::GlyphRun {
8430 id: "offscreen-text".into(),
8431 rect: Rect::new(0.0, 150.0, 80.0, 20.0),
8432 scissor: Some(Rect::new(0.0, 0.0, 100.0, 100.0)),
8433 shader: ShaderHandle::Stock(StockShader::Text),
8434 color: Color::srgb_u8a(255, 255, 255, 255),
8435 text: "offscreen".into(),
8436 size: 14.0,
8437 line_height: 20.0,
8438 family: Default::default(),
8439 mono_family: Default::default(),
8440 weight: FontWeight::Regular,
8441 mono: false,
8442 wrap: TextWrap::NoWrap,
8443 anchor: TextAnchor::Start,
8444 layout: empty_text_layout(20.0),
8445 underline: false,
8446 strikethrough: false,
8447 link: None,
8448 tabular_numerals: false,
8449 letter_spacing: 0.0,
8450 },
8451 DrawOp::GlyphRun {
8452 id: "visible-text".into(),
8453 rect: Rect::new(0.0, 10.0, 80.0, 20.0),
8454 scissor: Some(Rect::new(0.0, 0.0, 100.0, 100.0)),
8455 shader: ShaderHandle::Stock(StockShader::Text),
8456 color: Color::srgb_u8a(255, 255, 255, 255),
8457 text: "visible".into(),
8458 size: 14.0,
8459 line_height: 20.0,
8460 family: Default::default(),
8461 mono_family: Default::default(),
8462 weight: FontWeight::Regular,
8463 mono: false,
8464 wrap: TextWrap::NoWrap,
8465 anchor: TextAnchor::Start,
8466 layout: empty_text_layout(20.0),
8467 underline: false,
8468 strikethrough: false,
8469 link: None,
8470 tabular_numerals: false,
8471 letter_spacing: 0.0,
8472 },
8473 ];
8474 let mut text = CountingText::default();
8475 let mut timings = PrepareTimings::default();
8476 core.prepare_paint(&ops, |_| true, |_| false, &mut text, 1.0, &mut timings);
8477
8478 assert_eq!(timings.paint_culled_ops, 1);
8479 assert_eq!(text.records, 1, "offscreen text must not be shaped");
8480 }
8481
8482 #[test]
8483 fn samples_backdrop_inserts_snapshot_before_first_glass_quad() {
8484 let mut core = RunnerCore::new();
8485 core.set_surface_size(100, 100);
8486 let ops = vec![
8487 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
8488 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
8489 quad(ShaderHandle::Custom("liquid_glass")),
8490 quad(ShaderHandle::Custom("liquid_glass")),
8491 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
8492 ];
8493 let mut timings = PrepareTimings::default();
8494 core.prepare_paint(
8495 &ops,
8496 |_| true,
8497 |s| matches!(s, ShaderHandle::Custom(name) if *name == "liquid_glass"),
8498 &mut NoText,
8499 1.0,
8500 &mut timings,
8501 );
8502
8503 let kinds: Vec<&'static str> = core
8504 .paint_items
8505 .iter()
8506 .map(|p| match p {
8507 PaintItem::QuadRun(_) => "Q",
8508 PaintItem::IconRun(_) => "I",
8509 PaintItem::Text(_) => "T",
8510 PaintItem::Image(_) => "M",
8511 PaintItem::AppTexture(_) => "A",
8512 PaintItem::Vector(_) => "V",
8513 PaintItem::Scene3D(_) => "3",
8514 PaintItem::BackdropSnapshot => "S",
8515 })
8516 .collect();
8517 assert_eq!(
8518 kinds,
8519 vec!["Q", "S", "Q", "Q"],
8520 "expected one stock run, snapshot, then a glass run, then a foreground stock run"
8521 );
8522 }
8523
8524 #[test]
8525 fn no_snapshot_when_no_glass_drawn() {
8526 let mut core = RunnerCore::new();
8527 core.set_surface_size(100, 100);
8528 let ops = vec![
8529 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
8530 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
8531 ];
8532 let mut timings = PrepareTimings::default();
8533 core.prepare_paint(&ops, |_| true, |_| false, &mut NoText, 1.0, &mut timings);
8534 assert!(
8535 !core
8536 .paint_items
8537 .iter()
8538 .any(|p| matches!(p, PaintItem::BackdropSnapshot)),
8539 "no glass shader registered → no snapshot"
8540 );
8541 }
8542
8543 #[test]
8548 fn scene3d_op_emits_paint_item_only_when_recorder_records() {
8549 use crate::scene::{Aabb, CameraState, LightRig, Scene3DData, SceneStyle};
8550
8551 fn scene_op() -> DrawOp {
8552 let scene = Scene3DData {
8553 meshes: Vec::new(),
8554 points: Vec::new(),
8555 lines: Vec::new(),
8556 camera: CameraState::default().resolve(Aabb::EMPTY),
8557 lights: LightRig::default(),
8558 style: SceneStyle::default(),
8559 capture_depth: false,
8560 };
8561 DrawOp::Scene3D {
8562 id: "scene".into(),
8563 rect: Rect::new(0.0, 0.0, 40.0, 40.0),
8564 scissor: None,
8565 scene: std::sync::Arc::new(scene),
8566 }
8567 }
8568
8569 let mut core = RunnerCore::new();
8571 core.set_surface_size(100, 100);
8572 let mut timings = PrepareTimings::default();
8573 core.prepare_paint(
8574 &[scene_op()],
8575 |_| true,
8576 |_| false,
8577 &mut NoText,
8578 1.0,
8579 &mut timings,
8580 );
8581 assert!(
8582 !core
8583 .paint_items
8584 .iter()
8585 .any(|p| matches!(p, PaintItem::Scene3D(_))),
8586 "default no-op recorder must not emit a Scene3D paint item",
8587 );
8588
8589 struct SceneRecorder {
8591 calls: usize,
8592 }
8593 impl TextRecorder for SceneRecorder {
8594 fn record(
8595 &mut self,
8596 _: Rect,
8597 _: Option<PhysicalScissor>,
8598 _: &RunStyle,
8599 _: &str,
8600 _: f32,
8601 _: f32,
8602 _: TextWrap,
8603 _: TextAnchor,
8604 _: f32,
8605 ) -> Range<usize> {
8606 0..0
8607 }
8608 fn record_runs(
8609 &mut self,
8610 _: Rect,
8611 _: Option<PhysicalScissor>,
8612 _: &[(String, RunStyle)],
8613 _: f32,
8614 _: f32,
8615 _: TextWrap,
8616 _: TextAnchor,
8617 _: f32,
8618 ) -> Range<usize> {
8619 0..0
8620 }
8621 fn record_scene3d(
8622 &mut self,
8623 _: Rect,
8624 _: Option<PhysicalScissor>,
8625 id: &str,
8626 _: &std::sync::Arc<Scene3DData>,
8627 _: f32,
8628 ) -> Range<usize> {
8629 assert_eq!(id, "scene", "node id threads through to the recorder");
8630 let start = self.calls;
8631 self.calls += 1;
8632 start..self.calls
8633 }
8634 }
8635
8636 let mut core = RunnerCore::new();
8637 core.set_surface_size(100, 100);
8638 let mut rec = SceneRecorder { calls: 0 };
8639 let mut timings = PrepareTimings::default();
8640 core.prepare_paint(
8641 &[scene_op()],
8642 |_| true,
8643 |_| false,
8644 &mut rec,
8645 1.0,
8646 &mut timings,
8647 );
8648 let scenes = core
8649 .paint_items
8650 .iter()
8651 .filter(|p| matches!(p, PaintItem::Scene3D(_)))
8652 .count();
8653 assert_eq!(
8654 scenes, 1,
8655 "recorded scene must emit exactly one Scene3D item"
8656 );
8657 }
8658
8659 #[test]
8666 fn keyed_scene_still_begins_camera_drag() {
8667 use crate::scene::glam::Vec3;
8668 use crate::scene::{PointData, PointsHandle, ScenePoint, SceneSpec};
8669 use crate::tree::chart3d;
8670
8671 let spec = || {
8672 SceneSpec::new().points(PointsHandle::new(PointData {
8673 points: vec![
8674 ScenePoint {
8675 position: Vec3::splat(-1.0),
8676 color: [1.0; 4],
8677 },
8678 ScenePoint {
8679 position: Vec3::splat(1.0),
8680 color: [1.0; 4],
8681 },
8682 ],
8683 }))
8684 };
8685
8686 let drag_active_after_press = |mut tree: crate::tree::El| {
8689 let mut core = RunnerCore::new();
8690 crate::layout::layout(
8691 &mut tree,
8692 &mut core.ui_state,
8693 Rect::new(0.0, 0.0, 200.0, 200.0),
8694 );
8695 core.ui_state.tick_scene_cameras(&tree, Instant::now());
8696 let mut t = PrepareTimings::default();
8697 core.snapshot(&tree, &mut t);
8698 core.pointer_down(Pointer::mouse(100.0, 100.0, PointerButton::Primary));
8699 core.ui_state.camera_drag_active()
8700 };
8701
8702 assert!(
8704 drag_active_after_press(chart3d(spec())),
8705 "unkeyed scene should begin a camera drag"
8706 );
8707 assert!(
8709 drag_active_after_press(chart3d(spec()).key("scene")),
8710 "keyed scene must still begin a camera drag (its own node hit must not suppress it)"
8711 );
8712 }
8713
8714 #[test]
8718 fn plot_control_scheme_routes_primary_drag() {
8719 use crate::event::KeyModifiers;
8720 use crate::plot::{PlotControls, PlotSpec, Sample, Scale, SeriesHandle, line};
8721 use crate::tree::plot;
8722
8723 let make = |controls| {
8724 let h = SeriesHandle::new(vec![Sample::new(0.0, 0.0), Sample::new(10.0, 10.0)]);
8725 plot(
8726 PlotSpec::new()
8727 .x(Scale::linear())
8728 .y(Scale::linear())
8729 .add_mark(line(&h))
8730 .controls(controls),
8731 )
8732 .key("p")
8733 };
8734
8735 let press = |mut tree: crate::tree::El, shift: bool| {
8737 let mut core = RunnerCore::new();
8738 crate::layout::layout(
8739 &mut tree,
8740 &mut core.ui_state,
8741 Rect::new(0.0, 0.0, 200.0, 200.0),
8742 );
8743 core.ui_state.prepare_plots(&tree);
8744 let mut t = PrepareTimings::default();
8745 core.snapshot(&tree, &mut t);
8746 core.ui_state.set_modifiers(KeyModifiers {
8747 shift,
8748 ..Default::default()
8749 });
8750 core.pointer_down(Pointer::mouse(100.0, 100.0, PointerButton::Primary));
8751 (
8752 core.ui_state.plot_zoom_active(),
8753 core.ui_state.plot_pan_active(),
8754 )
8755 };
8756
8757 assert_eq!(press(make(PlotControls::ZoomDrag), false), (true, false));
8759 assert_eq!(press(make(PlotControls::ZoomDrag), true), (false, true));
8760 assert_eq!(press(make(PlotControls::PanDrag), false), (false, true));
8762 assert_eq!(press(make(PlotControls::PanDrag), true), (true, false));
8763 }
8764
8765 #[test]
8766 fn at_most_one_snapshot_per_frame() {
8767 let mut core = RunnerCore::new();
8768 core.set_surface_size(100, 100);
8769 let ops = vec![
8770 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
8771 quad(ShaderHandle::Custom("g")),
8772 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
8773 quad(ShaderHandle::Custom("g")),
8774 ];
8775 let mut timings = PrepareTimings::default();
8776 core.prepare_paint(
8777 &ops,
8778 |_| true,
8779 |s| matches!(s, ShaderHandle::Custom("g")),
8780 &mut NoText,
8781 1.0,
8782 &mut timings,
8783 );
8784 let snapshots = core
8785 .paint_items
8786 .iter()
8787 .filter(|p| matches!(p, PaintItem::BackdropSnapshot))
8788 .count();
8789 assert_eq!(snapshots, 1, "backdrop depth is capped at 1");
8790 }
8791}