1use std::cmp::Ordering;
51use std::ops::Range;
52use std::time::Duration;
53
54use web_time::Instant;
55
56use crate::draw_ops::{self, DrawOpsStats};
57use crate::event::{
58 KeyChord, KeyModifiers, Pointer, PointerButton, PointerKind, UiEvent, UiEventKind, UiKey,
59 UiTarget,
60};
61use crate::focus;
62use crate::hit_test;
63use crate::ir::{DrawOp, TextAnchor};
64use crate::layout;
65use crate::paint::{
66 InstanceRun, PaintItem, PhysicalScissor, QuadInstance, close_run, pack_instance,
67 physical_scissor,
68};
69use crate::shader::ShaderHandle;
70use crate::state::{
71 AnimationMode, LONG_PRESS_DELAY, SelectionDragGranularity, TOUCH_DRAG_THRESHOLD,
72 TouchGestureState, UiState,
73};
74use crate::text::atlas::RunStyle;
75use crate::text::metrics::TextLayoutCacheStats;
76use crate::theme::Theme;
77use crate::toast;
78use crate::tooltip;
79use crate::tree::{Color, El, FontWeight, Rect, TextWrap};
80
81const SCROLL_PAGE_OVERLAP: f32 = 24.0;
87
88#[derive(Clone, Copy, Debug, Default)]
111pub struct PrepareResult {
112 pub needs_redraw: bool,
116 pub next_redraw_in: Option<std::time::Duration>,
120 pub next_layout_redraw_in: Option<std::time::Duration>,
124 pub next_paint_redraw_in: Option<std::time::Duration>,
129 pub timings: PrepareTimings,
130}
131
132#[derive(Debug, Default)]
144pub struct PointerMove {
145 pub events: Vec<UiEvent>,
148 pub needs_redraw: bool,
152}
153
154pub struct LayoutPrepared {
161 pub ops: Vec<DrawOp>,
162 pub needs_redraw: bool,
163 pub next_layout_redraw_in: Option<std::time::Duration>,
164 pub next_paint_redraw_in: Option<std::time::Duration>,
165}
166
167#[derive(Clone, Copy, Debug, Default)]
178pub struct PrepareTimings {
179 pub layout: Duration,
180 pub layout_intrinsic_cache: layout::LayoutIntrinsicCacheStats,
181 pub layout_prune: layout::LayoutPruneStats,
182 pub draw_ops: Duration,
183 pub draw_ops_culled_text_ops: u64,
184 pub paint: Duration,
185 pub paint_culled_ops: u64,
186 pub gpu_upload: Duration,
187 pub snapshot: Duration,
188 pub text_layout_cache: TextLayoutCacheStats,
189}
190
191pub struct RunnerCore {
199 pub ui_state: UiState,
200 pub last_tree: Option<El>,
204
205 pub quad_scratch: Vec<QuadInstance>,
208 pub runs: Vec<InstanceRun>,
209 pub paint_items: Vec<PaintItem>,
210
211 pub last_ops: Vec<DrawOp>,
218
219 pub viewport_px: (u32, u32),
223 pub surface_size_override: Option<(u32, u32)>,
229
230 pub theme: Theme,
232}
233
234impl Default for RunnerCore {
235 fn default() -> Self {
236 Self::new()
237 }
238}
239
240impl RunnerCore {
241 pub fn new() -> Self {
242 Self {
243 ui_state: UiState::default(),
244 last_tree: None,
245 quad_scratch: Vec::new(),
246 runs: Vec::new(),
247 paint_items: Vec::new(),
248 last_ops: Vec::new(),
249 viewport_px: (1, 1),
250 surface_size_override: None,
251 theme: Theme::default(),
252 }
253 }
254
255 pub fn set_theme(&mut self, theme: Theme) {
256 self.theme = theme;
257 }
258
259 pub fn theme(&self) -> &Theme {
260 &self.theme
261 }
262
263 pub fn set_surface_size(&mut self, width: u32, height: u32) {
269 self.surface_size_override = Some((width.max(1), height.max(1)));
270 }
271
272 pub fn ui_state(&self) -> &UiState {
273 &self.ui_state
274 }
275
276 pub fn debug_summary(&self) -> String {
277 self.ui_state.debug_summary()
278 }
279
280 pub fn rect_of_key(&self, key: &str) -> Option<Rect> {
281 self.last_tree
282 .as_ref()
283 .and_then(|t| self.ui_state.rect_of_key(t, key))
284 }
285
286 pub fn would_press_focus_text_input(&self, x: f32, y: f32) -> bool {
303 let Some(tree) = self.last_tree.as_ref() else {
304 return false;
305 };
306 let Some(target) = hit_test::hit_test_target(tree, &self.ui_state, (x, y)) else {
307 return false;
308 };
309 find_capture_keys(tree, &target.node_id).unwrap_or(false)
310 }
311
312 pub fn pointer_moved(&mut self, p: Pointer) -> PointerMove {
326 let Pointer { x, y, kind, .. } = p;
327 self.ui_state.pointer_pos = Some((x, y));
328 self.ui_state.pointer_kind = kind;
329
330 if let Some(drag) = self.ui_state.scroll.thumb_drag.clone() {
336 let dy = y - drag.start_pointer_y;
337 let new_offset = if drag.track_remaining > 0.0 {
338 drag.start_offset + dy * (drag.max_offset / drag.track_remaining)
339 } else {
340 drag.start_offset
341 };
342 let clamped = new_offset.clamp(0.0, drag.max_offset);
343 let prev = self.ui_state.scroll.offsets.insert(drag.scroll_id, clamped);
344 let changed = prev.is_none_or(|old| (old - clamped).abs() > f32::EPSILON);
345 return PointerMove {
346 events: Vec::new(),
347 needs_redraw: changed,
348 };
349 }
350
351 let hit = self
352 .last_tree
353 .as_ref()
354 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
355 let prev_hover = self.ui_state.hovered.clone();
359 let hover_changed = self.ui_state.set_hovered(hit, Instant::now());
360 let prev_hovered_link = self.ui_state.hovered_link.clone();
366 let new_hovered_link = self
367 .last_tree
368 .as_ref()
369 .and_then(|t| hit_test::link_at(t, &self.ui_state, (x, y)));
370 let link_hover_changed = new_hovered_link != prev_hovered_link;
371 self.ui_state.hovered_link = new_hovered_link;
372 let modifiers = self.ui_state.modifiers;
373
374 let mut out = Vec::new();
375
376 let touch_no_press = matches!(kind, PointerKind::Touch) && self.ui_state.pressed.is_none();
392 if hover_changed && !touch_no_press {
393 if let Some(prev) = prev_hover {
394 out.push(UiEvent {
395 key: Some(prev.key.clone()),
396 target: Some(prev),
397 pointer: Some((x, y)),
398 key_press: None,
399 text: None,
400 selection: None,
401 modifiers,
402 click_count: 0,
403 path: None,
404 pointer_kind: Some(kind),
405 kind: UiEventKind::PointerLeave,
406 });
407 }
408 if let Some(new) = self.ui_state.hovered.clone() {
409 out.push(UiEvent {
410 key: Some(new.key.clone()),
411 target: Some(new),
412 pointer: Some((x, y)),
413 key_press: None,
414 text: None,
415 selection: None,
416 modifiers,
417 click_count: 0,
418 path: None,
419 pointer_kind: Some(kind),
420 kind: UiEventKind::PointerEnter,
421 });
422 }
423 }
424
425 if matches!(kind, PointerKind::Touch) {
430 match self.ui_state.touch_gesture.clone() {
431 TouchGestureState::Pending {
432 initial,
433 consumes_drag,
434 started_at,
435 } => {
436 let dx = x - initial.0;
437 let dy = y - initial.1;
438 if (dx * dx + dy * dy).sqrt() < TOUCH_DRAG_THRESHOLD {
439 let needs_redraw = hover_changed || link_hover_changed || !out.is_empty();
444 return PointerMove {
445 events: out,
446 needs_redraw,
447 };
448 }
449 if consumes_drag {
450 self.ui_state.touch_gesture = TouchGestureState::None;
455 } else {
456 let now = Instant::now();
463 self.cancel_press_for_scroll(&mut out, x, y, kind, modifiers);
464 let scroll_dy = initial.1 - y;
470 let step = self.last_tree.as_ref().and_then(|tree| {
471 self.ui_state.scroll_by_pointer(tree, initial, scroll_dy)
472 });
473 let dt = now
474 .duration_since(started_at)
475 .as_secs_f32()
476 .max(1.0 / 120.0);
477 let velocity = step.as_ref().map(|s| s.applied_delta / dt).unwrap_or(0.0);
478 self.ui_state.touch_gesture = TouchGestureState::Scrolling {
479 last_pos: (x, y),
480 last_time: now,
481 velocity,
482 scroll_id: step.map(|s| s.scroll_id),
483 };
484 return PointerMove {
485 events: out,
486 needs_redraw: true,
487 };
488 }
489 }
490 TouchGestureState::Scrolling {
491 last_pos,
492 last_time,
493 velocity,
494 scroll_id,
495 } => {
496 let now = Instant::now();
497 let scroll_dy = last_pos.1 - y;
498 let step = scroll_id
499 .as_ref()
500 .and_then(|id| self.ui_state.scroll_by_id(id, scroll_dy))
501 .or_else(|| {
502 self.last_tree.as_ref().and_then(|tree| {
503 self.ui_state.scroll_by_pointer(tree, (x, y), scroll_dy)
504 })
505 });
506 let dt = now.duration_since(last_time).as_secs_f32().max(1.0 / 240.0);
507 let sample_velocity =
508 step.as_ref().map(|s| s.applied_delta / dt).unwrap_or(0.0);
509 let velocity = sample_velocity * 0.65 + velocity * 0.35;
510 self.ui_state.touch_gesture = TouchGestureState::Scrolling {
511 last_pos: (x, y),
512 last_time: now,
513 velocity,
514 scroll_id: step.map(|s| s.scroll_id).or(scroll_id),
515 };
516 return PointerMove {
517 events: out,
518 needs_redraw: true,
519 };
520 }
521 TouchGestureState::None => {
522 }
525 TouchGestureState::LongPressed => {
526 self.extend_selection_drag_at(x, y, kind, modifiers, &mut out);
531 if self.ui_state.pressed.is_none() {
532 let needs_redraw = hover_changed || link_hover_changed || !out.is_empty();
533 return PointerMove {
534 events: out,
535 needs_redraw,
536 };
537 }
538 }
539 }
540 }
541
542 self.extend_selection_drag_at(x, y, kind, modifiers, &mut out);
549
550 if let Some(p) = self.ui_state.pressed.clone() {
556 if self.focused_captures_keys() {
560 self.ui_state.bump_caret_activity(Instant::now());
561 }
562 out.push(UiEvent {
563 key: Some(p.key.clone()),
564 target: Some(p),
565 pointer: Some((x, y)),
566 key_press: None,
567 text: None,
568 selection: None,
569 modifiers,
570 click_count: self.ui_state.current_click_count(),
571 path: None,
572 pointer_kind: Some(kind),
573 kind: UiEventKind::Drag,
574 });
575 }
576
577 let needs_redraw = hover_changed || link_hover_changed || !out.is_empty();
578 PointerMove {
579 events: out,
580 needs_redraw,
581 }
582 }
583
584 pub fn pointer_left(&mut self) -> Vec<UiEvent> {
592 let last_pos = self.ui_state.pointer_pos;
593 let prev_hover = self.ui_state.hovered.clone();
594 let modifiers = self.ui_state.modifiers;
595 let kind = self.ui_state.pointer_kind;
600 self.ui_state.pointer_pos = None;
601 self.ui_state.set_hovered(None, Instant::now());
602 self.ui_state.pressed = None;
603 self.ui_state.pressed_secondary = None;
604 self.ui_state.touch_gesture = TouchGestureState::None;
605 self.ui_state.cancel_scroll_momentum();
606 self.ui_state.hovered_link = None;
612 self.ui_state.pressed_link = None;
613
614 let mut out = Vec::new();
615 if let Some(prev) = prev_hover {
616 out.push(UiEvent {
617 key: Some(prev.key.clone()),
618 target: Some(prev),
619 pointer: last_pos,
620 key_press: None,
621 text: None,
622 selection: None,
623 modifiers,
624 click_count: 0,
625 path: None,
626 pointer_kind: Some(kind),
627 kind: UiEventKind::PointerLeave,
628 });
629 }
630 out
631 }
632
633 pub fn file_hovered(&mut self, path: std::path::PathBuf, x: f32, y: f32) -> Vec<UiEvent> {
647 self.ui_state.pointer_pos = Some((x, y));
648 let target = self
649 .last_tree
650 .as_ref()
651 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
652 let key = target.as_ref().map(|t| t.key.clone());
653 vec![UiEvent {
654 key,
655 target,
656 pointer: Some((x, y)),
657 key_press: None,
658 text: None,
659 selection: None,
660 modifiers: self.ui_state.modifiers,
661 click_count: 0,
662 path: Some(path),
663 pointer_kind: None,
664 kind: UiEventKind::FileHovered,
665 }]
666 }
667
668 pub fn file_hover_cancelled(&mut self) -> Vec<UiEvent> {
673 vec![UiEvent {
674 key: None,
675 target: None,
676 pointer: self.ui_state.pointer_pos,
677 key_press: None,
678 text: None,
679 selection: None,
680 modifiers: self.ui_state.modifiers,
681 click_count: 0,
682 path: None,
683 pointer_kind: None,
684 kind: UiEventKind::FileHoverCancelled,
685 }]
686 }
687
688 pub fn file_dropped(&mut self, path: std::path::PathBuf, x: f32, y: f32) -> Vec<UiEvent> {
693 self.ui_state.pointer_pos = Some((x, y));
694 let target = self
695 .last_tree
696 .as_ref()
697 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
698 let key = target.as_ref().map(|t| t.key.clone());
699 vec![UiEvent {
700 key,
701 target,
702 pointer: Some((x, y)),
703 key_press: None,
704 text: None,
705 selection: None,
706 modifiers: self.ui_state.modifiers,
707 click_count: 0,
708 path: Some(path),
709 pointer_kind: None,
710 kind: UiEventKind::FileDropped,
711 }]
712 }
713
714 pub fn pointer_down(&mut self, p: Pointer) -> Vec<UiEvent> {
727 let Pointer {
728 x, y, button, kind, ..
729 } = p;
730 self.ui_state.pointer_kind = kind;
731 self.ui_state.cancel_scroll_momentum();
732 if matches!(button, PointerButton::Primary)
741 && let Some((scroll_id, _track, thumb_rect)) = self.ui_state.thumb_at(x, y)
742 {
743 let metrics = self
744 .ui_state
745 .scroll
746 .metrics
747 .get(&scroll_id)
748 .copied()
749 .unwrap_or_default();
750 let start_offset = self
751 .ui_state
752 .scroll
753 .offsets
754 .get(&scroll_id)
755 .copied()
756 .unwrap_or(0.0);
757
758 let grabbed = y >= thumb_rect.y && y <= thumb_rect.y + thumb_rect.h;
762 if grabbed {
763 let track_remaining = (metrics.viewport_h - thumb_rect.h).max(0.0);
764 self.ui_state.scroll.thumb_drag = Some(crate::state::ThumbDrag {
765 scroll_id,
766 start_pointer_y: y,
767 start_offset,
768 track_remaining,
769 max_offset: metrics.max_offset,
770 });
771 } else {
772 let page = (metrics.viewport_h - SCROLL_PAGE_OVERLAP).max(0.0);
778 let delta = if y < thumb_rect.y { -page } else { page };
779 let new_offset = (start_offset + delta).clamp(0.0, metrics.max_offset);
780 self.ui_state.scroll.offsets.insert(scroll_id, new_offset);
781 }
782 return Vec::new();
783 }
784
785 let hit = self
786 .last_tree
787 .as_ref()
788 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
789 if !matches!(button, PointerButton::Primary) {
794 self.ui_state.pressed_secondary = hit.map(|h| (h, button));
797 return Vec::new();
798 }
799
800 self.ui_state.pressed_link = self
808 .last_tree
809 .as_ref()
810 .and_then(|t| hit_test::link_at(t, &self.ui_state, (x, y)));
811 self.ui_state.set_focus(hit.clone());
812 self.ui_state.set_focus_visible(false);
816 self.ui_state.pressed = hit.clone();
817 self.ui_state.tooltip.dismissed_for_hover = true;
820 let modifiers = self.ui_state.modifiers;
821
822 let now = Instant::now();
825 let click_count =
826 self.ui_state
827 .next_click_count(now, (x, y), hit.as_ref().map(|t| t.node_id.as_str()));
828
829 let mut out = Vec::new();
830
831 if matches!(kind, PointerKind::Touch) {
839 let prev_hover = self.ui_state.hovered.clone();
840 let hover_changed = self.ui_state.set_hovered(hit.clone(), now);
841 if hover_changed {
842 if let Some(prev) = prev_hover {
843 out.push(UiEvent {
844 key: Some(prev.key.clone()),
845 target: Some(prev),
846 pointer: Some((x, y)),
847 key_press: None,
848 text: None,
849 selection: None,
850 modifiers,
851 click_count: 0,
852 path: None,
853 pointer_kind: Some(kind),
854 kind: UiEventKind::PointerLeave,
855 });
856 }
857 if let Some(new) = hit.clone() {
858 out.push(UiEvent {
859 key: Some(new.key.clone()),
860 target: Some(new),
861 pointer: Some((x, y)),
862 key_press: None,
863 text: None,
864 selection: None,
865 modifiers,
866 click_count: 0,
867 path: None,
868 pointer_kind: Some(kind),
869 kind: UiEventKind::PointerEnter,
870 });
871 }
872 }
873 let consumes_drag = hit
881 .as_ref()
882 .and_then(|t| {
883 self.last_tree
884 .as_ref()
885 .and_then(|tree| find_consumes_touch_drag(tree, &t.node_id, false))
886 })
887 .unwrap_or(false);
888 self.ui_state.touch_gesture = TouchGestureState::Pending {
889 initial: (x, y),
890 consumes_drag,
891 started_at: now,
892 };
893 }
894
895 if let Some(p) = hit.clone() {
896 if self.focused_captures_keys() {
903 self.ui_state.bump_caret_activity(now);
904 }
905 out.push(UiEvent {
906 key: Some(p.key.clone()),
907 target: Some(p),
908 pointer: Some((x, y)),
909 key_press: None,
910 text: None,
911 selection: None,
912 modifiers,
913 click_count,
914 path: None,
915 pointer_kind: Some(kind),
916 kind: UiEventKind::PointerDown,
917 });
918 }
919
920 if let Some(point) = self
928 .last_tree
929 .as_ref()
930 .and_then(|t| hit_test::selection_point_at(t, &self.ui_state, (x, y)))
931 {
932 self.start_selection_drag(point, &mut out, modifiers, (x, y), click_count, kind);
933 } else if !self.ui_state.current_selection.is_empty() {
934 let click_handles_selection = match (&hit, &self.ui_state.current_selection.range) {
956 (Some(h), Some(range)) => {
957 h.key == range.anchor.key
958 || h.key == range.head.key
959 || self
960 .last_tree
961 .as_ref()
962 .and_then(|t| find_capture_keys(t, &h.node_id))
963 .unwrap_or(false)
964 }
965 _ => false,
966 };
967 if !click_handles_selection {
968 out.push(selection_event(
969 crate::selection::Selection::default(),
970 modifiers,
971 Some((x, y)),
972 Some(kind),
973 ));
974 self.ui_state.current_selection = crate::selection::Selection::default();
975 self.ui_state.selection.drag = None;
976 }
977 }
978
979 out
980 }
981
982 fn start_selection_drag(
990 &mut self,
991 point: crate::selection::SelectionPoint,
992 out: &mut Vec<UiEvent>,
993 modifiers: KeyModifiers,
994 pointer: (f32, f32),
995 click_count: u8,
996 kind: PointerKind,
997 ) {
998 let leaf_text = self
999 .last_tree
1000 .as_ref()
1001 .and_then(|t| crate::selection::find_keyed_text(t, &point.key))
1002 .unwrap_or_default();
1003 let (anchor_byte, head_byte) = match click_count {
1004 2 => crate::selection::word_range_at(&leaf_text, point.byte),
1005 n if n >= 3 => (0, leaf_text.len()),
1006 _ => (point.byte, point.byte),
1007 };
1008 let granularity = match click_count {
1009 2 => SelectionDragGranularity::Word,
1010 n if n >= 3 => SelectionDragGranularity::Leaf,
1011 _ => SelectionDragGranularity::Character,
1012 };
1013 let anchor = crate::selection::SelectionPoint::new(point.key.clone(), anchor_byte);
1014 let head = crate::selection::SelectionPoint::new(point.key.clone(), head_byte);
1015 let new_sel = crate::selection::Selection {
1016 range: Some(crate::selection::SelectionRange {
1017 anchor: anchor.clone(),
1018 head: head.clone(),
1019 }),
1020 };
1021 self.ui_state.current_selection = new_sel.clone();
1022 self.ui_state.selection.drag = Some(crate::state::SelectionDrag {
1023 anchor,
1024 head,
1025 granularity,
1026 });
1027 out.push(selection_event(
1028 new_sel,
1029 modifiers,
1030 Some(pointer),
1031 Some(kind),
1032 ));
1033 }
1034
1035 fn extend_selection_drag_at(
1036 &mut self,
1037 x: f32,
1038 y: f32,
1039 kind: PointerKind,
1040 modifiers: KeyModifiers,
1041 out: &mut Vec<UiEvent>,
1042 ) {
1043 let Some(drag) = self.ui_state.selection.drag.clone() else {
1044 return;
1045 };
1046 let Some(tree) = self.last_tree.as_ref() else {
1047 return;
1048 };
1049 let raw_head =
1050 head_for_drag(tree, &self.ui_state, (x, y)).unwrap_or_else(|| drag.anchor.clone());
1051 let (anchor, head) = selection_range_for_drag(tree, &self.ui_state, &drag, raw_head);
1052 let new_sel = crate::selection::Selection {
1053 range: Some(crate::selection::SelectionRange { anchor, head }),
1054 };
1055 if new_sel != self.ui_state.current_selection {
1056 self.ui_state.current_selection = new_sel.clone();
1057 out.push(selection_event(
1058 new_sel,
1059 modifiers,
1060 Some((x, y)),
1061 Some(kind),
1062 ));
1063 }
1064 }
1065
1066 fn cancel_press_for_scroll(
1076 &mut self,
1077 out: &mut Vec<UiEvent>,
1078 x: f32,
1079 y: f32,
1080 kind: PointerKind,
1081 modifiers: KeyModifiers,
1082 ) {
1083 let pressed = self.ui_state.pressed.take();
1084 let hovered = self.ui_state.hovered.clone();
1085 self.ui_state.set_hovered(None, Instant::now());
1086 self.ui_state.pressed_secondary = None;
1087 self.ui_state.pressed_link = None;
1088 self.ui_state.selection.drag = None;
1089 if let Some(p) = pressed {
1090 out.push(UiEvent {
1091 key: Some(p.key.clone()),
1092 target: Some(p),
1093 pointer: Some((x, y)),
1094 key_press: None,
1095 text: None,
1096 selection: None,
1097 modifiers,
1098 click_count: 0,
1099 path: None,
1100 pointer_kind: Some(kind),
1101 kind: UiEventKind::PointerCancel,
1102 });
1103 }
1104 if let Some(h) = hovered {
1105 out.push(UiEvent {
1106 key: Some(h.key.clone()),
1107 target: Some(h),
1108 pointer: Some((x, y)),
1109 key_press: None,
1110 text: None,
1111 selection: None,
1112 modifiers,
1113 click_count: 0,
1114 path: None,
1115 pointer_kind: Some(kind),
1116 kind: UiEventKind::PointerLeave,
1117 });
1118 }
1119 }
1120
1121 pub fn pointer_up(&mut self, p: Pointer) -> Vec<UiEvent> {
1129 let Pointer {
1130 x, y, button, kind, ..
1131 } = p;
1132 self.ui_state.pointer_kind = kind;
1133 if matches!(button, PointerButton::Primary) && self.ui_state.scroll.thumb_drag.is_some() {
1138 self.ui_state.scroll.thumb_drag = None;
1139 self.ui_state.touch_gesture = TouchGestureState::None;
1140 return Vec::new();
1141 }
1142
1143 let was_long_pressed =
1149 matches!(self.ui_state.touch_gesture, TouchGestureState::LongPressed);
1150 let momentum = match &self.ui_state.touch_gesture {
1151 TouchGestureState::Scrolling {
1152 velocity,
1153 scroll_id,
1154 ..
1155 } if matches!(kind, PointerKind::Touch) => {
1156 Some((scroll_id.clone(), *velocity, Instant::now()))
1157 }
1158 _ => None,
1159 };
1160 let was_scrolling_or_long = matches!(
1161 self.ui_state.touch_gesture,
1162 TouchGestureState::Scrolling { .. } | TouchGestureState::LongPressed
1163 );
1164 self.ui_state.touch_gesture = TouchGestureState::None;
1165 if was_scrolling_or_long {
1166 if let Some((scroll_id, velocity, now)) = momentum {
1167 self.ui_state
1168 .start_scroll_momentum(scroll_id, velocity, now);
1169 }
1170 if was_long_pressed {
1171 self.ui_state.pressed = None;
1172 self.ui_state.pressed_secondary = None;
1173 self.ui_state.pressed_link = None;
1174 self.ui_state.selection.drag = None;
1175 self.ui_state.set_hovered(None, Instant::now());
1176 }
1177 return Vec::new();
1178 }
1179
1180 if matches!(button, PointerButton::Primary) {
1183 self.ui_state.selection.drag = None;
1184 }
1185
1186 let hit = self
1187 .last_tree
1188 .as_ref()
1189 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
1190 let modifiers = self.ui_state.modifiers;
1191 let mut out = Vec::new();
1192 match button {
1193 PointerButton::Primary => {
1194 let pressed = self.ui_state.pressed.take();
1195 let click_count = self.ui_state.current_click_count();
1196 if let Some(p) = pressed.clone() {
1197 out.push(UiEvent {
1198 key: Some(p.key.clone()),
1199 target: Some(p),
1200 pointer: Some((x, y)),
1201 key_press: None,
1202 text: None,
1203 selection: None,
1204 modifiers,
1205 click_count,
1206 path: None,
1207 pointer_kind: Some(kind),
1208 kind: UiEventKind::PointerUp,
1209 });
1210 }
1211 if let (Some(p), Some(h)) = (pressed, hit)
1212 && p.node_id == h.node_id
1213 {
1214 if let Some(id) = toast::parse_dismiss_key(&p.key) {
1220 self.ui_state.dismiss_toast(id);
1221 } else {
1222 out.push(UiEvent {
1223 key: Some(p.key.clone()),
1224 target: Some(p),
1225 pointer: Some((x, y)),
1226 key_press: None,
1227 text: None,
1228 selection: None,
1229 modifiers,
1230 click_count,
1231 path: None,
1232 pointer_kind: Some(kind),
1233 kind: UiEventKind::Click,
1234 });
1235 }
1236 }
1237 if let Some(pressed_url) = self.ui_state.pressed_link.take() {
1243 let up_link = self
1244 .last_tree
1245 .as_ref()
1246 .and_then(|t| hit_test::link_at(t, &self.ui_state, (x, y)));
1247 if up_link.as_ref() == Some(&pressed_url) {
1248 out.push(UiEvent {
1249 key: Some(pressed_url),
1250 target: None,
1251 pointer: Some((x, y)),
1252 key_press: None,
1253 text: None,
1254 selection: None,
1255 modifiers,
1256 click_count: 1,
1257 path: None,
1258 pointer_kind: Some(kind),
1259 kind: UiEventKind::LinkActivated,
1260 });
1261 }
1262 }
1263 }
1264 PointerButton::Secondary | PointerButton::Middle => {
1265 let pressed = self.ui_state.pressed_secondary.take();
1266 if let (Some((p, b)), Some(h)) = (pressed, hit)
1267 && b == button
1268 && p.node_id == h.node_id
1269 {
1270 let event_kind = match button {
1271 PointerButton::Secondary => UiEventKind::SecondaryClick,
1272 PointerButton::Middle => UiEventKind::MiddleClick,
1273 PointerButton::Primary => unreachable!(),
1274 };
1275 out.push(UiEvent {
1276 key: Some(p.key.clone()),
1277 target: Some(p),
1278 pointer: Some((x, y)),
1279 key_press: None,
1280 text: None,
1281 selection: None,
1282 modifiers,
1283 click_count: 1,
1284 path: None,
1285 pointer_kind: Some(kind),
1286 kind: event_kind,
1287 });
1288 }
1289 }
1290 }
1291
1292 if matches!(kind, PointerKind::Touch)
1298 && let Some(prev) = self.ui_state.hovered.clone()
1299 {
1300 self.ui_state.set_hovered(None, Instant::now());
1301 out.push(UiEvent {
1302 key: Some(prev.key.clone()),
1303 target: Some(prev),
1304 pointer: Some((x, y)),
1305 key_press: None,
1306 text: None,
1307 selection: None,
1308 modifiers,
1309 click_count: 0,
1310 path: None,
1311 pointer_kind: Some(kind),
1312 kind: UiEventKind::PointerLeave,
1313 });
1314 }
1315
1316 out
1317 }
1318
1319 pub fn key_down(&mut self, key: UiKey, modifiers: KeyModifiers, repeat: bool) -> Vec<UiEvent> {
1320 if self.focused_captures_keys() {
1328 if let Some(event) = self.ui_state.try_hotkey(&key, modifiers, repeat) {
1329 return vec![event];
1330 }
1331 self.ui_state.bump_caret_activity(Instant::now());
1338 self.ui_state.set_focus_visible(true);
1339 let blur_after = matches!(key, UiKey::Escape);
1340 let out = self
1341 .ui_state
1342 .key_down_raw(key, modifiers, repeat)
1343 .into_iter()
1344 .collect();
1345 if blur_after {
1346 self.ui_state.set_focus(None);
1347 self.ui_state.set_focus_visible(false);
1348 }
1349 return out;
1350 }
1351
1352 if matches!(
1358 key,
1359 UiKey::ArrowUp | UiKey::ArrowDown | UiKey::Home | UiKey::End
1360 ) && let Some(siblings) = self.focused_arrow_nav_group()
1361 {
1362 if let Some(event) = self.ui_state.try_hotkey(&key, modifiers, repeat) {
1363 return vec![event];
1364 }
1365 self.move_focus_in_group(&key, &siblings);
1366 return Vec::new();
1367 }
1368
1369 let mut out: Vec<UiEvent> = self
1370 .ui_state
1371 .key_down(key, modifiers, repeat)
1372 .into_iter()
1373 .collect();
1374
1375 if matches!(out.first().map(|e| e.kind), Some(UiEventKind::Escape))
1383 && !self.ui_state.current_selection.is_empty()
1384 {
1385 self.ui_state.current_selection = crate::selection::Selection::default();
1386 self.ui_state.selection.drag = None;
1387 out.push(selection_event(
1388 crate::selection::Selection::default(),
1389 modifiers,
1390 None,
1391 None,
1392 ));
1393 }
1394
1395 out
1396 }
1397
1398 fn focused_arrow_nav_group(&self) -> Option<Vec<UiTarget>> {
1405 let focused = self.ui_state.focused.as_ref()?;
1406 let tree = self.last_tree.as_ref()?;
1407 focus::arrow_nav_group(tree, &self.ui_state, &focused.node_id)
1408 }
1409
1410 fn move_focus_in_group(&mut self, key: &UiKey, siblings: &[UiTarget]) {
1415 if siblings.is_empty() {
1416 return;
1417 }
1418 let focused_id = match self.ui_state.focused.as_ref() {
1419 Some(t) => t.node_id.clone(),
1420 None => return,
1421 };
1422 let idx = siblings.iter().position(|t| t.node_id == focused_id);
1423 let next_idx = match (key, idx) {
1424 (UiKey::ArrowUp, Some(i)) => i.saturating_sub(1),
1425 (UiKey::ArrowDown, Some(i)) => (i + 1).min(siblings.len() - 1),
1426 (UiKey::Home, _) => 0,
1427 (UiKey::End, _) => siblings.len() - 1,
1428 _ => return,
1429 };
1430 if Some(next_idx) != idx {
1431 self.ui_state.set_focus(Some(siblings[next_idx].clone()));
1432 self.ui_state.set_focus_visible(true);
1433 }
1434 }
1435
1436 pub fn focused_captures_keys(&self) -> bool {
1443 let Some(focused) = self.ui_state.focused.as_ref() else {
1444 return false;
1445 };
1446 let Some(tree) = self.last_tree.as_ref() else {
1447 return false;
1448 };
1449 find_capture_keys(tree, &focused.node_id).unwrap_or(false)
1450 }
1451
1452 pub fn text_input(&mut self, text: String) -> Option<UiEvent> {
1458 if text.is_empty() {
1459 return None;
1460 }
1461 let target = self.ui_state.focused.clone()?;
1462 let modifiers = self.ui_state.modifiers;
1463 self.ui_state.bump_caret_activity(Instant::now());
1466 Some(UiEvent {
1467 key: Some(target.key.clone()),
1468 target: Some(target),
1469 pointer: None,
1470 key_press: None,
1471 text: Some(text),
1472 selection: None,
1473 modifiers,
1474 click_count: 0,
1475 path: None,
1476 pointer_kind: None,
1477 kind: UiEventKind::TextInput,
1478 })
1479 }
1480
1481 pub fn set_hotkeys(&mut self, hotkeys: Vec<(KeyChord, String)>) {
1482 self.ui_state.set_hotkeys(hotkeys);
1483 }
1484
1485 pub fn set_selection(&mut self, selection: crate::selection::Selection) {
1490 if self.ui_state.current_selection != selection {
1491 self.ui_state.bump_caret_activity(Instant::now());
1492 }
1493 self.ui_state.current_selection = selection;
1494 }
1495
1496 pub fn selected_text(&self) -> Option<String> {
1510 self.selected_text_for(&self.ui_state.current_selection)
1511 }
1512
1513 pub fn selected_text_for(&self, selection: &crate::selection::Selection) -> Option<String> {
1519 let tree = self.last_tree.as_ref()?;
1520 crate::selection::selected_text(tree, selection)
1521 }
1522
1523 pub fn push_toasts(&mut self, specs: Vec<crate::toast::ToastSpec>) {
1529 let now = Instant::now();
1530 for spec in specs {
1531 self.ui_state.push_toast(spec, now);
1532 }
1533 }
1534
1535 pub fn dismiss_toast(&mut self, id: u64) {
1539 self.ui_state.dismiss_toast(id);
1540 }
1541
1542 pub fn push_focus_requests(&mut self, keys: Vec<String>) {
1548 self.ui_state.push_focus_requests(keys);
1549 }
1550
1551 pub fn push_scroll_requests(&mut self, requests: Vec<crate::scroll::ScrollRequest>) {
1557 self.ui_state.push_scroll_requests(requests);
1558 }
1559
1560 pub fn set_animation_mode(&mut self, mode: AnimationMode) {
1561 self.ui_state.set_animation_mode(mode);
1562 }
1563
1564 pub fn pointer_wheel(&mut self, x: f32, y: f32, dy: f32) -> bool {
1565 let Some(tree) = self.last_tree.as_ref() else {
1566 return false;
1567 };
1568 self.ui_state.cancel_scroll_momentum();
1569 self.ui_state.pointer_wheel(tree, (x, y), dy)
1570 }
1571
1572 pub fn poll_input(&mut self, now: Instant) -> Vec<UiEvent> {
1587 let TouchGestureState::Pending {
1588 initial,
1589 started_at,
1590 ..
1591 } = self.ui_state.touch_gesture.clone()
1592 else {
1593 return Vec::new();
1594 };
1595 if now.duration_since(started_at) < LONG_PRESS_DELAY {
1596 return Vec::new();
1597 }
1598 let mut out = Vec::new();
1599 let modifiers = self.ui_state.modifiers;
1600 let kind = PointerKind::Touch;
1601 let (x, y) = initial;
1602 let press_target = self.ui_state.pressed.clone();
1603 let preserves_press_for_drag = press_target.as_ref().is_some_and(|t| {
1604 self.last_tree
1605 .as_ref()
1606 .and_then(|tree| find_capture_keys(tree, &t.node_id))
1607 .unwrap_or(false)
1608 });
1609 if preserves_press_for_drag {
1610 self.ui_state.pressed_secondary = None;
1611 self.ui_state.pressed_link = None;
1612 self.ui_state.selection.drag = None;
1613 } else {
1614 self.cancel_press_for_scroll(&mut out, x, y, kind, modifiers);
1621 }
1622 if let Some(t) = press_target {
1623 out.push(UiEvent {
1624 key: Some(t.key.clone()),
1625 target: Some(t),
1626 pointer: Some((x, y)),
1627 key_press: None,
1628 text: None,
1629 selection: None,
1630 modifiers,
1631 click_count: 0,
1632 path: None,
1633 pointer_kind: Some(kind),
1634 kind: UiEventKind::LongPress,
1635 });
1636 } else {
1637 out.push(UiEvent {
1641 key: None,
1642 target: None,
1643 pointer: Some((x, y)),
1644 key_press: None,
1645 text: None,
1646 selection: None,
1647 modifiers,
1648 click_count: 0,
1649 path: None,
1650 pointer_kind: Some(kind),
1651 kind: UiEventKind::LongPress,
1652 });
1653 }
1654 if !preserves_press_for_drag
1655 && let Some(point) = self
1656 .last_tree
1657 .as_ref()
1658 .and_then(|t| hit_test::selection_point_at(t, &self.ui_state, (x, y)))
1659 {
1660 self.start_selection_drag(point, &mut out, modifiers, (x, y), 2, kind);
1661 }
1662 self.ui_state.touch_gesture = TouchGestureState::LongPressed;
1663 out
1664 }
1665
1666 pub fn next_input_deadline(&self, now: Instant) -> Option<std::time::Duration> {
1676 if self.ui_state.has_scroll_momentum() {
1677 return Some(std::time::Duration::ZERO);
1678 }
1679 let TouchGestureState::Pending { started_at, .. } = self.ui_state.touch_gesture.clone()
1680 else {
1681 return None;
1682 };
1683 let elapsed = now.duration_since(started_at);
1684 Some(LONG_PRESS_DELAY.saturating_sub(elapsed))
1685 }
1686
1687 pub fn prepare_layout<F>(
1704 &mut self,
1705 root: &mut El,
1706 viewport: Rect,
1707 scale_factor: f32,
1708 timings: &mut PrepareTimings,
1709 samples_time: F,
1710 ) -> LayoutPrepared
1711 where
1712 F: Fn(&ShaderHandle) -> bool,
1713 {
1714 let t0 = Instant::now();
1715 let scroll_momentum_pending = self.ui_state.tick_scroll_momentum(t0);
1716 let mut needs_redraw = {
1723 crate::profile_span!("prepare::layout");
1724 {
1725 crate::profile_span!("prepare::layout::assign_ids");
1726 layout::assign_ids(root);
1727 }
1728 let tooltip_pending = {
1729 crate::profile_span!("prepare::layout::tooltip");
1730 tooltip::synthesize_tooltip(root, &self.ui_state, t0)
1731 };
1732 let toast_pending = {
1733 crate::profile_span!("prepare::layout::toast");
1734 toast::synthesize_toasts(root, &mut self.ui_state, t0)
1735 };
1736 {
1737 crate::profile_span!("prepare::layout::apply_metrics");
1738 self.theme.apply_metrics(root);
1739 }
1740 {
1741 crate::profile_span!("prepare::layout::layout");
1742 layout::layout_post_assign(root, &mut self.ui_state, viewport);
1749 self.ui_state.clear_pending_scroll_requests();
1754 }
1755 {
1756 crate::profile_span!("prepare::layout::sync_focus_order");
1757 self.ui_state.sync_focus_order(root);
1758 }
1759 {
1760 crate::profile_span!("prepare::layout::sync_selection_order");
1761 self.ui_state.sync_selection_order(root);
1762 }
1763 {
1764 crate::profile_span!("prepare::layout::sync_popover_focus");
1765 focus::sync_popover_focus(root, &mut self.ui_state);
1766 }
1767 {
1768 crate::profile_span!("prepare::layout::drain_focus_requests");
1773 self.ui_state.drain_focus_requests();
1774 }
1775 {
1776 crate::profile_span!("prepare::layout::apply_state");
1777 self.ui_state.apply_to_state();
1778 }
1779 self.viewport_px = self.surface_size_override.unwrap_or_else(|| {
1780 (
1781 (viewport.w * scale_factor).ceil().max(1.0) as u32,
1782 (viewport.h * scale_factor).ceil().max(1.0) as u32,
1783 )
1784 });
1785 let animations = {
1786 crate::profile_span!("prepare::layout::tick_animations");
1787 self.ui_state
1788 .tick_visual_animations(root, Instant::now(), self.theme.palette())
1789 };
1790 animations || tooltip_pending || toast_pending || scroll_momentum_pending
1791 };
1792 let t_after_layout = Instant::now();
1793 timings.layout_intrinsic_cache = layout::take_intrinsic_cache_stats();
1794 timings.layout_prune = layout::take_prune_stats();
1795 let (ops, draw_ops_stats) = {
1796 crate::profile_span!("prepare::draw_ops");
1797 let mut stats = DrawOpsStats::default();
1798 let ops = draw_ops::draw_ops_with_theme_and_stats(
1799 root,
1800 &self.ui_state,
1801 &self.theme,
1802 &mut stats,
1803 );
1804 (ops, stats)
1805 };
1806 let t_after_draw_ops = Instant::now();
1807 timings.layout = t_after_layout - t0;
1808 timings.draw_ops = t_after_draw_ops - t_after_layout;
1809 timings.draw_ops_culled_text_ops = draw_ops_stats.culled_text_ops;
1810 timings.text_layout_cache = crate::text::metrics::take_shape_cache_stats();
1811
1812 let shader_needs_redraw = ops.iter().any(|op| op_is_continuous(op, &samples_time));
1829 let widget_redraw =
1830 aggregate_redraw_within(root, viewport, &self.ui_state.layout.computed_rects);
1831 let input_deadline = self.next_input_deadline(Instant::now());
1837 let widget_redraw = match (widget_redraw, input_deadline) {
1838 (Some(a), Some(b)) => Some(a.min(b)),
1839 (a, b) => a.or(b),
1840 };
1841
1842 let next_layout_redraw_in = match (needs_redraw, widget_redraw) {
1843 (true, Some(d)) => Some(d.min(std::time::Duration::ZERO)),
1844 (true, None) => Some(std::time::Duration::ZERO),
1845 (false, d) => d,
1846 };
1847 let next_paint_redraw_in = if shader_needs_redraw {
1848 Some(std::time::Duration::ZERO)
1849 } else {
1850 None
1851 };
1852 if next_layout_redraw_in.is_some() || next_paint_redraw_in.is_some() {
1853 needs_redraw = true;
1854 }
1855
1856 LayoutPrepared {
1861 ops,
1862 needs_redraw,
1863 next_layout_redraw_in,
1864 next_paint_redraw_in,
1865 }
1866 }
1867
1868 pub fn prepare_paint_cached<F1, F2>(
1881 &mut self,
1882 is_registered: F1,
1883 samples_backdrop: F2,
1884 text: &mut dyn TextRecorder,
1885 scale_factor: f32,
1886 timings: &mut PrepareTimings,
1887 ) where
1888 F1: Fn(&ShaderHandle) -> bool,
1889 F2: Fn(&ShaderHandle) -> bool,
1890 {
1891 let ops = std::mem::take(&mut self.last_ops);
1895 self.prepare_paint(
1896 &ops,
1897 is_registered,
1898 samples_backdrop,
1899 text,
1900 scale_factor,
1901 timings,
1902 );
1903 self.last_ops = ops;
1904 }
1905
1906 pub fn no_time_shaders(_shader: &ShaderHandle) -> bool {
1911 false
1912 }
1913
1914 pub fn scan_continuous_shaders<F>(&self, samples_time: F) -> Option<std::time::Duration>
1921 where
1922 F: Fn(&ShaderHandle) -> bool,
1923 {
1924 let any = self
1925 .last_ops
1926 .iter()
1927 .any(|op| op_is_continuous(op, &samples_time));
1928 if any {
1929 Some(std::time::Duration::ZERO)
1930 } else {
1931 None
1932 }
1933 }
1934
1935 pub fn prepare_paint<F1, F2>(
1946 &mut self,
1947 ops: &[DrawOp],
1948 is_registered: F1,
1949 samples_backdrop: F2,
1950 text: &mut dyn TextRecorder,
1951 scale_factor: f32,
1952 timings: &mut PrepareTimings,
1953 ) where
1954 F1: Fn(&ShaderHandle) -> bool,
1955 F2: Fn(&ShaderHandle) -> bool,
1956 {
1957 crate::profile_span!("prepare::paint");
1958 let t0 = Instant::now();
1959 self.quad_scratch.clear();
1960 self.runs.clear();
1961 self.paint_items.clear();
1962
1963 let mut current: Option<(ShaderHandle, Option<PhysicalScissor>)> = None;
1964 let mut run_first: u32 = 0;
1965 let mut snapshot_emitted = false;
1968
1969 for op in ops {
1970 match op {
1971 DrawOp::Quad {
1972 rect,
1973 scissor,
1974 shader,
1975 uniforms,
1976 ..
1977 } => {
1978 if !is_registered(shader) {
1979 continue;
1980 }
1981 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
1982 timings.paint_culled_ops += 1;
1983 continue;
1984 }
1985 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
1986 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
1987 timings.paint_culled_ops += 1;
1988 continue;
1989 }
1990 if !snapshot_emitted && samples_backdrop(shader) {
1991 close_run(
1992 &mut self.runs,
1993 &mut self.paint_items,
1994 current,
1995 run_first,
1996 self.quad_scratch.len() as u32,
1997 );
1998 current = None;
1999 run_first = self.quad_scratch.len() as u32;
2000 self.paint_items.push(PaintItem::BackdropSnapshot);
2001 snapshot_emitted = true;
2002 }
2003 let inst = pack_instance(*rect, *shader, uniforms);
2004
2005 let key = (*shader, phys);
2006 if current != Some(key) {
2007 close_run(
2008 &mut self.runs,
2009 &mut self.paint_items,
2010 current,
2011 run_first,
2012 self.quad_scratch.len() as u32,
2013 );
2014 current = Some(key);
2015 run_first = self.quad_scratch.len() as u32;
2016 }
2017 self.quad_scratch.push(inst);
2018 }
2019 DrawOp::GlyphRun {
2020 rect,
2021 scissor,
2022 color,
2023 text: glyph_text,
2024 size,
2025 line_height,
2026 family,
2027 mono_family,
2028 weight,
2029 mono,
2030 wrap,
2031 anchor,
2032 underline,
2033 strikethrough,
2034 link,
2035 ..
2036 } => {
2037 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2038 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2039 timings.paint_culled_ops += 1;
2040 continue;
2041 }
2042 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2043 timings.paint_culled_ops += 1;
2044 continue;
2045 }
2046 close_run(
2047 &mut self.runs,
2048 &mut self.paint_items,
2049 current,
2050 run_first,
2051 self.quad_scratch.len() as u32,
2052 );
2053 current = None;
2054 run_first = self.quad_scratch.len() as u32;
2055
2056 let mut style = crate::text::atlas::RunStyle::new(*weight, *color)
2057 .family(*family)
2058 .mono_family(*mono_family);
2059 if *mono {
2060 style = style.mono();
2061 }
2062 if *underline {
2063 style = style.underline();
2064 }
2065 if *strikethrough {
2066 style = style.strikethrough();
2067 }
2068 if let Some(url) = link {
2069 style = style.with_link(url.clone());
2070 }
2071 let layers = text.record(
2072 *rect,
2073 phys,
2074 &style,
2075 glyph_text,
2076 *size,
2077 *line_height,
2078 *wrap,
2079 *anchor,
2080 scale_factor,
2081 );
2082 for index in layers {
2083 self.paint_items.push(PaintItem::Text(index));
2084 }
2085 }
2086 DrawOp::AttributedText {
2087 rect,
2088 scissor,
2089 runs,
2090 size,
2091 line_height,
2092 wrap,
2093 anchor,
2094 ..
2095 } => {
2096 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2097 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2098 timings.paint_culled_ops += 1;
2099 continue;
2100 }
2101 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2102 timings.paint_culled_ops += 1;
2103 continue;
2104 }
2105 close_run(
2106 &mut self.runs,
2107 &mut self.paint_items,
2108 current,
2109 run_first,
2110 self.quad_scratch.len() as u32,
2111 );
2112 current = None;
2113 run_first = self.quad_scratch.len() as u32;
2114
2115 let layers = text.record_runs(
2116 *rect,
2117 phys,
2118 runs,
2119 *size,
2120 *line_height,
2121 *wrap,
2122 *anchor,
2123 scale_factor,
2124 );
2125 for index in layers {
2126 self.paint_items.push(PaintItem::Text(index));
2127 }
2128 }
2129 DrawOp::Icon {
2130 rect,
2131 scissor,
2132 source,
2133 color,
2134 size,
2135 stroke_width,
2136 ..
2137 } => {
2138 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2139 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2140 timings.paint_culled_ops += 1;
2141 continue;
2142 }
2143 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2144 timings.paint_culled_ops += 1;
2145 continue;
2146 }
2147 close_run(
2148 &mut self.runs,
2149 &mut self.paint_items,
2150 current,
2151 run_first,
2152 self.quad_scratch.len() as u32,
2153 );
2154 current = None;
2155 run_first = self.quad_scratch.len() as u32;
2156
2157 let recorded = text.record_icon(
2158 *rect,
2159 phys,
2160 source,
2161 *color,
2162 *size,
2163 *stroke_width,
2164 scale_factor,
2165 );
2166 match recorded {
2167 RecordedPaint::Text(layers) => {
2168 for index in layers {
2169 self.paint_items.push(PaintItem::Text(index));
2170 }
2171 }
2172 RecordedPaint::Icon(runs) => {
2173 for index in runs {
2174 self.paint_items.push(PaintItem::IconRun(index));
2175 }
2176 }
2177 }
2178 }
2179 DrawOp::Image {
2180 rect,
2181 scissor,
2182 image,
2183 tint,
2184 radius,
2185 fit,
2186 ..
2187 } => {
2188 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2189 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2190 timings.paint_culled_ops += 1;
2191 continue;
2192 }
2193 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2194 timings.paint_culled_ops += 1;
2195 continue;
2196 }
2197 close_run(
2198 &mut self.runs,
2199 &mut self.paint_items,
2200 current,
2201 run_first,
2202 self.quad_scratch.len() as u32,
2203 );
2204 current = None;
2205 run_first = self.quad_scratch.len() as u32;
2206
2207 let recorded =
2208 text.record_image(*rect, phys, image, *tint, *radius, *fit, scale_factor);
2209 for index in recorded {
2210 self.paint_items.push(PaintItem::Image(index));
2211 }
2212 }
2213 DrawOp::AppTexture {
2214 rect,
2215 scissor,
2216 texture,
2217 alpha,
2218 transform,
2219 ..
2220 } => {
2221 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2222 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2223 timings.paint_culled_ops += 1;
2224 continue;
2225 }
2226 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2227 timings.paint_culled_ops += 1;
2228 continue;
2229 }
2230 close_run(
2231 &mut self.runs,
2232 &mut self.paint_items,
2233 current,
2234 run_first,
2235 self.quad_scratch.len() as u32,
2236 );
2237 current = None;
2238 run_first = self.quad_scratch.len() as u32;
2239
2240 let recorded = text.record_app_texture(
2241 *rect,
2242 phys,
2243 texture,
2244 *alpha,
2245 *transform,
2246 scale_factor,
2247 );
2248 for index in recorded {
2249 self.paint_items.push(PaintItem::AppTexture(index));
2250 }
2251 }
2252 DrawOp::Vector {
2253 rect,
2254 scissor,
2255 asset,
2256 render_mode,
2257 ..
2258 } => {
2259 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
2260 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
2261 timings.paint_culled_ops += 1;
2262 continue;
2263 }
2264 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
2265 timings.paint_culled_ops += 1;
2266 continue;
2267 }
2268 close_run(
2269 &mut self.runs,
2270 &mut self.paint_items,
2271 current,
2272 run_first,
2273 self.quad_scratch.len() as u32,
2274 );
2275 current = None;
2276 run_first = self.quad_scratch.len() as u32;
2277
2278 let recorded =
2279 text.record_vector(*rect, phys, asset, *render_mode, scale_factor);
2280 for index in recorded {
2281 self.paint_items.push(PaintItem::Vector(index));
2282 }
2283 }
2284 DrawOp::BackdropSnapshot => {
2285 close_run(
2286 &mut self.runs,
2287 &mut self.paint_items,
2288 current,
2289 run_first,
2290 self.quad_scratch.len() as u32,
2291 );
2292 current = None;
2293 run_first = self.quad_scratch.len() as u32;
2294 if !snapshot_emitted {
2297 self.paint_items.push(PaintItem::BackdropSnapshot);
2298 snapshot_emitted = true;
2299 }
2300 }
2301 }
2302 }
2303 close_run(
2304 &mut self.runs,
2305 &mut self.paint_items,
2306 current,
2307 run_first,
2308 self.quad_scratch.len() as u32,
2309 );
2310 timings.paint = Instant::now() - t0;
2311 }
2312
2313 pub fn snapshot(&mut self, root: &El, timings: &mut PrepareTimings) {
2318 crate::profile_span!("prepare::snapshot");
2319 let t0 = Instant::now();
2320 self.last_tree = Some(root.clone());
2321 timings.snapshot = Instant::now() - t0;
2322 }
2323}
2324
2325fn paint_rect_visible(
2326 rect: Rect,
2327 scissor: Option<Rect>,
2328 viewport_px: (u32, u32),
2329 scale_factor: f32,
2330) -> bool {
2331 if rect.w <= 0.0 || rect.h <= 0.0 {
2332 return false;
2333 }
2334 let scale = scale_factor.max(f32::EPSILON);
2335 let viewport = Rect::new(
2336 0.0,
2337 0.0,
2338 viewport_px.0 as f32 / scale,
2339 viewport_px.1 as f32 / scale,
2340 );
2341 let Some(clip) = scissor.map_or(Some(viewport), |s| s.intersect(viewport)) else {
2342 return false;
2343 };
2344 rect.intersect(clip).is_some()
2345}
2346
2347fn op_is_continuous<F>(op: &DrawOp, samples_time: &F) -> bool
2354where
2355 F: Fn(&ShaderHandle) -> bool,
2356{
2357 match op.shader() {
2358 Some(handle @ ShaderHandle::Stock(s)) => s.is_continuous() || samples_time(handle),
2359 Some(handle @ ShaderHandle::Custom(_)) => samples_time(handle),
2360 None => false,
2361 }
2362}
2363
2364fn aggregate_redraw_within(
2370 node: &El,
2371 viewport: Rect,
2372 rects: &rustc_hash::FxHashMap<String, Rect>,
2373) -> Option<std::time::Duration> {
2374 let mut acc: Option<std::time::Duration> = None;
2375 visit_redraw_within(node, viewport, rects, VisibilityClip::Unclipped, &mut acc);
2376 acc
2377}
2378
2379#[derive(Clone, Copy)]
2380enum VisibilityClip {
2381 Unclipped,
2382 Clipped(Rect),
2383 Empty,
2384}
2385
2386impl VisibilityClip {
2387 fn intersect(self, rect: Rect) -> Self {
2388 if rect.w <= 0.0 || rect.h <= 0.0 {
2389 return Self::Empty;
2390 }
2391 match self {
2392 Self::Unclipped => Self::Clipped(rect),
2393 Self::Clipped(prev) => prev
2394 .intersect(rect)
2395 .map(Self::Clipped)
2396 .unwrap_or(Self::Empty),
2397 Self::Empty => Self::Empty,
2398 }
2399 }
2400
2401 fn permits(self, rect: Rect) -> bool {
2402 if rect.w <= 0.0 || rect.h <= 0.0 {
2403 return false;
2404 }
2405 match self {
2406 Self::Unclipped => true,
2407 Self::Clipped(clip) => rect.intersect(clip).is_some(),
2408 Self::Empty => false,
2409 }
2410 }
2411}
2412
2413fn visit_redraw_within(
2414 node: &El,
2415 viewport: Rect,
2416 rects: &rustc_hash::FxHashMap<String, Rect>,
2417 inherited_clip: VisibilityClip,
2418 acc: &mut Option<std::time::Duration>,
2419) {
2420 let rect = rects.get(&node.computed_id).copied();
2421 if let Some(d) = node.redraw_within {
2422 if let Some(rect) = rect
2423 && rect.w > 0.0
2424 && rect.h > 0.0
2425 && rect.intersect(viewport).is_some()
2426 && inherited_clip.permits(rect)
2427 {
2428 *acc = Some(match *acc {
2429 Some(prev) => prev.min(d),
2430 None => d,
2431 });
2432 }
2433 }
2434 let child_clip = if node.clip {
2435 rect.map(|r| inherited_clip.intersect(r))
2436 .unwrap_or(VisibilityClip::Empty)
2437 } else {
2438 inherited_clip
2439 };
2440 for child in &node.children {
2441 visit_redraw_within(child, viewport, rects, child_clip, acc);
2442 }
2443}
2444
2445pub(crate) fn find_capture_keys(node: &El, id: &str) -> Option<bool> {
2450 if node.computed_id == id {
2451 return Some(node.capture_keys);
2452 }
2453 node.children.iter().find_map(|c| find_capture_keys(c, id))
2454}
2455
2456fn find_consumes_touch_drag(node: &El, id: &str, ancestor_consumes: bool) -> Option<bool> {
2466 let consumes = ancestor_consumes || node.consumes_touch_drag;
2467 if node.computed_id == id {
2468 return Some(consumes);
2469 }
2470 node.children
2471 .iter()
2472 .find_map(|c| find_consumes_touch_drag(c, id, consumes))
2473}
2474
2475fn selection_event(
2477 new_sel: crate::selection::Selection,
2478 modifiers: KeyModifiers,
2479 pointer: Option<(f32, f32)>,
2480 pointer_kind: Option<PointerKind>,
2481) -> UiEvent {
2482 UiEvent {
2483 kind: UiEventKind::SelectionChanged,
2484 key: None,
2485 target: None,
2486 pointer,
2487 key_press: None,
2488 text: None,
2489 selection: Some(new_sel),
2490 modifiers,
2491 click_count: 0,
2492 path: None,
2493 pointer_kind,
2494 }
2495}
2496
2497fn head_for_drag(
2509 root: &El,
2510 ui_state: &UiState,
2511 point: (f32, f32),
2512) -> Option<crate::selection::SelectionPoint> {
2513 if let Some(p) = hit_test::selection_point_at(root, ui_state, point) {
2514 return Some(p);
2515 }
2516
2517 let order = &ui_state.selection.order;
2518 if order.is_empty() {
2519 return None;
2520 }
2521 let target = order
2526 .iter()
2527 .find(|t| point.1 >= t.rect.y && point.1 < t.rect.y + t.rect.h)
2528 .or_else(|| {
2529 order.iter().min_by(|a, b| {
2530 let da = y_distance(a.rect, point.1);
2531 let db = y_distance(b.rect, point.1);
2532 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
2533 })
2534 })?;
2535 let target_rect = target.rect;
2536 let cy = point
2537 .1
2538 .clamp(target_rect.y, target_rect.y + target_rect.h - 1.0);
2539 if let Some(p) = hit_test::selection_point_at(root, ui_state, (point.0, cy)) {
2540 return Some(p);
2541 }
2542 let leaf_len = find_text_len(root, &target.node_id).unwrap_or(0);
2545 let byte = if point.0 < target_rect.x { 0 } else { leaf_len };
2546 Some(crate::selection::SelectionPoint {
2547 key: target.key.clone(),
2548 byte,
2549 })
2550}
2551
2552fn selection_range_for_drag(
2553 root: &El,
2554 ui_state: &UiState,
2555 drag: &crate::state::SelectionDrag,
2556 raw_head: crate::selection::SelectionPoint,
2557) -> (
2558 crate::selection::SelectionPoint,
2559 crate::selection::SelectionPoint,
2560) {
2561 match drag.granularity {
2562 SelectionDragGranularity::Character => (drag.anchor.clone(), raw_head),
2563 SelectionDragGranularity::Word => {
2564 let text = crate::selection::find_keyed_text(root, &raw_head.key).unwrap_or_default();
2565 let (lo, hi) = crate::selection::word_range_at(&text, raw_head.byte);
2566 if point_cmp(ui_state, &raw_head, &drag.anchor) == Ordering::Less {
2567 (
2568 drag.head.clone(),
2569 crate::selection::SelectionPoint::new(raw_head.key, lo),
2570 )
2571 } else {
2572 (
2573 drag.anchor.clone(),
2574 crate::selection::SelectionPoint::new(raw_head.key, hi),
2575 )
2576 }
2577 }
2578 SelectionDragGranularity::Leaf => {
2579 let len = crate::selection::find_keyed_text(root, &raw_head.key)
2580 .map(|text| text.len())
2581 .unwrap_or(raw_head.byte);
2582 if point_cmp(ui_state, &raw_head, &drag.anchor) == Ordering::Less {
2583 (
2584 drag.head.clone(),
2585 crate::selection::SelectionPoint::new(raw_head.key, 0),
2586 )
2587 } else {
2588 (
2589 drag.anchor.clone(),
2590 crate::selection::SelectionPoint::new(raw_head.key, len),
2591 )
2592 }
2593 }
2594 }
2595}
2596
2597fn point_cmp(
2598 ui_state: &UiState,
2599 a: &crate::selection::SelectionPoint,
2600 b: &crate::selection::SelectionPoint,
2601) -> Ordering {
2602 let order_index = |key: &str| {
2603 ui_state
2604 .selection
2605 .order
2606 .iter()
2607 .position(|target| target.key == key)
2608 .unwrap_or(usize::MAX)
2609 };
2610 order_index(&a.key)
2611 .cmp(&order_index(&b.key))
2612 .then_with(|| a.byte.cmp(&b.byte))
2613}
2614
2615fn y_distance(rect: Rect, y: f32) -> f32 {
2616 if y < rect.y {
2617 rect.y - y
2618 } else if y > rect.y + rect.h {
2619 y - (rect.y + rect.h)
2620 } else {
2621 0.0
2622 }
2623}
2624
2625fn find_text_len(node: &El, id: &str) -> Option<usize> {
2626 if node.computed_id == id {
2627 if let Some(source) = &node.selection_source {
2628 return Some(source.visible_len());
2629 }
2630 return node.text.as_ref().map(|t| t.len());
2631 }
2632 node.children.iter().find_map(|c| find_text_len(c, id))
2633}
2634
2635pub enum RecordedPaint {
2638 Text(Range<usize>),
2639 Icon(Range<usize>),
2640}
2641
2642pub trait TextRecorder {
2646 #[allow(clippy::too_many_arguments)]
2654 fn record(
2655 &mut self,
2656 rect: Rect,
2657 scissor: Option<PhysicalScissor>,
2658 style: &RunStyle,
2659 text: &str,
2660 size: f32,
2661 line_height: f32,
2662 wrap: TextWrap,
2663 anchor: TextAnchor,
2664 scale_factor: f32,
2665 ) -> Range<usize>;
2666
2667 #[allow(clippy::too_many_arguments)]
2672 fn record_runs(
2673 &mut self,
2674 rect: Rect,
2675 scissor: Option<PhysicalScissor>,
2676 runs: &[(String, RunStyle)],
2677 size: f32,
2678 line_height: f32,
2679 wrap: TextWrap,
2680 anchor: TextAnchor,
2681 scale_factor: f32,
2682 ) -> Range<usize>;
2683
2684 #[allow(clippy::too_many_arguments)]
2690 fn record_icon(
2691 &mut self,
2692 rect: Rect,
2693 scissor: Option<PhysicalScissor>,
2694 source: &crate::icons::svg::IconSource,
2695 color: Color,
2696 size: f32,
2697 _stroke_width: f32,
2698 scale_factor: f32,
2699 ) -> RecordedPaint {
2700 let glyph = match source {
2701 crate::icons::svg::IconSource::Builtin(name) => name.fallback_glyph(),
2702 crate::icons::svg::IconSource::Custom(_) => "?",
2703 };
2704 RecordedPaint::Text(self.record(
2705 rect,
2706 scissor,
2707 &RunStyle::new(FontWeight::Regular, color),
2708 glyph,
2709 size,
2710 crate::text::metrics::line_height(size),
2711 TextWrap::NoWrap,
2712 TextAnchor::Middle,
2713 scale_factor,
2714 ))
2715 }
2716
2717 #[allow(clippy::too_many_arguments)]
2724 fn record_image(
2725 &mut self,
2726 _rect: Rect,
2727 _scissor: Option<PhysicalScissor>,
2728 _image: &crate::image::Image,
2729 _tint: Option<Color>,
2730 _radius: crate::tree::Corners,
2731 _fit: crate::image::ImageFit,
2732 _scale_factor: f32,
2733 ) -> Range<usize> {
2734 0..0
2735 }
2736
2737 fn record_app_texture(
2743 &mut self,
2744 _rect: Rect,
2745 _scissor: Option<PhysicalScissor>,
2746 _texture: &crate::surface::AppTexture,
2747 _alpha: crate::surface::SurfaceAlpha,
2748 _transform: crate::affine::Affine2,
2749 _scale_factor: f32,
2750 ) -> Range<usize> {
2751 0..0
2752 }
2753
2754 fn record_vector(
2760 &mut self,
2761 _rect: Rect,
2762 _scissor: Option<PhysicalScissor>,
2763 _asset: &crate::vector::VectorAsset,
2764 _render_mode: crate::vector::VectorRenderMode,
2765 _scale_factor: f32,
2766 ) -> Range<usize> {
2767 0..0
2768 }
2769}
2770
2771#[cfg(test)]
2772mod tests {
2773 use super::*;
2774 use crate::event::PointerId;
2775 use crate::shader::{ShaderHandle, StockShader, UniformBlock};
2776
2777 struct NoText;
2779 impl TextRecorder for NoText {
2780 fn record(
2781 &mut self,
2782 _rect: Rect,
2783 _scissor: Option<PhysicalScissor>,
2784 _style: &RunStyle,
2785 _text: &str,
2786 _size: f32,
2787 _line_height: f32,
2788 _wrap: TextWrap,
2789 _anchor: TextAnchor,
2790 _scale_factor: f32,
2791 ) -> Range<usize> {
2792 0..0
2793 }
2794 fn record_runs(
2795 &mut self,
2796 _rect: Rect,
2797 _scissor: Option<PhysicalScissor>,
2798 _runs: &[(String, RunStyle)],
2799 _size: f32,
2800 _line_height: f32,
2801 _wrap: TextWrap,
2802 _anchor: TextAnchor,
2803 _scale_factor: f32,
2804 ) -> Range<usize> {
2805 0..0
2806 }
2807 }
2808
2809 #[derive(Default)]
2810 struct CountingText {
2811 records: usize,
2812 }
2813
2814 impl TextRecorder for CountingText {
2815 fn record(
2816 &mut self,
2817 _rect: Rect,
2818 _scissor: Option<PhysicalScissor>,
2819 _style: &RunStyle,
2820 _text: &str,
2821 _size: f32,
2822 _line_height: f32,
2823 _wrap: TextWrap,
2824 _anchor: TextAnchor,
2825 _scale_factor: f32,
2826 ) -> Range<usize> {
2827 self.records += 1;
2828 0..0
2829 }
2830
2831 fn record_runs(
2832 &mut self,
2833 _rect: Rect,
2834 _scissor: Option<PhysicalScissor>,
2835 _runs: &[(String, RunStyle)],
2836 _size: f32,
2837 _line_height: f32,
2838 _wrap: TextWrap,
2839 _anchor: TextAnchor,
2840 _scale_factor: f32,
2841 ) -> Range<usize> {
2842 self.records += 1;
2843 0..0
2844 }
2845 }
2846
2847 fn empty_text_layout(line_height: f32) -> crate::text::metrics::TextLayout {
2848 crate::text::metrics::TextLayout {
2849 lines: Vec::new(),
2850 width: 0.0,
2851 height: 0.0,
2852 line_height,
2853 }
2854 }
2855
2856 fn lay_out_input_tree(capture: bool) -> RunnerCore {
2863 use crate::tree::*;
2864 let ti = if capture {
2865 crate::widgets::text::text("input").key("ti").capture_keys()
2866 } else {
2867 crate::widgets::text::text("noop").key("ti").focusable()
2868 };
2869 let mut tree =
2870 crate::column([crate::widgets::button::button("Btn").key("btn"), ti]).padding(10.0);
2871 let mut core = RunnerCore::new();
2872 crate::layout::layout(
2873 &mut tree,
2874 &mut core.ui_state,
2875 Rect::new(0.0, 0.0, 200.0, 200.0),
2876 );
2877 core.ui_state.sync_focus_order(&tree);
2878 let mut t = PrepareTimings::default();
2879 core.snapshot(&tree, &mut t);
2880 core
2881 }
2882
2883 #[test]
2884 fn pointer_up_emits_pointer_up_then_click() {
2885 let mut core = lay_out_input_tree(false);
2886 let btn_rect = core.rect_of_key("btn").expect("btn rect");
2887 let cx = btn_rect.x + btn_rect.w * 0.5;
2888 let cy = btn_rect.y + btn_rect.h * 0.5;
2889 core.pointer_moved(Pointer::moving(cx, cy));
2890 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
2891 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
2892 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
2893 assert_eq!(kinds, vec![UiEventKind::PointerUp, UiEventKind::Click]);
2894 }
2895
2896 fn lay_out_link_tree() -> (RunnerCore, Rect, &'static str) {
2902 use crate::tree::*;
2903 const URL: &str = "https://github.com/computer-whisperer/aetna";
2904 let mut tree = crate::column([crate::text_runs([
2905 crate::text("Visit "),
2906 crate::text("github.com/computer-whisperer/aetna").link(URL),
2907 crate::text("."),
2908 ])])
2909 .padding(10.0);
2910 let mut core = RunnerCore::new();
2911 crate::layout::layout(
2912 &mut tree,
2913 &mut core.ui_state,
2914 Rect::new(0.0, 0.0, 600.0, 200.0),
2915 );
2916 core.ui_state.sync_focus_order(&tree);
2917 let mut t = PrepareTimings::default();
2918 core.snapshot(&tree, &mut t);
2919 let para = core
2920 .last_tree
2921 .as_ref()
2922 .and_then(|t| t.children.first())
2923 .map(|p| core.ui_state.rect(&p.computed_id))
2924 .expect("paragraph rect");
2925 (core, para, URL)
2926 }
2927
2928 #[test]
2929 fn pointer_up_on_link_emits_link_activated_with_url() {
2930 let (mut core, para, url) = lay_out_link_tree();
2931 let cx = para.x + 100.0;
2935 let cy = para.y + para.h * 0.5;
2936 core.pointer_moved(Pointer::moving(cx, cy));
2937 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
2938 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
2939 let link = events
2940 .iter()
2941 .find(|e| e.kind == UiEventKind::LinkActivated)
2942 .expect("LinkActivated event");
2943 assert_eq!(link.key.as_deref(), Some(url));
2944 }
2945
2946 #[test]
2947 fn pointer_up_after_drag_off_link_does_not_activate() {
2948 let (mut core, para, _url) = lay_out_link_tree();
2949 let press_x = para.x + 100.0;
2950 let cy = para.y + para.h * 0.5;
2951 core.pointer_moved(Pointer::moving(press_x, cy));
2952 core.pointer_down(Pointer::mouse(press_x, cy, PointerButton::Primary));
2953 let events = core.pointer_up(Pointer::mouse(press_x, 180.0, PointerButton::Primary));
2957 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
2958 assert!(
2959 !kinds.contains(&UiEventKind::LinkActivated),
2960 "drag-off-link should cancel the link activation; got {kinds:?}",
2961 );
2962 }
2963
2964 #[test]
2965 fn pointer_moved_over_link_resolves_cursor_to_pointer_and_requests_redraw() {
2966 use crate::cursor::Cursor;
2967 let (mut core, para, _url) = lay_out_link_tree();
2968 let cx = para.x + 100.0;
2969 let cy = para.y + para.h * 0.5;
2970 let initial = core.pointer_moved(Pointer::moving(para.x - 50.0, cy));
2972 assert!(
2973 !initial.needs_redraw,
2974 "moving in empty space shouldn't request a redraw"
2975 );
2976 let tree = core.last_tree.as_ref().expect("tree").clone();
2977 assert_eq!(
2978 core.ui_state.cursor(&tree),
2979 Cursor::Default,
2980 "no link under pointer → default cursor"
2981 );
2982 let onto = core.pointer_moved(Pointer::moving(cx, cy));
2985 assert!(
2986 onto.needs_redraw,
2987 "entering a link region should flag a redraw so the cursor refresh isn't stale"
2988 );
2989 assert_eq!(
2990 core.ui_state.cursor(&tree),
2991 Cursor::Pointer,
2992 "pointer over a link → Pointer cursor"
2993 );
2994 let off = core.pointer_moved(Pointer::moving(para.x - 50.0, cy));
2997 assert!(
2998 off.needs_redraw,
2999 "leaving a link region should flag a redraw"
3000 );
3001 assert_eq!(core.ui_state.cursor(&tree), Cursor::Default);
3002 }
3003
3004 #[test]
3005 fn pointer_up_on_unlinked_text_does_not_emit_link_activated() {
3006 let (mut core, para, _url) = lay_out_link_tree();
3007 let cx = para.x + 1.0;
3010 let cy = para.y + para.h * 0.5;
3011 core.pointer_moved(Pointer::moving(cx, cy));
3012 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3013 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
3014 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3015 assert!(
3016 !kinds.contains(&UiEventKind::LinkActivated),
3017 "click on the unlinked prefix should not surface a link event; got {kinds:?}",
3018 );
3019 }
3020
3021 #[test]
3022 fn pointer_up_off_target_emits_only_pointer_up() {
3023 let mut core = lay_out_input_tree(false);
3024 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3025 let cx = btn_rect.x + btn_rect.w * 0.5;
3026 let cy = btn_rect.y + btn_rect.h * 0.5;
3027 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3028 let events = core.pointer_up(Pointer::mouse(180.0, 180.0, PointerButton::Primary));
3030 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3031 assert_eq!(
3032 kinds,
3033 vec![UiEventKind::PointerUp],
3034 "drag-off-target should still surface PointerUp so widgets see drag-end"
3035 );
3036 }
3037
3038 #[test]
3039 fn pointer_moved_while_pressed_emits_drag() {
3040 let mut core = lay_out_input_tree(false);
3041 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3042 let cx = btn_rect.x + btn_rect.w * 0.5;
3043 let cy = btn_rect.y + btn_rect.h * 0.5;
3044 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3045 let drag = core
3046 .pointer_moved(Pointer::moving(cx + 30.0, cy))
3047 .events
3048 .into_iter()
3049 .find(|e| e.kind == UiEventKind::Drag)
3050 .expect("drag while pressed");
3051 assert_eq!(drag.target.as_ref().map(|t| t.key.as_str()), Some("btn"));
3052 assert_eq!(drag.pointer, Some((cx + 30.0, cy)));
3053 }
3054
3055 #[test]
3056 fn toast_dismiss_click_removes_toast_and_suppresses_click_event() {
3057 use crate::toast::ToastSpec;
3058 use crate::tree::Size;
3059 let mut core = RunnerCore::new();
3063 core.ui_state
3064 .push_toast(ToastSpec::success("hi"), Instant::now());
3065 let toast_id = core.ui_state.toasts()[0].id;
3066
3067 let mut tree: El = crate::stack(std::iter::empty::<El>())
3071 .width(Size::Fill(1.0))
3072 .height(Size::Fill(1.0));
3073 crate::layout::assign_ids(&mut tree);
3074 let _ = crate::toast::synthesize_toasts(&mut tree, &mut core.ui_state, Instant::now());
3075 crate::layout::layout(
3076 &mut tree,
3077 &mut core.ui_state,
3078 Rect::new(0.0, 0.0, 800.0, 600.0),
3079 );
3080 core.ui_state.sync_focus_order(&tree);
3081 let mut t = PrepareTimings::default();
3082 core.snapshot(&tree, &mut t);
3083
3084 let dismiss_key = format!("toast-dismiss-{toast_id}");
3085 let dismiss_rect = core.rect_of_key(&dismiss_key).expect("dismiss button");
3086 let cx = dismiss_rect.x + dismiss_rect.w * 0.5;
3087 let cy = dismiss_rect.y + dismiss_rect.h * 0.5;
3088
3089 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
3090 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
3091 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3092 assert!(
3096 !kinds.contains(&UiEventKind::Click),
3097 "Click on toast-dismiss should not be surfaced: {kinds:?}",
3098 );
3099 assert!(
3100 core.ui_state.toasts().iter().all(|t| t.id != toast_id),
3101 "toast {toast_id} should be dropped after dismiss-click",
3102 );
3103 }
3104
3105 #[test]
3106 fn pointer_moved_without_press_emits_no_drag() {
3107 let mut core = lay_out_input_tree(false);
3108 let events = core.pointer_moved(Pointer::moving(50.0, 50.0)).events;
3109 assert!(!events.iter().any(|e| e.kind == UiEventKind::Drag));
3113 }
3114
3115 #[test]
3116 fn spinner_in_tree_keeps_needs_redraw_set() {
3117 use crate::widgets::spinner::spinner;
3122 let mut tree = crate::column([spinner()]);
3123 let mut core = RunnerCore::new();
3124 let mut t = PrepareTimings::default();
3125 let LayoutPrepared { needs_redraw, .. } = core.prepare_layout(
3126 &mut tree,
3127 Rect::new(0.0, 0.0, 200.0, 200.0),
3128 1.0,
3129 &mut t,
3130 RunnerCore::no_time_shaders,
3131 );
3132 assert!(
3133 needs_redraw,
3134 "tree with a spinner must request continuous redraw",
3135 );
3136
3137 let mut bare = crate::column([crate::widgets::text::text("idle")]);
3141 let mut core2 = RunnerCore::new();
3142 let mut t2 = PrepareTimings::default();
3143 let LayoutPrepared {
3144 needs_redraw: needs_redraw2,
3145 ..
3146 } = core2.prepare_layout(
3147 &mut bare,
3148 Rect::new(0.0, 0.0, 200.0, 200.0),
3149 1.0,
3150 &mut t2,
3151 RunnerCore::no_time_shaders,
3152 );
3153 assert!(
3154 !needs_redraw2,
3155 "tree without time-driven shaders should idle: got needs_redraw={needs_redraw2}",
3156 );
3157 }
3158
3159 #[test]
3160 fn custom_samples_time_shader_keeps_needs_redraw_set() {
3161 let mut tree = crate::column([crate::tree::El::new(crate::tree::Kind::Custom("anim"))
3165 .shader(crate::shader::ShaderBinding::custom("my_animated_glow"))
3166 .width(crate::tree::Size::Fixed(32.0))
3167 .height(crate::tree::Size::Fixed(32.0))]);
3168 let mut core = RunnerCore::new();
3169 let mut t = PrepareTimings::default();
3170
3171 let LayoutPrepared {
3172 needs_redraw: idle, ..
3173 } = core.prepare_layout(
3174 &mut tree,
3175 Rect::new(0.0, 0.0, 200.0, 200.0),
3176 1.0,
3177 &mut t,
3178 RunnerCore::no_time_shaders,
3179 );
3180 assert!(
3181 !idle,
3182 "without a samples_time registration the host should idle",
3183 );
3184
3185 let mut t2 = PrepareTimings::default();
3186 let LayoutPrepared {
3187 needs_redraw: animated,
3188 ..
3189 } = core.prepare_layout(
3190 &mut tree,
3191 Rect::new(0.0, 0.0, 200.0, 200.0),
3192 1.0,
3193 &mut t2,
3194 |handle| matches!(handle, ShaderHandle::Custom("my_animated_glow")),
3195 );
3196 assert!(
3197 animated,
3198 "custom shader registered as samples_time=true must request continuous redraw",
3199 );
3200 }
3201
3202 #[test]
3203 fn redraw_within_aggregates_to_minimum_visible_deadline() {
3204 use std::time::Duration;
3205 let mut tree = crate::column([
3206 crate::widgets::text::text("a")
3208 .redraw_within(Duration::from_millis(16))
3209 .width(crate::tree::Size::Fixed(20.0))
3210 .height(crate::tree::Size::Fixed(20.0)),
3211 crate::widgets::text::text("b")
3213 .redraw_within(Duration::from_millis(50))
3214 .width(crate::tree::Size::Fixed(20.0))
3215 .height(crate::tree::Size::Fixed(20.0)),
3216 ]);
3217 let mut core = RunnerCore::new();
3218 let mut t = PrepareTimings::default();
3219 let LayoutPrepared {
3220 needs_redraw,
3221 next_layout_redraw_in,
3222 ..
3223 } = core.prepare_layout(
3224 &mut tree,
3225 Rect::new(0.0, 0.0, 200.0, 200.0),
3226 1.0,
3227 &mut t,
3228 RunnerCore::no_time_shaders,
3229 );
3230 assert!(needs_redraw, "redraw_within must lift the legacy bool");
3231 assert_eq!(
3232 next_layout_redraw_in,
3233 Some(Duration::from_millis(16)),
3234 "tightest visible deadline wins, on the layout lane",
3235 );
3236 }
3237
3238 #[test]
3239 fn redraw_within_off_screen_widget_is_ignored() {
3240 use std::time::Duration;
3241 let mut tree = crate::column([
3247 crate::tree::spacer().height(crate::tree::Size::Fixed(150.0)),
3248 crate::widgets::text::text("offscreen")
3249 .redraw_within(Duration::from_millis(16))
3250 .width(crate::tree::Size::Fixed(10.0))
3251 .height(crate::tree::Size::Fixed(10.0)),
3252 ]);
3253 let mut core = RunnerCore::new();
3254 let mut t = PrepareTimings::default();
3255 let LayoutPrepared {
3256 next_layout_redraw_in,
3257 ..
3258 } = core.prepare_layout(
3259 &mut tree,
3260 Rect::new(0.0, 0.0, 100.0, 100.0),
3261 1.0,
3262 &mut t,
3263 RunnerCore::no_time_shaders,
3264 );
3265 assert_eq!(
3266 next_layout_redraw_in, None,
3267 "off-screen redraw_within must not contribute to the aggregate",
3268 );
3269 }
3270
3271 #[test]
3272 fn redraw_within_clipped_out_widget_is_ignored() {
3273 use std::time::Duration;
3274
3275 let clipped = crate::column([crate::widgets::text::text("clipped")
3276 .redraw_within(Duration::from_millis(16))
3277 .width(crate::tree::Size::Fixed(10.0))
3278 .height(crate::tree::Size::Fixed(10.0))])
3279 .clip()
3280 .width(crate::tree::Size::Fixed(100.0))
3281 .height(crate::tree::Size::Fixed(20.0))
3282 .layout(|ctx| {
3283 vec![Rect::new(
3284 ctx.container.x,
3285 ctx.container.y + 30.0,
3286 10.0,
3287 10.0,
3288 )]
3289 });
3290 let mut tree = crate::column([clipped]);
3291
3292 let mut core = RunnerCore::new();
3293 let mut t = PrepareTimings::default();
3294 let LayoutPrepared {
3295 next_layout_redraw_in,
3296 ..
3297 } = core.prepare_layout(
3298 &mut tree,
3299 Rect::new(0.0, 0.0, 100.0, 100.0),
3300 1.0,
3301 &mut t,
3302 RunnerCore::no_time_shaders,
3303 );
3304 assert_eq!(
3305 next_layout_redraw_in, None,
3306 "redraw_within inside an inherited clip but outside the clip rect must not contribute",
3307 );
3308 }
3309
3310 #[test]
3311 fn pointer_moved_within_same_hovered_node_does_not_request_redraw() {
3312 let mut core = lay_out_input_tree(false);
3318 let btn = core.rect_of_key("btn").expect("btn rect");
3319 let (cx, cy) = (btn.x + btn.w * 0.5, btn.y + btn.h * 0.5);
3320
3321 let first = core.pointer_moved(Pointer::moving(cx, cy));
3325 assert_eq!(first.events.len(), 1);
3326 assert_eq!(first.events[0].kind, UiEventKind::PointerEnter);
3327 assert_eq!(first.events[0].key.as_deref(), Some("btn"));
3328 assert!(
3329 first.needs_redraw,
3330 "entering a focusable should warrant a redraw",
3331 );
3332
3333 let second = core.pointer_moved(Pointer::moving(cx + 1.0, cy));
3337 assert!(second.events.is_empty());
3338 assert!(
3339 !second.needs_redraw,
3340 "identical hover, no drag → host should idle",
3341 );
3342
3343 let off = core.pointer_moved(Pointer::moving(0.0, 0.0));
3347 assert_eq!(off.events.len(), 1);
3348 assert_eq!(off.events[0].kind, UiEventKind::PointerLeave);
3349 assert_eq!(off.events[0].key.as_deref(), Some("btn"));
3350 assert!(
3351 off.needs_redraw,
3352 "leaving a hovered node still warrants a redraw",
3353 );
3354 }
3355
3356 #[test]
3357 fn pointer_moved_between_keyed_targets_emits_leave_then_enter() {
3358 let mut core = lay_out_input_tree(false);
3365 let btn = core.rect_of_key("btn").expect("btn rect");
3366 let ti = core.rect_of_key("ti").expect("ti rect");
3367
3368 let _ = core.pointer_moved(Pointer::moving(btn.x + 4.0, btn.y + 4.0));
3370
3371 let cross = core.pointer_moved(Pointer::moving(ti.x + 4.0, ti.y + 4.0));
3373 let kinds: Vec<UiEventKind> = cross.events.iter().map(|e| e.kind).collect();
3374 assert_eq!(
3375 kinds,
3376 vec![UiEventKind::PointerLeave, UiEventKind::PointerEnter],
3377 "paired Leave-then-Enter on cross-target hover transition",
3378 );
3379 assert_eq!(cross.events[0].key.as_deref(), Some("btn"));
3380 assert_eq!(cross.events[1].key.as_deref(), Some("ti"));
3381 assert!(cross.needs_redraw);
3382 }
3383
3384 #[test]
3385 fn touch_pointer_down_emits_pointer_enter_then_pointer_down() {
3386 let mut core = lay_out_input_tree(false);
3392 let btn = core.rect_of_key("btn").expect("btn rect");
3393 let cx = btn.x + btn.w * 0.5;
3394 let cy = btn.y + btn.h * 0.5;
3395 let events = core.pointer_down(Pointer::touch(
3396 cx,
3397 cy,
3398 PointerButton::Primary,
3399 PointerId::PRIMARY,
3400 ));
3401 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3402 assert_eq!(
3403 kinds,
3404 vec![UiEventKind::PointerEnter, UiEventKind::PointerDown],
3405 );
3406 for e in &events {
3407 assert_eq!(e.pointer_kind, Some(PointerKind::Touch));
3408 }
3409 assert_eq!(core.ui_state().hovered_key(), Some("btn"));
3410 }
3411
3412 #[test]
3413 fn touch_pointer_up_emits_pointer_leave_after_click() {
3414 let mut core = lay_out_input_tree(false);
3419 let btn = core.rect_of_key("btn").expect("btn rect");
3420 let cx = btn.x + btn.w * 0.5;
3421 let cy = btn.y + btn.h * 0.5;
3422 let _ = core.pointer_down(Pointer::touch(
3423 cx,
3424 cy,
3425 PointerButton::Primary,
3426 PointerId::PRIMARY,
3427 ));
3428 let events = core.pointer_up(Pointer::touch(
3429 cx,
3430 cy,
3431 PointerButton::Primary,
3432 PointerId::PRIMARY,
3433 ));
3434 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3435 assert_eq!(
3436 kinds,
3437 vec![
3438 UiEventKind::PointerUp,
3439 UiEventKind::Click,
3440 UiEventKind::PointerLeave,
3441 ],
3442 );
3443 assert_eq!(core.ui_state().hovered_key(), None);
3444 }
3445
3446 #[test]
3447 fn touch_pointer_moved_without_press_does_not_emit_hover_transitions() {
3448 let mut core = lay_out_input_tree(false);
3456 let btn = core.rect_of_key("btn").expect("btn rect");
3457 let mut p = Pointer::moving(btn.x + 4.0, btn.y + 4.0);
3458 p.kind = PointerKind::Touch;
3459 let moved = core.pointer_moved(p);
3460 assert!(
3461 moved.events.is_empty(),
3462 "touch move without press should not emit hover events, got {:?}",
3463 moved.events.iter().map(|e| e.kind).collect::<Vec<_>>(),
3464 );
3465 }
3466
3467 #[test]
3468 fn touch_drag_between_targets_still_emits_hover_transitions() {
3469 use crate::tree::*;
3480 let mut tree = crate::column([
3481 crate::widgets::button::button("Btn")
3482 .key("btn")
3483 .consumes_touch_drag(),
3484 crate::widgets::button::button("Other").key("other"),
3485 ])
3486 .padding(10.0);
3487 let mut core = RunnerCore::new();
3488 crate::layout::layout(
3489 &mut tree,
3490 &mut core.ui_state,
3491 Rect::new(0.0, 0.0, 200.0, 200.0),
3492 );
3493 core.ui_state.sync_focus_order(&tree);
3494 let mut t = PrepareTimings::default();
3495 core.snapshot(&tree, &mut t);
3496
3497 let btn = core.rect_of_key("btn").expect("btn rect");
3498 let other = core.rect_of_key("other").expect("other rect");
3499 let _ = core.pointer_down(Pointer::touch(
3500 btn.x + 4.0,
3501 btn.y + 4.0,
3502 PointerButton::Primary,
3503 PointerId::PRIMARY,
3504 ));
3505 let mut move_p = Pointer::moving(other.x + 4.0, other.y + 4.0);
3506 move_p.kind = PointerKind::Touch;
3507 let cross = core.pointer_moved(move_p);
3508 let kinds: Vec<UiEventKind> = cross.events.iter().map(|e| e.kind).collect();
3509 assert!(
3510 kinds.contains(&UiEventKind::PointerLeave)
3511 && kinds.contains(&UiEventKind::PointerEnter),
3512 "touch drag across targets should emit Leave + Enter, got {kinds:?}",
3513 );
3514 assert!(kinds.contains(&UiEventKind::Drag));
3518 }
3519
3520 #[test]
3521 fn would_press_focus_text_input_distinguishes_capture_keys() {
3522 let core = lay_out_input_tree(true);
3527 let ti = core.rect_of_key("ti").expect("ti rect");
3528 let btn = core.rect_of_key("btn").expect("btn rect");
3529
3530 assert!(
3531 core.would_press_focus_text_input(ti.center_x(), ti.center_y()),
3532 "press on capture_keys widget should report true",
3533 );
3534 assert!(
3535 !core.would_press_focus_text_input(btn.center_x(), btn.center_y()),
3536 "press on plain focusable should report false",
3537 );
3538 assert!(!core.would_press_focus_text_input(0.0, 0.0));
3540 }
3541
3542 #[test]
3543 fn touch_jiggle_below_threshold_still_taps() {
3544 let mut core = lay_out_input_tree(false);
3550 let btn = core.rect_of_key("btn").expect("btn rect");
3551 let cx = btn.x + btn.w * 0.5;
3552 let cy = btn.y + btn.h * 0.5;
3553 let _ = core.pointer_down(Pointer::touch(
3554 cx,
3555 cy,
3556 PointerButton::Primary,
3557 PointerId::PRIMARY,
3558 ));
3559 let mut jiggle = Pointer::moving(cx + 3.0, cy + 2.0);
3561 jiggle.kind = PointerKind::Touch;
3562 let _ = core.pointer_moved(jiggle);
3563 let events = core.pointer_up(Pointer::touch(
3564 cx + 3.0,
3565 cy + 2.0,
3566 PointerButton::Primary,
3567 PointerId::PRIMARY,
3568 ));
3569 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3570 assert!(
3571 kinds.contains(&UiEventKind::Click),
3572 "small jiggle should not commit to scroll, expected Click in {kinds:?}",
3573 );
3574 }
3575
3576 #[test]
3577 fn touch_drag_on_consuming_widget_emits_drag_not_cancel() {
3578 use crate::tree::*;
3583 let mut tree = crate::column([crate::widgets::button::button("Drag me")
3584 .key("draggable")
3585 .consumes_touch_drag()])
3586 .padding(10.0);
3587 let mut core = RunnerCore::new();
3588 crate::layout::layout(
3589 &mut tree,
3590 &mut core.ui_state,
3591 Rect::new(0.0, 0.0, 200.0, 200.0),
3592 );
3593 core.ui_state.sync_focus_order(&tree);
3594 let mut t = PrepareTimings::default();
3595 core.snapshot(&tree, &mut t);
3596
3597 let r = core.rect_of_key("draggable").expect("rect");
3598 let cx = r.x + r.w * 0.5;
3599 let cy = r.y + r.h * 0.5;
3600 let _ = core.pointer_down(Pointer::touch(
3601 cx,
3602 cy,
3603 PointerButton::Primary,
3604 PointerId::PRIMARY,
3605 ));
3606 let mut over = Pointer::moving(cx + 30.0, cy);
3609 over.kind = PointerKind::Touch;
3610 let moved = core.pointer_moved(over);
3611 let kinds: Vec<UiEventKind> = moved.events.iter().map(|e| e.kind).collect();
3612 assert!(
3613 kinds.contains(&UiEventKind::Drag),
3614 "drag-consuming widget should receive Drag past threshold, got {kinds:?}",
3615 );
3616 assert!(
3617 !kinds.contains(&UiEventKind::PointerCancel),
3618 "drag-consuming widget should not see PointerCancel, got {kinds:?}",
3619 );
3620 }
3621
3622 #[test]
3623 fn touch_drag_in_scrollable_cancels_press_and_scrolls() {
3624 use crate::tree::*;
3631 let mut tree = crate::scroll([
3632 crate::widgets::button::button("row 0")
3633 .key("row0")
3634 .height(Size::Fixed(50.0)),
3635 crate::widgets::button::button("row 1")
3636 .key("row1")
3637 .height(Size::Fixed(50.0)),
3638 crate::widgets::button::button("row 2")
3639 .key("row2")
3640 .height(Size::Fixed(50.0)),
3641 crate::widgets::button::button("row 3")
3642 .key("row3")
3643 .height(Size::Fixed(50.0)),
3644 crate::widgets::button::button("row 4")
3645 .key("row4")
3646 .height(Size::Fixed(50.0)),
3647 ])
3648 .key("list")
3649 .height(Size::Fixed(120.0));
3650 let mut core = RunnerCore::new();
3651 crate::layout::layout(
3652 &mut tree,
3653 &mut core.ui_state,
3654 Rect::new(0.0, 0.0, 200.0, 120.0),
3655 );
3656 core.ui_state.sync_focus_order(&tree);
3657 let mut t = PrepareTimings::default();
3658 core.snapshot(&tree, &mut t);
3659 let scroll_id = core
3660 .last_tree
3661 .as_ref()
3662 .map(|t| t.computed_id.clone())
3663 .expect("scroll id");
3664
3665 let row1 = core.rect_of_key("row1").expect("row1");
3670 let cx = row1.x + row1.w * 0.5;
3671 let cy = row1.y + row1.h * 0.5;
3672
3673 let down_events = core.pointer_down(Pointer::touch(
3675 cx,
3676 cy,
3677 PointerButton::Primary,
3678 PointerId::PRIMARY,
3679 ));
3680 assert!(
3682 down_events
3683 .iter()
3684 .any(|e| matches!(e.kind, UiEventKind::PointerDown)),
3685 "expected PointerDown on press",
3686 );
3687
3688 let mut up_finger = Pointer::moving(cx, cy - 40.0);
3692 up_finger.kind = PointerKind::Touch;
3693 let move_events = core.pointer_moved(up_finger);
3694 let kinds: Vec<UiEventKind> = move_events.events.iter().map(|e| e.kind).collect();
3695 assert!(
3696 kinds.contains(&UiEventKind::PointerCancel),
3697 "scroll commit should fire PointerCancel, got {kinds:?}",
3698 );
3699 assert!(
3700 !kinds.contains(&UiEventKind::Drag),
3701 "scroll commit should NOT emit Drag, got {kinds:?}",
3702 );
3703
3704 let offset = core.ui_state().scroll_offset(&scroll_id);
3706 assert!(
3707 offset > 30.0 && offset <= 50.0,
3708 "scroll offset should advance ~40px after a 40px finger drag, got {offset}",
3709 );
3710
3711 let up_events = core.pointer_up(Pointer::touch(
3714 cx,
3715 cy - 40.0,
3716 PointerButton::Primary,
3717 PointerId::PRIMARY,
3718 ));
3719 let up_kinds: Vec<UiEventKind> = up_events.iter().map(|e| e.kind).collect();
3720 assert!(
3721 !up_kinds.contains(&UiEventKind::Click),
3722 "scroll-committed gesture must not fire Click on release, got {up_kinds:?}",
3723 );
3724 }
3725
3726 #[test]
3727 fn touch_scroll_release_starts_momentum_after_fast_swipe_outside_viewport() {
3728 use crate::tree::*;
3729 let mut tree = crate::scroll((0..20).map(|i| {
3730 crate::widgets::button::button(format!("row {i}"))
3731 .key(format!("row{i}"))
3732 .height(Size::Fixed(50.0))
3733 }))
3734 .key("list")
3735 .height(Size::Fixed(120.0));
3736 let mut core = RunnerCore::new();
3737 crate::layout::layout(
3738 &mut tree,
3739 &mut core.ui_state,
3740 Rect::new(0.0, 0.0, 200.0, 120.0),
3741 );
3742 core.ui_state.sync_focus_order(&tree);
3743 let mut t = PrepareTimings::default();
3744 core.snapshot(&tree, &mut t);
3745 let scroll_id = core
3746 .last_tree
3747 .as_ref()
3748 .map(|t| t.computed_id.clone())
3749 .expect("scroll id");
3750
3751 let row1 = core.rect_of_key("row1").expect("row1");
3752 let cx = row1.x + row1.w * 0.5;
3753 let cy = row1.y + row1.h * 0.5;
3754
3755 core.pointer_down(Pointer::touch(
3756 cx,
3757 cy,
3758 PointerButton::Primary,
3759 PointerId::PRIMARY,
3760 ));
3761 let mut up_finger = Pointer::moving(cx, cy - 80.0);
3762 up_finger.kind = PointerKind::Touch;
3763 core.pointer_moved(up_finger);
3764 let before_release = core.ui_state().scroll_offset(&scroll_id);
3765 core.pointer_up(Pointer::touch(
3766 cx,
3767 cy - 80.0,
3768 PointerButton::Primary,
3769 PointerId::PRIMARY,
3770 ));
3771
3772 assert!(
3773 core.ui_state.has_scroll_momentum(),
3774 "quick touch scroll release should retain inertial velocity"
3775 );
3776 assert_eq!(
3777 core.next_input_deadline(Instant::now()),
3778 Some(Duration::ZERO),
3779 "active scroll momentum should request the next layout frame"
3780 );
3781
3782 let ticked = core
3783 .ui_state
3784 .tick_scroll_momentum(Instant::now() + Duration::from_millis(16));
3785 let after_tick = core.ui_state().scroll_offset(&scroll_id);
3786 assert!(ticked, "momentum tick should report visual work");
3787 assert!(
3788 after_tick > before_release,
3789 "momentum should continue in release direction: before={before_release}, after={after_tick}"
3790 );
3791 }
3792
3793 #[test]
3794 fn pointer_left_emits_leave_for_prior_hover() {
3795 let mut core = lay_out_input_tree(false);
3796 let btn = core.rect_of_key("btn").expect("btn rect");
3797 let _ = core.pointer_moved(Pointer::moving(btn.x + 4.0, btn.y + 4.0));
3798
3799 let events = core.pointer_left();
3800 assert_eq!(events.len(), 1);
3801 assert_eq!(events[0].kind, UiEventKind::PointerLeave);
3802 assert_eq!(events[0].key.as_deref(), Some("btn"));
3803 }
3804
3805 #[test]
3806 fn pointer_left_with_no_prior_hover_emits_nothing() {
3807 let mut core = lay_out_input_tree(false);
3808 let events = core.pointer_left();
3811 assert!(events.is_empty());
3812 }
3813
3814 #[test]
3815 fn poll_input_before_long_press_delay_emits_nothing() {
3816 let mut core = lay_out_input_tree(false);
3819 let btn = core.rect_of_key("btn").expect("btn rect");
3820 let cx = btn.x + btn.w * 0.5;
3821 let cy = btn.y + btn.h * 0.5;
3822 let _ = core.pointer_down(Pointer::touch(
3823 cx,
3824 cy,
3825 PointerButton::Primary,
3826 PointerId::PRIMARY,
3827 ));
3828 let polled = core.poll_input(Instant::now() + Duration::from_millis(100));
3830 assert!(polled.is_empty(), "should not fire before delay");
3831 }
3832
3833 #[test]
3834 fn poll_input_after_long_press_delay_fires_cancel_then_long_press() {
3835 let mut core = lay_out_input_tree(false);
3839 let btn = core.rect_of_key("btn").expect("btn rect");
3840 let cx = btn.x + btn.w * 0.5;
3841 let cy = btn.y + btn.h * 0.5;
3842 let _ = core.pointer_down(Pointer::touch(
3843 cx,
3844 cy,
3845 PointerButton::Primary,
3846 PointerId::PRIMARY,
3847 ));
3848 let polled = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
3849 let kinds: Vec<UiEventKind> = polled.iter().map(|e| e.kind).collect();
3850 assert!(
3851 kinds.contains(&UiEventKind::PointerCancel),
3852 "expected PointerCancel before LongPress, got {kinds:?}",
3853 );
3854 let long_press = polled
3855 .iter()
3856 .find(|e| matches!(e.kind, UiEventKind::LongPress))
3857 .expect("LongPress event missing");
3858 assert_eq!(
3859 long_press.key.as_deref(),
3860 Some("btn"),
3861 "LongPress should target the originally pressed node",
3862 );
3863 assert_eq!(
3864 long_press.pointer_kind,
3865 Some(PointerKind::Touch),
3866 "LongPress is touch-only",
3867 );
3868 }
3869
3870 #[test]
3871 fn touch_long_press_on_editable_preserves_drag_extension() {
3872 let mut core = lay_out_input_tree(true);
3873 let ti = core.rect_of_key("ti").expect("ti rect");
3874 let cx = ti.x + 4.0;
3875 let cy = ti.y + ti.h * 0.5;
3876 let _ = core.pointer_down(Pointer::touch(
3877 cx,
3878 cy,
3879 PointerButton::Primary,
3880 PointerId::PRIMARY,
3881 ));
3882
3883 let polled = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
3884 assert!(
3885 polled.iter().any(|e| e.kind == UiEventKind::LongPress),
3886 "editable long-press should emit LongPress"
3887 );
3888 assert!(
3889 !polled.iter().any(|e| e.kind == UiEventKind::PointerCancel),
3890 "editable long-press keeps the press captured so drag can extend selection"
3891 );
3892
3893 let mut moved = Pointer::moving(cx + 40.0, cy);
3894 moved.kind = PointerKind::Touch;
3895 let drag = core.pointer_moved(moved);
3896 assert!(
3897 drag.events.iter().any(|e| e.kind == UiEventKind::Drag),
3898 "held touch move after editable long-press should emit Drag"
3899 );
3900
3901 let up_events = core.pointer_up(Pointer::touch(
3902 cx + 40.0,
3903 cy,
3904 PointerButton::Primary,
3905 PointerId::PRIMARY,
3906 ));
3907 assert!(
3908 up_events.is_empty(),
3909 "long-press release should not synthesize click or pointer-up"
3910 );
3911 }
3912
3913 #[test]
3914 fn pointer_up_after_long_press_emits_no_click() {
3915 let mut core = lay_out_input_tree(false);
3919 let btn = core.rect_of_key("btn").expect("btn rect");
3920 let cx = btn.x + btn.w * 0.5;
3921 let cy = btn.y + btn.h * 0.5;
3922 let _ = core.pointer_down(Pointer::touch(
3923 cx,
3924 cy,
3925 PointerButton::Primary,
3926 PointerId::PRIMARY,
3927 ));
3928 let _ = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
3929 let up_events = core.pointer_up(Pointer::touch(
3930 cx,
3931 cy,
3932 PointerButton::Primary,
3933 PointerId::PRIMARY,
3934 ));
3935 assert!(
3936 up_events.is_empty(),
3937 "lift after long-press emits nothing, got {:?}",
3938 up_events.iter().map(|e| e.kind).collect::<Vec<_>>(),
3939 );
3940 }
3941
3942 #[test]
3943 fn moving_past_threshold_before_long_press_cancels_the_timer() {
3944 let mut core = lay_out_input_tree(false);
3949 let btn = core.rect_of_key("btn").expect("btn rect");
3950 let cx = btn.x + btn.w * 0.5;
3951 let cy = btn.y + btn.h * 0.5;
3952 let _ = core.pointer_down(Pointer::touch(
3953 cx,
3954 cy,
3955 PointerButton::Primary,
3956 PointerId::PRIMARY,
3957 ));
3958 let mut over = Pointer::moving(cx + 30.0, cy);
3960 over.kind = PointerKind::Touch;
3961 let _ = core.pointer_moved(over);
3962 let polled = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
3964 assert!(
3965 polled.is_empty(),
3966 "long-press should not fire after gesture committed",
3967 );
3968 }
3969
3970 #[test]
3971 fn ui_state_hovered_key_returns_leaf_key() {
3972 let mut core = lay_out_input_tree(false);
3973 assert_eq!(core.ui_state().hovered_key(), None);
3974
3975 let btn = core.rect_of_key("btn").expect("btn rect");
3976 core.pointer_moved(Pointer::moving(btn.x + 4.0, btn.y + 4.0));
3977 assert_eq!(core.ui_state().hovered_key(), Some("btn"));
3978
3979 core.pointer_moved(Pointer::moving(0.0, 0.0));
3981 assert_eq!(core.ui_state().hovered_key(), None);
3982 }
3983
3984 #[test]
3985 fn ui_state_is_hovering_within_walks_subtree() {
3986 use crate::tree::*;
3990 let mut tree = crate::column([crate::stack([
3991 crate::widgets::button::button("Inner").key("inner_btn")
3992 ])
3993 .key("card")
3994 .focusable()
3995 .width(Size::Fixed(120.0))
3996 .height(Size::Fixed(60.0))])
3997 .padding(20.0);
3998 let mut core = RunnerCore::new();
3999 crate::layout::layout(
4000 &mut tree,
4001 &mut core.ui_state,
4002 Rect::new(0.0, 0.0, 400.0, 200.0),
4003 );
4004 core.ui_state.sync_focus_order(&tree);
4005 let mut t = PrepareTimings::default();
4006 core.snapshot(&tree, &mut t);
4007
4008 assert!(!core.ui_state().is_hovering_within("card"));
4010 assert!(!core.ui_state().is_hovering_within("inner_btn"));
4011
4012 let inner = core.rect_of_key("inner_btn").expect("inner rect");
4015 core.pointer_moved(Pointer::moving(inner.x + 4.0, inner.y + 4.0));
4016 assert!(core.ui_state().is_hovering_within("card"));
4017 assert!(core.ui_state().is_hovering_within("inner_btn"));
4018
4019 assert!(!core.ui_state().is_hovering_within("not_a_key"));
4021
4022 core.pointer_moved(Pointer::moving(0.0, 0.0));
4024 assert!(!core.ui_state().is_hovering_within("card"));
4025 assert!(!core.ui_state().is_hovering_within("inner_btn"));
4026 }
4027
4028 #[test]
4029 fn hover_driven_scale_via_is_hovering_within_plus_animate() {
4030 use crate::Theme;
4037 use crate::anim::Timing;
4038 use crate::tree::*;
4039
4040 let build_card = |hovering: bool| -> El {
4043 let scale = if hovering { 1.05 } else { 1.0 };
4044 crate::column([crate::stack(
4045 [crate::widgets::button::button("Inner").key("inner_btn")],
4046 )
4047 .key("card")
4048 .focusable()
4049 .scale(scale)
4050 .animate(Timing::SPRING_QUICK)
4051 .width(Size::Fixed(120.0))
4052 .height(Size::Fixed(60.0))])
4053 .padding(20.0)
4054 };
4055
4056 let mut core = RunnerCore::new();
4057 core.ui_state
4060 .set_animation_mode(crate::state::AnimationMode::Settled);
4061
4062 let theme = Theme::default();
4064 let cx_pre = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
4065 assert!(!cx_pre.is_hovering_within("card"));
4066 let mut tree = build_card(cx_pre.is_hovering_within("card"));
4067 crate::layout::layout(
4068 &mut tree,
4069 &mut core.ui_state,
4070 Rect::new(0.0, 0.0, 400.0, 200.0),
4071 );
4072 core.ui_state.sync_focus_order(&tree);
4073 let mut t = PrepareTimings::default();
4074 core.snapshot(&tree, &mut t);
4075 core.ui_state
4076 .tick_visual_animations(&mut tree, web_time::Instant::now(), theme.palette());
4077 let card_at_rest = tree.children[0].clone();
4078 assert!((card_at_rest.scale - 1.0).abs() < 1e-3);
4079
4080 let card_rect = core.rect_of_key("card").expect("card rect");
4082 core.pointer_moved(Pointer::moving(card_rect.x + 4.0, card_rect.y + 4.0));
4083
4084 let cx_hot = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
4087 assert!(cx_hot.is_hovering_within("card"));
4088 let mut tree = build_card(cx_hot.is_hovering_within("card"));
4089 crate::layout::layout(
4090 &mut tree,
4091 &mut core.ui_state,
4092 Rect::new(0.0, 0.0, 400.0, 200.0),
4093 );
4094 core.ui_state.sync_focus_order(&tree);
4095 core.snapshot(&tree, &mut t);
4096 core.ui_state
4097 .tick_visual_animations(&mut tree, web_time::Instant::now(), theme.palette());
4098 let card_hot = tree.children[0].clone();
4099 assert!(
4100 (card_hot.scale - 1.05).abs() < 1e-3,
4101 "hover should drive card scale to 1.05 via animate; got {}",
4102 card_hot.scale,
4103 );
4104
4105 core.pointer_moved(Pointer::moving(0.0, 0.0));
4107 let cx_cold = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
4108 assert!(!cx_cold.is_hovering_within("card"));
4109 let mut tree = build_card(cx_cold.is_hovering_within("card"));
4110 crate::layout::layout(
4111 &mut tree,
4112 &mut core.ui_state,
4113 Rect::new(0.0, 0.0, 400.0, 200.0),
4114 );
4115 core.ui_state.sync_focus_order(&tree);
4116 core.snapshot(&tree, &mut t);
4117 core.ui_state
4118 .tick_visual_animations(&mut tree, web_time::Instant::now(), theme.palette());
4119 let card_after = tree.children[0].clone();
4120 assert!((card_after.scale - 1.0).abs() < 1e-3);
4121 }
4122
4123 #[test]
4124 fn file_dropped_routes_to_keyed_leaf_at_pointer() {
4125 let mut core = lay_out_input_tree(false);
4126 let btn = core.rect_of_key("btn").expect("btn rect");
4127 let path = std::path::PathBuf::from("/tmp/screenshot.png");
4128 let events = core.file_dropped(path.clone(), btn.x + 4.0, btn.y + 4.0);
4129 assert_eq!(events.len(), 1);
4130 let event = &events[0];
4131 assert_eq!(event.kind, UiEventKind::FileDropped);
4132 assert_eq!(event.key.as_deref(), Some("btn"));
4133 assert_eq!(event.path.as_deref(), Some(path.as_path()));
4134 assert_eq!(event.pointer, Some((btn.x + 4.0, btn.y + 4.0)));
4135 }
4136
4137 #[test]
4138 fn file_dropped_outside_keyed_surface_emits_window_level_event() {
4139 let mut core = lay_out_input_tree(false);
4140 let path = std::path::PathBuf::from("/tmp/screenshot.png");
4142 let events = core.file_dropped(path.clone(), 1.0, 1.0);
4143 assert_eq!(events.len(), 1);
4144 let event = &events[0];
4145 assert_eq!(event.kind, UiEventKind::FileDropped);
4146 assert!(
4147 event.target.is_none(),
4148 "drop outside any keyed surface routes window-level",
4149 );
4150 assert!(event.key.is_none());
4151 assert_eq!(event.path.as_deref(), Some(path.as_path()));
4153 }
4154
4155 #[test]
4156 fn file_hovered_then_cancelled_pair() {
4157 let mut core = lay_out_input_tree(false);
4158 let btn = core.rect_of_key("btn").expect("btn rect");
4159 let path = std::path::PathBuf::from("/tmp/a.png");
4160
4161 let hover = core.file_hovered(path.clone(), btn.x + 4.0, btn.y + 4.0);
4162 assert_eq!(hover.len(), 1);
4163 assert_eq!(hover[0].kind, UiEventKind::FileHovered);
4164 assert_eq!(hover[0].key.as_deref(), Some("btn"));
4165 assert_eq!(hover[0].path.as_deref(), Some(path.as_path()));
4166
4167 let cancel = core.file_hover_cancelled();
4168 assert_eq!(cancel.len(), 1);
4169 assert_eq!(cancel[0].kind, UiEventKind::FileHoverCancelled);
4170 assert!(cancel[0].target.is_none());
4171 assert!(cancel[0].path.is_none());
4172 }
4173
4174 #[test]
4175 fn build_cx_hover_accessors_default_off_without_state() {
4176 use crate::Theme;
4177 let theme = Theme::default();
4178 let cx = crate::BuildCx::new(&theme);
4179 assert_eq!(cx.hovered_key(), None);
4180 assert!(!cx.is_hovering_within("anything"));
4181 }
4182
4183 #[test]
4184 fn build_cx_hover_accessors_delegate_when_state_attached() {
4185 use crate::Theme;
4186 let mut core = lay_out_input_tree(false);
4187 let btn = core.rect_of_key("btn").expect("btn rect");
4188 core.pointer_moved(Pointer::moving(btn.x + 4.0, btn.y + 4.0));
4189
4190 let theme = Theme::default();
4191 let cx = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
4192 assert_eq!(cx.hovered_key(), Some("btn"));
4193 assert!(cx.is_hovering_within("btn"));
4194 assert!(!cx.is_hovering_within("ti"));
4195 }
4196
4197 fn lay_out_paragraph_tree() -> RunnerCore {
4198 use crate::tree::*;
4199 let mut tree = crate::column([
4200 crate::widgets::text::text("First paragraph of text.")
4201 .key("p1")
4202 .selectable(),
4203 crate::widgets::text::text("Second paragraph of text.")
4204 .key("p2")
4205 .selectable(),
4206 ])
4207 .padding(20.0);
4208 let mut core = RunnerCore::new();
4209 crate::layout::layout(
4210 &mut tree,
4211 &mut core.ui_state,
4212 Rect::new(0.0, 0.0, 400.0, 300.0),
4213 );
4214 core.ui_state.sync_focus_order(&tree);
4215 core.ui_state.sync_selection_order(&tree);
4216 let mut t = PrepareTimings::default();
4217 core.snapshot(&tree, &mut t);
4218 core
4219 }
4220
4221 #[test]
4222 fn pointer_down_on_selectable_text_emits_selection_changed() {
4223 let mut core = lay_out_paragraph_tree();
4224 let p1 = core.rect_of_key("p1").expect("p1 rect");
4225 let cx = p1.x + 4.0;
4226 let cy = p1.y + p1.h * 0.5;
4227 let events = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4228 let sel_event = events
4229 .iter()
4230 .find(|e| e.kind == UiEventKind::SelectionChanged)
4231 .expect("SelectionChanged emitted");
4232 let new_sel = sel_event
4233 .selection
4234 .as_ref()
4235 .expect("SelectionChanged carries a selection");
4236 let range = new_sel.range.as_ref().expect("collapsed selection at hit");
4237 assert_eq!(range.anchor.key, "p1");
4238 assert_eq!(range.head.key, "p1");
4239 assert_eq!(range.anchor.byte, range.head.byte);
4240 assert!(core.ui_state.selection.drag.is_some());
4241 }
4242
4243 #[test]
4244 fn touch_long_press_on_selectable_text_selects_word_and_drags() {
4245 let mut core = lay_out_paragraph_tree();
4246 let p1 = core.rect_of_key("p1").expect("p1 rect");
4247 let p2 = core.rect_of_key("p2").expect("p2 rect");
4248 let x = p1.x + 4.0;
4249 let y = p1.y + p1.h * 0.5;
4250
4251 let _ = core.pointer_down(Pointer::touch(
4252 x,
4253 y,
4254 PointerButton::Primary,
4255 PointerId::PRIMARY,
4256 ));
4257 let polled = core.poll_input(Instant::now() + LONG_PRESS_DELAY + Duration::from_millis(10));
4258 let selection = polled
4259 .iter()
4260 .rev()
4261 .find(|e| e.kind == UiEventKind::SelectionChanged)
4262 .and_then(|e| e.selection.as_ref())
4263 .expect("touch long-press should select text");
4264 let range = selection.range.as_ref().expect("word selection");
4265 assert_eq!(range.anchor.key, "p1");
4266 assert_eq!(range.head.key, "p1");
4267 assert_eq!(range.anchor.byte, 0);
4268 assert_eq!(range.head.byte, 5);
4269 assert!(
4270 core.ui_state.selection.drag.is_some(),
4271 "long-pressed selectable text should stay ready for drag extension"
4272 );
4273
4274 let mut moved = Pointer::moving(p2.x + 8.0, p2.y + p2.h * 0.5);
4275 moved.kind = PointerKind::Touch;
4276 let events = core.pointer_moved(moved).events;
4277 let selection = events
4278 .iter()
4279 .find(|e| e.kind == UiEventKind::SelectionChanged)
4280 .and_then(|e| e.selection.as_ref())
4281 .unwrap_or(&core.ui_state.current_selection);
4282 let range = selection.range.as_ref().expect("extended selection");
4283 assert_eq!(range.anchor.key, "p1");
4284 assert_eq!(range.head.key, "p2");
4285
4286 let _ = core.pointer_up(Pointer::touch(
4287 p2.x + 8.0,
4288 p2.y + p2.h * 0.5,
4289 PointerButton::Primary,
4290 PointerId::PRIMARY,
4291 ));
4292 assert!(
4293 core.ui_state.selection.drag.is_none(),
4294 "selection drag should end on lift"
4295 );
4296 }
4297
4298 #[test]
4299 fn pointer_drag_on_selectable_text_extends_head() {
4300 let mut core = lay_out_paragraph_tree();
4301 let p1 = core.rect_of_key("p1").expect("p1 rect");
4302 let cx = p1.x + 4.0;
4303 let cy = p1.y + p1.h * 0.5;
4304 core.pointer_moved(Pointer::moving(cx, cy));
4305 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4306
4307 let events = core
4309 .pointer_moved(Pointer::moving(p1.x + p1.w - 10.0, cy))
4310 .events;
4311 let sel_event = events
4312 .iter()
4313 .find(|e| e.kind == UiEventKind::SelectionChanged)
4314 .expect("Drag emits SelectionChanged");
4315 let new_sel = sel_event.selection.as_ref().unwrap();
4316 let range = new_sel.range.as_ref().unwrap();
4317 assert_eq!(range.anchor.key, "p1");
4318 assert_eq!(range.head.key, "p1");
4319 assert!(
4320 range.head.byte > range.anchor.byte,
4321 "head should advance past anchor (anchor={}, head={})",
4322 range.anchor.byte,
4323 range.head.byte
4324 );
4325 }
4326
4327 #[test]
4328 fn double_click_hold_drag_inside_selectable_word_keeps_word_selected() {
4329 let mut core = lay_out_paragraph_tree();
4330 let p1 = core.rect_of_key("p1").expect("p1 rect");
4331 let cx = p1.x + 4.0;
4332 let cy = p1.y + p1.h * 0.5;
4333
4334 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4335 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
4336 let down = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4337 let sel = down
4338 .iter()
4339 .find(|e| e.kind == UiEventKind::SelectionChanged)
4340 .and_then(|e| e.selection.as_ref())
4341 .and_then(|s| s.range.as_ref())
4342 .expect("double-click selects word");
4343 assert_eq!(sel.anchor.byte, 0);
4344 assert_eq!(sel.head.byte, 5);
4345
4346 let events = core.pointer_moved(Pointer::moving(cx + 1.0, cy)).events;
4347 assert!(
4348 !events
4349 .iter()
4350 .any(|e| e.kind == UiEventKind::SelectionChanged),
4351 "drag jitter within the double-clicked word should not collapse the selection"
4352 );
4353 let range = core
4354 .ui_state
4355 .current_selection
4356 .range
4357 .as_ref()
4358 .expect("selection persists");
4359 assert_eq!(range.anchor.byte, 0);
4360 assert_eq!(range.head.byte, 5);
4361 }
4362
4363 #[test]
4364 fn pointer_up_clears_drag_but_keeps_selection() {
4365 let mut core = lay_out_paragraph_tree();
4366 let p1 = core.rect_of_key("p1").expect("p1 rect");
4367 let cx = p1.x + 4.0;
4368 let cy = p1.y + p1.h * 0.5;
4369 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4370 core.pointer_moved(Pointer::moving(p1.x + p1.w - 10.0, cy));
4371 let _ = core.pointer_up(Pointer::mouse(
4372 p1.x + p1.w - 10.0,
4373 cy,
4374 PointerButton::Primary,
4375 ));
4376 assert!(
4377 core.ui_state.selection.drag.is_none(),
4378 "drag flag should clear on pointer_up"
4379 );
4380 assert!(
4381 !core.ui_state.current_selection.is_empty(),
4382 "selection itself should persist after pointer_up"
4383 );
4384 }
4385
4386 #[test]
4387 fn drag_past_a_leaf_bottom_keeps_head_in_that_leaf_not_anchor() {
4388 let mut core = lay_out_paragraph_tree();
4394 let p1 = core.rect_of_key("p1").expect("p1 rect");
4395 let p2 = core.rect_of_key("p2").expect("p2 rect");
4396 core.pointer_down(Pointer::mouse(
4398 p1.x + 4.0,
4399 p1.y + p1.h * 0.5,
4400 PointerButton::Primary,
4401 ));
4402 core.pointer_moved(Pointer::moving(p2.x + 8.0, p2.y + p2.h * 0.5));
4404 let events = core
4407 .pointer_moved(Pointer::moving(p2.x + 8.0, p2.y + p2.h + 200.0))
4408 .events;
4409 let sel = events
4410 .iter()
4411 .find(|e| e.kind == UiEventKind::SelectionChanged)
4412 .map(|e| e.selection.as_ref().unwrap().clone())
4413 .unwrap_or_else(|| core.ui_state.current_selection.clone());
4416 let r = sel.range.as_ref().expect("selection still active");
4417 assert_eq!(r.anchor.key, "p1", "anchor unchanged");
4418 assert_eq!(
4419 r.head.key, "p2",
4420 "head must stay in p2 even when pointer is below p2's rect"
4421 );
4422 }
4423
4424 #[test]
4425 fn drag_into_a_sibling_selectable_extends_head_into_that_leaf() {
4426 let mut core = lay_out_paragraph_tree();
4427 let p1 = core.rect_of_key("p1").expect("p1 rect");
4428 let p2 = core.rect_of_key("p2").expect("p2 rect");
4429 core.pointer_down(Pointer::mouse(
4431 p1.x + 4.0,
4432 p1.y + p1.h * 0.5,
4433 PointerButton::Primary,
4434 ));
4435 let events = core
4437 .pointer_moved(Pointer::moving(p2.x + 8.0, p2.y + p2.h * 0.5))
4438 .events;
4439 let sel_event = events
4440 .iter()
4441 .find(|e| e.kind == UiEventKind::SelectionChanged)
4442 .expect("Drag emits SelectionChanged");
4443 let new_sel = sel_event.selection.as_ref().unwrap();
4444 let range = new_sel.range.as_ref().unwrap();
4445 assert_eq!(range.anchor.key, "p1", "anchor stays in p1");
4446 assert_eq!(range.head.key, "p2", "head migrates into p2");
4447 }
4448
4449 #[test]
4450 fn pointer_down_on_focusable_owning_selection_does_not_clear_it() {
4451 let mut core = lay_out_input_tree(true);
4459 core.set_selection(crate::selection::Selection::caret("ti", 3));
4462 let ti = core.rect_of_key("ti").expect("ti rect");
4463 let cx = ti.x + ti.w * 0.5;
4464 let cy = ti.y + ti.h * 0.5;
4465
4466 let events = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4467 let cleared = events.iter().find(|e| {
4468 e.kind == UiEventKind::SelectionChanged
4469 && e.selection.as_ref().map(|s| s.is_empty()).unwrap_or(false)
4470 });
4471 assert!(
4472 cleared.is_none(),
4473 "click on the selection-owning input must not emit a clearing SelectionChanged"
4474 );
4475 assert_eq!(
4476 core.ui_state.current_selection,
4477 crate::selection::Selection::caret("ti", 3),
4478 "runtime mirror is preserved when the click owns the selection"
4479 );
4480 }
4481
4482 #[test]
4483 fn pointer_down_into_a_different_capture_keys_widget_does_not_clear_first() {
4484 let mut core = lay_out_input_tree(true);
4494 core.set_selection(crate::selection::Selection::caret("other", 4));
4496 let ti = core.rect_of_key("ti").expect("ti rect");
4497 let cx = ti.x + ti.w * 0.5;
4498 let cy = ti.y + ti.h * 0.5;
4499
4500 let events = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4501 let cleared = events.iter().any(|e| {
4502 e.kind == UiEventKind::SelectionChanged
4503 && e.selection.as_ref().map(|s| s.is_empty()).unwrap_or(false)
4504 });
4505 assert!(
4506 !cleared,
4507 "click on a different capture_keys widget must not race-clear the selection"
4508 );
4509 }
4510
4511 #[test]
4512 fn pointer_down_on_non_selectable_clears_existing_selection() {
4513 let mut core = lay_out_paragraph_tree();
4514 let p1 = core.rect_of_key("p1").expect("p1 rect");
4515 let cy = p1.y + p1.h * 0.5;
4516 core.pointer_down(Pointer::mouse(p1.x + 4.0, cy, PointerButton::Primary));
4518 core.pointer_up(Pointer::mouse(p1.x + 4.0, cy, PointerButton::Primary));
4519 assert!(!core.ui_state.current_selection.is_empty());
4520
4521 let events = core.pointer_down(Pointer::mouse(2.0, 2.0, PointerButton::Primary));
4523 let cleared = events
4524 .iter()
4525 .find(|e| e.kind == UiEventKind::SelectionChanged)
4526 .expect("clearing emits SelectionChanged");
4527 let new_sel = cleared.selection.as_ref().unwrap();
4528 assert!(new_sel.is_empty(), "new selection should be empty");
4529 assert!(core.ui_state.current_selection.is_empty());
4530 }
4531
4532 #[test]
4533 fn pointer_down_in_dead_space_clears_focus() {
4534 let mut core = lay_out_input_tree(false);
4535 let btn = core.rect_of_key("btn").expect("btn rect");
4536 let cx = btn.x + btn.w * 0.5;
4537 let cy = btn.y + btn.h * 0.5;
4538 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4539 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
4540 assert_eq!(
4541 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4542 Some("btn")
4543 );
4544
4545 core.pointer_down(Pointer::mouse(2.0, 2.0, PointerButton::Primary));
4546
4547 assert_eq!(core.ui_state.focused.as_ref().map(|t| t.key.as_str()), None);
4548 }
4549
4550 #[test]
4551 fn key_down_bumps_caret_activity_when_focused_widget_captures_keys() {
4552 let mut core = lay_out_input_tree(true);
4557 let target = core
4558 .ui_state
4559 .focus
4560 .order
4561 .iter()
4562 .find(|t| t.key == "ti")
4563 .cloned();
4564 core.ui_state.set_focus(target); let after_focus = core.ui_state.caret.activity_at.expect("focus bump");
4566
4567 std::thread::sleep(std::time::Duration::from_millis(2));
4568 let _ = core.key_down(UiKey::ArrowRight, KeyModifiers::default(), false);
4569 let after_arrow = core
4570 .ui_state
4571 .caret
4572 .activity_at
4573 .expect("arrow key bumps even without app-side selection");
4574 assert!(
4575 after_arrow > after_focus,
4576 "ArrowRight to a capture_keys focused widget bumps caret activity"
4577 );
4578 }
4579
4580 #[test]
4581 fn text_input_bumps_caret_activity_when_focused() {
4582 let mut core = lay_out_input_tree(true);
4583 let target = core
4584 .ui_state
4585 .focus
4586 .order
4587 .iter()
4588 .find(|t| t.key == "ti")
4589 .cloned();
4590 core.ui_state.set_focus(target);
4591 let after_focus = core.ui_state.caret.activity_at.unwrap();
4592
4593 std::thread::sleep(std::time::Duration::from_millis(2));
4594 let _ = core.text_input("a".into());
4595 let after_text = core.ui_state.caret.activity_at.unwrap();
4596 assert!(
4597 after_text > after_focus,
4598 "TextInput to focused widget bumps caret activity"
4599 );
4600 }
4601
4602 #[test]
4603 fn pointer_down_inside_focused_input_bumps_caret_activity() {
4604 let mut core = lay_out_input_tree(true);
4609 let ti = core.rect_of_key("ti").expect("ti rect");
4610 let cx = ti.x + ti.w * 0.5;
4611 let cy = ti.y + ti.h * 0.5;
4612
4613 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4615 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
4616 let after_first = core.ui_state.caret.activity_at.unwrap();
4617
4618 std::thread::sleep(std::time::Duration::from_millis(2));
4621 core.pointer_down(Pointer::mouse(cx + 1.0, cy, PointerButton::Primary));
4622 let after_second = core
4623 .ui_state
4624 .caret
4625 .activity_at
4626 .expect("second click bumps too");
4627 assert!(
4628 after_second > after_first,
4629 "click within already-focused capture_keys widget still bumps"
4630 );
4631 }
4632
4633 #[test]
4634 fn arrow_key_through_apply_event_mutates_selection_and_bumps_on_set() {
4635 use crate::widgets::text_input;
4641 let mut sel = crate::selection::Selection::caret("ti", 2);
4642 let mut value = String::from("hello");
4643
4644 let mut core = RunnerCore::new();
4645 core.set_selection(sel.clone());
4648 let baseline = core.ui_state.caret.activity_at;
4649
4650 let arrow_right = UiEvent {
4652 key: Some("ti".into()),
4653 target: None,
4654 pointer: None,
4655 key_press: Some(crate::event::KeyPress {
4656 key: UiKey::ArrowRight,
4657 modifiers: KeyModifiers::default(),
4658 repeat: false,
4659 }),
4660 text: None,
4661 selection: None,
4662 modifiers: KeyModifiers::default(),
4663 click_count: 0,
4664 path: None,
4665 pointer_kind: None,
4666 kind: UiEventKind::KeyDown,
4667 };
4668
4669 let mutated = text_input::apply_event(&mut value, &mut sel, "ti", &arrow_right);
4671 assert!(mutated, "ArrowRight should mutate selection");
4672 assert_eq!(
4673 sel.within("ti").unwrap().head,
4674 3,
4675 "head moved one char right (h-e-l-l-o, byte 2 → 3)"
4676 );
4677
4678 std::thread::sleep(std::time::Duration::from_millis(2));
4680 core.set_selection(sel);
4681 let after = core.ui_state.caret.activity_at.unwrap();
4682 if let Some(b) = baseline {
4686 assert!(after > b, "arrow-key flow should bump activity");
4687 }
4688 }
4689
4690 #[test]
4691 fn set_selection_bumps_caret_activity_only_when_value_changes() {
4692 let mut core = lay_out_paragraph_tree();
4693 core.set_selection(crate::selection::Selection::default());
4696 assert!(
4697 core.ui_state.caret.activity_at.is_none(),
4698 "no-op set_selection should not bump activity"
4699 );
4700
4701 let sel_a = crate::selection::Selection::caret("p1", 3);
4703 core.set_selection(sel_a.clone());
4704 let bumped_at = core
4705 .ui_state
4706 .caret
4707 .activity_at
4708 .expect("first real selection bumps");
4709
4710 core.set_selection(sel_a.clone());
4713 assert_eq!(
4714 core.ui_state.caret.activity_at,
4715 Some(bumped_at),
4716 "set_selection with same value is a no-op"
4717 );
4718
4719 std::thread::sleep(std::time::Duration::from_millis(2));
4722 let sel_b = crate::selection::Selection::caret("p1", 7);
4723 core.set_selection(sel_b);
4724 let new_bump = core.ui_state.caret.activity_at.expect("second bump");
4725 assert!(
4726 new_bump > bumped_at,
4727 "moving the caret bumps activity again",
4728 );
4729 }
4730
4731 #[test]
4732 fn escape_clears_active_selection_and_emits_selection_changed() {
4733 let mut core = lay_out_paragraph_tree();
4734 let p1 = core.rect_of_key("p1").expect("p1 rect");
4735 let cy = p1.y + p1.h * 0.5;
4736 core.pointer_down(Pointer::mouse(p1.x + 4.0, cy, PointerButton::Primary));
4738 core.pointer_moved(Pointer::moving(p1.x + p1.w - 10.0, cy));
4739 core.pointer_up(Pointer::mouse(
4740 p1.x + p1.w - 10.0,
4741 cy,
4742 PointerButton::Primary,
4743 ));
4744 assert!(!core.ui_state.current_selection.is_empty());
4745
4746 let events = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
4747 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
4748 assert_eq!(
4749 kinds,
4750 vec![UiEventKind::Escape, UiEventKind::SelectionChanged],
4751 "Esc emits Escape (for popover dismiss) AND SelectionChanged"
4752 );
4753 let cleared = events
4754 .iter()
4755 .find(|e| e.kind == UiEventKind::SelectionChanged)
4756 .unwrap();
4757 assert!(cleared.selection.as_ref().unwrap().is_empty());
4758 assert!(core.ui_state.current_selection.is_empty());
4759 }
4760
4761 #[test]
4762 fn consecutive_clicks_on_same_target_extend_count() {
4763 let mut core = lay_out_input_tree(false);
4764 let btn = core.rect_of_key("btn").expect("btn rect");
4765 let cx = btn.x + btn.w * 0.5;
4766 let cy = btn.y + btn.h * 0.5;
4767
4768 let down1 = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4770 let pd1 = down1
4771 .iter()
4772 .find(|e| e.kind == UiEventKind::PointerDown)
4773 .expect("PointerDown emitted");
4774 assert_eq!(pd1.click_count, 1, "first press starts the sequence");
4775 let up1 = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
4776 let click1 = up1
4777 .iter()
4778 .find(|e| e.kind == UiEventKind::Click)
4779 .expect("Click emitted");
4780 assert_eq!(
4781 click1.click_count, 1,
4782 "Click carries the same count as its PointerDown"
4783 );
4784
4785 let down2 = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4787 let pd2 = down2
4788 .iter()
4789 .find(|e| e.kind == UiEventKind::PointerDown)
4790 .unwrap();
4791 assert_eq!(pd2.click_count, 2, "second press extends the sequence");
4792 let up2 = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
4793 assert_eq!(
4794 up2.iter()
4795 .find(|e| e.kind == UiEventKind::Click)
4796 .unwrap()
4797 .click_count,
4798 2
4799 );
4800
4801 let down3 = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4803 let pd3 = down3
4804 .iter()
4805 .find(|e| e.kind == UiEventKind::PointerDown)
4806 .unwrap();
4807 assert_eq!(pd3.click_count, 3, "third press → triple-click");
4808 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
4809 }
4810
4811 #[test]
4812 fn click_count_resets_when_target_changes() {
4813 let mut core = lay_out_input_tree(false);
4814 let btn = core.rect_of_key("btn").expect("btn rect");
4815 let ti = core.rect_of_key("ti").expect("ti rect");
4816
4817 let down1 = core.pointer_down(Pointer::mouse(
4819 btn.x + btn.w * 0.5,
4820 btn.y + btn.h * 0.5,
4821 PointerButton::Primary,
4822 ));
4823 assert_eq!(
4824 down1
4825 .iter()
4826 .find(|e| e.kind == UiEventKind::PointerDown)
4827 .unwrap()
4828 .click_count,
4829 1
4830 );
4831 let _ = core.pointer_up(Pointer::mouse(
4832 btn.x + btn.w * 0.5,
4833 btn.y + btn.h * 0.5,
4834 PointerButton::Primary,
4835 ));
4836
4837 let down2 = core.pointer_down(Pointer::mouse(
4839 ti.x + ti.w * 0.5,
4840 ti.y + ti.h * 0.5,
4841 PointerButton::Primary,
4842 ));
4843 let pd2 = down2
4844 .iter()
4845 .find(|e| e.kind == UiEventKind::PointerDown)
4846 .unwrap();
4847 assert_eq!(
4848 pd2.click_count, 1,
4849 "press on a new target resets the multi-click sequence"
4850 );
4851 }
4852
4853 #[test]
4854 fn double_click_on_selectable_text_selects_word_at_hit() {
4855 let mut core = lay_out_paragraph_tree();
4856 let p1 = core.rect_of_key("p1").expect("p1 rect");
4857 let cy = p1.y + p1.h * 0.5;
4858 let cx = p1.x + 4.0;
4861 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4862 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
4863 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4864 let sel = &core.ui_state.current_selection;
4866 let r = sel.range.as_ref().expect("selection set");
4867 assert_eq!(r.anchor.key, "p1");
4868 assert_eq!(r.head.key, "p1");
4869 assert_eq!(r.anchor.byte.min(r.head.byte), 0);
4871 assert_eq!(r.anchor.byte.max(r.head.byte), 5);
4872 }
4873
4874 #[test]
4875 fn triple_click_on_selectable_text_selects_whole_leaf() {
4876 let mut core = lay_out_paragraph_tree();
4877 let p1 = core.rect_of_key("p1").expect("p1 rect");
4878 let cy = p1.y + p1.h * 0.5;
4879 let cx = p1.x + 4.0;
4880 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4881 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
4882 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4883 core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
4884 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4885 let sel = &core.ui_state.current_selection;
4886 let r = sel.range.as_ref().expect("selection set");
4887 assert_eq!(r.anchor.byte, 0);
4888 assert_eq!(r.head.byte, 24);
4890 }
4891
4892 #[test]
4893 fn click_count_resets_when_press_drifts_outside_distance_window() {
4894 let mut core = lay_out_input_tree(false);
4895 let btn = core.rect_of_key("btn").expect("btn rect");
4896 let cx = btn.x + btn.w * 0.5;
4897 let cy = btn.y + btn.h * 0.5;
4898
4899 let _ = core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
4900 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
4901
4902 let down2 = core.pointer_down(Pointer::mouse(cx + 10.0, cy, PointerButton::Primary));
4905 let pd2 = down2
4906 .iter()
4907 .find(|e| e.kind == UiEventKind::PointerDown)
4908 .unwrap();
4909 assert_eq!(pd2.click_count, 1);
4910 }
4911
4912 #[test]
4913 fn escape_with_no_selection_emits_only_escape() {
4914 let mut core = lay_out_paragraph_tree();
4915 assert!(core.ui_state.current_selection.is_empty());
4916 let events = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
4917 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
4918 assert_eq!(
4919 kinds,
4920 vec![UiEventKind::Escape],
4921 "no selection → no SelectionChanged side-effect"
4922 );
4923 }
4924
4925 fn lay_out_scroll_tree() -> (RunnerCore, String) {
4928 use crate::tree::*;
4929 let mut tree = crate::scroll(
4930 (0..6)
4931 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
4932 )
4933 .gap(12.0)
4934 .height(Size::Fixed(200.0));
4935 let mut core = RunnerCore::new();
4936 crate::layout::layout(
4937 &mut tree,
4938 &mut core.ui_state,
4939 Rect::new(0.0, 0.0, 300.0, 200.0),
4940 );
4941 let scroll_id = tree.computed_id.clone();
4942 let mut t = PrepareTimings::default();
4943 core.snapshot(&tree, &mut t);
4944 (core, scroll_id)
4945 }
4946
4947 #[test]
4948 fn thumb_pointer_down_captures_drag_and_suppresses_events() {
4949 let (mut core, scroll_id) = lay_out_scroll_tree();
4950 let thumb = core
4951 .ui_state
4952 .scroll
4953 .thumb_rects
4954 .get(&scroll_id)
4955 .copied()
4956 .expect("scrollable should have a thumb");
4957 let event = core.pointer_down(Pointer::mouse(
4958 thumb.x + thumb.w * 0.5,
4959 thumb.y + thumb.h * 0.5,
4960 PointerButton::Primary,
4961 ));
4962 assert!(
4963 event.is_empty(),
4964 "thumb press should not emit PointerDown to the app"
4965 );
4966 let drag = core
4967 .ui_state
4968 .scroll
4969 .thumb_drag
4970 .as_ref()
4971 .expect("scroll.thumb_drag should be set after pointer_down on thumb");
4972 assert_eq!(drag.scroll_id, scroll_id);
4973 }
4974
4975 #[test]
4976 fn track_click_above_thumb_pages_up_below_pages_down() {
4977 let (mut core, scroll_id) = lay_out_scroll_tree();
4978 let track = core
4979 .ui_state
4980 .scroll
4981 .thumb_tracks
4982 .get(&scroll_id)
4983 .copied()
4984 .expect("scrollable should have a track");
4985 let thumb = core
4986 .ui_state
4987 .scroll
4988 .thumb_rects
4989 .get(&scroll_id)
4990 .copied()
4991 .unwrap();
4992 let metrics = core
4993 .ui_state
4994 .scroll
4995 .metrics
4996 .get(&scroll_id)
4997 .copied()
4998 .unwrap();
4999
5000 let evt = core.pointer_down(Pointer::mouse(
5002 track.x + track.w * 0.5,
5003 thumb.y + thumb.h + 10.0,
5004 PointerButton::Primary,
5005 ));
5006 assert!(evt.is_empty(), "track press should not surface PointerDown");
5007 assert!(
5008 core.ui_state.scroll.thumb_drag.is_none(),
5009 "track click outside the thumb should not start a drag",
5010 );
5011 let after_down = core.ui_state.scroll_offset(&scroll_id);
5012 let expected_page = (metrics.viewport_h - SCROLL_PAGE_OVERLAP).max(0.0);
5013 assert!(
5014 (after_down - expected_page.min(metrics.max_offset)).abs() < 0.5,
5015 "page-down offset = {after_down} (expected ~{expected_page})",
5016 );
5017 let _ = core.pointer_up(Pointer::mouse(0.0, 0.0, PointerButton::Primary));
5019
5020 let mut tree = lay_out_scroll_tree_only();
5023 crate::layout::layout(
5024 &mut tree,
5025 &mut core.ui_state,
5026 Rect::new(0.0, 0.0, 300.0, 200.0),
5027 );
5028 let mut t = PrepareTimings::default();
5029 core.snapshot(&tree, &mut t);
5030 let track = core
5031 .ui_state
5032 .scroll
5033 .thumb_tracks
5034 .get(&tree.computed_id)
5035 .copied()
5036 .unwrap();
5037 let thumb = core
5038 .ui_state
5039 .scroll
5040 .thumb_rects
5041 .get(&tree.computed_id)
5042 .copied()
5043 .unwrap();
5044
5045 core.pointer_down(Pointer::mouse(
5046 track.x + track.w * 0.5,
5047 thumb.y - 4.0,
5048 PointerButton::Primary,
5049 ));
5050 let after_up = core.ui_state.scroll_offset(&tree.computed_id);
5051 assert!(
5052 after_up < after_down,
5053 "page-up should reduce offset: before={after_down} after={after_up}",
5054 );
5055 }
5056
5057 fn lay_out_scroll_tree_only() -> El {
5062 use crate::tree::*;
5063 crate::scroll(
5064 (0..6)
5065 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
5066 )
5067 .gap(12.0)
5068 .height(Size::Fixed(200.0))
5069 }
5070
5071 #[test]
5072 fn thumb_drag_translates_pointer_delta_into_scroll_offset() {
5073 let (mut core, scroll_id) = lay_out_scroll_tree();
5074 let thumb = core
5075 .ui_state
5076 .scroll
5077 .thumb_rects
5078 .get(&scroll_id)
5079 .copied()
5080 .unwrap();
5081 let metrics = core
5082 .ui_state
5083 .scroll
5084 .metrics
5085 .get(&scroll_id)
5086 .copied()
5087 .unwrap();
5088 let track_remaining = (metrics.viewport_h - thumb.h).max(0.0);
5089
5090 let press_y = thumb.y + thumb.h * 0.5;
5091 core.pointer_down(Pointer::mouse(
5092 thumb.x + thumb.w * 0.5,
5093 press_y,
5094 PointerButton::Primary,
5095 ));
5096 let evt = core.pointer_moved(Pointer::moving(thumb.x + thumb.w * 0.5, press_y + 20.0));
5098 assert!(
5099 evt.events.is_empty(),
5100 "thumb-drag move should suppress Drag event",
5101 );
5102 let offset = core.ui_state.scroll_offset(&scroll_id);
5103 let expected = 20.0 * (metrics.max_offset / track_remaining);
5104 assert!(
5105 (offset - expected).abs() < 0.5,
5106 "offset {offset} (expected {expected})",
5107 );
5108 core.pointer_moved(Pointer::moving(thumb.x + thumb.w * 0.5, press_y + 9999.0));
5110 let offset = core.ui_state.scroll_offset(&scroll_id);
5111 assert!(
5112 (offset - metrics.max_offset).abs() < 0.5,
5113 "overshoot offset {offset} (expected {})",
5114 metrics.max_offset
5115 );
5116 let events = core.pointer_up(Pointer::mouse(thumb.x, press_y, PointerButton::Primary));
5118 assert!(events.is_empty(), "thumb release shouldn't emit events");
5119 assert!(core.ui_state.scroll.thumb_drag.is_none());
5120 }
5121
5122 #[test]
5123 fn secondary_click_does_not_steal_focus_or_press() {
5124 let mut core = lay_out_input_tree(false);
5125 let btn_rect = core.rect_of_key("btn").expect("btn rect");
5126 let cx = btn_rect.x + btn_rect.w * 0.5;
5127 let cy = btn_rect.y + btn_rect.h * 0.5;
5128 let ti_rect = core.rect_of_key("ti").expect("ti rect");
5130 let tx = ti_rect.x + ti_rect.w * 0.5;
5131 let ty = ti_rect.y + ti_rect.h * 0.5;
5132 core.pointer_down(Pointer::mouse(tx, ty, PointerButton::Primary));
5133 let _ = core.pointer_up(Pointer::mouse(tx, ty, PointerButton::Primary));
5134 let focused_before = core.ui_state.focused.as_ref().map(|t| t.key.clone());
5135 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Secondary));
5137 let events = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Secondary));
5138 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
5139 assert_eq!(kinds, vec![UiEventKind::SecondaryClick]);
5140 let focused_after = core.ui_state.focused.as_ref().map(|t| t.key.clone());
5141 assert_eq!(
5142 focused_before, focused_after,
5143 "right-click must not steal focus"
5144 );
5145 assert!(
5146 core.ui_state.pressed.is_none(),
5147 "right-click must not set primary press"
5148 );
5149 }
5150
5151 #[test]
5152 fn text_input_routes_to_focused_only() {
5153 let mut core = lay_out_input_tree(false);
5154 assert!(core.text_input("a".into()).is_none());
5156 let btn_rect = core.rect_of_key("btn").expect("btn rect");
5158 let cx = btn_rect.x + btn_rect.w * 0.5;
5159 let cy = btn_rect.y + btn_rect.h * 0.5;
5160 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5161 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5162 let event = core.text_input("hi".into()).expect("focused → event");
5163 assert_eq!(event.kind, UiEventKind::TextInput);
5164 assert_eq!(event.text.as_deref(), Some("hi"));
5165 assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("btn"));
5166 assert!(core.text_input(String::new()).is_none());
5168 }
5169
5170 #[test]
5171 fn capture_keys_bypasses_tab_traversal_for_focused_node() {
5172 let mut core = lay_out_input_tree(true);
5175 let ti_rect = core.rect_of_key("ti").expect("ti rect");
5176 let tx = ti_rect.x + ti_rect.w * 0.5;
5177 let ty = ti_rect.y + ti_rect.h * 0.5;
5178 core.pointer_down(Pointer::mouse(tx, ty, PointerButton::Primary));
5179 let _ = core.pointer_up(Pointer::mouse(tx, ty, PointerButton::Primary));
5180 assert_eq!(
5181 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5182 Some("ti"),
5183 "primary click on capture_keys node still focuses it"
5184 );
5185
5186 let events = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
5187 assert_eq!(events.len(), 1, "Tab → exactly one KeyDown");
5188 let event = &events[0];
5189 assert_eq!(event.kind, UiEventKind::KeyDown);
5190 assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("ti"));
5191 assert_eq!(
5192 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5193 Some("ti"),
5194 "Tab inside capture_keys must NOT move focus"
5195 );
5196 }
5197
5198 #[test]
5199 fn escape_blurs_capture_keys_after_delivering_raw_keydown() {
5200 let mut core = lay_out_input_tree(true);
5201 let ti_rect = core.rect_of_key("ti").expect("ti rect");
5202 let tx = ti_rect.x + ti_rect.w * 0.5;
5203 let ty = ti_rect.y + ti_rect.h * 0.5;
5204 core.pointer_down(Pointer::mouse(tx, ty, PointerButton::Primary));
5205 let _ = core.pointer_up(Pointer::mouse(tx, ty, PointerButton::Primary));
5206 assert_eq!(
5207 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5208 Some("ti")
5209 );
5210
5211 let events = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
5212
5213 assert_eq!(events.len(), 1);
5214 let event = &events[0];
5215 assert_eq!(event.kind, UiEventKind::KeyDown);
5216 assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("ti"));
5217 assert!(matches!(
5218 event.key_press.as_ref().map(|p| &p.key),
5219 Some(UiKey::Escape)
5220 ));
5221 assert_eq!(core.ui_state.focused.as_ref().map(|t| t.key.as_str()), None);
5222 }
5223
5224 #[test]
5225 fn pointer_down_focus_does_not_raise_focus_visible() {
5226 let mut core = lay_out_input_tree(false);
5229 let btn_rect = core.rect_of_key("btn").expect("btn rect");
5230 let cx = btn_rect.x + btn_rect.w * 0.5;
5231 let cy = btn_rect.y + btn_rect.h * 0.5;
5232 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5233 assert_eq!(
5234 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5235 Some("btn"),
5236 "primary click focuses the button",
5237 );
5238 assert!(
5239 !core.ui_state.focus_visible,
5240 "click focus must not raise focus_visible — ring stays off",
5241 );
5242 }
5243
5244 #[test]
5245 fn tab_key_raises_focus_visible_so_ring_appears() {
5246 let mut core = lay_out_input_tree(false);
5247 let btn_rect = core.rect_of_key("btn").expect("btn rect");
5249 let cx = btn_rect.x + btn_rect.w * 0.5;
5250 let cy = btn_rect.y + btn_rect.h * 0.5;
5251 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5252 assert!(!core.ui_state.focus_visible);
5253 let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
5255 assert!(
5256 core.ui_state.focus_visible,
5257 "Tab must raise focus_visible so the ring paints on the new target",
5258 );
5259 }
5260
5261 #[test]
5262 fn click_after_tab_clears_focus_visible_again() {
5263 let mut core = lay_out_input_tree(false);
5266 let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
5267 assert!(core.ui_state.focus_visible, "Tab raises ring");
5268 let btn_rect = core.rect_of_key("btn").expect("btn rect");
5269 let cx = btn_rect.x + btn_rect.w * 0.5;
5270 let cy = btn_rect.y + btn_rect.h * 0.5;
5271 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5272 assert!(
5273 !core.ui_state.focus_visible,
5274 "pointer-down clears focus_visible — ring fades back out",
5275 );
5276 }
5277
5278 #[test]
5279 fn keypress_on_focused_widget_raises_focus_visible_after_click() {
5280 let mut core = lay_out_input_tree(false);
5284 let btn_rect = core.rect_of_key("btn").expect("btn rect");
5285 let cx = btn_rect.x + btn_rect.w * 0.5;
5286 let cy = btn_rect.y + btn_rect.h * 0.5;
5287 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5288 assert!(!core.ui_state.focus_visible);
5289 let _ = core.key_down(UiKey::ArrowRight, KeyModifiers::default(), false);
5290 assert!(
5291 core.ui_state.focus_visible,
5292 "non-Tab key on focused widget raises focus_visible",
5293 );
5294 }
5295
5296 #[test]
5297 fn selected_text_resolves_a_selection_inside_a_virtual_list() {
5298 use crate::selection::{Selection, SelectionPoint, SelectionRange};
5305 use crate::tree::*;
5306
5307 let mut tree = virtual_list_dyn(
5311 20,
5312 50.0,
5313 |i| format!("row-{i}"),
5314 |i| {
5315 crate::widgets::text::text(format!("row {i} text"))
5316 .key(format!("row-{i}"))
5317 .selectable()
5318 .height(Size::Fixed(50.0))
5319 },
5320 );
5321 let mut core = RunnerCore::new();
5322 crate::layout::layout(
5323 &mut tree,
5324 &mut core.ui_state,
5325 Rect::new(0.0, 0.0, 200.0, 200.0),
5326 );
5327 let mut t = PrepareTimings::default();
5328 core.snapshot(&tree, &mut t);
5329
5330 let selection = Selection {
5332 range: Some(SelectionRange {
5333 anchor: SelectionPoint::new("row-1", 0),
5334 head: SelectionPoint::new("row-1", 9),
5335 }),
5336 };
5337 core.set_selection(selection);
5338
5339 assert_eq!(
5340 core.selected_text().as_deref(),
5341 Some("row 1 tex"),
5342 "runtime.selected_text() must walk last_tree (realized rows) — \
5343 a build-only path would miss virtual_list children entirely",
5344 );
5345 }
5346
5347 #[test]
5348 fn shortcut_chord_does_not_raise_focus_visible() {
5349 let mut core = lay_out_input_tree(false);
5356 let btn_rect = core.rect_of_key("btn").expect("btn rect");
5357 let cx = btn_rect.x + btn_rect.w * 0.5;
5358 let cy = btn_rect.y + btn_rect.h * 0.5;
5359 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5360 assert!(!core.ui_state.focus_visible);
5361
5362 let ctrl = KeyModifiers {
5363 ctrl: true,
5364 ..Default::default()
5365 };
5366 let _ = core.key_down(UiKey::Other("Control".into()), ctrl, false);
5367 assert!(
5368 !core.ui_state.focus_visible,
5369 "bare Ctrl press must not raise focus_visible on a pointer-focused widget",
5370 );
5371 let _ = core.key_down(UiKey::Character("c".into()), ctrl, false);
5372 assert!(
5373 !core.ui_state.focus_visible,
5374 "Ctrl+C is a shortcut, not interaction with the focused widget",
5375 );
5376
5377 let _ = core.key_down(UiKey::Other("Shift".into()), KeyModifiers::default(), false);
5378 assert!(
5379 !core.ui_state.focus_visible,
5380 "bare Shift press must not raise focus_visible",
5381 );
5382 let _ = core.key_down(UiKey::Character("a".into()), KeyModifiers::default(), false);
5383 assert!(
5384 !core.ui_state.focus_visible,
5385 "bare character keys are typing/activation guesses, not navigation",
5386 );
5387 let _ = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
5388 assert!(
5389 !core.ui_state.focus_visible,
5390 "Escape is dismissal, not navigation — no ring",
5391 );
5392 }
5393
5394 #[test]
5395 fn arrow_nav_in_sibling_group_raises_focus_visible() {
5396 let mut core = lay_out_arrow_nav_tree();
5397 core.ui_state.set_focus_visible(false);
5400 let _ = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
5401 assert!(
5402 core.ui_state.focus_visible,
5403 "arrow-nav within an arrow_nav_siblings group is keyboard navigation",
5404 );
5405 }
5406
5407 #[test]
5408 fn capture_keys_falls_back_to_default_when_focus_off_capturing_node() {
5409 let mut core = lay_out_input_tree(true);
5413 let btn_rect = core.rect_of_key("btn").expect("btn rect");
5414 let cx = btn_rect.x + btn_rect.w * 0.5;
5415 let cy = btn_rect.y + btn_rect.h * 0.5;
5416 core.pointer_down(Pointer::mouse(cx, cy, PointerButton::Primary));
5417 let _ = core.pointer_up(Pointer::mouse(cx, cy, PointerButton::Primary));
5418 assert_eq!(
5419 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5420 Some("btn"),
5421 "primary click focuses button"
5422 );
5423 let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
5425 assert_eq!(
5426 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5427 Some("ti"),
5428 "Tab from non-capturing focused does library-default traversal"
5429 );
5430 }
5431
5432 fn lay_out_arrow_nav_tree() -> RunnerCore {
5437 use crate::tree::*;
5438 let mut tree = crate::column([
5439 crate::widgets::button::button("Red").key("opt-red"),
5440 crate::widgets::button::button("Green").key("opt-green"),
5441 crate::widgets::button::button("Blue").key("opt-blue"),
5442 ])
5443 .arrow_nav_siblings()
5444 .padding(10.0);
5445 let mut core = RunnerCore::new();
5446 crate::layout::layout(
5447 &mut tree,
5448 &mut core.ui_state,
5449 Rect::new(0.0, 0.0, 200.0, 300.0),
5450 );
5451 core.ui_state.sync_focus_order(&tree);
5452 let mut t = PrepareTimings::default();
5453 core.snapshot(&tree, &mut t);
5454 let target = core
5457 .ui_state
5458 .focus
5459 .order
5460 .iter()
5461 .find(|t| t.key == "opt-green")
5462 .cloned();
5463 core.ui_state.set_focus(target);
5464 core
5465 }
5466
5467 #[test]
5468 fn arrow_nav_moves_focus_among_siblings() {
5469 let mut core = lay_out_arrow_nav_tree();
5470
5471 let down = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
5474 assert!(down.is_empty(), "arrow-nav consumes the key event");
5475 assert_eq!(
5476 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5477 Some("opt-blue"),
5478 );
5479
5480 core.key_down(UiKey::ArrowUp, KeyModifiers::default(), false);
5482 assert_eq!(
5483 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5484 Some("opt-green"),
5485 );
5486
5487 core.key_down(UiKey::Home, KeyModifiers::default(), false);
5489 assert_eq!(
5490 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5491 Some("opt-red"),
5492 );
5493
5494 core.key_down(UiKey::End, KeyModifiers::default(), false);
5496 assert_eq!(
5497 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5498 Some("opt-blue"),
5499 );
5500 }
5501
5502 #[test]
5503 fn arrow_nav_saturates_at_ends() {
5504 let mut core = lay_out_arrow_nav_tree();
5505 core.key_down(UiKey::Home, KeyModifiers::default(), false);
5507 core.key_down(UiKey::ArrowUp, KeyModifiers::default(), false);
5508 assert_eq!(
5509 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5510 Some("opt-red"),
5511 "ArrowUp at top stays at top — no wrap",
5512 );
5513 core.key_down(UiKey::End, KeyModifiers::default(), false);
5515 core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
5516 assert_eq!(
5517 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5518 Some("opt-blue"),
5519 "ArrowDown at bottom stays at bottom — no wrap",
5520 );
5521 }
5522
5523 fn build_popover_tree(open: bool) -> El {
5527 use crate::widgets::button::button;
5528 use crate::widgets::overlay::overlay;
5529 use crate::widgets::popover::{dropdown, menu_item};
5530 let mut layers: Vec<El> = vec![button("Trigger").key("trigger")];
5531 if open {
5532 layers.push(dropdown(
5533 "menu",
5534 "trigger",
5535 [
5536 menu_item("A").key("item-a"),
5537 menu_item("B").key("item-b"),
5538 menu_item("C").key("item-c"),
5539 ],
5540 ));
5541 }
5542 overlay(layers).padding(20.0)
5543 }
5544
5545 fn run_frame(core: &mut RunnerCore, tree: &mut El) {
5549 let mut t = PrepareTimings::default();
5550 core.prepare_layout(
5551 tree,
5552 Rect::new(0.0, 0.0, 400.0, 300.0),
5553 1.0,
5554 &mut t,
5555 RunnerCore::no_time_shaders,
5556 );
5557 core.snapshot(tree, &mut t);
5558 }
5559
5560 #[test]
5561 fn popover_open_pushes_focus_and_auto_focuses_first_item() {
5562 let mut core = RunnerCore::new();
5563 let mut closed = build_popover_tree(false);
5564 run_frame(&mut core, &mut closed);
5565 let trigger = core
5568 .ui_state
5569 .focus
5570 .order
5571 .iter()
5572 .find(|t| t.key == "trigger")
5573 .cloned();
5574 core.ui_state.set_focus(trigger);
5575 assert_eq!(
5576 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5577 Some("trigger"),
5578 );
5579
5580 let mut open = build_popover_tree(true);
5583 run_frame(&mut core, &mut open);
5584 assert_eq!(
5585 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5586 Some("item-a"),
5587 "popover open should auto-focus the first menu item",
5588 );
5589 assert_eq!(
5590 core.ui_state.popover_focus.focus_stack.len(),
5591 1,
5592 "trigger should be saved on the focus stack",
5593 );
5594 assert_eq!(
5595 core.ui_state.popover_focus.focus_stack[0].key.as_str(),
5596 "trigger",
5597 "saved focus should be the pre-open target",
5598 );
5599 }
5600
5601 #[test]
5602 fn popover_close_restores_focus_to_trigger() {
5603 let mut core = RunnerCore::new();
5604 let mut closed = build_popover_tree(false);
5605 run_frame(&mut core, &mut closed);
5606 let trigger = core
5607 .ui_state
5608 .focus
5609 .order
5610 .iter()
5611 .find(|t| t.key == "trigger")
5612 .cloned();
5613 core.ui_state.set_focus(trigger);
5614
5615 let mut open = build_popover_tree(true);
5617 run_frame(&mut core, &mut open);
5618 assert_eq!(
5619 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5620 Some("item-a"),
5621 );
5622
5623 let mut closed_again = build_popover_tree(false);
5625 run_frame(&mut core, &mut closed_again);
5626 assert_eq!(
5627 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5628 Some("trigger"),
5629 "closing the popover should pop the saved focus",
5630 );
5631 assert!(
5632 core.ui_state.popover_focus.focus_stack.is_empty(),
5633 "focus stack should be drained after restore",
5634 );
5635 }
5636
5637 #[test]
5638 fn popover_close_does_not_override_intentional_focus_move() {
5639 let mut core = RunnerCore::new();
5640 let build = |open: bool| -> El {
5643 use crate::widgets::button::button;
5644 use crate::widgets::overlay::overlay;
5645 use crate::widgets::popover::{dropdown, menu_item};
5646 let main = crate::row([
5647 button("Trigger").key("trigger"),
5648 button("Other").key("other"),
5649 ]);
5650 let mut layers: Vec<El> = vec![main];
5651 if open {
5652 layers.push(dropdown("menu", "trigger", [menu_item("A").key("item-a")]));
5653 }
5654 overlay(layers).padding(20.0)
5655 };
5656
5657 let mut closed = build(false);
5658 run_frame(&mut core, &mut closed);
5659 let trigger = core
5660 .ui_state
5661 .focus
5662 .order
5663 .iter()
5664 .find(|t| t.key == "trigger")
5665 .cloned();
5666 core.ui_state.set_focus(trigger);
5667
5668 let mut open = build(true);
5669 run_frame(&mut core, &mut open);
5670 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
5671
5672 let other = core
5677 .ui_state
5678 .focus
5679 .order
5680 .iter()
5681 .find(|t| t.key == "other")
5682 .cloned();
5683 core.ui_state.set_focus(other);
5684
5685 let mut closed_again = build(false);
5686 run_frame(&mut core, &mut closed_again);
5687 assert_eq!(
5688 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5689 Some("other"),
5690 "focus moved before close should not be overridden by restore",
5691 );
5692 assert!(core.ui_state.popover_focus.focus_stack.is_empty());
5693 }
5694
5695 #[test]
5696 fn nested_popovers_stack_and_unwind_focus_correctly() {
5697 let mut core = RunnerCore::new();
5698 let build = |outer: bool, inner: bool| -> El {
5703 use crate::widgets::button::button;
5704 use crate::widgets::overlay::overlay;
5705 use crate::widgets::popover::{Anchor, popover, popover_panel};
5706 let main = button("Trigger").key("trigger");
5707 let mut layers: Vec<El> = vec![main];
5708 if outer {
5709 layers.push(popover(
5710 "outer",
5711 Anchor::below_key("trigger"),
5712 popover_panel([button("Open inner").key("inner-trigger")]),
5713 ));
5714 }
5715 if inner {
5716 layers.push(popover(
5717 "inner",
5718 Anchor::below_key("inner-trigger"),
5719 popover_panel([button("X").key("inner-a"), button("Y").key("inner-b")]),
5720 ));
5721 }
5722 overlay(layers).padding(20.0)
5723 };
5724
5725 let mut closed = build(false, false);
5727 run_frame(&mut core, &mut closed);
5728 let trigger = core
5729 .ui_state
5730 .focus
5731 .order
5732 .iter()
5733 .find(|t| t.key == "trigger")
5734 .cloned();
5735 core.ui_state.set_focus(trigger);
5736
5737 let mut outer = build(true, false);
5739 run_frame(&mut core, &mut outer);
5740 assert_eq!(
5741 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5742 Some("inner-trigger"),
5743 );
5744 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
5745
5746 let mut both = build(true, true);
5748 run_frame(&mut core, &mut both);
5749 assert_eq!(
5750 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5751 Some("inner-a"),
5752 );
5753 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 2);
5754
5755 let mut outer_only = build(true, false);
5757 run_frame(&mut core, &mut outer_only);
5758 assert_eq!(
5759 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5760 Some("inner-trigger"),
5761 );
5762 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
5763
5764 let mut none = build(false, false);
5766 run_frame(&mut core, &mut none);
5767 assert_eq!(
5768 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
5769 Some("trigger"),
5770 );
5771 assert!(core.ui_state.popover_focus.focus_stack.is_empty());
5772 }
5773
5774 #[test]
5775 fn arrow_nav_does_not_intercept_outside_navigable_groups() {
5776 let mut core = lay_out_input_tree(false);
5780 let target = core
5781 .ui_state
5782 .focus
5783 .order
5784 .iter()
5785 .find(|t| t.key == "btn")
5786 .cloned();
5787 core.ui_state.set_focus(target);
5788 let events = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
5789 assert_eq!(
5790 events.len(),
5791 1,
5792 "ArrowDown without navigable parent → event"
5793 );
5794 assert_eq!(events[0].kind, UiEventKind::KeyDown);
5795 }
5796
5797 fn quad(shader: ShaderHandle) -> DrawOp {
5798 DrawOp::Quad {
5799 id: "q".into(),
5800 rect: Rect::new(0.0, 0.0, 10.0, 10.0),
5801 scissor: None,
5802 shader,
5803 uniforms: UniformBlock::new(),
5804 }
5805 }
5806
5807 #[test]
5808 fn prepare_paint_skips_ops_outside_viewport() {
5809 let mut core = RunnerCore::new();
5810 core.set_surface_size(100, 100);
5811 core.viewport_px = (100, 100);
5812 let ops = vec![
5813 DrawOp::Quad {
5814 id: "offscreen".into(),
5815 rect: Rect::new(0.0, 150.0, 10.0, 10.0),
5816 scissor: None,
5817 shader: ShaderHandle::Stock(StockShader::RoundedRect),
5818 uniforms: UniformBlock::new(),
5819 },
5820 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
5821 ];
5822 let mut timings = PrepareTimings::default();
5823 core.prepare_paint(&ops, |_| true, |_| false, &mut NoText, 1.0, &mut timings);
5824
5825 assert_eq!(timings.paint_culled_ops, 1);
5826 assert_eq!(
5827 core.runs.len(),
5828 1,
5829 "only the visible quad should become a paint run"
5830 );
5831 }
5832
5833 #[test]
5834 fn prepare_paint_does_not_shape_text_outside_clip() {
5835 let mut core = RunnerCore::new();
5836 core.set_surface_size(100, 100);
5837 core.viewport_px = (100, 100);
5838 let ops = vec![
5839 DrawOp::GlyphRun {
5840 id: "offscreen-text".into(),
5841 rect: Rect::new(0.0, 150.0, 80.0, 20.0),
5842 scissor: Some(Rect::new(0.0, 0.0, 100.0, 100.0)),
5843 shader: ShaderHandle::Stock(StockShader::Text),
5844 color: Color::rgba(255, 255, 255, 255),
5845 text: "offscreen".into(),
5846 size: 14.0,
5847 line_height: 20.0,
5848 family: Default::default(),
5849 mono_family: Default::default(),
5850 weight: FontWeight::Regular,
5851 mono: false,
5852 wrap: TextWrap::NoWrap,
5853 anchor: TextAnchor::Start,
5854 layout: empty_text_layout(20.0),
5855 underline: false,
5856 strikethrough: false,
5857 link: None,
5858 },
5859 DrawOp::GlyphRun {
5860 id: "visible-text".into(),
5861 rect: Rect::new(0.0, 10.0, 80.0, 20.0),
5862 scissor: Some(Rect::new(0.0, 0.0, 100.0, 100.0)),
5863 shader: ShaderHandle::Stock(StockShader::Text),
5864 color: Color::rgba(255, 255, 255, 255),
5865 text: "visible".into(),
5866 size: 14.0,
5867 line_height: 20.0,
5868 family: Default::default(),
5869 mono_family: Default::default(),
5870 weight: FontWeight::Regular,
5871 mono: false,
5872 wrap: TextWrap::NoWrap,
5873 anchor: TextAnchor::Start,
5874 layout: empty_text_layout(20.0),
5875 underline: false,
5876 strikethrough: false,
5877 link: None,
5878 },
5879 ];
5880 let mut text = CountingText::default();
5881 let mut timings = PrepareTimings::default();
5882 core.prepare_paint(&ops, |_| true, |_| false, &mut text, 1.0, &mut timings);
5883
5884 assert_eq!(timings.paint_culled_ops, 1);
5885 assert_eq!(text.records, 1, "offscreen text must not be shaped");
5886 }
5887
5888 #[test]
5889 fn samples_backdrop_inserts_snapshot_before_first_glass_quad() {
5890 let mut core = RunnerCore::new();
5891 core.set_surface_size(100, 100);
5892 let ops = vec![
5893 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
5894 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
5895 quad(ShaderHandle::Custom("liquid_glass")),
5896 quad(ShaderHandle::Custom("liquid_glass")),
5897 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
5898 ];
5899 let mut timings = PrepareTimings::default();
5900 core.prepare_paint(
5901 &ops,
5902 |_| true,
5903 |s| matches!(s, ShaderHandle::Custom(name) if *name == "liquid_glass"),
5904 &mut NoText,
5905 1.0,
5906 &mut timings,
5907 );
5908
5909 let kinds: Vec<&'static str> = core
5910 .paint_items
5911 .iter()
5912 .map(|p| match p {
5913 PaintItem::QuadRun(_) => "Q",
5914 PaintItem::IconRun(_) => "I",
5915 PaintItem::Text(_) => "T",
5916 PaintItem::Image(_) => "M",
5917 PaintItem::AppTexture(_) => "A",
5918 PaintItem::Vector(_) => "V",
5919 PaintItem::BackdropSnapshot => "S",
5920 })
5921 .collect();
5922 assert_eq!(
5923 kinds,
5924 vec!["Q", "S", "Q", "Q"],
5925 "expected one stock run, snapshot, then a glass run, then a foreground stock run"
5926 );
5927 }
5928
5929 #[test]
5930 fn no_snapshot_when_no_glass_drawn() {
5931 let mut core = RunnerCore::new();
5932 core.set_surface_size(100, 100);
5933 let ops = vec![
5934 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
5935 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
5936 ];
5937 let mut timings = PrepareTimings::default();
5938 core.prepare_paint(&ops, |_| true, |_| false, &mut NoText, 1.0, &mut timings);
5939 assert!(
5940 !core
5941 .paint_items
5942 .iter()
5943 .any(|p| matches!(p, PaintItem::BackdropSnapshot)),
5944 "no glass shader registered → no snapshot"
5945 );
5946 }
5947
5948 #[test]
5949 fn at_most_one_snapshot_per_frame() {
5950 let mut core = RunnerCore::new();
5951 core.set_surface_size(100, 100);
5952 let ops = vec![
5953 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
5954 quad(ShaderHandle::Custom("g")),
5955 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
5956 quad(ShaderHandle::Custom("g")),
5957 ];
5958 let mut timings = PrepareTimings::default();
5959 core.prepare_paint(
5960 &ops,
5961 |_| true,
5962 |s| matches!(s, ShaderHandle::Custom("g")),
5963 &mut NoText,
5964 1.0,
5965 &mut timings,
5966 );
5967 let snapshots = core
5968 .paint_items
5969 .iter()
5970 .filter(|p| matches!(p, PaintItem::BackdropSnapshot))
5971 .count();
5972 assert_eq!(snapshots, 1, "backdrop depth is capped at 1");
5973 }
5974}