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::{KeyChord, KeyModifiers, PointerButton, UiEvent, UiEventKind, UiKey, UiTarget};
58use crate::focus;
59use crate::hit_test;
60use crate::ir::{DrawOp, TextAnchor};
61use crate::layout;
62use crate::paint::{
63 InstanceRun, PaintItem, PhysicalScissor, QuadInstance, close_run, pack_instance,
64 physical_scissor,
65};
66use crate::shader::ShaderHandle;
67use crate::state::{AnimationMode, SelectionDragGranularity, UiState};
68use crate::text::atlas::RunStyle;
69use crate::text::metrics::TextLayoutCacheStats;
70use crate::theme::Theme;
71use crate::toast;
72use crate::tooltip;
73use crate::tree::{Color, El, FontWeight, Rect, TextWrap};
74
75const SCROLL_PAGE_OVERLAP: f32 = 24.0;
81
82#[derive(Clone, Copy, Debug, Default)]
105pub struct PrepareResult {
106 pub needs_redraw: bool,
110 pub next_redraw_in: Option<std::time::Duration>,
114 pub next_layout_redraw_in: Option<std::time::Duration>,
118 pub next_paint_redraw_in: Option<std::time::Duration>,
123 pub timings: PrepareTimings,
124}
125
126#[derive(Debug, Default)]
138pub struct PointerMove {
139 pub events: Vec<UiEvent>,
142 pub needs_redraw: bool,
146}
147
148pub struct LayoutPrepared {
155 pub ops: Vec<DrawOp>,
156 pub needs_redraw: bool,
157 pub next_layout_redraw_in: Option<std::time::Duration>,
158 pub next_paint_redraw_in: Option<std::time::Duration>,
159}
160
161#[derive(Clone, Copy, Debug, Default)]
172pub struct PrepareTimings {
173 pub layout: Duration,
174 pub layout_intrinsic_cache: layout::LayoutIntrinsicCacheStats,
175 pub layout_prune: layout::LayoutPruneStats,
176 pub draw_ops: Duration,
177 pub draw_ops_culled_text_ops: u64,
178 pub paint: Duration,
179 pub paint_culled_ops: u64,
180 pub gpu_upload: Duration,
181 pub snapshot: Duration,
182 pub text_layout_cache: TextLayoutCacheStats,
183}
184
185pub struct RunnerCore {
193 pub ui_state: UiState,
194 pub last_tree: Option<El>,
198
199 pub quad_scratch: Vec<QuadInstance>,
202 pub runs: Vec<InstanceRun>,
203 pub paint_items: Vec<PaintItem>,
204
205 pub last_ops: Vec<DrawOp>,
212
213 pub viewport_px: (u32, u32),
217 pub surface_size_override: Option<(u32, u32)>,
223
224 pub theme: Theme,
226}
227
228impl Default for RunnerCore {
229 fn default() -> Self {
230 Self::new()
231 }
232}
233
234impl RunnerCore {
235 pub fn new() -> Self {
236 Self {
237 ui_state: UiState::default(),
238 last_tree: None,
239 quad_scratch: Vec::new(),
240 runs: Vec::new(),
241 paint_items: Vec::new(),
242 last_ops: Vec::new(),
243 viewport_px: (1, 1),
244 surface_size_override: None,
245 theme: Theme::default(),
246 }
247 }
248
249 pub fn set_theme(&mut self, theme: Theme) {
250 self.theme = theme;
251 }
252
253 pub fn theme(&self) -> &Theme {
254 &self.theme
255 }
256
257 pub fn set_surface_size(&mut self, width: u32, height: u32) {
263 self.surface_size_override = Some((width.max(1), height.max(1)));
264 }
265
266 pub fn ui_state(&self) -> &UiState {
267 &self.ui_state
268 }
269
270 pub fn debug_summary(&self) -> String {
271 self.ui_state.debug_summary()
272 }
273
274 pub fn rect_of_key(&self, key: &str) -> Option<Rect> {
275 self.last_tree
276 .as_ref()
277 .and_then(|t| self.ui_state.rect_of_key(t, key))
278 }
279
280 pub fn pointer_moved(&mut self, x: f32, y: f32) -> PointerMove {
289 self.ui_state.pointer_pos = Some((x, y));
290
291 if let Some(drag) = self.ui_state.scroll.thumb_drag.clone() {
297 let dy = y - drag.start_pointer_y;
298 let new_offset = if drag.track_remaining > 0.0 {
299 drag.start_offset + dy * (drag.max_offset / drag.track_remaining)
300 } else {
301 drag.start_offset
302 };
303 let clamped = new_offset.clamp(0.0, drag.max_offset);
304 let prev = self.ui_state.scroll.offsets.insert(drag.scroll_id, clamped);
305 let changed = prev.is_none_or(|old| (old - clamped).abs() > f32::EPSILON);
306 return PointerMove {
307 events: Vec::new(),
308 needs_redraw: changed,
309 };
310 }
311
312 let hit = self
313 .last_tree
314 .as_ref()
315 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
316 let prev_hover = self.ui_state.hovered.clone();
320 let hover_changed = self.ui_state.set_hovered(hit, Instant::now());
321 let prev_hovered_link = self.ui_state.hovered_link.clone();
327 let new_hovered_link = self
328 .last_tree
329 .as_ref()
330 .and_then(|t| hit_test::link_at(t, &self.ui_state, (x, y)));
331 let link_hover_changed = new_hovered_link != prev_hovered_link;
332 self.ui_state.hovered_link = new_hovered_link;
333 let modifiers = self.ui_state.modifiers;
334
335 let mut out = Vec::new();
336
337 if hover_changed {
344 if let Some(prev) = prev_hover {
345 out.push(UiEvent {
346 key: Some(prev.key.clone()),
347 target: Some(prev),
348 pointer: Some((x, y)),
349 key_press: None,
350 text: None,
351 selection: None,
352 modifiers,
353 click_count: 0,
354 path: None,
355 kind: UiEventKind::PointerLeave,
356 });
357 }
358 if let Some(new) = self.ui_state.hovered.clone() {
359 out.push(UiEvent {
360 key: Some(new.key.clone()),
361 target: Some(new),
362 pointer: Some((x, y)),
363 key_press: None,
364 text: None,
365 selection: None,
366 modifiers,
367 click_count: 0,
368 path: None,
369 kind: UiEventKind::PointerEnter,
370 });
371 }
372 }
373
374 if let Some(drag) = self.ui_state.selection.drag.clone()
381 && let Some(tree) = self.last_tree.as_ref()
382 {
383 let raw_head =
384 head_for_drag(tree, &self.ui_state, (x, y)).unwrap_or_else(|| drag.anchor.clone());
385 let (anchor, head) = selection_range_for_drag(tree, &self.ui_state, &drag, raw_head);
386 let new_sel = crate::selection::Selection {
387 range: Some(crate::selection::SelectionRange { anchor, head }),
388 };
389 if new_sel != self.ui_state.current_selection {
390 self.ui_state.current_selection = new_sel.clone();
391 out.push(selection_event(new_sel, modifiers, Some((x, y))));
392 }
393 }
394
395 if let Some(p) = self.ui_state.pressed.clone() {
401 if self.focused_captures_keys() {
405 self.ui_state.bump_caret_activity(Instant::now());
406 }
407 out.push(UiEvent {
408 key: Some(p.key.clone()),
409 target: Some(p),
410 pointer: Some((x, y)),
411 key_press: None,
412 text: None,
413 selection: None,
414 modifiers,
415 click_count: self.ui_state.current_click_count(),
416 path: None,
417 kind: UiEventKind::Drag,
418 });
419 }
420
421 let needs_redraw = hover_changed || link_hover_changed || !out.is_empty();
422 PointerMove {
423 events: out,
424 needs_redraw,
425 }
426 }
427
428 pub fn pointer_left(&mut self) -> Vec<UiEvent> {
436 let last_pos = self.ui_state.pointer_pos;
437 let prev_hover = self.ui_state.hovered.clone();
438 let modifiers = self.ui_state.modifiers;
439 self.ui_state.pointer_pos = None;
440 self.ui_state.set_hovered(None, Instant::now());
441 self.ui_state.pressed = None;
442 self.ui_state.pressed_secondary = None;
443 self.ui_state.hovered_link = None;
449 self.ui_state.pressed_link = None;
450
451 let mut out = Vec::new();
452 if let Some(prev) = prev_hover {
453 out.push(UiEvent {
454 key: Some(prev.key.clone()),
455 target: Some(prev),
456 pointer: last_pos,
457 key_press: None,
458 text: None,
459 selection: None,
460 modifiers,
461 click_count: 0,
462 path: None,
463 kind: UiEventKind::PointerLeave,
464 });
465 }
466 out
467 }
468
469 pub fn file_hovered(&mut self, path: std::path::PathBuf, x: f32, y: f32) -> Vec<UiEvent> {
483 self.ui_state.pointer_pos = Some((x, y));
484 let target = self
485 .last_tree
486 .as_ref()
487 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
488 let key = target.as_ref().map(|t| t.key.clone());
489 vec![UiEvent {
490 key,
491 target,
492 pointer: Some((x, y)),
493 key_press: None,
494 text: None,
495 selection: None,
496 modifiers: self.ui_state.modifiers,
497 click_count: 0,
498 path: Some(path),
499 kind: UiEventKind::FileHovered,
500 }]
501 }
502
503 pub fn file_hover_cancelled(&mut self) -> Vec<UiEvent> {
508 vec![UiEvent {
509 key: None,
510 target: None,
511 pointer: self.ui_state.pointer_pos,
512 key_press: None,
513 text: None,
514 selection: None,
515 modifiers: self.ui_state.modifiers,
516 click_count: 0,
517 path: None,
518 kind: UiEventKind::FileHoverCancelled,
519 }]
520 }
521
522 pub fn file_dropped(&mut self, path: std::path::PathBuf, x: f32, y: f32) -> Vec<UiEvent> {
527 self.ui_state.pointer_pos = Some((x, y));
528 let target = self
529 .last_tree
530 .as_ref()
531 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
532 let key = target.as_ref().map(|t| t.key.clone());
533 vec![UiEvent {
534 key,
535 target,
536 pointer: Some((x, y)),
537 key_press: None,
538 text: None,
539 selection: None,
540 modifiers: self.ui_state.modifiers,
541 click_count: 0,
542 path: Some(path),
543 kind: UiEventKind::FileDropped,
544 }]
545 }
546
547 pub fn pointer_down(&mut self, x: f32, y: f32, button: PointerButton) -> Vec<UiEvent> {
560 if matches!(button, PointerButton::Primary)
569 && let Some((scroll_id, _track, thumb_rect)) = self.ui_state.thumb_at(x, y)
570 {
571 let metrics = self
572 .ui_state
573 .scroll
574 .metrics
575 .get(&scroll_id)
576 .copied()
577 .unwrap_or_default();
578 let start_offset = self
579 .ui_state
580 .scroll
581 .offsets
582 .get(&scroll_id)
583 .copied()
584 .unwrap_or(0.0);
585
586 let grabbed = y >= thumb_rect.y && y <= thumb_rect.y + thumb_rect.h;
590 if grabbed {
591 let track_remaining = (metrics.viewport_h - thumb_rect.h).max(0.0);
592 self.ui_state.scroll.thumb_drag = Some(crate::state::ThumbDrag {
593 scroll_id,
594 start_pointer_y: y,
595 start_offset,
596 track_remaining,
597 max_offset: metrics.max_offset,
598 });
599 } else {
600 let page = (metrics.viewport_h - SCROLL_PAGE_OVERLAP).max(0.0);
606 let delta = if y < thumb_rect.y { -page } else { page };
607 let new_offset = (start_offset + delta).clamp(0.0, metrics.max_offset);
608 self.ui_state.scroll.offsets.insert(scroll_id, new_offset);
609 }
610 return Vec::new();
611 }
612
613 let hit = self
614 .last_tree
615 .as_ref()
616 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
617 if !matches!(button, PointerButton::Primary) {
622 self.ui_state.pressed_secondary = hit.map(|h| (h, button));
625 return Vec::new();
626 }
627
628 self.ui_state.pressed_link = self
636 .last_tree
637 .as_ref()
638 .and_then(|t| hit_test::link_at(t, &self.ui_state, (x, y)));
639 self.ui_state.set_focus(hit.clone());
640 self.ui_state.set_focus_visible(false);
644 self.ui_state.pressed = hit.clone();
645 self.ui_state.tooltip.dismissed_for_hover = true;
648 let modifiers = self.ui_state.modifiers;
649
650 let now = Instant::now();
653 let click_count =
654 self.ui_state
655 .next_click_count(now, (x, y), hit.as_ref().map(|t| t.node_id.as_str()));
656
657 let mut out = Vec::new();
658 if let Some(p) = hit.clone() {
659 if self.focused_captures_keys() {
666 self.ui_state.bump_caret_activity(now);
667 }
668 out.push(UiEvent {
669 key: Some(p.key.clone()),
670 target: Some(p),
671 pointer: Some((x, y)),
672 key_press: None,
673 text: None,
674 selection: None,
675 modifiers,
676 click_count,
677 path: None,
678 kind: UiEventKind::PointerDown,
679 });
680 }
681
682 if let Some(point) = self
690 .last_tree
691 .as_ref()
692 .and_then(|t| hit_test::selection_point_at(t, &self.ui_state, (x, y)))
693 {
694 self.start_selection_drag(point, &mut out, modifiers, (x, y), click_count);
695 } else if !self.ui_state.current_selection.is_empty() {
696 let click_handles_selection = match (&hit, &self.ui_state.current_selection.range) {
718 (Some(h), Some(range)) => {
719 h.key == range.anchor.key
720 || h.key == range.head.key
721 || self
722 .last_tree
723 .as_ref()
724 .and_then(|t| find_capture_keys(t, &h.node_id))
725 .unwrap_or(false)
726 }
727 _ => false,
728 };
729 if !click_handles_selection {
730 out.push(selection_event(
731 crate::selection::Selection::default(),
732 modifiers,
733 Some((x, y)),
734 ));
735 self.ui_state.current_selection = crate::selection::Selection::default();
736 self.ui_state.selection.drag = None;
737 }
738 }
739
740 out
741 }
742
743 fn start_selection_drag(
751 &mut self,
752 point: crate::selection::SelectionPoint,
753 out: &mut Vec<UiEvent>,
754 modifiers: KeyModifiers,
755 pointer: (f32, f32),
756 click_count: u8,
757 ) {
758 let leaf_text = self
759 .last_tree
760 .as_ref()
761 .and_then(|t| crate::selection::find_keyed_text(t, &point.key))
762 .unwrap_or_default();
763 let (anchor_byte, head_byte) = match click_count {
764 2 => crate::selection::word_range_at(&leaf_text, point.byte),
765 n if n >= 3 => (0, leaf_text.len()),
766 _ => (point.byte, point.byte),
767 };
768 let granularity = match click_count {
769 2 => SelectionDragGranularity::Word,
770 n if n >= 3 => SelectionDragGranularity::Leaf,
771 _ => SelectionDragGranularity::Character,
772 };
773 let anchor = crate::selection::SelectionPoint::new(point.key.clone(), anchor_byte);
774 let head = crate::selection::SelectionPoint::new(point.key.clone(), head_byte);
775 let new_sel = crate::selection::Selection {
776 range: Some(crate::selection::SelectionRange {
777 anchor: anchor.clone(),
778 head: head.clone(),
779 }),
780 };
781 self.ui_state.current_selection = new_sel.clone();
782 self.ui_state.selection.drag = Some(crate::state::SelectionDrag {
783 anchor,
784 head,
785 granularity,
786 });
787 out.push(selection_event(new_sel, modifiers, Some(pointer)));
788 }
789
790 pub fn pointer_up(&mut self, x: f32, y: f32, button: PointerButton) -> Vec<UiEvent> {
798 if matches!(button, PointerButton::Primary) && self.ui_state.scroll.thumb_drag.is_some() {
803 self.ui_state.scroll.thumb_drag = None;
804 return Vec::new();
805 }
806
807 if matches!(button, PointerButton::Primary) {
810 self.ui_state.selection.drag = None;
811 }
812
813 let hit = self
814 .last_tree
815 .as_ref()
816 .and_then(|t| hit_test::hit_test_target(t, &self.ui_state, (x, y)));
817 let modifiers = self.ui_state.modifiers;
818 let mut out = Vec::new();
819 match button {
820 PointerButton::Primary => {
821 let pressed = self.ui_state.pressed.take();
822 let click_count = self.ui_state.current_click_count();
823 if let Some(p) = pressed.clone() {
824 out.push(UiEvent {
825 key: Some(p.key.clone()),
826 target: Some(p),
827 pointer: Some((x, y)),
828 key_press: None,
829 text: None,
830 selection: None,
831 modifiers,
832 click_count,
833 path: None,
834 kind: UiEventKind::PointerUp,
835 });
836 }
837 if let (Some(p), Some(h)) = (pressed, hit)
838 && p.node_id == h.node_id
839 {
840 if let Some(id) = toast::parse_dismiss_key(&p.key) {
846 self.ui_state.dismiss_toast(id);
847 } else {
848 out.push(UiEvent {
849 key: Some(p.key.clone()),
850 target: Some(p),
851 pointer: Some((x, y)),
852 key_press: None,
853 text: None,
854 selection: None,
855 modifiers,
856 click_count,
857 path: None,
858 kind: UiEventKind::Click,
859 });
860 }
861 }
862 if let Some(pressed_url) = self.ui_state.pressed_link.take() {
868 let up_link = self
869 .last_tree
870 .as_ref()
871 .and_then(|t| hit_test::link_at(t, &self.ui_state, (x, y)));
872 if up_link.as_ref() == Some(&pressed_url) {
873 out.push(UiEvent {
874 key: Some(pressed_url),
875 target: None,
876 pointer: Some((x, y)),
877 key_press: None,
878 text: None,
879 selection: None,
880 modifiers,
881 click_count: 1,
882 path: None,
883 kind: UiEventKind::LinkActivated,
884 });
885 }
886 }
887 }
888 PointerButton::Secondary | PointerButton::Middle => {
889 let pressed = self.ui_state.pressed_secondary.take();
890 if let (Some((p, b)), Some(h)) = (pressed, hit)
891 && b == button
892 && p.node_id == h.node_id
893 {
894 let kind = match button {
895 PointerButton::Secondary => UiEventKind::SecondaryClick,
896 PointerButton::Middle => UiEventKind::MiddleClick,
897 PointerButton::Primary => unreachable!(),
898 };
899 out.push(UiEvent {
900 key: Some(p.key.clone()),
901 target: Some(p),
902 pointer: Some((x, y)),
903 key_press: None,
904 text: None,
905 selection: None,
906 modifiers,
907 click_count: 1,
908 path: None,
909 kind,
910 });
911 }
912 }
913 }
914 out
915 }
916
917 pub fn key_down(&mut self, key: UiKey, modifiers: KeyModifiers, repeat: bool) -> Vec<UiEvent> {
918 if self.focused_captures_keys() {
926 if let Some(event) = self.ui_state.try_hotkey(&key, modifiers, repeat) {
927 return vec![event];
928 }
929 self.ui_state.bump_caret_activity(Instant::now());
936 self.ui_state.set_focus_visible(true);
937 let blur_after = matches!(key, UiKey::Escape);
938 let out = self
939 .ui_state
940 .key_down_raw(key, modifiers, repeat)
941 .into_iter()
942 .collect();
943 if blur_after {
944 self.ui_state.set_focus(None);
945 self.ui_state.set_focus_visible(false);
946 }
947 return out;
948 }
949
950 if matches!(
956 key,
957 UiKey::ArrowUp | UiKey::ArrowDown | UiKey::Home | UiKey::End
958 ) && let Some(siblings) = self.focused_arrow_nav_group()
959 {
960 if let Some(event) = self.ui_state.try_hotkey(&key, modifiers, repeat) {
961 return vec![event];
962 }
963 self.move_focus_in_group(&key, &siblings);
964 return Vec::new();
965 }
966
967 let mut out: Vec<UiEvent> = self
968 .ui_state
969 .key_down(key, modifiers, repeat)
970 .into_iter()
971 .collect();
972
973 if matches!(out.first().map(|e| e.kind), Some(UiEventKind::Escape))
981 && !self.ui_state.current_selection.is_empty()
982 {
983 self.ui_state.current_selection = crate::selection::Selection::default();
984 self.ui_state.selection.drag = None;
985 out.push(selection_event(
986 crate::selection::Selection::default(),
987 modifiers,
988 None,
989 ));
990 }
991
992 out
993 }
994
995 fn focused_arrow_nav_group(&self) -> Option<Vec<UiTarget>> {
1002 let focused = self.ui_state.focused.as_ref()?;
1003 let tree = self.last_tree.as_ref()?;
1004 focus::arrow_nav_group(tree, &self.ui_state, &focused.node_id)
1005 }
1006
1007 fn move_focus_in_group(&mut self, key: &UiKey, siblings: &[UiTarget]) {
1012 if siblings.is_empty() {
1013 return;
1014 }
1015 let focused_id = match self.ui_state.focused.as_ref() {
1016 Some(t) => t.node_id.clone(),
1017 None => return,
1018 };
1019 let idx = siblings.iter().position(|t| t.node_id == focused_id);
1020 let next_idx = match (key, idx) {
1021 (UiKey::ArrowUp, Some(i)) => i.saturating_sub(1),
1022 (UiKey::ArrowDown, Some(i)) => (i + 1).min(siblings.len() - 1),
1023 (UiKey::Home, _) => 0,
1024 (UiKey::End, _) => siblings.len() - 1,
1025 _ => return,
1026 };
1027 if Some(next_idx) != idx {
1028 self.ui_state.set_focus(Some(siblings[next_idx].clone()));
1029 self.ui_state.set_focus_visible(true);
1030 }
1031 }
1032
1033 fn focused_captures_keys(&self) -> bool {
1037 let Some(focused) = self.ui_state.focused.as_ref() else {
1038 return false;
1039 };
1040 let Some(tree) = self.last_tree.as_ref() else {
1041 return false;
1042 };
1043 find_capture_keys(tree, &focused.node_id).unwrap_or(false)
1044 }
1045
1046 pub fn text_input(&mut self, text: String) -> Option<UiEvent> {
1052 if text.is_empty() {
1053 return None;
1054 }
1055 let target = self.ui_state.focused.clone()?;
1056 let modifiers = self.ui_state.modifiers;
1057 self.ui_state.bump_caret_activity(Instant::now());
1060 Some(UiEvent {
1061 key: Some(target.key.clone()),
1062 target: Some(target),
1063 pointer: None,
1064 key_press: None,
1065 text: Some(text),
1066 selection: None,
1067 modifiers,
1068 click_count: 0,
1069 path: None,
1070 kind: UiEventKind::TextInput,
1071 })
1072 }
1073
1074 pub fn set_hotkeys(&mut self, hotkeys: Vec<(KeyChord, String)>) {
1075 self.ui_state.set_hotkeys(hotkeys);
1076 }
1077
1078 pub fn set_selection(&mut self, selection: crate::selection::Selection) {
1083 if self.ui_state.current_selection != selection {
1084 self.ui_state.bump_caret_activity(Instant::now());
1085 }
1086 self.ui_state.current_selection = selection;
1087 }
1088
1089 pub fn push_toasts(&mut self, specs: Vec<crate::toast::ToastSpec>) {
1095 let now = Instant::now();
1096 for spec in specs {
1097 self.ui_state.push_toast(spec, now);
1098 }
1099 }
1100
1101 pub fn dismiss_toast(&mut self, id: u64) {
1105 self.ui_state.dismiss_toast(id);
1106 }
1107
1108 pub fn push_focus_requests(&mut self, keys: Vec<String>) {
1114 self.ui_state.push_focus_requests(keys);
1115 }
1116
1117 pub fn push_scroll_requests(&mut self, requests: Vec<crate::scroll::ScrollRequest>) {
1123 self.ui_state.push_scroll_requests(requests);
1124 }
1125
1126 pub fn set_animation_mode(&mut self, mode: AnimationMode) {
1127 self.ui_state.set_animation_mode(mode);
1128 }
1129
1130 pub fn pointer_wheel(&mut self, x: f32, y: f32, dy: f32) -> bool {
1131 let Some(tree) = self.last_tree.as_ref() else {
1132 return false;
1133 };
1134 self.ui_state.pointer_wheel(tree, (x, y), dy)
1135 }
1136
1137 pub fn prepare_layout<F>(
1154 &mut self,
1155 root: &mut El,
1156 viewport: Rect,
1157 scale_factor: f32,
1158 timings: &mut PrepareTimings,
1159 samples_time: F,
1160 ) -> LayoutPrepared
1161 where
1162 F: Fn(&ShaderHandle) -> bool,
1163 {
1164 let t0 = Instant::now();
1165 let mut needs_redraw = {
1172 crate::profile_span!("prepare::layout");
1173 {
1174 crate::profile_span!("prepare::layout::assign_ids");
1175 layout::assign_ids(root);
1176 }
1177 let tooltip_pending = {
1178 crate::profile_span!("prepare::layout::tooltip");
1179 tooltip::synthesize_tooltip(root, &self.ui_state, t0)
1180 };
1181 let toast_pending = {
1182 crate::profile_span!("prepare::layout::toast");
1183 toast::synthesize_toasts(root, &mut self.ui_state, t0)
1184 };
1185 {
1186 crate::profile_span!("prepare::layout::apply_metrics");
1187 self.theme.apply_metrics(root);
1188 }
1189 {
1190 crate::profile_span!("prepare::layout::layout");
1191 layout::layout_post_assign(root, &mut self.ui_state, viewport);
1198 self.ui_state.clear_pending_scroll_requests();
1203 }
1204 {
1205 crate::profile_span!("prepare::layout::sync_focus_order");
1206 self.ui_state.sync_focus_order(root);
1207 }
1208 {
1209 crate::profile_span!("prepare::layout::sync_selection_order");
1210 self.ui_state.sync_selection_order(root);
1211 }
1212 {
1213 crate::profile_span!("prepare::layout::sync_popover_focus");
1214 focus::sync_popover_focus(root, &mut self.ui_state);
1215 }
1216 {
1217 crate::profile_span!("prepare::layout::drain_focus_requests");
1222 self.ui_state.drain_focus_requests();
1223 }
1224 {
1225 crate::profile_span!("prepare::layout::apply_state");
1226 self.ui_state.apply_to_state();
1227 }
1228 self.viewport_px = self.surface_size_override.unwrap_or_else(|| {
1229 (
1230 (viewport.w * scale_factor).ceil().max(1.0) as u32,
1231 (viewport.h * scale_factor).ceil().max(1.0) as u32,
1232 )
1233 });
1234 let animations = {
1235 crate::profile_span!("prepare::layout::tick_animations");
1236 self.ui_state.tick_visual_animations(root, Instant::now())
1237 };
1238 animations || tooltip_pending || toast_pending
1239 };
1240 let t_after_layout = Instant::now();
1241 timings.layout_intrinsic_cache = layout::take_intrinsic_cache_stats();
1242 timings.layout_prune = layout::take_prune_stats();
1243 let (ops, draw_ops_stats) = {
1244 crate::profile_span!("prepare::draw_ops");
1245 let mut stats = DrawOpsStats::default();
1246 let ops = draw_ops::draw_ops_with_theme_and_stats(
1247 root,
1248 &self.ui_state,
1249 &self.theme,
1250 &mut stats,
1251 );
1252 (ops, stats)
1253 };
1254 let t_after_draw_ops = Instant::now();
1255 timings.layout = t_after_layout - t0;
1256 timings.draw_ops = t_after_draw_ops - t_after_layout;
1257 timings.draw_ops_culled_text_ops = draw_ops_stats.culled_text_ops;
1258 timings.text_layout_cache = crate::text::metrics::take_shape_cache_stats();
1259
1260 let shader_needs_redraw = ops.iter().any(|op| op_is_continuous(op, &samples_time));
1277 let widget_redraw =
1278 aggregate_redraw_within(root, viewport, &self.ui_state.layout.computed_rects);
1279
1280 let next_layout_redraw_in = match (needs_redraw, widget_redraw) {
1281 (true, Some(d)) => Some(d.min(std::time::Duration::ZERO)),
1282 (true, None) => Some(std::time::Duration::ZERO),
1283 (false, d) => d,
1284 };
1285 let next_paint_redraw_in = if shader_needs_redraw {
1286 Some(std::time::Duration::ZERO)
1287 } else {
1288 None
1289 };
1290 if next_layout_redraw_in.is_some() || next_paint_redraw_in.is_some() {
1291 needs_redraw = true;
1292 }
1293
1294 LayoutPrepared {
1299 ops,
1300 needs_redraw,
1301 next_layout_redraw_in,
1302 next_paint_redraw_in,
1303 }
1304 }
1305
1306 pub fn prepare_paint_cached<F1, F2>(
1319 &mut self,
1320 is_registered: F1,
1321 samples_backdrop: F2,
1322 text: &mut dyn TextRecorder,
1323 scale_factor: f32,
1324 timings: &mut PrepareTimings,
1325 ) where
1326 F1: Fn(&ShaderHandle) -> bool,
1327 F2: Fn(&ShaderHandle) -> bool,
1328 {
1329 let ops = std::mem::take(&mut self.last_ops);
1333 self.prepare_paint(
1334 &ops,
1335 is_registered,
1336 samples_backdrop,
1337 text,
1338 scale_factor,
1339 timings,
1340 );
1341 self.last_ops = ops;
1342 }
1343
1344 pub fn no_time_shaders(_shader: &ShaderHandle) -> bool {
1349 false
1350 }
1351
1352 pub fn scan_continuous_shaders<F>(&self, samples_time: F) -> Option<std::time::Duration>
1359 where
1360 F: Fn(&ShaderHandle) -> bool,
1361 {
1362 let any = self
1363 .last_ops
1364 .iter()
1365 .any(|op| op_is_continuous(op, &samples_time));
1366 if any {
1367 Some(std::time::Duration::ZERO)
1368 } else {
1369 None
1370 }
1371 }
1372
1373 pub fn prepare_paint<F1, F2>(
1384 &mut self,
1385 ops: &[DrawOp],
1386 is_registered: F1,
1387 samples_backdrop: F2,
1388 text: &mut dyn TextRecorder,
1389 scale_factor: f32,
1390 timings: &mut PrepareTimings,
1391 ) where
1392 F1: Fn(&ShaderHandle) -> bool,
1393 F2: Fn(&ShaderHandle) -> bool,
1394 {
1395 crate::profile_span!("prepare::paint");
1396 let t0 = Instant::now();
1397 self.quad_scratch.clear();
1398 self.runs.clear();
1399 self.paint_items.clear();
1400
1401 let mut current: Option<(ShaderHandle, Option<PhysicalScissor>)> = None;
1402 let mut run_first: u32 = 0;
1403 let mut snapshot_emitted = false;
1406
1407 for op in ops {
1408 match op {
1409 DrawOp::Quad {
1410 rect,
1411 scissor,
1412 shader,
1413 uniforms,
1414 ..
1415 } => {
1416 if !is_registered(shader) {
1417 continue;
1418 }
1419 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
1420 timings.paint_culled_ops += 1;
1421 continue;
1422 }
1423 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
1424 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
1425 timings.paint_culled_ops += 1;
1426 continue;
1427 }
1428 if !snapshot_emitted && samples_backdrop(shader) {
1429 close_run(
1430 &mut self.runs,
1431 &mut self.paint_items,
1432 current,
1433 run_first,
1434 self.quad_scratch.len() as u32,
1435 );
1436 current = None;
1437 run_first = self.quad_scratch.len() as u32;
1438 self.paint_items.push(PaintItem::BackdropSnapshot);
1439 snapshot_emitted = true;
1440 }
1441 let inst = pack_instance(*rect, *shader, uniforms);
1442
1443 let key = (*shader, phys);
1444 if current != Some(key) {
1445 close_run(
1446 &mut self.runs,
1447 &mut self.paint_items,
1448 current,
1449 run_first,
1450 self.quad_scratch.len() as u32,
1451 );
1452 current = Some(key);
1453 run_first = self.quad_scratch.len() as u32;
1454 }
1455 self.quad_scratch.push(inst);
1456 }
1457 DrawOp::GlyphRun {
1458 rect,
1459 scissor,
1460 color,
1461 text: glyph_text,
1462 size,
1463 line_height,
1464 family,
1465 mono_family,
1466 weight,
1467 mono,
1468 wrap,
1469 anchor,
1470 underline,
1471 strikethrough,
1472 link,
1473 ..
1474 } => {
1475 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
1476 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
1477 timings.paint_culled_ops += 1;
1478 continue;
1479 }
1480 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
1481 timings.paint_culled_ops += 1;
1482 continue;
1483 }
1484 close_run(
1485 &mut self.runs,
1486 &mut self.paint_items,
1487 current,
1488 run_first,
1489 self.quad_scratch.len() as u32,
1490 );
1491 current = None;
1492 run_first = self.quad_scratch.len() as u32;
1493
1494 let mut style = crate::text::atlas::RunStyle::new(*weight, *color)
1495 .family(*family)
1496 .mono_family(*mono_family);
1497 if *mono {
1498 style = style.mono();
1499 }
1500 if *underline {
1501 style = style.underline();
1502 }
1503 if *strikethrough {
1504 style = style.strikethrough();
1505 }
1506 if let Some(url) = link {
1507 style = style.with_link(url.clone());
1508 }
1509 let layers = text.record(
1510 *rect,
1511 phys,
1512 &style,
1513 glyph_text,
1514 *size,
1515 *line_height,
1516 *wrap,
1517 *anchor,
1518 scale_factor,
1519 );
1520 for index in layers {
1521 self.paint_items.push(PaintItem::Text(index));
1522 }
1523 }
1524 DrawOp::AttributedText {
1525 rect,
1526 scissor,
1527 runs,
1528 size,
1529 line_height,
1530 wrap,
1531 anchor,
1532 ..
1533 } => {
1534 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
1535 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
1536 timings.paint_culled_ops += 1;
1537 continue;
1538 }
1539 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
1540 timings.paint_culled_ops += 1;
1541 continue;
1542 }
1543 close_run(
1544 &mut self.runs,
1545 &mut self.paint_items,
1546 current,
1547 run_first,
1548 self.quad_scratch.len() as u32,
1549 );
1550 current = None;
1551 run_first = self.quad_scratch.len() as u32;
1552
1553 let layers = text.record_runs(
1554 *rect,
1555 phys,
1556 runs,
1557 *size,
1558 *line_height,
1559 *wrap,
1560 *anchor,
1561 scale_factor,
1562 );
1563 for index in layers {
1564 self.paint_items.push(PaintItem::Text(index));
1565 }
1566 }
1567 DrawOp::Icon {
1568 rect,
1569 scissor,
1570 source,
1571 color,
1572 size,
1573 stroke_width,
1574 ..
1575 } => {
1576 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
1577 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
1578 timings.paint_culled_ops += 1;
1579 continue;
1580 }
1581 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
1582 timings.paint_culled_ops += 1;
1583 continue;
1584 }
1585 close_run(
1586 &mut self.runs,
1587 &mut self.paint_items,
1588 current,
1589 run_first,
1590 self.quad_scratch.len() as u32,
1591 );
1592 current = None;
1593 run_first = self.quad_scratch.len() as u32;
1594
1595 let recorded = text.record_icon(
1596 *rect,
1597 phys,
1598 source,
1599 *color,
1600 *size,
1601 *stroke_width,
1602 scale_factor,
1603 );
1604 match recorded {
1605 RecordedPaint::Text(layers) => {
1606 for index in layers {
1607 self.paint_items.push(PaintItem::Text(index));
1608 }
1609 }
1610 RecordedPaint::Icon(runs) => {
1611 for index in runs {
1612 self.paint_items.push(PaintItem::IconRun(index));
1613 }
1614 }
1615 }
1616 }
1617 DrawOp::Image {
1618 rect,
1619 scissor,
1620 image,
1621 tint,
1622 radius,
1623 fit,
1624 ..
1625 } => {
1626 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
1627 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
1628 timings.paint_culled_ops += 1;
1629 continue;
1630 }
1631 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
1632 timings.paint_culled_ops += 1;
1633 continue;
1634 }
1635 close_run(
1636 &mut self.runs,
1637 &mut self.paint_items,
1638 current,
1639 run_first,
1640 self.quad_scratch.len() as u32,
1641 );
1642 current = None;
1643 run_first = self.quad_scratch.len() as u32;
1644
1645 let recorded =
1646 text.record_image(*rect, phys, image, *tint, *radius, *fit, scale_factor);
1647 for index in recorded {
1648 self.paint_items.push(PaintItem::Image(index));
1649 }
1650 }
1651 DrawOp::AppTexture {
1652 rect,
1653 scissor,
1654 texture,
1655 alpha,
1656 transform,
1657 ..
1658 } => {
1659 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
1660 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
1661 timings.paint_culled_ops += 1;
1662 continue;
1663 }
1664 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
1665 timings.paint_culled_ops += 1;
1666 continue;
1667 }
1668 close_run(
1669 &mut self.runs,
1670 &mut self.paint_items,
1671 current,
1672 run_first,
1673 self.quad_scratch.len() as u32,
1674 );
1675 current = None;
1676 run_first = self.quad_scratch.len() as u32;
1677
1678 let recorded = text.record_app_texture(
1679 *rect,
1680 phys,
1681 texture,
1682 *alpha,
1683 *transform,
1684 scale_factor,
1685 );
1686 for index in recorded {
1687 self.paint_items.push(PaintItem::AppTexture(index));
1688 }
1689 }
1690 DrawOp::Vector {
1691 rect,
1692 scissor,
1693 asset,
1694 render_mode,
1695 ..
1696 } => {
1697 let phys = physical_scissor(*scissor, scale_factor, self.viewport_px);
1698 if matches!(phys, Some(s) if s.w == 0 || s.h == 0) {
1699 timings.paint_culled_ops += 1;
1700 continue;
1701 }
1702 if !paint_rect_visible(*rect, *scissor, self.viewport_px, scale_factor) {
1703 timings.paint_culled_ops += 1;
1704 continue;
1705 }
1706 close_run(
1707 &mut self.runs,
1708 &mut self.paint_items,
1709 current,
1710 run_first,
1711 self.quad_scratch.len() as u32,
1712 );
1713 current = None;
1714 run_first = self.quad_scratch.len() as u32;
1715
1716 let recorded =
1717 text.record_vector(*rect, phys, asset, *render_mode, scale_factor);
1718 for index in recorded {
1719 self.paint_items.push(PaintItem::Vector(index));
1720 }
1721 }
1722 DrawOp::BackdropSnapshot => {
1723 close_run(
1724 &mut self.runs,
1725 &mut self.paint_items,
1726 current,
1727 run_first,
1728 self.quad_scratch.len() as u32,
1729 );
1730 current = None;
1731 run_first = self.quad_scratch.len() as u32;
1732 if !snapshot_emitted {
1735 self.paint_items.push(PaintItem::BackdropSnapshot);
1736 snapshot_emitted = true;
1737 }
1738 }
1739 }
1740 }
1741 close_run(
1742 &mut self.runs,
1743 &mut self.paint_items,
1744 current,
1745 run_first,
1746 self.quad_scratch.len() as u32,
1747 );
1748 timings.paint = Instant::now() - t0;
1749 }
1750
1751 pub fn snapshot(&mut self, root: &El, timings: &mut PrepareTimings) {
1756 crate::profile_span!("prepare::snapshot");
1757 let t0 = Instant::now();
1758 self.last_tree = Some(root.clone());
1759 timings.snapshot = Instant::now() - t0;
1760 }
1761}
1762
1763fn paint_rect_visible(
1764 rect: Rect,
1765 scissor: Option<Rect>,
1766 viewport_px: (u32, u32),
1767 scale_factor: f32,
1768) -> bool {
1769 if rect.w <= 0.0 || rect.h <= 0.0 {
1770 return false;
1771 }
1772 let scale = scale_factor.max(f32::EPSILON);
1773 let viewport = Rect::new(
1774 0.0,
1775 0.0,
1776 viewport_px.0 as f32 / scale,
1777 viewport_px.1 as f32 / scale,
1778 );
1779 let Some(clip) = scissor.map_or(Some(viewport), |s| s.intersect(viewport)) else {
1780 return false;
1781 };
1782 rect.intersect(clip).is_some()
1783}
1784
1785fn op_is_continuous<F>(op: &DrawOp, samples_time: &F) -> bool
1792where
1793 F: Fn(&ShaderHandle) -> bool,
1794{
1795 match op.shader() {
1796 Some(handle @ ShaderHandle::Stock(s)) => s.is_continuous() || samples_time(handle),
1797 Some(handle @ ShaderHandle::Custom(_)) => samples_time(handle),
1798 None => false,
1799 }
1800}
1801
1802fn aggregate_redraw_within(
1808 node: &El,
1809 viewport: Rect,
1810 rects: &rustc_hash::FxHashMap<String, Rect>,
1811) -> Option<std::time::Duration> {
1812 let mut acc: Option<std::time::Duration> = None;
1813 visit_redraw_within(node, viewport, rects, VisibilityClip::Unclipped, &mut acc);
1814 acc
1815}
1816
1817#[derive(Clone, Copy)]
1818enum VisibilityClip {
1819 Unclipped,
1820 Clipped(Rect),
1821 Empty,
1822}
1823
1824impl VisibilityClip {
1825 fn intersect(self, rect: Rect) -> Self {
1826 if rect.w <= 0.0 || rect.h <= 0.0 {
1827 return Self::Empty;
1828 }
1829 match self {
1830 Self::Unclipped => Self::Clipped(rect),
1831 Self::Clipped(prev) => prev
1832 .intersect(rect)
1833 .map(Self::Clipped)
1834 .unwrap_or(Self::Empty),
1835 Self::Empty => Self::Empty,
1836 }
1837 }
1838
1839 fn permits(self, rect: Rect) -> bool {
1840 if rect.w <= 0.0 || rect.h <= 0.0 {
1841 return false;
1842 }
1843 match self {
1844 Self::Unclipped => true,
1845 Self::Clipped(clip) => rect.intersect(clip).is_some(),
1846 Self::Empty => false,
1847 }
1848 }
1849}
1850
1851fn visit_redraw_within(
1852 node: &El,
1853 viewport: Rect,
1854 rects: &rustc_hash::FxHashMap<String, Rect>,
1855 inherited_clip: VisibilityClip,
1856 acc: &mut Option<std::time::Duration>,
1857) {
1858 let rect = rects.get(&node.computed_id).copied();
1859 if let Some(d) = node.redraw_within {
1860 if let Some(rect) = rect
1861 && rect.w > 0.0
1862 && rect.h > 0.0
1863 && rect.intersect(viewport).is_some()
1864 && inherited_clip.permits(rect)
1865 {
1866 *acc = Some(match *acc {
1867 Some(prev) => prev.min(d),
1868 None => d,
1869 });
1870 }
1871 }
1872 let child_clip = if node.clip {
1873 rect.map(|r| inherited_clip.intersect(r))
1874 .unwrap_or(VisibilityClip::Empty)
1875 } else {
1876 inherited_clip
1877 };
1878 for child in &node.children {
1879 visit_redraw_within(child, viewport, rects, child_clip, acc);
1880 }
1881}
1882
1883pub(crate) fn find_capture_keys(node: &El, id: &str) -> Option<bool> {
1888 if node.computed_id == id {
1889 return Some(node.capture_keys);
1890 }
1891 node.children.iter().find_map(|c| find_capture_keys(c, id))
1892}
1893
1894fn selection_event(
1896 new_sel: crate::selection::Selection,
1897 modifiers: KeyModifiers,
1898 pointer: Option<(f32, f32)>,
1899) -> UiEvent {
1900 UiEvent {
1901 kind: UiEventKind::SelectionChanged,
1902 key: None,
1903 target: None,
1904 pointer,
1905 key_press: None,
1906 text: None,
1907 selection: Some(new_sel),
1908 modifiers,
1909 click_count: 0,
1910 path: None,
1911 }
1912}
1913
1914fn head_for_drag(
1926 root: &El,
1927 ui_state: &UiState,
1928 point: (f32, f32),
1929) -> Option<crate::selection::SelectionPoint> {
1930 if let Some(p) = hit_test::selection_point_at(root, ui_state, point) {
1931 return Some(p);
1932 }
1933
1934 let order = &ui_state.selection.order;
1935 if order.is_empty() {
1936 return None;
1937 }
1938 let target = order
1943 .iter()
1944 .find(|t| point.1 >= t.rect.y && point.1 < t.rect.y + t.rect.h)
1945 .or_else(|| {
1946 order.iter().min_by(|a, b| {
1947 let da = y_distance(a.rect, point.1);
1948 let db = y_distance(b.rect, point.1);
1949 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
1950 })
1951 })?;
1952 let target_rect = target.rect;
1953 let cy = point
1954 .1
1955 .clamp(target_rect.y, target_rect.y + target_rect.h - 1.0);
1956 if let Some(p) = hit_test::selection_point_at(root, ui_state, (point.0, cy)) {
1957 return Some(p);
1958 }
1959 let leaf_len = find_text_len(root, &target.node_id).unwrap_or(0);
1962 let byte = if point.0 < target_rect.x { 0 } else { leaf_len };
1963 Some(crate::selection::SelectionPoint {
1964 key: target.key.clone(),
1965 byte,
1966 })
1967}
1968
1969fn selection_range_for_drag(
1970 root: &El,
1971 ui_state: &UiState,
1972 drag: &crate::state::SelectionDrag,
1973 raw_head: crate::selection::SelectionPoint,
1974) -> (
1975 crate::selection::SelectionPoint,
1976 crate::selection::SelectionPoint,
1977) {
1978 match drag.granularity {
1979 SelectionDragGranularity::Character => (drag.anchor.clone(), raw_head),
1980 SelectionDragGranularity::Word => {
1981 let text = crate::selection::find_keyed_text(root, &raw_head.key).unwrap_or_default();
1982 let (lo, hi) = crate::selection::word_range_at(&text, raw_head.byte);
1983 if point_cmp(ui_state, &raw_head, &drag.anchor) == Ordering::Less {
1984 (
1985 drag.head.clone(),
1986 crate::selection::SelectionPoint::new(raw_head.key, lo),
1987 )
1988 } else {
1989 (
1990 drag.anchor.clone(),
1991 crate::selection::SelectionPoint::new(raw_head.key, hi),
1992 )
1993 }
1994 }
1995 SelectionDragGranularity::Leaf => {
1996 let len = crate::selection::find_keyed_text(root, &raw_head.key)
1997 .map(|text| text.len())
1998 .unwrap_or(raw_head.byte);
1999 if point_cmp(ui_state, &raw_head, &drag.anchor) == Ordering::Less {
2000 (
2001 drag.head.clone(),
2002 crate::selection::SelectionPoint::new(raw_head.key, 0),
2003 )
2004 } else {
2005 (
2006 drag.anchor.clone(),
2007 crate::selection::SelectionPoint::new(raw_head.key, len),
2008 )
2009 }
2010 }
2011 }
2012}
2013
2014fn point_cmp(
2015 ui_state: &UiState,
2016 a: &crate::selection::SelectionPoint,
2017 b: &crate::selection::SelectionPoint,
2018) -> Ordering {
2019 let order_index = |key: &str| {
2020 ui_state
2021 .selection
2022 .order
2023 .iter()
2024 .position(|target| target.key == key)
2025 .unwrap_or(usize::MAX)
2026 };
2027 order_index(&a.key)
2028 .cmp(&order_index(&b.key))
2029 .then_with(|| a.byte.cmp(&b.byte))
2030}
2031
2032fn y_distance(rect: Rect, y: f32) -> f32 {
2033 if y < rect.y {
2034 rect.y - y
2035 } else if y > rect.y + rect.h {
2036 y - (rect.y + rect.h)
2037 } else {
2038 0.0
2039 }
2040}
2041
2042fn find_text_len(node: &El, id: &str) -> Option<usize> {
2043 if node.computed_id == id {
2044 if let Some(source) = &node.selection_source {
2045 return Some(source.visible_len());
2046 }
2047 return node.text.as_ref().map(|t| t.len());
2048 }
2049 node.children.iter().find_map(|c| find_text_len(c, id))
2050}
2051
2052pub enum RecordedPaint {
2055 Text(Range<usize>),
2056 Icon(Range<usize>),
2057}
2058
2059pub trait TextRecorder {
2063 #[allow(clippy::too_many_arguments)]
2071 fn record(
2072 &mut self,
2073 rect: Rect,
2074 scissor: Option<PhysicalScissor>,
2075 style: &RunStyle,
2076 text: &str,
2077 size: f32,
2078 line_height: f32,
2079 wrap: TextWrap,
2080 anchor: TextAnchor,
2081 scale_factor: f32,
2082 ) -> Range<usize>;
2083
2084 #[allow(clippy::too_many_arguments)]
2089 fn record_runs(
2090 &mut self,
2091 rect: Rect,
2092 scissor: Option<PhysicalScissor>,
2093 runs: &[(String, RunStyle)],
2094 size: f32,
2095 line_height: f32,
2096 wrap: TextWrap,
2097 anchor: TextAnchor,
2098 scale_factor: f32,
2099 ) -> Range<usize>;
2100
2101 #[allow(clippy::too_many_arguments)]
2107 fn record_icon(
2108 &mut self,
2109 rect: Rect,
2110 scissor: Option<PhysicalScissor>,
2111 source: &crate::svg_icon::IconSource,
2112 color: Color,
2113 size: f32,
2114 _stroke_width: f32,
2115 scale_factor: f32,
2116 ) -> RecordedPaint {
2117 let glyph = match source {
2118 crate::svg_icon::IconSource::Builtin(name) => name.fallback_glyph(),
2119 crate::svg_icon::IconSource::Custom(_) => "?",
2120 };
2121 RecordedPaint::Text(self.record(
2122 rect,
2123 scissor,
2124 &RunStyle::new(FontWeight::Regular, color),
2125 glyph,
2126 size,
2127 crate::text::metrics::line_height(size),
2128 TextWrap::NoWrap,
2129 TextAnchor::Middle,
2130 scale_factor,
2131 ))
2132 }
2133
2134 #[allow(clippy::too_many_arguments)]
2141 fn record_image(
2142 &mut self,
2143 _rect: Rect,
2144 _scissor: Option<PhysicalScissor>,
2145 _image: &crate::image::Image,
2146 _tint: Option<Color>,
2147 _radius: crate::tree::Corners,
2148 _fit: crate::image::ImageFit,
2149 _scale_factor: f32,
2150 ) -> Range<usize> {
2151 0..0
2152 }
2153
2154 fn record_app_texture(
2160 &mut self,
2161 _rect: Rect,
2162 _scissor: Option<PhysicalScissor>,
2163 _texture: &crate::surface::AppTexture,
2164 _alpha: crate::surface::SurfaceAlpha,
2165 _transform: crate::affine::Affine2,
2166 _scale_factor: f32,
2167 ) -> Range<usize> {
2168 0..0
2169 }
2170
2171 fn record_vector(
2177 &mut self,
2178 _rect: Rect,
2179 _scissor: Option<PhysicalScissor>,
2180 _asset: &crate::vector::VectorAsset,
2181 _render_mode: crate::vector::VectorRenderMode,
2182 _scale_factor: f32,
2183 ) -> Range<usize> {
2184 0..0
2185 }
2186}
2187
2188#[cfg(test)]
2189mod tests {
2190 use super::*;
2191 use crate::shader::{ShaderHandle, StockShader, UniformBlock};
2192
2193 struct NoText;
2195 impl TextRecorder for NoText {
2196 fn record(
2197 &mut self,
2198 _rect: Rect,
2199 _scissor: Option<PhysicalScissor>,
2200 _style: &RunStyle,
2201 _text: &str,
2202 _size: f32,
2203 _line_height: f32,
2204 _wrap: TextWrap,
2205 _anchor: TextAnchor,
2206 _scale_factor: f32,
2207 ) -> Range<usize> {
2208 0..0
2209 }
2210 fn record_runs(
2211 &mut self,
2212 _rect: Rect,
2213 _scissor: Option<PhysicalScissor>,
2214 _runs: &[(String, RunStyle)],
2215 _size: f32,
2216 _line_height: f32,
2217 _wrap: TextWrap,
2218 _anchor: TextAnchor,
2219 _scale_factor: f32,
2220 ) -> Range<usize> {
2221 0..0
2222 }
2223 }
2224
2225 #[derive(Default)]
2226 struct CountingText {
2227 records: usize,
2228 }
2229
2230 impl TextRecorder for CountingText {
2231 fn record(
2232 &mut self,
2233 _rect: Rect,
2234 _scissor: Option<PhysicalScissor>,
2235 _style: &RunStyle,
2236 _text: &str,
2237 _size: f32,
2238 _line_height: f32,
2239 _wrap: TextWrap,
2240 _anchor: TextAnchor,
2241 _scale_factor: f32,
2242 ) -> Range<usize> {
2243 self.records += 1;
2244 0..0
2245 }
2246
2247 fn record_runs(
2248 &mut self,
2249 _rect: Rect,
2250 _scissor: Option<PhysicalScissor>,
2251 _runs: &[(String, RunStyle)],
2252 _size: f32,
2253 _line_height: f32,
2254 _wrap: TextWrap,
2255 _anchor: TextAnchor,
2256 _scale_factor: f32,
2257 ) -> Range<usize> {
2258 self.records += 1;
2259 0..0
2260 }
2261 }
2262
2263 fn empty_text_layout(line_height: f32) -> crate::text::metrics::TextLayout {
2264 crate::text::metrics::TextLayout {
2265 lines: Vec::new(),
2266 width: 0.0,
2267 height: 0.0,
2268 line_height,
2269 }
2270 }
2271
2272 fn lay_out_input_tree(capture: bool) -> RunnerCore {
2279 use crate::tree::*;
2280 let ti = if capture {
2281 crate::widgets::text::text("input").key("ti").capture_keys()
2282 } else {
2283 crate::widgets::text::text("noop").key("ti").focusable()
2284 };
2285 let mut tree =
2286 crate::column([crate::widgets::button::button("Btn").key("btn"), ti]).padding(10.0);
2287 let mut core = RunnerCore::new();
2288 crate::layout::layout(
2289 &mut tree,
2290 &mut core.ui_state,
2291 Rect::new(0.0, 0.0, 200.0, 200.0),
2292 );
2293 core.ui_state.sync_focus_order(&tree);
2294 let mut t = PrepareTimings::default();
2295 core.snapshot(&tree, &mut t);
2296 core
2297 }
2298
2299 #[test]
2300 fn pointer_up_emits_pointer_up_then_click() {
2301 let mut core = lay_out_input_tree(false);
2302 let btn_rect = core.rect_of_key("btn").expect("btn rect");
2303 let cx = btn_rect.x + btn_rect.w * 0.5;
2304 let cy = btn_rect.y + btn_rect.h * 0.5;
2305 core.pointer_moved(cx, cy);
2306 core.pointer_down(cx, cy, PointerButton::Primary);
2307 let events = core.pointer_up(cx, cy, PointerButton::Primary);
2308 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
2309 assert_eq!(kinds, vec![UiEventKind::PointerUp, UiEventKind::Click]);
2310 }
2311
2312 fn lay_out_link_tree() -> (RunnerCore, Rect, &'static str) {
2318 use crate::tree::*;
2319 const URL: &str = "https://github.com/computer-whisperer/aetna";
2320 let mut tree = crate::column([crate::text_runs([
2321 crate::text("Visit "),
2322 crate::text("github.com/computer-whisperer/aetna").link(URL),
2323 crate::text("."),
2324 ])])
2325 .padding(10.0);
2326 let mut core = RunnerCore::new();
2327 crate::layout::layout(
2328 &mut tree,
2329 &mut core.ui_state,
2330 Rect::new(0.0, 0.0, 600.0, 200.0),
2331 );
2332 core.ui_state.sync_focus_order(&tree);
2333 let mut t = PrepareTimings::default();
2334 core.snapshot(&tree, &mut t);
2335 let para = core
2336 .last_tree
2337 .as_ref()
2338 .and_then(|t| t.children.first())
2339 .map(|p| core.ui_state.rect(&p.computed_id))
2340 .expect("paragraph rect");
2341 (core, para, URL)
2342 }
2343
2344 #[test]
2345 fn pointer_up_on_link_emits_link_activated_with_url() {
2346 let (mut core, para, url) = lay_out_link_tree();
2347 let cx = para.x + 100.0;
2351 let cy = para.y + para.h * 0.5;
2352 core.pointer_moved(cx, cy);
2353 core.pointer_down(cx, cy, PointerButton::Primary);
2354 let events = core.pointer_up(cx, cy, PointerButton::Primary);
2355 let link = events
2356 .iter()
2357 .find(|e| e.kind == UiEventKind::LinkActivated)
2358 .expect("LinkActivated event");
2359 assert_eq!(link.key.as_deref(), Some(url));
2360 }
2361
2362 #[test]
2363 fn pointer_up_after_drag_off_link_does_not_activate() {
2364 let (mut core, para, _url) = lay_out_link_tree();
2365 let press_x = para.x + 100.0;
2366 let cy = para.y + para.h * 0.5;
2367 core.pointer_moved(press_x, cy);
2368 core.pointer_down(press_x, cy, PointerButton::Primary);
2369 let events = core.pointer_up(press_x, 180.0, PointerButton::Primary);
2373 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
2374 assert!(
2375 !kinds.contains(&UiEventKind::LinkActivated),
2376 "drag-off-link should cancel the link activation; got {kinds:?}",
2377 );
2378 }
2379
2380 #[test]
2381 fn pointer_moved_over_link_resolves_cursor_to_pointer_and_requests_redraw() {
2382 use crate::cursor::Cursor;
2383 let (mut core, para, _url) = lay_out_link_tree();
2384 let cx = para.x + 100.0;
2385 let cy = para.y + para.h * 0.5;
2386 let initial = core.pointer_moved(para.x - 50.0, cy);
2388 assert!(
2389 !initial.needs_redraw,
2390 "moving in empty space shouldn't request a redraw"
2391 );
2392 let tree = core.last_tree.as_ref().expect("tree").clone();
2393 assert_eq!(
2394 core.ui_state.cursor(&tree),
2395 Cursor::Default,
2396 "no link under pointer → default cursor"
2397 );
2398 let onto = core.pointer_moved(cx, cy);
2401 assert!(
2402 onto.needs_redraw,
2403 "entering a link region should flag a redraw so the cursor refresh isn't stale"
2404 );
2405 assert_eq!(
2406 core.ui_state.cursor(&tree),
2407 Cursor::Pointer,
2408 "pointer over a link → Pointer cursor"
2409 );
2410 let off = core.pointer_moved(para.x - 50.0, cy);
2413 assert!(
2414 off.needs_redraw,
2415 "leaving a link region should flag a redraw"
2416 );
2417 assert_eq!(core.ui_state.cursor(&tree), Cursor::Default);
2418 }
2419
2420 #[test]
2421 fn pointer_up_on_unlinked_text_does_not_emit_link_activated() {
2422 let (mut core, para, _url) = lay_out_link_tree();
2423 let cx = para.x + 1.0;
2426 let cy = para.y + para.h * 0.5;
2427 core.pointer_moved(cx, cy);
2428 core.pointer_down(cx, cy, PointerButton::Primary);
2429 let events = core.pointer_up(cx, cy, PointerButton::Primary);
2430 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
2431 assert!(
2432 !kinds.contains(&UiEventKind::LinkActivated),
2433 "click on the unlinked prefix should not surface a link event; got {kinds:?}",
2434 );
2435 }
2436
2437 #[test]
2438 fn pointer_up_off_target_emits_only_pointer_up() {
2439 let mut core = lay_out_input_tree(false);
2440 let btn_rect = core.rect_of_key("btn").expect("btn rect");
2441 let cx = btn_rect.x + btn_rect.w * 0.5;
2442 let cy = btn_rect.y + btn_rect.h * 0.5;
2443 core.pointer_down(cx, cy, PointerButton::Primary);
2444 let events = core.pointer_up(180.0, 180.0, PointerButton::Primary);
2446 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
2447 assert_eq!(
2448 kinds,
2449 vec![UiEventKind::PointerUp],
2450 "drag-off-target should still surface PointerUp so widgets see drag-end"
2451 );
2452 }
2453
2454 #[test]
2455 fn pointer_moved_while_pressed_emits_drag() {
2456 let mut core = lay_out_input_tree(false);
2457 let btn_rect = core.rect_of_key("btn").expect("btn rect");
2458 let cx = btn_rect.x + btn_rect.w * 0.5;
2459 let cy = btn_rect.y + btn_rect.h * 0.5;
2460 core.pointer_down(cx, cy, PointerButton::Primary);
2461 let drag = core
2462 .pointer_moved(cx + 30.0, cy)
2463 .events
2464 .into_iter()
2465 .find(|e| e.kind == UiEventKind::Drag)
2466 .expect("drag while pressed");
2467 assert_eq!(drag.target.as_ref().map(|t| t.key.as_str()), Some("btn"));
2468 assert_eq!(drag.pointer, Some((cx + 30.0, cy)));
2469 }
2470
2471 #[test]
2472 fn toast_dismiss_click_removes_toast_and_suppresses_click_event() {
2473 use crate::toast::ToastSpec;
2474 use crate::tree::Size;
2475 let mut core = RunnerCore::new();
2479 core.ui_state
2480 .push_toast(ToastSpec::success("hi"), Instant::now());
2481 let toast_id = core.ui_state.toasts()[0].id;
2482
2483 let mut tree: El = crate::stack(std::iter::empty::<El>())
2487 .width(Size::Fill(1.0))
2488 .height(Size::Fill(1.0));
2489 crate::layout::assign_ids(&mut tree);
2490 let _ = crate::toast::synthesize_toasts(&mut tree, &mut core.ui_state, Instant::now());
2491 crate::layout::layout(
2492 &mut tree,
2493 &mut core.ui_state,
2494 Rect::new(0.0, 0.0, 800.0, 600.0),
2495 );
2496 core.ui_state.sync_focus_order(&tree);
2497 let mut t = PrepareTimings::default();
2498 core.snapshot(&tree, &mut t);
2499
2500 let dismiss_key = format!("toast-dismiss-{toast_id}");
2501 let dismiss_rect = core.rect_of_key(&dismiss_key).expect("dismiss button");
2502 let cx = dismiss_rect.x + dismiss_rect.w * 0.5;
2503 let cy = dismiss_rect.y + dismiss_rect.h * 0.5;
2504
2505 core.pointer_down(cx, cy, PointerButton::Primary);
2506 let events = core.pointer_up(cx, cy, PointerButton::Primary);
2507 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
2508 assert!(
2512 !kinds.contains(&UiEventKind::Click),
2513 "Click on toast-dismiss should not be surfaced: {kinds:?}",
2514 );
2515 assert!(
2516 core.ui_state.toasts().iter().all(|t| t.id != toast_id),
2517 "toast {toast_id} should be dropped after dismiss-click",
2518 );
2519 }
2520
2521 #[test]
2522 fn pointer_moved_without_press_emits_no_drag() {
2523 let mut core = lay_out_input_tree(false);
2524 let events = core.pointer_moved(50.0, 50.0).events;
2525 assert!(!events.iter().any(|e| e.kind == UiEventKind::Drag));
2529 }
2530
2531 #[test]
2532 fn spinner_in_tree_keeps_needs_redraw_set() {
2533 use crate::widgets::spinner::spinner;
2538 let mut tree = crate::column([spinner()]);
2539 let mut core = RunnerCore::new();
2540 let mut t = PrepareTimings::default();
2541 let LayoutPrepared { needs_redraw, .. } = core.prepare_layout(
2542 &mut tree,
2543 Rect::new(0.0, 0.0, 200.0, 200.0),
2544 1.0,
2545 &mut t,
2546 RunnerCore::no_time_shaders,
2547 );
2548 assert!(
2549 needs_redraw,
2550 "tree with a spinner must request continuous redraw",
2551 );
2552
2553 let mut bare = crate::column([crate::widgets::text::text("idle")]);
2557 let mut core2 = RunnerCore::new();
2558 let mut t2 = PrepareTimings::default();
2559 let LayoutPrepared {
2560 needs_redraw: needs_redraw2,
2561 ..
2562 } = core2.prepare_layout(
2563 &mut bare,
2564 Rect::new(0.0, 0.0, 200.0, 200.0),
2565 1.0,
2566 &mut t2,
2567 RunnerCore::no_time_shaders,
2568 );
2569 assert!(
2570 !needs_redraw2,
2571 "tree without time-driven shaders should idle: got needs_redraw={needs_redraw2}",
2572 );
2573 }
2574
2575 #[test]
2576 fn custom_samples_time_shader_keeps_needs_redraw_set() {
2577 let mut tree = crate::column([crate::tree::El::new(crate::tree::Kind::Custom("anim"))
2581 .shader(crate::shader::ShaderBinding::custom("my_animated_glow"))
2582 .width(crate::tree::Size::Fixed(32.0))
2583 .height(crate::tree::Size::Fixed(32.0))]);
2584 let mut core = RunnerCore::new();
2585 let mut t = PrepareTimings::default();
2586
2587 let LayoutPrepared {
2588 needs_redraw: idle, ..
2589 } = core.prepare_layout(
2590 &mut tree,
2591 Rect::new(0.0, 0.0, 200.0, 200.0),
2592 1.0,
2593 &mut t,
2594 RunnerCore::no_time_shaders,
2595 );
2596 assert!(
2597 !idle,
2598 "without a samples_time registration the host should idle",
2599 );
2600
2601 let mut t2 = PrepareTimings::default();
2602 let LayoutPrepared {
2603 needs_redraw: animated,
2604 ..
2605 } = core.prepare_layout(
2606 &mut tree,
2607 Rect::new(0.0, 0.0, 200.0, 200.0),
2608 1.0,
2609 &mut t2,
2610 |handle| matches!(handle, ShaderHandle::Custom("my_animated_glow")),
2611 );
2612 assert!(
2613 animated,
2614 "custom shader registered as samples_time=true must request continuous redraw",
2615 );
2616 }
2617
2618 #[test]
2619 fn redraw_within_aggregates_to_minimum_visible_deadline() {
2620 use std::time::Duration;
2621 let mut tree = crate::column([
2622 crate::widgets::text::text("a")
2624 .redraw_within(Duration::from_millis(16))
2625 .width(crate::tree::Size::Fixed(20.0))
2626 .height(crate::tree::Size::Fixed(20.0)),
2627 crate::widgets::text::text("b")
2629 .redraw_within(Duration::from_millis(50))
2630 .width(crate::tree::Size::Fixed(20.0))
2631 .height(crate::tree::Size::Fixed(20.0)),
2632 ]);
2633 let mut core = RunnerCore::new();
2634 let mut t = PrepareTimings::default();
2635 let LayoutPrepared {
2636 needs_redraw,
2637 next_layout_redraw_in,
2638 ..
2639 } = core.prepare_layout(
2640 &mut tree,
2641 Rect::new(0.0, 0.0, 200.0, 200.0),
2642 1.0,
2643 &mut t,
2644 RunnerCore::no_time_shaders,
2645 );
2646 assert!(needs_redraw, "redraw_within must lift the legacy bool");
2647 assert_eq!(
2648 next_layout_redraw_in,
2649 Some(Duration::from_millis(16)),
2650 "tightest visible deadline wins, on the layout lane",
2651 );
2652 }
2653
2654 #[test]
2655 fn redraw_within_off_screen_widget_is_ignored() {
2656 use std::time::Duration;
2657 let mut tree = crate::column([
2663 crate::tree::spacer().height(crate::tree::Size::Fixed(150.0)),
2664 crate::widgets::text::text("offscreen")
2665 .redraw_within(Duration::from_millis(16))
2666 .width(crate::tree::Size::Fixed(10.0))
2667 .height(crate::tree::Size::Fixed(10.0)),
2668 ]);
2669 let mut core = RunnerCore::new();
2670 let mut t = PrepareTimings::default();
2671 let LayoutPrepared {
2672 next_layout_redraw_in,
2673 ..
2674 } = core.prepare_layout(
2675 &mut tree,
2676 Rect::new(0.0, 0.0, 100.0, 100.0),
2677 1.0,
2678 &mut t,
2679 RunnerCore::no_time_shaders,
2680 );
2681 assert_eq!(
2682 next_layout_redraw_in, None,
2683 "off-screen redraw_within must not contribute to the aggregate",
2684 );
2685 }
2686
2687 #[test]
2688 fn redraw_within_clipped_out_widget_is_ignored() {
2689 use std::time::Duration;
2690
2691 let clipped = crate::column([crate::widgets::text::text("clipped")
2692 .redraw_within(Duration::from_millis(16))
2693 .width(crate::tree::Size::Fixed(10.0))
2694 .height(crate::tree::Size::Fixed(10.0))])
2695 .clip()
2696 .width(crate::tree::Size::Fixed(100.0))
2697 .height(crate::tree::Size::Fixed(20.0))
2698 .layout(|ctx| {
2699 vec![Rect::new(
2700 ctx.container.x,
2701 ctx.container.y + 30.0,
2702 10.0,
2703 10.0,
2704 )]
2705 });
2706 let mut tree = crate::column([clipped]);
2707
2708 let mut core = RunnerCore::new();
2709 let mut t = PrepareTimings::default();
2710 let LayoutPrepared {
2711 next_layout_redraw_in,
2712 ..
2713 } = core.prepare_layout(
2714 &mut tree,
2715 Rect::new(0.0, 0.0, 100.0, 100.0),
2716 1.0,
2717 &mut t,
2718 RunnerCore::no_time_shaders,
2719 );
2720 assert_eq!(
2721 next_layout_redraw_in, None,
2722 "redraw_within inside an inherited clip but outside the clip rect must not contribute",
2723 );
2724 }
2725
2726 #[test]
2727 fn pointer_moved_within_same_hovered_node_does_not_request_redraw() {
2728 let mut core = lay_out_input_tree(false);
2734 let btn = core.rect_of_key("btn").expect("btn rect");
2735 let (cx, cy) = (btn.x + btn.w * 0.5, btn.y + btn.h * 0.5);
2736
2737 let first = core.pointer_moved(cx, cy);
2741 assert_eq!(first.events.len(), 1);
2742 assert_eq!(first.events[0].kind, UiEventKind::PointerEnter);
2743 assert_eq!(first.events[0].key.as_deref(), Some("btn"));
2744 assert!(
2745 first.needs_redraw,
2746 "entering a focusable should warrant a redraw",
2747 );
2748
2749 let second = core.pointer_moved(cx + 1.0, cy);
2753 assert!(second.events.is_empty());
2754 assert!(
2755 !second.needs_redraw,
2756 "identical hover, no drag → host should idle",
2757 );
2758
2759 let off = core.pointer_moved(0.0, 0.0);
2763 assert_eq!(off.events.len(), 1);
2764 assert_eq!(off.events[0].kind, UiEventKind::PointerLeave);
2765 assert_eq!(off.events[0].key.as_deref(), Some("btn"));
2766 assert!(
2767 off.needs_redraw,
2768 "leaving a hovered node still warrants a redraw",
2769 );
2770 }
2771
2772 #[test]
2773 fn pointer_moved_between_keyed_targets_emits_leave_then_enter() {
2774 let mut core = lay_out_input_tree(false);
2781 let btn = core.rect_of_key("btn").expect("btn rect");
2782 let ti = core.rect_of_key("ti").expect("ti rect");
2783
2784 let _ = core.pointer_moved(btn.x + 4.0, btn.y + 4.0);
2786
2787 let cross = core.pointer_moved(ti.x + 4.0, ti.y + 4.0);
2789 let kinds: Vec<UiEventKind> = cross.events.iter().map(|e| e.kind).collect();
2790 assert_eq!(
2791 kinds,
2792 vec![UiEventKind::PointerLeave, UiEventKind::PointerEnter],
2793 "paired Leave-then-Enter on cross-target hover transition",
2794 );
2795 assert_eq!(cross.events[0].key.as_deref(), Some("btn"));
2796 assert_eq!(cross.events[1].key.as_deref(), Some("ti"));
2797 assert!(cross.needs_redraw);
2798 }
2799
2800 #[test]
2801 fn pointer_left_emits_leave_for_prior_hover() {
2802 let mut core = lay_out_input_tree(false);
2803 let btn = core.rect_of_key("btn").expect("btn rect");
2804 let _ = core.pointer_moved(btn.x + 4.0, btn.y + 4.0);
2805
2806 let events = core.pointer_left();
2807 assert_eq!(events.len(), 1);
2808 assert_eq!(events[0].kind, UiEventKind::PointerLeave);
2809 assert_eq!(events[0].key.as_deref(), Some("btn"));
2810 }
2811
2812 #[test]
2813 fn pointer_left_with_no_prior_hover_emits_nothing() {
2814 let mut core = lay_out_input_tree(false);
2815 let events = core.pointer_left();
2818 assert!(events.is_empty());
2819 }
2820
2821 #[test]
2822 fn ui_state_hovered_key_returns_leaf_key() {
2823 let mut core = lay_out_input_tree(false);
2824 assert_eq!(core.ui_state().hovered_key(), None);
2825
2826 let btn = core.rect_of_key("btn").expect("btn rect");
2827 core.pointer_moved(btn.x + 4.0, btn.y + 4.0);
2828 assert_eq!(core.ui_state().hovered_key(), Some("btn"));
2829
2830 core.pointer_moved(0.0, 0.0);
2832 assert_eq!(core.ui_state().hovered_key(), None);
2833 }
2834
2835 #[test]
2836 fn ui_state_is_hovering_within_walks_subtree() {
2837 use crate::tree::*;
2841 let mut tree = crate::column([crate::stack([
2842 crate::widgets::button::button("Inner").key("inner_btn")
2843 ])
2844 .key("card")
2845 .focusable()
2846 .width(Size::Fixed(120.0))
2847 .height(Size::Fixed(60.0))])
2848 .padding(20.0);
2849 let mut core = RunnerCore::new();
2850 crate::layout::layout(
2851 &mut tree,
2852 &mut core.ui_state,
2853 Rect::new(0.0, 0.0, 400.0, 200.0),
2854 );
2855 core.ui_state.sync_focus_order(&tree);
2856 let mut t = PrepareTimings::default();
2857 core.snapshot(&tree, &mut t);
2858
2859 assert!(!core.ui_state().is_hovering_within("card"));
2861 assert!(!core.ui_state().is_hovering_within("inner_btn"));
2862
2863 let inner = core.rect_of_key("inner_btn").expect("inner rect");
2866 core.pointer_moved(inner.x + 4.0, inner.y + 4.0);
2867 assert!(core.ui_state().is_hovering_within("card"));
2868 assert!(core.ui_state().is_hovering_within("inner_btn"));
2869
2870 assert!(!core.ui_state().is_hovering_within("not_a_key"));
2872
2873 core.pointer_moved(0.0, 0.0);
2875 assert!(!core.ui_state().is_hovering_within("card"));
2876 assert!(!core.ui_state().is_hovering_within("inner_btn"));
2877 }
2878
2879 #[test]
2880 fn hover_driven_scale_via_is_hovering_within_plus_animate() {
2881 use crate::Theme;
2888 use crate::anim::Timing;
2889 use crate::tree::*;
2890
2891 let build_card = |hovering: bool| -> El {
2894 let scale = if hovering { 1.05 } else { 1.0 };
2895 crate::column([crate::stack(
2896 [crate::widgets::button::button("Inner").key("inner_btn")],
2897 )
2898 .key("card")
2899 .focusable()
2900 .scale(scale)
2901 .animate(Timing::SPRING_QUICK)
2902 .width(Size::Fixed(120.0))
2903 .height(Size::Fixed(60.0))])
2904 .padding(20.0)
2905 };
2906
2907 let mut core = RunnerCore::new();
2908 core.ui_state
2911 .set_animation_mode(crate::state::AnimationMode::Settled);
2912
2913 let theme = Theme::default();
2915 let cx_pre = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
2916 assert!(!cx_pre.is_hovering_within("card"));
2917 let mut tree = build_card(cx_pre.is_hovering_within("card"));
2918 crate::layout::layout(
2919 &mut tree,
2920 &mut core.ui_state,
2921 Rect::new(0.0, 0.0, 400.0, 200.0),
2922 );
2923 core.ui_state.sync_focus_order(&tree);
2924 let mut t = PrepareTimings::default();
2925 core.snapshot(&tree, &mut t);
2926 core.ui_state
2927 .tick_visual_animations(&mut tree, web_time::Instant::now());
2928 let card_at_rest = tree.children[0].clone();
2929 assert!((card_at_rest.scale - 1.0).abs() < 1e-3);
2930
2931 let card_rect = core.rect_of_key("card").expect("card rect");
2933 core.pointer_moved(card_rect.x + 4.0, card_rect.y + 4.0);
2934
2935 let cx_hot = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
2938 assert!(cx_hot.is_hovering_within("card"));
2939 let mut tree = build_card(cx_hot.is_hovering_within("card"));
2940 crate::layout::layout(
2941 &mut tree,
2942 &mut core.ui_state,
2943 Rect::new(0.0, 0.0, 400.0, 200.0),
2944 );
2945 core.ui_state.sync_focus_order(&tree);
2946 core.snapshot(&tree, &mut t);
2947 core.ui_state
2948 .tick_visual_animations(&mut tree, web_time::Instant::now());
2949 let card_hot = tree.children[0].clone();
2950 assert!(
2951 (card_hot.scale - 1.05).abs() < 1e-3,
2952 "hover should drive card scale to 1.05 via animate; got {}",
2953 card_hot.scale,
2954 );
2955
2956 core.pointer_moved(0.0, 0.0);
2958 let cx_cold = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
2959 assert!(!cx_cold.is_hovering_within("card"));
2960 let mut tree = build_card(cx_cold.is_hovering_within("card"));
2961 crate::layout::layout(
2962 &mut tree,
2963 &mut core.ui_state,
2964 Rect::new(0.0, 0.0, 400.0, 200.0),
2965 );
2966 core.ui_state.sync_focus_order(&tree);
2967 core.snapshot(&tree, &mut t);
2968 core.ui_state
2969 .tick_visual_animations(&mut tree, web_time::Instant::now());
2970 let card_after = tree.children[0].clone();
2971 assert!((card_after.scale - 1.0).abs() < 1e-3);
2972 }
2973
2974 #[test]
2975 fn file_dropped_routes_to_keyed_leaf_at_pointer() {
2976 let mut core = lay_out_input_tree(false);
2977 let btn = core.rect_of_key("btn").expect("btn rect");
2978 let path = std::path::PathBuf::from("/tmp/screenshot.png");
2979 let events = core.file_dropped(path.clone(), btn.x + 4.0, btn.y + 4.0);
2980 assert_eq!(events.len(), 1);
2981 let event = &events[0];
2982 assert_eq!(event.kind, UiEventKind::FileDropped);
2983 assert_eq!(event.key.as_deref(), Some("btn"));
2984 assert_eq!(event.path.as_deref(), Some(path.as_path()));
2985 assert_eq!(event.pointer, Some((btn.x + 4.0, btn.y + 4.0)));
2986 }
2987
2988 #[test]
2989 fn file_dropped_outside_keyed_surface_emits_window_level_event() {
2990 let mut core = lay_out_input_tree(false);
2991 let path = std::path::PathBuf::from("/tmp/screenshot.png");
2993 let events = core.file_dropped(path.clone(), 1.0, 1.0);
2994 assert_eq!(events.len(), 1);
2995 let event = &events[0];
2996 assert_eq!(event.kind, UiEventKind::FileDropped);
2997 assert!(
2998 event.target.is_none(),
2999 "drop outside any keyed surface routes window-level",
3000 );
3001 assert!(event.key.is_none());
3002 assert_eq!(event.path.as_deref(), Some(path.as_path()));
3004 }
3005
3006 #[test]
3007 fn file_hovered_then_cancelled_pair() {
3008 let mut core = lay_out_input_tree(false);
3009 let btn = core.rect_of_key("btn").expect("btn rect");
3010 let path = std::path::PathBuf::from("/tmp/a.png");
3011
3012 let hover = core.file_hovered(path.clone(), btn.x + 4.0, btn.y + 4.0);
3013 assert_eq!(hover.len(), 1);
3014 assert_eq!(hover[0].kind, UiEventKind::FileHovered);
3015 assert_eq!(hover[0].key.as_deref(), Some("btn"));
3016 assert_eq!(hover[0].path.as_deref(), Some(path.as_path()));
3017
3018 let cancel = core.file_hover_cancelled();
3019 assert_eq!(cancel.len(), 1);
3020 assert_eq!(cancel[0].kind, UiEventKind::FileHoverCancelled);
3021 assert!(cancel[0].target.is_none());
3022 assert!(cancel[0].path.is_none());
3023 }
3024
3025 #[test]
3026 fn build_cx_hover_accessors_default_off_without_state() {
3027 use crate::Theme;
3028 let theme = Theme::default();
3029 let cx = crate::BuildCx::new(&theme);
3030 assert_eq!(cx.hovered_key(), None);
3031 assert!(!cx.is_hovering_within("anything"));
3032 }
3033
3034 #[test]
3035 fn build_cx_hover_accessors_delegate_when_state_attached() {
3036 use crate::Theme;
3037 let mut core = lay_out_input_tree(false);
3038 let btn = core.rect_of_key("btn").expect("btn rect");
3039 core.pointer_moved(btn.x + 4.0, btn.y + 4.0);
3040
3041 let theme = Theme::default();
3042 let cx = crate::BuildCx::new(&theme).with_ui_state(core.ui_state());
3043 assert_eq!(cx.hovered_key(), Some("btn"));
3044 assert!(cx.is_hovering_within("btn"));
3045 assert!(!cx.is_hovering_within("ti"));
3046 }
3047
3048 fn lay_out_paragraph_tree() -> RunnerCore {
3049 use crate::tree::*;
3050 let mut tree = crate::column([
3051 crate::widgets::text::text("First paragraph of text.")
3052 .key("p1")
3053 .selectable(),
3054 crate::widgets::text::text("Second paragraph of text.")
3055 .key("p2")
3056 .selectable(),
3057 ])
3058 .padding(20.0);
3059 let mut core = RunnerCore::new();
3060 crate::layout::layout(
3061 &mut tree,
3062 &mut core.ui_state,
3063 Rect::new(0.0, 0.0, 400.0, 300.0),
3064 );
3065 core.ui_state.sync_focus_order(&tree);
3066 core.ui_state.sync_selection_order(&tree);
3067 let mut t = PrepareTimings::default();
3068 core.snapshot(&tree, &mut t);
3069 core
3070 }
3071
3072 #[test]
3073 fn pointer_down_on_selectable_text_emits_selection_changed() {
3074 let mut core = lay_out_paragraph_tree();
3075 let p1 = core.rect_of_key("p1").expect("p1 rect");
3076 let cx = p1.x + 4.0;
3077 let cy = p1.y + p1.h * 0.5;
3078 let events = core.pointer_down(cx, cy, PointerButton::Primary);
3079 let sel_event = events
3080 .iter()
3081 .find(|e| e.kind == UiEventKind::SelectionChanged)
3082 .expect("SelectionChanged emitted");
3083 let new_sel = sel_event
3084 .selection
3085 .as_ref()
3086 .expect("SelectionChanged carries a selection");
3087 let range = new_sel.range.as_ref().expect("collapsed selection at hit");
3088 assert_eq!(range.anchor.key, "p1");
3089 assert_eq!(range.head.key, "p1");
3090 assert_eq!(range.anchor.byte, range.head.byte);
3091 assert!(core.ui_state.selection.drag.is_some());
3092 }
3093
3094 #[test]
3095 fn pointer_drag_on_selectable_text_extends_head() {
3096 let mut core = lay_out_paragraph_tree();
3097 let p1 = core.rect_of_key("p1").expect("p1 rect");
3098 let cx = p1.x + 4.0;
3099 let cy = p1.y + p1.h * 0.5;
3100 core.pointer_moved(cx, cy);
3101 core.pointer_down(cx, cy, PointerButton::Primary);
3102
3103 let events = core.pointer_moved(p1.x + p1.w - 10.0, cy).events;
3105 let sel_event = events
3106 .iter()
3107 .find(|e| e.kind == UiEventKind::SelectionChanged)
3108 .expect("Drag emits SelectionChanged");
3109 let new_sel = sel_event.selection.as_ref().unwrap();
3110 let range = new_sel.range.as_ref().unwrap();
3111 assert_eq!(range.anchor.key, "p1");
3112 assert_eq!(range.head.key, "p1");
3113 assert!(
3114 range.head.byte > range.anchor.byte,
3115 "head should advance past anchor (anchor={}, head={})",
3116 range.anchor.byte,
3117 range.head.byte
3118 );
3119 }
3120
3121 #[test]
3122 fn double_click_hold_drag_inside_selectable_word_keeps_word_selected() {
3123 let mut core = lay_out_paragraph_tree();
3124 let p1 = core.rect_of_key("p1").expect("p1 rect");
3125 let cx = p1.x + 4.0;
3126 let cy = p1.y + p1.h * 0.5;
3127
3128 core.pointer_down(cx, cy, PointerButton::Primary);
3129 core.pointer_up(cx, cy, PointerButton::Primary);
3130 let down = core.pointer_down(cx, cy, PointerButton::Primary);
3131 let sel = down
3132 .iter()
3133 .find(|e| e.kind == UiEventKind::SelectionChanged)
3134 .and_then(|e| e.selection.as_ref())
3135 .and_then(|s| s.range.as_ref())
3136 .expect("double-click selects word");
3137 assert_eq!(sel.anchor.byte, 0);
3138 assert_eq!(sel.head.byte, 5);
3139
3140 let events = core.pointer_moved(cx + 1.0, cy).events;
3141 assert!(
3142 !events
3143 .iter()
3144 .any(|e| e.kind == UiEventKind::SelectionChanged),
3145 "drag jitter within the double-clicked word should not collapse the selection"
3146 );
3147 let range = core
3148 .ui_state
3149 .current_selection
3150 .range
3151 .as_ref()
3152 .expect("selection persists");
3153 assert_eq!(range.anchor.byte, 0);
3154 assert_eq!(range.head.byte, 5);
3155 }
3156
3157 #[test]
3158 fn pointer_up_clears_drag_but_keeps_selection() {
3159 let mut core = lay_out_paragraph_tree();
3160 let p1 = core.rect_of_key("p1").expect("p1 rect");
3161 let cx = p1.x + 4.0;
3162 let cy = p1.y + p1.h * 0.5;
3163 core.pointer_down(cx, cy, PointerButton::Primary);
3164 core.pointer_moved(p1.x + p1.w - 10.0, cy);
3165 let _ = core.pointer_up(p1.x + p1.w - 10.0, cy, PointerButton::Primary);
3166 assert!(
3167 core.ui_state.selection.drag.is_none(),
3168 "drag flag should clear on pointer_up"
3169 );
3170 assert!(
3171 !core.ui_state.current_selection.is_empty(),
3172 "selection itself should persist after pointer_up"
3173 );
3174 }
3175
3176 #[test]
3177 fn drag_past_a_leaf_bottom_keeps_head_in_that_leaf_not_anchor() {
3178 let mut core = lay_out_paragraph_tree();
3184 let p1 = core.rect_of_key("p1").expect("p1 rect");
3185 let p2 = core.rect_of_key("p2").expect("p2 rect");
3186 core.pointer_down(p1.x + 4.0, p1.y + p1.h * 0.5, PointerButton::Primary);
3188 core.pointer_moved(p2.x + 8.0, p2.y + p2.h * 0.5);
3190 let events = core.pointer_moved(p2.x + 8.0, p2.y + p2.h + 200.0).events;
3193 let sel = events
3194 .iter()
3195 .find(|e| e.kind == UiEventKind::SelectionChanged)
3196 .map(|e| e.selection.as_ref().unwrap().clone())
3197 .unwrap_or_else(|| core.ui_state.current_selection.clone());
3200 let r = sel.range.as_ref().expect("selection still active");
3201 assert_eq!(r.anchor.key, "p1", "anchor unchanged");
3202 assert_eq!(
3203 r.head.key, "p2",
3204 "head must stay in p2 even when pointer is below p2's rect"
3205 );
3206 }
3207
3208 #[test]
3209 fn drag_into_a_sibling_selectable_extends_head_into_that_leaf() {
3210 let mut core = lay_out_paragraph_tree();
3211 let p1 = core.rect_of_key("p1").expect("p1 rect");
3212 let p2 = core.rect_of_key("p2").expect("p2 rect");
3213 core.pointer_down(p1.x + 4.0, p1.y + p1.h * 0.5, PointerButton::Primary);
3215 let events = core.pointer_moved(p2.x + 8.0, p2.y + p2.h * 0.5).events;
3217 let sel_event = events
3218 .iter()
3219 .find(|e| e.kind == UiEventKind::SelectionChanged)
3220 .expect("Drag emits SelectionChanged");
3221 let new_sel = sel_event.selection.as_ref().unwrap();
3222 let range = new_sel.range.as_ref().unwrap();
3223 assert_eq!(range.anchor.key, "p1", "anchor stays in p1");
3224 assert_eq!(range.head.key, "p2", "head migrates into p2");
3225 }
3226
3227 #[test]
3228 fn pointer_down_on_focusable_owning_selection_does_not_clear_it() {
3229 let mut core = lay_out_input_tree(true);
3237 core.set_selection(crate::selection::Selection::caret("ti", 3));
3240 let ti = core.rect_of_key("ti").expect("ti rect");
3241 let cx = ti.x + ti.w * 0.5;
3242 let cy = ti.y + ti.h * 0.5;
3243
3244 let events = core.pointer_down(cx, cy, PointerButton::Primary);
3245 let cleared = events.iter().find(|e| {
3246 e.kind == UiEventKind::SelectionChanged
3247 && e.selection.as_ref().map(|s| s.is_empty()).unwrap_or(false)
3248 });
3249 assert!(
3250 cleared.is_none(),
3251 "click on the selection-owning input must not emit a clearing SelectionChanged"
3252 );
3253 assert_eq!(
3254 core.ui_state.current_selection,
3255 crate::selection::Selection::caret("ti", 3),
3256 "runtime mirror is preserved when the click owns the selection"
3257 );
3258 }
3259
3260 #[test]
3261 fn pointer_down_into_a_different_capture_keys_widget_does_not_clear_first() {
3262 let mut core = lay_out_input_tree(true);
3272 core.set_selection(crate::selection::Selection::caret("other", 4));
3274 let ti = core.rect_of_key("ti").expect("ti rect");
3275 let cx = ti.x + ti.w * 0.5;
3276 let cy = ti.y + ti.h * 0.5;
3277
3278 let events = core.pointer_down(cx, cy, PointerButton::Primary);
3279 let cleared = events.iter().any(|e| {
3280 e.kind == UiEventKind::SelectionChanged
3281 && e.selection.as_ref().map(|s| s.is_empty()).unwrap_or(false)
3282 });
3283 assert!(
3284 !cleared,
3285 "click on a different capture_keys widget must not race-clear the selection"
3286 );
3287 }
3288
3289 #[test]
3290 fn pointer_down_on_non_selectable_clears_existing_selection() {
3291 let mut core = lay_out_paragraph_tree();
3292 let p1 = core.rect_of_key("p1").expect("p1 rect");
3293 let cy = p1.y + p1.h * 0.5;
3294 core.pointer_down(p1.x + 4.0, cy, PointerButton::Primary);
3296 core.pointer_up(p1.x + 4.0, cy, PointerButton::Primary);
3297 assert!(!core.ui_state.current_selection.is_empty());
3298
3299 let events = core.pointer_down(2.0, 2.0, PointerButton::Primary);
3301 let cleared = events
3302 .iter()
3303 .find(|e| e.kind == UiEventKind::SelectionChanged)
3304 .expect("clearing emits SelectionChanged");
3305 let new_sel = cleared.selection.as_ref().unwrap();
3306 assert!(new_sel.is_empty(), "new selection should be empty");
3307 assert!(core.ui_state.current_selection.is_empty());
3308 }
3309
3310 #[test]
3311 fn pointer_down_in_dead_space_clears_focus() {
3312 let mut core = lay_out_input_tree(false);
3313 let btn = core.rect_of_key("btn").expect("btn rect");
3314 let cx = btn.x + btn.w * 0.5;
3315 let cy = btn.y + btn.h * 0.5;
3316 core.pointer_down(cx, cy, PointerButton::Primary);
3317 let _ = core.pointer_up(cx, cy, PointerButton::Primary);
3318 assert_eq!(
3319 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3320 Some("btn")
3321 );
3322
3323 core.pointer_down(2.0, 2.0, PointerButton::Primary);
3324
3325 assert_eq!(core.ui_state.focused.as_ref().map(|t| t.key.as_str()), None);
3326 }
3327
3328 #[test]
3329 fn key_down_bumps_caret_activity_when_focused_widget_captures_keys() {
3330 let mut core = lay_out_input_tree(true);
3335 let target = core
3336 .ui_state
3337 .focus
3338 .order
3339 .iter()
3340 .find(|t| t.key == "ti")
3341 .cloned();
3342 core.ui_state.set_focus(target); let after_focus = core.ui_state.caret.activity_at.expect("focus bump");
3344
3345 std::thread::sleep(std::time::Duration::from_millis(2));
3346 let _ = core.key_down(UiKey::ArrowRight, KeyModifiers::default(), false);
3347 let after_arrow = core
3348 .ui_state
3349 .caret
3350 .activity_at
3351 .expect("arrow key bumps even without app-side selection");
3352 assert!(
3353 after_arrow > after_focus,
3354 "ArrowRight to a capture_keys focused widget bumps caret activity"
3355 );
3356 }
3357
3358 #[test]
3359 fn text_input_bumps_caret_activity_when_focused() {
3360 let mut core = lay_out_input_tree(true);
3361 let target = core
3362 .ui_state
3363 .focus
3364 .order
3365 .iter()
3366 .find(|t| t.key == "ti")
3367 .cloned();
3368 core.ui_state.set_focus(target);
3369 let after_focus = core.ui_state.caret.activity_at.unwrap();
3370
3371 std::thread::sleep(std::time::Duration::from_millis(2));
3372 let _ = core.text_input("a".into());
3373 let after_text = core.ui_state.caret.activity_at.unwrap();
3374 assert!(
3375 after_text > after_focus,
3376 "TextInput to focused widget bumps caret activity"
3377 );
3378 }
3379
3380 #[test]
3381 fn pointer_down_inside_focused_input_bumps_caret_activity() {
3382 let mut core = lay_out_input_tree(true);
3387 let ti = core.rect_of_key("ti").expect("ti rect");
3388 let cx = ti.x + ti.w * 0.5;
3389 let cy = ti.y + ti.h * 0.5;
3390
3391 core.pointer_down(cx, cy, PointerButton::Primary);
3393 let _ = core.pointer_up(cx, cy, PointerButton::Primary);
3394 let after_first = core.ui_state.caret.activity_at.unwrap();
3395
3396 std::thread::sleep(std::time::Duration::from_millis(2));
3399 core.pointer_down(cx + 1.0, cy, PointerButton::Primary);
3400 let after_second = core
3401 .ui_state
3402 .caret
3403 .activity_at
3404 .expect("second click bumps too");
3405 assert!(
3406 after_second > after_first,
3407 "click within already-focused capture_keys widget still bumps"
3408 );
3409 }
3410
3411 #[test]
3412 fn arrow_key_through_apply_event_mutates_selection_and_bumps_on_set() {
3413 use crate::widgets::text_input;
3419 let mut sel = crate::selection::Selection::caret("ti", 2);
3420 let mut value = String::from("hello");
3421
3422 let mut core = RunnerCore::new();
3423 core.set_selection(sel.clone());
3426 let baseline = core.ui_state.caret.activity_at;
3427
3428 let arrow_right = UiEvent {
3430 key: Some("ti".into()),
3431 target: None,
3432 pointer: None,
3433 key_press: Some(crate::event::KeyPress {
3434 key: UiKey::ArrowRight,
3435 modifiers: KeyModifiers::default(),
3436 repeat: false,
3437 }),
3438 text: None,
3439 selection: None,
3440 modifiers: KeyModifiers::default(),
3441 click_count: 0,
3442 path: None,
3443 kind: UiEventKind::KeyDown,
3444 };
3445
3446 let mutated = text_input::apply_event(&mut value, &mut sel, "ti", &arrow_right);
3448 assert!(mutated, "ArrowRight should mutate selection");
3449 assert_eq!(
3450 sel.within("ti").unwrap().head,
3451 3,
3452 "head moved one char right (h-e-l-l-o, byte 2 → 3)"
3453 );
3454
3455 std::thread::sleep(std::time::Duration::from_millis(2));
3457 core.set_selection(sel);
3458 let after = core.ui_state.caret.activity_at.unwrap();
3459 if let Some(b) = baseline {
3463 assert!(after > b, "arrow-key flow should bump activity");
3464 }
3465 }
3466
3467 #[test]
3468 fn set_selection_bumps_caret_activity_only_when_value_changes() {
3469 let mut core = lay_out_paragraph_tree();
3470 core.set_selection(crate::selection::Selection::default());
3473 assert!(
3474 core.ui_state.caret.activity_at.is_none(),
3475 "no-op set_selection should not bump activity"
3476 );
3477
3478 let sel_a = crate::selection::Selection::caret("p1", 3);
3480 core.set_selection(sel_a.clone());
3481 let bumped_at = core
3482 .ui_state
3483 .caret
3484 .activity_at
3485 .expect("first real selection bumps");
3486
3487 core.set_selection(sel_a.clone());
3490 assert_eq!(
3491 core.ui_state.caret.activity_at,
3492 Some(bumped_at),
3493 "set_selection with same value is a no-op"
3494 );
3495
3496 std::thread::sleep(std::time::Duration::from_millis(2));
3499 let sel_b = crate::selection::Selection::caret("p1", 7);
3500 core.set_selection(sel_b);
3501 let new_bump = core.ui_state.caret.activity_at.expect("second bump");
3502 assert!(
3503 new_bump > bumped_at,
3504 "moving the caret bumps activity again",
3505 );
3506 }
3507
3508 #[test]
3509 fn escape_clears_active_selection_and_emits_selection_changed() {
3510 let mut core = lay_out_paragraph_tree();
3511 let p1 = core.rect_of_key("p1").expect("p1 rect");
3512 let cy = p1.y + p1.h * 0.5;
3513 core.pointer_down(p1.x + 4.0, cy, PointerButton::Primary);
3515 core.pointer_moved(p1.x + p1.w - 10.0, cy);
3516 core.pointer_up(p1.x + p1.w - 10.0, cy, PointerButton::Primary);
3517 assert!(!core.ui_state.current_selection.is_empty());
3518
3519 let events = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
3520 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3521 assert_eq!(
3522 kinds,
3523 vec![UiEventKind::Escape, UiEventKind::SelectionChanged],
3524 "Esc emits Escape (for popover dismiss) AND SelectionChanged"
3525 );
3526 let cleared = events
3527 .iter()
3528 .find(|e| e.kind == UiEventKind::SelectionChanged)
3529 .unwrap();
3530 assert!(cleared.selection.as_ref().unwrap().is_empty());
3531 assert!(core.ui_state.current_selection.is_empty());
3532 }
3533
3534 #[test]
3535 fn consecutive_clicks_on_same_target_extend_count() {
3536 let mut core = lay_out_input_tree(false);
3537 let btn = core.rect_of_key("btn").expect("btn rect");
3538 let cx = btn.x + btn.w * 0.5;
3539 let cy = btn.y + btn.h * 0.5;
3540
3541 let down1 = core.pointer_down(cx, cy, PointerButton::Primary);
3543 let pd1 = down1
3544 .iter()
3545 .find(|e| e.kind == UiEventKind::PointerDown)
3546 .expect("PointerDown emitted");
3547 assert_eq!(pd1.click_count, 1, "first press starts the sequence");
3548 let up1 = core.pointer_up(cx, cy, PointerButton::Primary);
3549 let click1 = up1
3550 .iter()
3551 .find(|e| e.kind == UiEventKind::Click)
3552 .expect("Click emitted");
3553 assert_eq!(
3554 click1.click_count, 1,
3555 "Click carries the same count as its PointerDown"
3556 );
3557
3558 let down2 = core.pointer_down(cx, cy, PointerButton::Primary);
3560 let pd2 = down2
3561 .iter()
3562 .find(|e| e.kind == UiEventKind::PointerDown)
3563 .unwrap();
3564 assert_eq!(pd2.click_count, 2, "second press extends the sequence");
3565 let up2 = core.pointer_up(cx, cy, PointerButton::Primary);
3566 assert_eq!(
3567 up2.iter()
3568 .find(|e| e.kind == UiEventKind::Click)
3569 .unwrap()
3570 .click_count,
3571 2
3572 );
3573
3574 let down3 = core.pointer_down(cx, cy, PointerButton::Primary);
3576 let pd3 = down3
3577 .iter()
3578 .find(|e| e.kind == UiEventKind::PointerDown)
3579 .unwrap();
3580 assert_eq!(pd3.click_count, 3, "third press → triple-click");
3581 core.pointer_up(cx, cy, PointerButton::Primary);
3582 }
3583
3584 #[test]
3585 fn click_count_resets_when_target_changes() {
3586 let mut core = lay_out_input_tree(false);
3587 let btn = core.rect_of_key("btn").expect("btn rect");
3588 let ti = core.rect_of_key("ti").expect("ti rect");
3589
3590 let down1 = core.pointer_down(
3592 btn.x + btn.w * 0.5,
3593 btn.y + btn.h * 0.5,
3594 PointerButton::Primary,
3595 );
3596 assert_eq!(
3597 down1
3598 .iter()
3599 .find(|e| e.kind == UiEventKind::PointerDown)
3600 .unwrap()
3601 .click_count,
3602 1
3603 );
3604 let _ = core.pointer_up(
3605 btn.x + btn.w * 0.5,
3606 btn.y + btn.h * 0.5,
3607 PointerButton::Primary,
3608 );
3609
3610 let down2 = core.pointer_down(ti.x + ti.w * 0.5, ti.y + ti.h * 0.5, PointerButton::Primary);
3612 let pd2 = down2
3613 .iter()
3614 .find(|e| e.kind == UiEventKind::PointerDown)
3615 .unwrap();
3616 assert_eq!(
3617 pd2.click_count, 1,
3618 "press on a new target resets the multi-click sequence"
3619 );
3620 }
3621
3622 #[test]
3623 fn double_click_on_selectable_text_selects_word_at_hit() {
3624 let mut core = lay_out_paragraph_tree();
3625 let p1 = core.rect_of_key("p1").expect("p1 rect");
3626 let cy = p1.y + p1.h * 0.5;
3627 let cx = p1.x + 4.0;
3630 core.pointer_down(cx, cy, PointerButton::Primary);
3631 core.pointer_up(cx, cy, PointerButton::Primary);
3632 core.pointer_down(cx, cy, PointerButton::Primary);
3633 let sel = &core.ui_state.current_selection;
3635 let r = sel.range.as_ref().expect("selection set");
3636 assert_eq!(r.anchor.key, "p1");
3637 assert_eq!(r.head.key, "p1");
3638 assert_eq!(r.anchor.byte.min(r.head.byte), 0);
3640 assert_eq!(r.anchor.byte.max(r.head.byte), 5);
3641 }
3642
3643 #[test]
3644 fn triple_click_on_selectable_text_selects_whole_leaf() {
3645 let mut core = lay_out_paragraph_tree();
3646 let p1 = core.rect_of_key("p1").expect("p1 rect");
3647 let cy = p1.y + p1.h * 0.5;
3648 let cx = p1.x + 4.0;
3649 core.pointer_down(cx, cy, PointerButton::Primary);
3650 core.pointer_up(cx, cy, PointerButton::Primary);
3651 core.pointer_down(cx, cy, PointerButton::Primary);
3652 core.pointer_up(cx, cy, PointerButton::Primary);
3653 core.pointer_down(cx, cy, PointerButton::Primary);
3654 let sel = &core.ui_state.current_selection;
3655 let r = sel.range.as_ref().expect("selection set");
3656 assert_eq!(r.anchor.byte, 0);
3657 assert_eq!(r.head.byte, 24);
3659 }
3660
3661 #[test]
3662 fn click_count_resets_when_press_drifts_outside_distance_window() {
3663 let mut core = lay_out_input_tree(false);
3664 let btn = core.rect_of_key("btn").expect("btn rect");
3665 let cx = btn.x + btn.w * 0.5;
3666 let cy = btn.y + btn.h * 0.5;
3667
3668 let _ = core.pointer_down(cx, cy, PointerButton::Primary);
3669 let _ = core.pointer_up(cx, cy, PointerButton::Primary);
3670
3671 let down2 = core.pointer_down(cx + 10.0, cy, PointerButton::Primary);
3674 let pd2 = down2
3675 .iter()
3676 .find(|e| e.kind == UiEventKind::PointerDown)
3677 .unwrap();
3678 assert_eq!(pd2.click_count, 1);
3679 }
3680
3681 #[test]
3682 fn escape_with_no_selection_emits_only_escape() {
3683 let mut core = lay_out_paragraph_tree();
3684 assert!(core.ui_state.current_selection.is_empty());
3685 let events = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
3686 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3687 assert_eq!(
3688 kinds,
3689 vec![UiEventKind::Escape],
3690 "no selection → no SelectionChanged side-effect"
3691 );
3692 }
3693
3694 fn lay_out_scroll_tree() -> (RunnerCore, String) {
3697 use crate::tree::*;
3698 let mut tree = crate::scroll(
3699 (0..6)
3700 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
3701 )
3702 .gap(12.0)
3703 .height(Size::Fixed(200.0));
3704 let mut core = RunnerCore::new();
3705 crate::layout::layout(
3706 &mut tree,
3707 &mut core.ui_state,
3708 Rect::new(0.0, 0.0, 300.0, 200.0),
3709 );
3710 let scroll_id = tree.computed_id.clone();
3711 let mut t = PrepareTimings::default();
3712 core.snapshot(&tree, &mut t);
3713 (core, scroll_id)
3714 }
3715
3716 #[test]
3717 fn thumb_pointer_down_captures_drag_and_suppresses_events() {
3718 let (mut core, scroll_id) = lay_out_scroll_tree();
3719 let thumb = core
3720 .ui_state
3721 .scroll
3722 .thumb_rects
3723 .get(&scroll_id)
3724 .copied()
3725 .expect("scrollable should have a thumb");
3726 let event = core.pointer_down(
3727 thumb.x + thumb.w * 0.5,
3728 thumb.y + thumb.h * 0.5,
3729 PointerButton::Primary,
3730 );
3731 assert!(
3732 event.is_empty(),
3733 "thumb press should not emit PointerDown to the app"
3734 );
3735 let drag = core
3736 .ui_state
3737 .scroll
3738 .thumb_drag
3739 .as_ref()
3740 .expect("scroll.thumb_drag should be set after pointer_down on thumb");
3741 assert_eq!(drag.scroll_id, scroll_id);
3742 }
3743
3744 #[test]
3745 fn track_click_above_thumb_pages_up_below_pages_down() {
3746 let (mut core, scroll_id) = lay_out_scroll_tree();
3747 let track = core
3748 .ui_state
3749 .scroll
3750 .thumb_tracks
3751 .get(&scroll_id)
3752 .copied()
3753 .expect("scrollable should have a track");
3754 let thumb = core
3755 .ui_state
3756 .scroll
3757 .thumb_rects
3758 .get(&scroll_id)
3759 .copied()
3760 .unwrap();
3761 let metrics = core
3762 .ui_state
3763 .scroll
3764 .metrics
3765 .get(&scroll_id)
3766 .copied()
3767 .unwrap();
3768
3769 let evt = core.pointer_down(
3771 track.x + track.w * 0.5,
3772 thumb.y + thumb.h + 10.0,
3773 PointerButton::Primary,
3774 );
3775 assert!(evt.is_empty(), "track press should not surface PointerDown");
3776 assert!(
3777 core.ui_state.scroll.thumb_drag.is_none(),
3778 "track click outside the thumb should not start a drag",
3779 );
3780 let after_down = core.ui_state.scroll_offset(&scroll_id);
3781 let expected_page = (metrics.viewport_h - SCROLL_PAGE_OVERLAP).max(0.0);
3782 assert!(
3783 (after_down - expected_page.min(metrics.max_offset)).abs() < 0.5,
3784 "page-down offset = {after_down} (expected ~{expected_page})",
3785 );
3786 let _ = core.pointer_up(0.0, 0.0, PointerButton::Primary);
3788
3789 let mut tree = lay_out_scroll_tree_only();
3792 crate::layout::layout(
3793 &mut tree,
3794 &mut core.ui_state,
3795 Rect::new(0.0, 0.0, 300.0, 200.0),
3796 );
3797 let mut t = PrepareTimings::default();
3798 core.snapshot(&tree, &mut t);
3799 let track = core
3800 .ui_state
3801 .scroll
3802 .thumb_tracks
3803 .get(&tree.computed_id)
3804 .copied()
3805 .unwrap();
3806 let thumb = core
3807 .ui_state
3808 .scroll
3809 .thumb_rects
3810 .get(&tree.computed_id)
3811 .copied()
3812 .unwrap();
3813
3814 core.pointer_down(
3815 track.x + track.w * 0.5,
3816 thumb.y - 4.0,
3817 PointerButton::Primary,
3818 );
3819 let after_up = core.ui_state.scroll_offset(&tree.computed_id);
3820 assert!(
3821 after_up < after_down,
3822 "page-up should reduce offset: before={after_down} after={after_up}",
3823 );
3824 }
3825
3826 fn lay_out_scroll_tree_only() -> El {
3831 use crate::tree::*;
3832 crate::scroll(
3833 (0..6)
3834 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
3835 )
3836 .gap(12.0)
3837 .height(Size::Fixed(200.0))
3838 }
3839
3840 #[test]
3841 fn thumb_drag_translates_pointer_delta_into_scroll_offset() {
3842 let (mut core, scroll_id) = lay_out_scroll_tree();
3843 let thumb = core
3844 .ui_state
3845 .scroll
3846 .thumb_rects
3847 .get(&scroll_id)
3848 .copied()
3849 .unwrap();
3850 let metrics = core
3851 .ui_state
3852 .scroll
3853 .metrics
3854 .get(&scroll_id)
3855 .copied()
3856 .unwrap();
3857 let track_remaining = (metrics.viewport_h - thumb.h).max(0.0);
3858
3859 let press_y = thumb.y + thumb.h * 0.5;
3860 core.pointer_down(thumb.x + thumb.w * 0.5, press_y, PointerButton::Primary);
3861 let evt = core.pointer_moved(thumb.x + thumb.w * 0.5, press_y + 20.0);
3863 assert!(
3864 evt.events.is_empty(),
3865 "thumb-drag move should suppress Drag event",
3866 );
3867 let offset = core.ui_state.scroll_offset(&scroll_id);
3868 let expected = 20.0 * (metrics.max_offset / track_remaining);
3869 assert!(
3870 (offset - expected).abs() < 0.5,
3871 "offset {offset} (expected {expected})",
3872 );
3873 core.pointer_moved(thumb.x + thumb.w * 0.5, press_y + 9999.0);
3875 let offset = core.ui_state.scroll_offset(&scroll_id);
3876 assert!(
3877 (offset - metrics.max_offset).abs() < 0.5,
3878 "overshoot offset {offset} (expected {})",
3879 metrics.max_offset
3880 );
3881 let events = core.pointer_up(thumb.x, press_y, PointerButton::Primary);
3883 assert!(events.is_empty(), "thumb release shouldn't emit events");
3884 assert!(core.ui_state.scroll.thumb_drag.is_none());
3885 }
3886
3887 #[test]
3888 fn secondary_click_does_not_steal_focus_or_press() {
3889 let mut core = lay_out_input_tree(false);
3890 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3891 let cx = btn_rect.x + btn_rect.w * 0.5;
3892 let cy = btn_rect.y + btn_rect.h * 0.5;
3893 let ti_rect = core.rect_of_key("ti").expect("ti rect");
3895 let tx = ti_rect.x + ti_rect.w * 0.5;
3896 let ty = ti_rect.y + ti_rect.h * 0.5;
3897 core.pointer_down(tx, ty, PointerButton::Primary);
3898 let _ = core.pointer_up(tx, ty, PointerButton::Primary);
3899 let focused_before = core.ui_state.focused.as_ref().map(|t| t.key.clone());
3900 core.pointer_down(cx, cy, PointerButton::Secondary);
3902 let events = core.pointer_up(cx, cy, PointerButton::Secondary);
3903 let kinds: Vec<UiEventKind> = events.iter().map(|e| e.kind).collect();
3904 assert_eq!(kinds, vec![UiEventKind::SecondaryClick]);
3905 let focused_after = core.ui_state.focused.as_ref().map(|t| t.key.clone());
3906 assert_eq!(
3907 focused_before, focused_after,
3908 "right-click must not steal focus"
3909 );
3910 assert!(
3911 core.ui_state.pressed.is_none(),
3912 "right-click must not set primary press"
3913 );
3914 }
3915
3916 #[test]
3917 fn text_input_routes_to_focused_only() {
3918 let mut core = lay_out_input_tree(false);
3919 assert!(core.text_input("a".into()).is_none());
3921 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3923 let cx = btn_rect.x + btn_rect.w * 0.5;
3924 let cy = btn_rect.y + btn_rect.h * 0.5;
3925 core.pointer_down(cx, cy, PointerButton::Primary);
3926 let _ = core.pointer_up(cx, cy, PointerButton::Primary);
3927 let event = core.text_input("hi".into()).expect("focused → event");
3928 assert_eq!(event.kind, UiEventKind::TextInput);
3929 assert_eq!(event.text.as_deref(), Some("hi"));
3930 assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("btn"));
3931 assert!(core.text_input(String::new()).is_none());
3933 }
3934
3935 #[test]
3936 fn capture_keys_bypasses_tab_traversal_for_focused_node() {
3937 let mut core = lay_out_input_tree(true);
3940 let ti_rect = core.rect_of_key("ti").expect("ti rect");
3941 let tx = ti_rect.x + ti_rect.w * 0.5;
3942 let ty = ti_rect.y + ti_rect.h * 0.5;
3943 core.pointer_down(tx, ty, PointerButton::Primary);
3944 let _ = core.pointer_up(tx, ty, PointerButton::Primary);
3945 assert_eq!(
3946 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3947 Some("ti"),
3948 "primary click on capture_keys node still focuses it"
3949 );
3950
3951 let events = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
3952 assert_eq!(events.len(), 1, "Tab → exactly one KeyDown");
3953 let event = &events[0];
3954 assert_eq!(event.kind, UiEventKind::KeyDown);
3955 assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("ti"));
3956 assert_eq!(
3957 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3958 Some("ti"),
3959 "Tab inside capture_keys must NOT move focus"
3960 );
3961 }
3962
3963 #[test]
3964 fn escape_blurs_capture_keys_after_delivering_raw_keydown() {
3965 let mut core = lay_out_input_tree(true);
3966 let ti_rect = core.rect_of_key("ti").expect("ti rect");
3967 let tx = ti_rect.x + ti_rect.w * 0.5;
3968 let ty = ti_rect.y + ti_rect.h * 0.5;
3969 core.pointer_down(tx, ty, PointerButton::Primary);
3970 let _ = core.pointer_up(tx, ty, PointerButton::Primary);
3971 assert_eq!(
3972 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
3973 Some("ti")
3974 );
3975
3976 let events = core.key_down(UiKey::Escape, KeyModifiers::default(), false);
3977
3978 assert_eq!(events.len(), 1);
3979 let event = &events[0];
3980 assert_eq!(event.kind, UiEventKind::KeyDown);
3981 assert_eq!(event.target.as_ref().map(|t| t.key.as_str()), Some("ti"));
3982 assert!(matches!(
3983 event.key_press.as_ref().map(|p| &p.key),
3984 Some(UiKey::Escape)
3985 ));
3986 assert_eq!(core.ui_state.focused.as_ref().map(|t| t.key.as_str()), None);
3987 }
3988
3989 #[test]
3990 fn pointer_down_focus_does_not_raise_focus_visible() {
3991 let mut core = lay_out_input_tree(false);
3994 let btn_rect = core.rect_of_key("btn").expect("btn rect");
3995 let cx = btn_rect.x + btn_rect.w * 0.5;
3996 let cy = btn_rect.y + btn_rect.h * 0.5;
3997 core.pointer_down(cx, cy, PointerButton::Primary);
3998 assert_eq!(
3999 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4000 Some("btn"),
4001 "primary click focuses the button",
4002 );
4003 assert!(
4004 !core.ui_state.focus_visible,
4005 "click focus must not raise focus_visible — ring stays off",
4006 );
4007 }
4008
4009 #[test]
4010 fn tab_key_raises_focus_visible_so_ring_appears() {
4011 let mut core = lay_out_input_tree(false);
4012 let btn_rect = core.rect_of_key("btn").expect("btn rect");
4014 let cx = btn_rect.x + btn_rect.w * 0.5;
4015 let cy = btn_rect.y + btn_rect.h * 0.5;
4016 core.pointer_down(cx, cy, PointerButton::Primary);
4017 assert!(!core.ui_state.focus_visible);
4018 let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
4020 assert!(
4021 core.ui_state.focus_visible,
4022 "Tab must raise focus_visible so the ring paints on the new target",
4023 );
4024 }
4025
4026 #[test]
4027 fn click_after_tab_clears_focus_visible_again() {
4028 let mut core = lay_out_input_tree(false);
4031 let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
4032 assert!(core.ui_state.focus_visible, "Tab raises ring");
4033 let btn_rect = core.rect_of_key("btn").expect("btn rect");
4034 let cx = btn_rect.x + btn_rect.w * 0.5;
4035 let cy = btn_rect.y + btn_rect.h * 0.5;
4036 core.pointer_down(cx, cy, PointerButton::Primary);
4037 assert!(
4038 !core.ui_state.focus_visible,
4039 "pointer-down clears focus_visible — ring fades back out",
4040 );
4041 }
4042
4043 #[test]
4044 fn keypress_on_focused_widget_raises_focus_visible_after_click() {
4045 let mut core = lay_out_input_tree(false);
4049 let btn_rect = core.rect_of_key("btn").expect("btn rect");
4050 let cx = btn_rect.x + btn_rect.w * 0.5;
4051 let cy = btn_rect.y + btn_rect.h * 0.5;
4052 core.pointer_down(cx, cy, PointerButton::Primary);
4053 assert!(!core.ui_state.focus_visible);
4054 let _ = core.key_down(UiKey::ArrowRight, KeyModifiers::default(), false);
4055 assert!(
4056 core.ui_state.focus_visible,
4057 "non-Tab key on focused widget raises focus_visible",
4058 );
4059 }
4060
4061 #[test]
4062 fn arrow_nav_in_sibling_group_raises_focus_visible() {
4063 let mut core = lay_out_arrow_nav_tree();
4064 core.ui_state.set_focus_visible(false);
4067 let _ = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
4068 assert!(
4069 core.ui_state.focus_visible,
4070 "arrow-nav within an arrow_nav_siblings group is keyboard navigation",
4071 );
4072 }
4073
4074 #[test]
4075 fn capture_keys_falls_back_to_default_when_focus_off_capturing_node() {
4076 let mut core = lay_out_input_tree(true);
4080 let btn_rect = core.rect_of_key("btn").expect("btn rect");
4081 let cx = btn_rect.x + btn_rect.w * 0.5;
4082 let cy = btn_rect.y + btn_rect.h * 0.5;
4083 core.pointer_down(cx, cy, PointerButton::Primary);
4084 let _ = core.pointer_up(cx, cy, PointerButton::Primary);
4085 assert_eq!(
4086 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4087 Some("btn"),
4088 "primary click focuses button"
4089 );
4090 let _ = core.key_down(UiKey::Tab, KeyModifiers::default(), false);
4092 assert_eq!(
4093 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4094 Some("ti"),
4095 "Tab from non-capturing focused does library-default traversal"
4096 );
4097 }
4098
4099 fn lay_out_arrow_nav_tree() -> RunnerCore {
4104 use crate::tree::*;
4105 let mut tree = crate::column([
4106 crate::widgets::button::button("Red").key("opt-red"),
4107 crate::widgets::button::button("Green").key("opt-green"),
4108 crate::widgets::button::button("Blue").key("opt-blue"),
4109 ])
4110 .arrow_nav_siblings()
4111 .padding(10.0);
4112 let mut core = RunnerCore::new();
4113 crate::layout::layout(
4114 &mut tree,
4115 &mut core.ui_state,
4116 Rect::new(0.0, 0.0, 200.0, 300.0),
4117 );
4118 core.ui_state.sync_focus_order(&tree);
4119 let mut t = PrepareTimings::default();
4120 core.snapshot(&tree, &mut t);
4121 let target = core
4124 .ui_state
4125 .focus
4126 .order
4127 .iter()
4128 .find(|t| t.key == "opt-green")
4129 .cloned();
4130 core.ui_state.set_focus(target);
4131 core
4132 }
4133
4134 #[test]
4135 fn arrow_nav_moves_focus_among_siblings() {
4136 let mut core = lay_out_arrow_nav_tree();
4137
4138 let down = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
4141 assert!(down.is_empty(), "arrow-nav consumes the key event");
4142 assert_eq!(
4143 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4144 Some("opt-blue"),
4145 );
4146
4147 core.key_down(UiKey::ArrowUp, KeyModifiers::default(), false);
4149 assert_eq!(
4150 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4151 Some("opt-green"),
4152 );
4153
4154 core.key_down(UiKey::Home, KeyModifiers::default(), false);
4156 assert_eq!(
4157 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4158 Some("opt-red"),
4159 );
4160
4161 core.key_down(UiKey::End, KeyModifiers::default(), false);
4163 assert_eq!(
4164 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4165 Some("opt-blue"),
4166 );
4167 }
4168
4169 #[test]
4170 fn arrow_nav_saturates_at_ends() {
4171 let mut core = lay_out_arrow_nav_tree();
4172 core.key_down(UiKey::Home, KeyModifiers::default(), false);
4174 core.key_down(UiKey::ArrowUp, KeyModifiers::default(), false);
4175 assert_eq!(
4176 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4177 Some("opt-red"),
4178 "ArrowUp at top stays at top — no wrap",
4179 );
4180 core.key_down(UiKey::End, KeyModifiers::default(), false);
4182 core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
4183 assert_eq!(
4184 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4185 Some("opt-blue"),
4186 "ArrowDown at bottom stays at bottom — no wrap",
4187 );
4188 }
4189
4190 fn build_popover_tree(open: bool) -> El {
4194 use crate::widgets::button::button;
4195 use crate::widgets::overlay::overlay;
4196 use crate::widgets::popover::{dropdown, menu_item};
4197 let mut layers: Vec<El> = vec![button("Trigger").key("trigger")];
4198 if open {
4199 layers.push(dropdown(
4200 "menu",
4201 "trigger",
4202 [
4203 menu_item("A").key("item-a"),
4204 menu_item("B").key("item-b"),
4205 menu_item("C").key("item-c"),
4206 ],
4207 ));
4208 }
4209 overlay(layers).padding(20.0)
4210 }
4211
4212 fn run_frame(core: &mut RunnerCore, tree: &mut El) {
4216 let mut t = PrepareTimings::default();
4217 core.prepare_layout(
4218 tree,
4219 Rect::new(0.0, 0.0, 400.0, 300.0),
4220 1.0,
4221 &mut t,
4222 RunnerCore::no_time_shaders,
4223 );
4224 core.snapshot(tree, &mut t);
4225 }
4226
4227 #[test]
4228 fn popover_open_pushes_focus_and_auto_focuses_first_item() {
4229 let mut core = RunnerCore::new();
4230 let mut closed = build_popover_tree(false);
4231 run_frame(&mut core, &mut closed);
4232 let trigger = core
4235 .ui_state
4236 .focus
4237 .order
4238 .iter()
4239 .find(|t| t.key == "trigger")
4240 .cloned();
4241 core.ui_state.set_focus(trigger);
4242 assert_eq!(
4243 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4244 Some("trigger"),
4245 );
4246
4247 let mut open = build_popover_tree(true);
4250 run_frame(&mut core, &mut open);
4251 assert_eq!(
4252 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4253 Some("item-a"),
4254 "popover open should auto-focus the first menu item",
4255 );
4256 assert_eq!(
4257 core.ui_state.popover_focus.focus_stack.len(),
4258 1,
4259 "trigger should be saved on the focus stack",
4260 );
4261 assert_eq!(
4262 core.ui_state.popover_focus.focus_stack[0].key.as_str(),
4263 "trigger",
4264 "saved focus should be the pre-open target",
4265 );
4266 }
4267
4268 #[test]
4269 fn popover_close_restores_focus_to_trigger() {
4270 let mut core = RunnerCore::new();
4271 let mut closed = build_popover_tree(false);
4272 run_frame(&mut core, &mut closed);
4273 let trigger = core
4274 .ui_state
4275 .focus
4276 .order
4277 .iter()
4278 .find(|t| t.key == "trigger")
4279 .cloned();
4280 core.ui_state.set_focus(trigger);
4281
4282 let mut open = build_popover_tree(true);
4284 run_frame(&mut core, &mut open);
4285 assert_eq!(
4286 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4287 Some("item-a"),
4288 );
4289
4290 let mut closed_again = build_popover_tree(false);
4292 run_frame(&mut core, &mut closed_again);
4293 assert_eq!(
4294 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4295 Some("trigger"),
4296 "closing the popover should pop the saved focus",
4297 );
4298 assert!(
4299 core.ui_state.popover_focus.focus_stack.is_empty(),
4300 "focus stack should be drained after restore",
4301 );
4302 }
4303
4304 #[test]
4305 fn popover_close_does_not_override_intentional_focus_move() {
4306 let mut core = RunnerCore::new();
4307 let build = |open: bool| -> El {
4310 use crate::widgets::button::button;
4311 use crate::widgets::overlay::overlay;
4312 use crate::widgets::popover::{dropdown, menu_item};
4313 let main = crate::row([
4314 button("Trigger").key("trigger"),
4315 button("Other").key("other"),
4316 ]);
4317 let mut layers: Vec<El> = vec![main];
4318 if open {
4319 layers.push(dropdown("menu", "trigger", [menu_item("A").key("item-a")]));
4320 }
4321 overlay(layers).padding(20.0)
4322 };
4323
4324 let mut closed = build(false);
4325 run_frame(&mut core, &mut closed);
4326 let trigger = core
4327 .ui_state
4328 .focus
4329 .order
4330 .iter()
4331 .find(|t| t.key == "trigger")
4332 .cloned();
4333 core.ui_state.set_focus(trigger);
4334
4335 let mut open = build(true);
4336 run_frame(&mut core, &mut open);
4337 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
4338
4339 let other = core
4344 .ui_state
4345 .focus
4346 .order
4347 .iter()
4348 .find(|t| t.key == "other")
4349 .cloned();
4350 core.ui_state.set_focus(other);
4351
4352 let mut closed_again = build(false);
4353 run_frame(&mut core, &mut closed_again);
4354 assert_eq!(
4355 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4356 Some("other"),
4357 "focus moved before close should not be overridden by restore",
4358 );
4359 assert!(core.ui_state.popover_focus.focus_stack.is_empty());
4360 }
4361
4362 #[test]
4363 fn nested_popovers_stack_and_unwind_focus_correctly() {
4364 let mut core = RunnerCore::new();
4365 let build = |outer: bool, inner: bool| -> El {
4370 use crate::widgets::button::button;
4371 use crate::widgets::overlay::overlay;
4372 use crate::widgets::popover::{Anchor, popover, popover_panel};
4373 let main = button("Trigger").key("trigger");
4374 let mut layers: Vec<El> = vec![main];
4375 if outer {
4376 layers.push(popover(
4377 "outer",
4378 Anchor::below_key("trigger"),
4379 popover_panel([button("Open inner").key("inner-trigger")]),
4380 ));
4381 }
4382 if inner {
4383 layers.push(popover(
4384 "inner",
4385 Anchor::below_key("inner-trigger"),
4386 popover_panel([button("X").key("inner-a"), button("Y").key("inner-b")]),
4387 ));
4388 }
4389 overlay(layers).padding(20.0)
4390 };
4391
4392 let mut closed = build(false, false);
4394 run_frame(&mut core, &mut closed);
4395 let trigger = core
4396 .ui_state
4397 .focus
4398 .order
4399 .iter()
4400 .find(|t| t.key == "trigger")
4401 .cloned();
4402 core.ui_state.set_focus(trigger);
4403
4404 let mut outer = build(true, false);
4406 run_frame(&mut core, &mut outer);
4407 assert_eq!(
4408 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4409 Some("inner-trigger"),
4410 );
4411 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
4412
4413 let mut both = build(true, true);
4415 run_frame(&mut core, &mut both);
4416 assert_eq!(
4417 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4418 Some("inner-a"),
4419 );
4420 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 2);
4421
4422 let mut outer_only = build(true, false);
4424 run_frame(&mut core, &mut outer_only);
4425 assert_eq!(
4426 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4427 Some("inner-trigger"),
4428 );
4429 assert_eq!(core.ui_state.popover_focus.focus_stack.len(), 1);
4430
4431 let mut none = build(false, false);
4433 run_frame(&mut core, &mut none);
4434 assert_eq!(
4435 core.ui_state.focused.as_ref().map(|t| t.key.as_str()),
4436 Some("trigger"),
4437 );
4438 assert!(core.ui_state.popover_focus.focus_stack.is_empty());
4439 }
4440
4441 #[test]
4442 fn arrow_nav_does_not_intercept_outside_navigable_groups() {
4443 let mut core = lay_out_input_tree(false);
4447 let target = core
4448 .ui_state
4449 .focus
4450 .order
4451 .iter()
4452 .find(|t| t.key == "btn")
4453 .cloned();
4454 core.ui_state.set_focus(target);
4455 let events = core.key_down(UiKey::ArrowDown, KeyModifiers::default(), false);
4456 assert_eq!(
4457 events.len(),
4458 1,
4459 "ArrowDown without navigable parent → event"
4460 );
4461 assert_eq!(events[0].kind, UiEventKind::KeyDown);
4462 }
4463
4464 fn quad(shader: ShaderHandle) -> DrawOp {
4465 DrawOp::Quad {
4466 id: "q".into(),
4467 rect: Rect::new(0.0, 0.0, 10.0, 10.0),
4468 scissor: None,
4469 shader,
4470 uniforms: UniformBlock::new(),
4471 }
4472 }
4473
4474 #[test]
4475 fn prepare_paint_skips_ops_outside_viewport() {
4476 let mut core = RunnerCore::new();
4477 core.set_surface_size(100, 100);
4478 core.viewport_px = (100, 100);
4479 let ops = vec![
4480 DrawOp::Quad {
4481 id: "offscreen".into(),
4482 rect: Rect::new(0.0, 150.0, 10.0, 10.0),
4483 scissor: None,
4484 shader: ShaderHandle::Stock(StockShader::RoundedRect),
4485 uniforms: UniformBlock::new(),
4486 },
4487 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4488 ];
4489 let mut timings = PrepareTimings::default();
4490 core.prepare_paint(&ops, |_| true, |_| false, &mut NoText, 1.0, &mut timings);
4491
4492 assert_eq!(timings.paint_culled_ops, 1);
4493 assert_eq!(
4494 core.runs.len(),
4495 1,
4496 "only the visible quad should become a paint run"
4497 );
4498 }
4499
4500 #[test]
4501 fn prepare_paint_does_not_shape_text_outside_clip() {
4502 let mut core = RunnerCore::new();
4503 core.set_surface_size(100, 100);
4504 core.viewport_px = (100, 100);
4505 let ops = vec![
4506 DrawOp::GlyphRun {
4507 id: "offscreen-text".into(),
4508 rect: Rect::new(0.0, 150.0, 80.0, 20.0),
4509 scissor: Some(Rect::new(0.0, 0.0, 100.0, 100.0)),
4510 shader: ShaderHandle::Stock(StockShader::Text),
4511 color: Color::rgba(255, 255, 255, 255),
4512 text: "offscreen".into(),
4513 size: 14.0,
4514 line_height: 20.0,
4515 family: Default::default(),
4516 mono_family: Default::default(),
4517 weight: FontWeight::Regular,
4518 mono: false,
4519 wrap: TextWrap::NoWrap,
4520 anchor: TextAnchor::Start,
4521 layout: empty_text_layout(20.0),
4522 underline: false,
4523 strikethrough: false,
4524 link: None,
4525 },
4526 DrawOp::GlyphRun {
4527 id: "visible-text".into(),
4528 rect: Rect::new(0.0, 10.0, 80.0, 20.0),
4529 scissor: Some(Rect::new(0.0, 0.0, 100.0, 100.0)),
4530 shader: ShaderHandle::Stock(StockShader::Text),
4531 color: Color::rgba(255, 255, 255, 255),
4532 text: "visible".into(),
4533 size: 14.0,
4534 line_height: 20.0,
4535 family: Default::default(),
4536 mono_family: Default::default(),
4537 weight: FontWeight::Regular,
4538 mono: false,
4539 wrap: TextWrap::NoWrap,
4540 anchor: TextAnchor::Start,
4541 layout: empty_text_layout(20.0),
4542 underline: false,
4543 strikethrough: false,
4544 link: None,
4545 },
4546 ];
4547 let mut text = CountingText::default();
4548 let mut timings = PrepareTimings::default();
4549 core.prepare_paint(&ops, |_| true, |_| false, &mut text, 1.0, &mut timings);
4550
4551 assert_eq!(timings.paint_culled_ops, 1);
4552 assert_eq!(text.records, 1, "offscreen text must not be shaped");
4553 }
4554
4555 #[test]
4556 fn samples_backdrop_inserts_snapshot_before_first_glass_quad() {
4557 let mut core = RunnerCore::new();
4558 core.set_surface_size(100, 100);
4559 let ops = vec![
4560 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4561 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4562 quad(ShaderHandle::Custom("liquid_glass")),
4563 quad(ShaderHandle::Custom("liquid_glass")),
4564 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4565 ];
4566 let mut timings = PrepareTimings::default();
4567 core.prepare_paint(
4568 &ops,
4569 |_| true,
4570 |s| matches!(s, ShaderHandle::Custom(name) if *name == "liquid_glass"),
4571 &mut NoText,
4572 1.0,
4573 &mut timings,
4574 );
4575
4576 let kinds: Vec<&'static str> = core
4577 .paint_items
4578 .iter()
4579 .map(|p| match p {
4580 PaintItem::QuadRun(_) => "Q",
4581 PaintItem::IconRun(_) => "I",
4582 PaintItem::Text(_) => "T",
4583 PaintItem::Image(_) => "M",
4584 PaintItem::AppTexture(_) => "A",
4585 PaintItem::Vector(_) => "V",
4586 PaintItem::BackdropSnapshot => "S",
4587 })
4588 .collect();
4589 assert_eq!(
4590 kinds,
4591 vec!["Q", "S", "Q", "Q"],
4592 "expected one stock run, snapshot, then a glass run, then a foreground stock run"
4593 );
4594 }
4595
4596 #[test]
4597 fn no_snapshot_when_no_glass_drawn() {
4598 let mut core = RunnerCore::new();
4599 core.set_surface_size(100, 100);
4600 let ops = vec![
4601 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4602 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4603 ];
4604 let mut timings = PrepareTimings::default();
4605 core.prepare_paint(&ops, |_| true, |_| false, &mut NoText, 1.0, &mut timings);
4606 assert!(
4607 !core
4608 .paint_items
4609 .iter()
4610 .any(|p| matches!(p, PaintItem::BackdropSnapshot)),
4611 "no glass shader registered → no snapshot"
4612 );
4613 }
4614
4615 #[test]
4616 fn at_most_one_snapshot_per_frame() {
4617 let mut core = RunnerCore::new();
4618 core.set_surface_size(100, 100);
4619 let ops = vec![
4620 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4621 quad(ShaderHandle::Custom("g")),
4622 quad(ShaderHandle::Stock(StockShader::RoundedRect)),
4623 quad(ShaderHandle::Custom("g")),
4624 ];
4625 let mut timings = PrepareTimings::default();
4626 core.prepare_paint(
4627 &ops,
4628 |_| true,
4629 |s| matches!(s, ShaderHandle::Custom("g")),
4630 &mut NoText,
4631 1.0,
4632 &mut timings,
4633 );
4634 let snapshots = core
4635 .paint_items
4636 .iter()
4637 .filter(|p| matches!(p, PaintItem::BackdropSnapshot))
4638 .count();
4639 assert_eq!(snapshots, 1, "backdrop depth is capped at 1");
4640 }
4641}