1use crate::event::{MouseButton, Point, ScrollDelta, UiEvent};
32use crate::modal_stack::ModalStack;
33use crate::primitives::palette::PaletteEvent;
34use crate::primitives::scrollbar::ScrollAxis;
35use crate::primitives::split_tree::SplitDirection;
36use crate::types::WidgetId;
37use crate::Modifiers;
38
39#[derive(Debug, Clone, PartialEq)]
47pub enum DragTarget {
48 ScrollbarY {
61 widget: WidgetId,
64 track_start: f32,
66 track_length: f32,
68 thumb_length: f32,
73 max_scroll: usize,
77 grab_offset: f32,
87 inverted: bool,
92 },
93 ScrollbarX {
98 widget: WidgetId,
99 track_start: f32,
101 track_length: f32,
103 thumb_length: f32,
106 max_scroll: usize,
109 grab_offset: f32,
112 inverted: bool,
115 },
116 TextSelection {
123 region: WidgetId,
125 anchor: Point,
127 },
128 SplitDivider {
152 tree: WidgetId,
153 split_index: usize,
154 direction: SplitDirection,
155 axis_start: f32,
156 axis_size: f32,
157 },
158}
159
160#[derive(Debug, Clone, Default, PartialEq)]
165pub struct DragState {
166 current: Option<DragTarget>,
167}
168
169impl DragState {
170 pub fn new() -> Self {
171 Self::default()
172 }
173
174 pub fn begin(&mut self, target: DragTarget) {
180 self.current = Some(target);
181 }
182
183 pub fn end(&mut self) {
185 self.current = None;
186 }
187
188 pub fn is_active(&self) -> bool {
189 self.current.is_some()
190 }
191
192 pub fn target(&self) -> Option<&DragTarget> {
193 self.current.as_ref()
194 }
195}
196
197pub fn dispatch_mouse_down(
245 stack: &ModalStack,
246 position: Point,
247 button: MouseButton,
248 modifiers: Modifiers,
249) -> Vec<UiEvent> {
250 if let Some(widget_id) = stack.hit_test(position) {
252 return vec![UiEvent::MouseDown {
253 widget: Some(*widget_id),
254 button,
255 position,
256 modifiers,
257 }];
258 }
259
260 if let Some(top) = stack.top() {
262 return vec![
263 UiEvent::MouseDown {
264 widget: None,
265 button,
266 position,
267 modifiers,
268 },
269 UiEvent::Palette(top.id, PaletteEvent::Closed),
270 ];
271 }
272
273 vec![UiEvent::MouseDown {
275 widget: None,
276 button,
277 position,
278 modifiers,
279 }]
280}
281
282pub fn dispatch_mouse_drag(
302 drag: &DragState,
303 position: Point,
304 buttons: crate::event::ButtonMask,
305) -> Vec<UiEvent> {
306 let mut events = vec![UiEvent::MouseMoved { position, buttons }];
307
308 match drag.target() {
309 Some(DragTarget::ScrollbarY {
310 widget,
311 track_start,
312 track_length,
313 thumb_length,
314 max_scroll,
315 grab_offset,
316 inverted,
317 }) if *track_length > 0.0 && *max_scroll > 0 => {
318 let effective_track = (*track_length - *thumb_length).max(1.0);
319 let rel = (position.y - *track_start - *grab_offset) / effective_track;
320 let clamped = rel.clamp(0.0, 1.0);
321 let clamped = if *inverted { 1.0 - clamped } else { clamped };
322 let new_offset = (clamped * *max_scroll as f32).round() as usize;
323 events.push(UiEvent::ScrollOffsetChanged {
324 widget: *widget,
325 new_offset,
326 });
327 }
328 Some(DragTarget::ScrollbarX {
329 widget,
330 track_start,
331 track_length,
332 thumb_length,
333 max_scroll,
334 grab_offset,
335 inverted,
336 }) if *track_length > 0.0 && *max_scroll > 0 => {
337 let effective_track = (*track_length - *thumb_length).max(1.0);
338 let rel = (position.x - *track_start - *grab_offset) / effective_track;
339 let clamped = rel.clamp(0.0, 1.0);
340 let clamped = if *inverted { 1.0 - clamped } else { clamped };
341 let new_offset = (clamped * *max_scroll as f32).round() as usize;
342 events.push(UiEvent::ScrollOffsetChanged {
343 widget: *widget,
344 new_offset,
345 });
346 }
347 Some(DragTarget::TextSelection { region, anchor }) => {
348 events.push(UiEvent::TextSelectionChanged {
349 region: *region,
350 anchor: *anchor,
351 focus: position,
352 });
353 }
354 Some(DragTarget::SplitDivider {
355 tree,
356 split_index,
357 direction,
358 axis_start,
359 axis_size,
360 }) if *axis_size > 0.0 => {
361 let cursor_axis = match direction {
362 SplitDirection::Horizontal => position.x,
363 SplitDirection::Vertical => position.y,
364 };
365 let new_ratio = ((cursor_axis - *axis_start) / *axis_size).clamp(0.0, 1.0);
366 events.push(UiEvent::SplitDividerDragged {
367 tree: *tree,
368 split_index: *split_index,
369 new_ratio,
370 });
371 }
372 _ => {}
373 }
374
375 events
376}
377
378pub fn dispatch_mouse_up(
384 stack: &ModalStack,
385 drag: &mut DragState,
386 position: Point,
387 button: MouseButton,
388) -> Vec<UiEvent> {
389 drag.end();
390 let widget = stack.hit_test(position).cloned();
391 vec![UiEvent::MouseUp {
392 widget,
393 button,
394 position,
395 }]
396}
397
398#[derive(Debug, Clone, PartialEq)]
428pub struct TextRegion {
429 pub id: WidgetId,
430 pub bounds: crate::event::Rect,
431 pub lines: Vec<String>,
435}
436
437pub fn text_selection_line_range(
465 anchor: Point,
466 focus: Point,
467 bounds: crate::event::Rect,
468) -> Vec<(u16, f32, f32)> {
469 let ax = anchor.x.round() as i32;
471 let ay = anchor.y.round() as i32;
472 let fx = focus.x.round() as i32;
473 let fy = focus.y.round() as i32;
474 if ax == fx && ay == fy {
475 return Vec::new();
476 }
477
478 let (start, end) = if ay < fy || (ay == fy && ax <= fx) {
480 (anchor, focus)
481 } else {
482 (focus, anchor)
483 };
484
485 let region_x = bounds.x.round();
486 let region_end_x = (bounds.x + bounds.width).round();
487 let region_y = bounds.y.round() as u16;
488 let region_end_y = (bounds.y + bounds.height).round() as u16;
489
490 let start_row = (start.y.round() as u16).clamp(region_y, region_end_y.saturating_sub(1));
492 let end_row = (end.y.round() as u16).clamp(region_y, region_end_y.saturating_sub(1));
493 let start_col = start.x.round().clamp(region_x, region_end_x);
494 let end_col = (end.x.round() + 1.0).clamp(region_x, region_end_x);
496
497 if start_row == end_row {
498 if start_col >= end_col {
499 return Vec::new();
500 }
501 return vec![(start_row, start_col, end_col)];
502 }
503
504 let mut ranges = Vec::with_capacity((end_row - start_row + 1) as usize);
505 ranges.push((start_row, start_col, region_end_x));
507 for row in (start_row + 1)..end_row {
509 ranges.push((row, region_x, region_end_x));
510 }
511 ranges.push((end_row, region_x, end_col));
513 ranges
514}
515
516#[derive(Debug, Clone, PartialEq)]
526pub struct ScrollSurface {
527 pub id: WidgetId,
528 pub bounds: crate::event::Rect,
529 pub scrollbar: Option<SurfaceScrollbar>,
533}
534
535pub fn dispatch_scroll(
564 stack: &ModalStack,
565 scroll_surfaces: &[ScrollSurface],
566 position: Point,
567 delta: ScrollDelta,
568) -> Vec<UiEvent> {
569 if let Some(widget_id) = stack.hit_test(position) {
571 return vec![UiEvent::Scroll {
572 widget: Some(*widget_id),
573 delta,
574 position,
575 }];
576 }
577
578 if !stack.is_empty() {
580 return Vec::new();
581 }
582
583 for surface in scroll_surfaces.iter().rev() {
585 if position.x >= surface.bounds.x
586 && position.x < surface.bounds.x + surface.bounds.width
587 && position.y >= surface.bounds.y
588 && position.y < surface.bounds.y + surface.bounds.height
589 {
590 return vec![UiEvent::Scroll {
591 widget: Some(surface.id),
592 delta,
593 position,
594 }];
595 }
596 }
597
598 vec![UiEvent::Scroll {
600 widget: None,
601 delta,
602 position,
603 }]
604}
605
606#[derive(Debug, Clone, PartialEq)]
613pub struct SurfaceScrollbar {
614 pub axis: ScrollAxis,
618 pub track_bounds: crate::event::Rect,
620 pub thumb_bounds: crate::event::Rect,
622 pub total_items: usize,
624 pub visible_items: usize,
626 pub scroll_offset: usize,
628 pub inverted: bool,
632}
633
634pub fn dispatch_click(
661 stack: &ModalStack,
662 scroll_surfaces: &[ScrollSurface],
663 text_regions: &[TextRegion],
664 drag: &mut DragState,
665 position: Point,
666 button: MouseButton,
667 modifiers: Modifiers,
668) -> Vec<UiEvent> {
669 if let Some(widget_id) = stack.hit_test(position) {
671 return vec![UiEvent::MouseDown {
672 widget: Some(*widget_id),
673 button,
674 position,
675 modifiers,
676 }];
677 }
678
679 if let Some(top) = stack.top() {
681 return vec![
682 UiEvent::MouseDown {
683 widget: None,
684 button,
685 position,
686 modifiers,
687 },
688 UiEvent::Palette(top.id, PaletteEvent::Closed),
689 ];
690 }
691
692 for surface in scroll_surfaces.iter().rev() {
694 if !surface.bounds.contains(position) {
695 continue;
696 }
697
698 if let Some(ref sb) = surface.scrollbar {
700 let in_track = sb.track_bounds.contains(position);
701
702 if in_track {
703 let in_thumb = sb.thumb_bounds.contains(position);
704
705 if in_thumb {
706 let max_scroll = sb.total_items.saturating_sub(sb.visible_items);
707 match sb.axis {
708 ScrollAxis::Vertical => {
709 let grab_offset = position.y - sb.thumb_bounds.y;
710 drag.begin(DragTarget::ScrollbarY {
711 widget: surface.id,
712 track_start: sb.track_bounds.y,
713 track_length: sb.track_bounds.height,
714 thumb_length: sb.thumb_bounds.height,
715 max_scroll,
716 grab_offset,
717 inverted: sb.inverted,
718 });
719 }
720 ScrollAxis::Horizontal => {
721 let grab_offset = position.x - sb.thumb_bounds.x;
722 drag.begin(DragTarget::ScrollbarX {
723 widget: surface.id,
724 track_start: sb.track_bounds.x,
725 track_length: sb.track_bounds.width,
726 thumb_length: sb.thumb_bounds.width,
727 max_scroll,
728 grab_offset,
729 inverted: sb.inverted,
730 });
731 }
732 }
733 return vec![UiEvent::MouseDown {
734 widget: Some(surface.id),
735 button,
736 position,
737 modifiers,
738 }];
739 }
740
741 let max_offset = sb.total_items.saturating_sub(sb.visible_items);
745 let before_thumb = match sb.axis {
746 ScrollAxis::Vertical => position.y < sb.thumb_bounds.y,
747 ScrollAxis::Horizontal => position.x < sb.thumb_bounds.x,
748 };
749 let page_back = if sb.inverted {
750 !before_thumb
751 } else {
752 before_thumb
753 };
754 let new_offset = if page_back {
755 sb.scroll_offset.saturating_sub(sb.visible_items)
756 } else {
757 (sb.scroll_offset + sb.visible_items).min(max_offset)
758 };
759 return vec![UiEvent::ScrollOffsetChanged {
760 widget: surface.id,
761 new_offset,
762 }];
763 }
764 }
765
766 for tr in text_regions.iter().rev() {
769 if tr.bounds.contains(position) {
770 drag.begin(DragTarget::TextSelection {
771 region: tr.id,
772 anchor: position,
773 });
774 return vec![UiEvent::MouseDown {
775 widget: Some(tr.id),
776 button,
777 position,
778 modifiers,
779 }];
780 }
781 }
782
783 return vec![UiEvent::MouseDown {
785 widget: Some(surface.id),
786 button,
787 position,
788 modifiers,
789 }];
790 }
791
792 for tr in text_regions.iter().rev() {
795 if tr.bounds.contains(position) {
796 drag.begin(DragTarget::TextSelection {
797 region: tr.id,
798 anchor: position,
799 });
800 return vec![UiEvent::MouseDown {
801 widget: Some(tr.id),
802 button,
803 position,
804 modifiers,
805 }];
806 }
807 }
808
809 vec![UiEvent::MouseDown {
811 widget: None,
812 button,
813 position,
814 modifiers,
815 }]
816}
817
818#[cfg(any(
856 feature = "tui",
857 feature = "gtk",
858 feature = "win",
859 all(feature = "macos", target_os = "macos")
860))]
861pub(crate) fn route_pointer(
862 stack: &ModalStack,
863 drag: &mut DragState,
864 text_state: &mut crate::text_selection::TextSelectionState,
865 event: UiEvent,
866) -> Vec<UiEvent> {
867 match event {
868 UiEvent::MouseDown {
869 button,
870 position,
871 modifiers,
872 ..
873 } => {
874 let events = dispatch_click(
875 stack,
876 &[],
877 &text_state.text_regions,
878 drag,
879 position,
880 button,
881 modifiers,
882 );
883 if let Some(DragTarget::TextSelection { region, .. }) = drag.target() {
884 text_state.track_focused_text_region(*region);
885 }
886 events
887 }
888 UiEvent::MouseMoved { position, buttons } => dispatch_mouse_drag(drag, position, buttons),
889 UiEvent::MouseUp {
890 button, position, ..
891 } => dispatch_mouse_up(stack, drag, position, button),
892 other => vec![other],
893 }
894}
895
896use std::time::{Duration, Instant};
906
907const DOUBLE_CLICK_MS: u64 = 400;
908const DOUBLE_CLICK_RADIUS: f32 = 1.5;
917
918pub struct DoubleClickDetector {
922 last_click_time: Option<Instant>,
923 last_click_pos: Point,
924 last_click_button: MouseButton,
925 radius: f32,
931}
932
933impl Default for DoubleClickDetector {
934 fn default() -> Self {
935 Self {
936 last_click_time: None,
937 last_click_pos: Point::default(),
938 last_click_button: MouseButton::default(),
939 radius: DOUBLE_CLICK_RADIUS,
940 }
941 }
942}
943
944impl DoubleClickDetector {
945 pub fn new() -> Self {
946 Self::default()
947 }
948
949 pub fn with_radius(radius: f32) -> Self {
957 Self {
958 radius,
959 ..Self::default()
960 }
961 }
962
963 pub fn process(&mut self, events: &mut [UiEvent]) {
967 let now = Instant::now();
968 for ev in events.iter_mut() {
969 if let UiEvent::MouseDown {
970 button, position, ..
971 } = ev
972 {
973 let is_double = self
974 .last_click_time
975 .map(|t| now.duration_since(t) < Duration::from_millis(DOUBLE_CLICK_MS))
976 .unwrap_or(false)
977 && *button == self.last_click_button
978 && (position.x - self.last_click_pos.x).abs() <= self.radius
979 && (position.y - self.last_click_pos.y).abs() <= self.radius;
980
981 if is_double {
982 *ev = UiEvent::DoubleClick {
983 widget: None,
984 position: *position,
985 };
986 self.last_click_time = None;
987 } else {
988 self.last_click_time = Some(now);
989 self.last_click_pos = *position;
990 self.last_click_button = *button;
991 }
992 }
993 }
994 }
995}
996
997#[cfg(test)]
998mod tests {
999 use super::*;
1000 use crate::event::Rect;
1001 use crate::types::WidgetId;
1002
1003 fn id(s: &str) -> WidgetId {
1004 WidgetId::new(s)
1005 }
1006
1007 fn pt(x: f32, y: f32) -> Point {
1008 Point { x, y }
1009 }
1010
1011 fn rect(x: f32, y: f32, w: f32, h: f32) -> Rect {
1012 Rect {
1013 x,
1014 y,
1015 width: w,
1016 height: h,
1017 }
1018 }
1019
1020 #[test]
1021 fn click_inside_modal_emits_single_mousedown_with_widget() {
1022 let mut stack = ModalStack::new();
1023 stack.push(id("palette"), rect(10.0, 10.0, 100.0, 100.0));
1024 let events = dispatch_mouse_down(
1025 &stack,
1026 pt(50.0, 50.0),
1027 MouseButton::Left,
1028 Modifiers::default(),
1029 );
1030 assert_eq!(events.len(), 1);
1031 match &events[0] {
1032 UiEvent::MouseDown { widget, button, .. } => {
1033 assert_eq!(widget.as_ref().unwrap(), &id("palette"));
1034 assert_eq!(*button, MouseButton::Left);
1035 }
1036 _ => panic!("expected MouseDown, got {:?}", events[0]),
1037 }
1038 }
1039
1040 #[test]
1041 fn click_outside_open_modal_dismisses_topmost() {
1042 let mut stack = ModalStack::new();
1043 stack.push(id("palette"), rect(10.0, 10.0, 50.0, 50.0));
1044 let events = dispatch_mouse_down(
1046 &stack,
1047 pt(500.0, 500.0),
1048 MouseButton::Left,
1049 Modifiers::default(),
1050 );
1051 assert_eq!(events.len(), 2);
1052 assert!(matches!(
1054 &events[0],
1055 UiEvent::MouseDown { widget: None, .. }
1056 ));
1057 match &events[1] {
1059 UiEvent::Palette(wid, PaletteEvent::Closed) => {
1060 assert_eq!(wid, &id("palette"));
1061 }
1062 _ => panic!("expected Palette::Closed, got {:?}", events[1]),
1063 }
1064 }
1065
1066 #[test]
1067 fn click_when_no_modal_open_emits_single_mousedown_with_no_widget() {
1068 let stack = ModalStack::new();
1069 let events = dispatch_mouse_down(
1070 &stack,
1071 pt(100.0, 100.0),
1072 MouseButton::Left,
1073 Modifiers::default(),
1074 );
1075 assert_eq!(events.len(), 1);
1076 assert!(matches!(
1077 &events[0],
1078 UiEvent::MouseDown { widget: None, .. }
1079 ));
1080 }
1081
1082 #[test]
1083 fn stacked_modals_click_inside_top_targets_top() {
1084 let mut stack = ModalStack::new();
1087 stack.push(id("palette"), rect(0.0, 0.0, 200.0, 200.0));
1088 stack.push(id("dialog"), rect(50.0, 50.0, 100.0, 100.0));
1089 let events = dispatch_mouse_down(
1090 &stack,
1091 pt(100.0, 100.0),
1092 MouseButton::Left,
1093 Modifiers::default(),
1094 );
1095 match &events[0] {
1096 UiEvent::MouseDown { widget, .. } => {
1097 assert_eq!(widget.as_ref().unwrap(), &id("dialog"));
1098 }
1099 _ => panic!(),
1100 }
1101 }
1102
1103 #[test]
1104 fn stacked_modals_click_inside_lower_targets_lower() {
1105 let mut stack = ModalStack::new();
1109 stack.push(id("palette"), rect(0.0, 0.0, 200.0, 200.0));
1110 stack.push(id("dialog"), rect(50.0, 50.0, 100.0, 100.0));
1111 let events = dispatch_mouse_down(
1112 &stack,
1113 pt(10.0, 10.0),
1114 MouseButton::Left,
1115 Modifiers::default(),
1116 );
1117 assert_eq!(events.len(), 1);
1118 match &events[0] {
1119 UiEvent::MouseDown { widget, .. } => {
1120 assert_eq!(widget.as_ref().unwrap(), &id("palette"));
1121 }
1122 _ => panic!(),
1123 }
1124 }
1125
1126 fn buttons_mask_left() -> crate::event::ButtonMask {
1129 crate::event::ButtonMask {
1130 left: true,
1131 ..Default::default()
1132 }
1133 }
1134
1135 #[test]
1136 fn drag_state_begin_and_end() {
1137 let mut drag = DragState::new();
1138 assert!(!drag.is_active());
1139 drag.begin(DragTarget::ScrollbarY {
1140 widget: id("picker"),
1141 track_start: 100.0,
1142 track_length: 200.0,
1143 thumb_length: 16.0,
1144 max_scroll: 40,
1145 grab_offset: 0.0,
1146 inverted: false,
1147 });
1148 assert!(drag.is_active());
1149 match drag.target().unwrap() {
1150 DragTarget::ScrollbarY { widget, .. } => assert_eq!(widget, &id("picker")),
1151 DragTarget::ScrollbarX { .. } => panic!("expected ScrollbarY"),
1152 DragTarget::TextSelection { .. } => panic!("expected ScrollbarY"),
1153 DragTarget::SplitDivider { .. } => panic!("expected ScrollbarY"),
1154 }
1155 drag.end();
1156 assert!(!drag.is_active());
1157 assert!(drag.target().is_none());
1158 }
1159
1160 #[test]
1161 fn dispatch_mouse_drag_without_drag_emits_only_moved() {
1162 let drag = DragState::new();
1163 let events = dispatch_mouse_drag(&drag, pt(50.0, 50.0), buttons_mask_left());
1164 assert_eq!(events.len(), 1);
1165 assert!(matches!(events[0], UiEvent::MouseMoved { .. }));
1166 }
1167
1168 #[test]
1169 fn dispatch_mouse_drag_with_scrollbar_emits_scroll_offset_changed() {
1170 let mut drag = DragState::new();
1175 drag.begin(DragTarget::ScrollbarY {
1176 widget: id("picker"),
1177 track_start: 100.0,
1178 track_length: 80.0,
1179 thumb_length: 16.0,
1180 max_scroll: 80,
1181 grab_offset: 0.0,
1182 inverted: false,
1183 });
1184 let events = dispatch_mouse_drag(&drag, pt(500.0, 132.0), buttons_mask_left());
1185 assert_eq!(events.len(), 2);
1186 assert!(matches!(events[0], UiEvent::MouseMoved { .. }));
1187 match &events[1] {
1188 UiEvent::ScrollOffsetChanged { widget, new_offset } => {
1189 assert_eq!(widget, &id("picker"));
1190 assert_eq!(*new_offset, 40);
1191 }
1192 other => panic!("expected ScrollOffsetChanged, got {:?}", other),
1193 }
1194 }
1195
1196 #[test]
1197 fn dispatch_mouse_drag_clamps_above_and_below_track() {
1198 let mut drag = DragState::new();
1200 drag.begin(DragTarget::ScrollbarY {
1201 widget: id("p"),
1202 track_start: 100.0,
1203 track_length: 80.0,
1204 thumb_length: 16.0,
1205 max_scroll: 80,
1206 grab_offset: 0.0,
1207 inverted: false,
1208 });
1209 let events = dispatch_mouse_drag(&drag, pt(0.0, 50.0), buttons_mask_left());
1211 match &events[1] {
1212 UiEvent::ScrollOffsetChanged { new_offset, .. } => {
1213 assert_eq!(*new_offset, 0);
1214 }
1215 _ => panic!(),
1216 }
1217 let events = dispatch_mouse_drag(&drag, pt(0.0, 500.0), buttons_mask_left());
1219 match &events[1] {
1220 UiEvent::ScrollOffsetChanged { new_offset, .. } => {
1221 assert_eq!(*new_offset, 80);
1222 }
1223 _ => panic!(),
1224 }
1225 }
1226
1227 #[test]
1228 fn dispatch_mouse_drag_with_zero_track_does_not_crash_or_emit() {
1229 let mut drag = DragState::new();
1231 drag.begin(DragTarget::ScrollbarY {
1232 widget: id("p"),
1233 track_start: 0.0,
1234 track_length: 0.0,
1235 thumb_length: 0.0,
1236 max_scroll: 90,
1237 grab_offset: 0.0,
1238 inverted: false,
1239 });
1240 let events = dispatch_mouse_drag(&drag, pt(0.0, 0.0), buttons_mask_left());
1241 assert_eq!(events.len(), 1);
1242 assert!(matches!(events[0], UiEvent::MouseMoved { .. }));
1243 }
1244
1245 #[test]
1246 fn dispatch_mouse_up_clears_drag_state() {
1247 let mut drag = DragState::new();
1248 drag.begin(DragTarget::ScrollbarY {
1249 widget: id("p"),
1250 track_start: 0.0,
1251 track_length: 10.0,
1252 thumb_length: 5.0,
1253 max_scroll: 10,
1254 grab_offset: 0.0,
1255 inverted: false,
1256 });
1257 let stack = ModalStack::new();
1258 let events = dispatch_mouse_up(&stack, &mut drag, pt(5.0, 5.0), MouseButton::Left);
1259 assert_eq!(events.len(), 1);
1260 assert!(matches!(events[0], UiEvent::MouseUp { .. }));
1261 assert!(!drag.is_active());
1262 }
1263
1264 #[test]
1267 fn split_divider_drag_horizontal_computes_ratio() {
1268 let mut drag = DragState::new();
1271 drag.begin(DragTarget::SplitDivider {
1272 tree: id("tree"),
1273 split_index: 0,
1274 direction: SplitDirection::Horizontal,
1275 axis_start: 0.0,
1276 axis_size: 40.0,
1277 });
1278 let events = dispatch_mouse_drag(&drag, pt(30.0, 5.0), buttons_mask_left());
1280 assert_eq!(events.len(), 2);
1281 assert!(matches!(events[0], UiEvent::MouseMoved { .. }));
1282 match &events[1] {
1283 UiEvent::SplitDividerDragged {
1284 tree,
1285 split_index,
1286 new_ratio,
1287 } => {
1288 assert_eq!(tree, &id("tree"));
1289 assert_eq!(*split_index, 0);
1290 assert!((new_ratio - 0.75).abs() < 0.001);
1291 }
1292 other => panic!("expected SplitDividerDragged, got {:?}", other),
1293 }
1294 }
1295
1296 #[test]
1297 fn split_divider_drag_vertical_reads_y_axis() {
1298 let mut drag = DragState::new();
1299 drag.begin(DragTarget::SplitDivider {
1300 tree: id("tree"),
1301 split_index: 2,
1302 direction: SplitDirection::Vertical,
1303 axis_start: 100.0,
1304 axis_size: 50.0,
1305 });
1306 let events = dispatch_mouse_drag(&drag, pt(999.0, 125.0), buttons_mask_left());
1308 match &events[1] {
1309 UiEvent::SplitDividerDragged {
1310 split_index,
1311 new_ratio,
1312 ..
1313 } => {
1314 assert_eq!(*split_index, 2);
1315 assert!((new_ratio - 0.5).abs() < 0.001);
1316 }
1317 other => panic!("expected SplitDividerDragged, got {:?}", other),
1318 }
1319 }
1320
1321 #[test]
1322 fn split_divider_drag_clamps_beyond_bounds() {
1323 let mut drag = DragState::new();
1324 drag.begin(DragTarget::SplitDivider {
1325 tree: id("tree"),
1326 split_index: 0,
1327 direction: SplitDirection::Horizontal,
1328 axis_start: 0.0,
1329 axis_size: 40.0,
1330 });
1331 let events = dispatch_mouse_drag(&drag, pt(-100.0, 5.0), buttons_mask_left());
1333 match &events[1] {
1334 UiEvent::SplitDividerDragged { new_ratio, .. } => assert_eq!(*new_ratio, 0.0),
1335 other => panic!("expected SplitDividerDragged, got {:?}", other),
1336 }
1337 let events = dispatch_mouse_drag(&drag, pt(1000.0, 5.0), buttons_mask_left());
1339 match &events[1] {
1340 UiEvent::SplitDividerDragged { new_ratio, .. } => assert_eq!(*new_ratio, 1.0),
1341 other => panic!("expected SplitDividerDragged, got {:?}", other),
1342 }
1343 }
1344
1345 #[test]
1346 fn split_divider_drag_with_zero_axis_size_does_not_crash_or_emit() {
1347 let mut drag = DragState::new();
1348 drag.begin(DragTarget::SplitDivider {
1349 tree: id("tree"),
1350 split_index: 0,
1351 direction: SplitDirection::Horizontal,
1352 axis_start: 0.0,
1353 axis_size: 0.0,
1354 });
1355 let events = dispatch_mouse_drag(&drag, pt(5.0, 5.0), buttons_mask_left());
1356 assert_eq!(events.len(), 1);
1357 assert!(matches!(events[0], UiEvent::MouseMoved { .. }));
1358 }
1359
1360 #[test]
1361 fn split_divider_drag_ends_on_mouse_up() {
1362 let mut drag = DragState::new();
1363 drag.begin(DragTarget::SplitDivider {
1364 tree: id("tree"),
1365 split_index: 0,
1366 direction: SplitDirection::Horizontal,
1367 axis_start: 0.0,
1368 axis_size: 40.0,
1369 });
1370 let stack = ModalStack::new();
1371 dispatch_mouse_up(&stack, &mut drag, pt(5.0, 5.0), MouseButton::Left);
1372 assert!(!drag.is_active());
1373 }
1374
1375 #[test]
1376 fn dispatch_mouse_drag_with_grab_offset_keeps_thumb_relative_to_cursor() {
1377 let mut drag = DragState::new();
1383 drag.begin(DragTarget::ScrollbarY {
1384 widget: id("p"),
1385 track_start: 100.0,
1386 track_length: 80.0,
1387 thumb_length: 16.0,
1388 max_scroll: 80,
1389 grab_offset: 8.0,
1390 inverted: false,
1391 });
1392 let events = dispatch_mouse_drag(&drag, pt(500.0, 140.0), buttons_mask_left());
1393 match &events[1] {
1394 UiEvent::ScrollOffsetChanged { new_offset, .. } => assert_eq!(*new_offset, 40),
1395 other => panic!("expected ScrollOffsetChanged, got {:?}", other),
1396 }
1397 }
1398
1399 #[test]
1400 fn dispatch_mouse_drag_horizontal_scrollbar() {
1401 let mut drag = DragState::new();
1405 drag.begin(DragTarget::ScrollbarX {
1406 widget: id("editor:hsb"),
1407 track_start: 100.0,
1408 track_length: 80.0,
1409 thumb_length: 16.0,
1410 max_scroll: 80,
1411 grab_offset: 0.0,
1412 inverted: false,
1413 });
1414 let events = dispatch_mouse_drag(&drag, pt(132.0, 500.0), buttons_mask_left());
1415 assert_eq!(events.len(), 2);
1416 match &events[1] {
1417 UiEvent::ScrollOffsetChanged { widget, new_offset } => {
1418 assert_eq!(widget, &id("editor:hsb"));
1419 assert_eq!(*new_offset, 40);
1420 }
1421 other => panic!("expected ScrollOffsetChanged, got {:?}", other),
1422 }
1423 }
1424
1425 #[test]
1426 fn dispatch_mouse_drag_horizontal_grab_offset_preserves_relative() {
1427 let mut drag = DragState::new();
1430 drag.begin(DragTarget::ScrollbarX {
1431 widget: id("editor:hsb"),
1432 track_start: 100.0,
1433 track_length: 80.0,
1434 thumb_length: 16.0,
1435 max_scroll: 80,
1436 grab_offset: 8.0,
1437 inverted: false,
1438 });
1439 let events = dispatch_mouse_drag(&drag, pt(140.0, 500.0), buttons_mask_left());
1440 match &events[1] {
1441 UiEvent::ScrollOffsetChanged { new_offset, .. } => assert_eq!(*new_offset, 40),
1442 other => panic!("expected ScrollOffsetChanged, got {:?}", other),
1443 }
1444 }
1445
1446 #[test]
1447 fn painted_thumb_length_controls_effective_track() {
1448 let mut drag = DragState::new();
1451 drag.begin(DragTarget::ScrollbarX {
1452 widget: id("editor:hsb"),
1453 track_start: 100.0,
1454 track_length: 400.0,
1455 thumb_length: 20.0,
1456 max_scroll: 3980,
1457 grab_offset: 0.0,
1458 inverted: false,
1459 });
1460 let events = dispatch_mouse_drag(&drag, pt(480.0, 0.0), buttons_mask_left());
1462 match &events[1] {
1463 UiEvent::ScrollOffsetChanged { new_offset, .. } => assert_eq!(*new_offset, 3980),
1464 other => panic!("expected ScrollOffsetChanged, got {:?}", other),
1465 }
1466 let events = dispatch_mouse_drag(&drag, pt(290.0, 0.0), buttons_mask_left());
1468 match &events[1] {
1469 UiEvent::ScrollOffsetChanged { new_offset, .. } => assert_eq!(*new_offset, 1990),
1470 other => panic!("expected ScrollOffsetChanged, got {:?}", other),
1471 }
1472 }
1473
1474 fn assert_round_trip(
1482 label: &str,
1483 track_start: f32,
1484 track_length: f32,
1485 total: usize,
1486 visible: usize,
1487 min_thumb: f32,
1488 scroll: usize,
1489 ) {
1490 use crate::primitives::scrollbar::fit_thumb;
1491 let max_scroll = total.saturating_sub(visible);
1492 let (thumb_start, thumb_len) = fit_thumb(
1493 scroll as f32,
1494 total as f32,
1495 visible as f32,
1496 track_length,
1497 min_thumb,
1498 );
1499 let grab = thumb_len / 2.0;
1500 let cursor_x = track_start + thumb_start + grab;
1501 let mut drag = DragState::new();
1502 drag.begin(DragTarget::ScrollbarX {
1503 widget: id("sb"),
1504 track_start,
1505 track_length,
1506 thumb_length: thumb_len,
1507 max_scroll,
1508 grab_offset: grab,
1509 inverted: false,
1510 });
1511 let events = dispatch_mouse_drag(&drag, pt(cursor_x, 0.0), buttons_mask_left());
1512 match &events[1] {
1513 UiEvent::ScrollOffsetChanged { new_offset, .. } => {
1514 assert_eq!(
1515 *new_offset, scroll,
1516 "{label}: expected offset {scroll}, got {new_offset}"
1517 );
1518 }
1519 other => panic!("{label}: expected ScrollOffsetChanged, got {other:?}"),
1520 }
1521 }
1522
1523 #[test]
1524 fn round_trip_vimcode_numbers_at_max() {
1525 assert_round_trip("3609 max", 50.0, 800.0, 3609, 137, 20.0, 3609 - 137);
1526 }
1527
1528 #[test]
1529 fn round_trip_vimcode_numbers_at_zero() {
1530 assert_round_trip("3609 zero", 50.0, 800.0, 3609, 137, 20.0, 0);
1531 }
1532
1533 #[test]
1534 fn round_trip_vimcode_numbers_at_midpoint() {
1535 assert_round_trip("3609 mid", 50.0, 800.0, 3609, 137, 20.0, 1736);
1536 }
1537
1538 #[test]
1539 fn round_trip_small_content_at_max() {
1540 assert_round_trip("small max", 0.0, 100.0, 200, 50, 10.0, 150);
1541 }
1542
1543 #[test]
1544 fn round_trip_large_min_thumb_at_max() {
1545 assert_round_trip("big thumb max", 0.0, 100.0, 5000, 50, 40.0, 4950);
1547 }
1548
1549 #[test]
1550 fn round_trip_cursor_past_track_end_reaches_max() {
1551 use crate::primitives::scrollbar::fit_thumb;
1552 let track_start = 50.0;
1553 let track_length = 800.0;
1554 let total = 3609_usize;
1555 let visible = 137_usize;
1556 let max_scroll = total - visible;
1557 let (_, thumb_len) = fit_thumb(0.0, total as f32, visible as f32, track_length, 20.0);
1558 let mut drag = DragState::new();
1559 drag.begin(DragTarget::ScrollbarX {
1560 widget: id("sb"),
1561 track_start,
1562 track_length,
1563 thumb_length: thumb_len,
1564 max_scroll,
1565 grab_offset: 0.0,
1566 inverted: false,
1567 });
1568 let events = dispatch_mouse_drag(
1570 &drag,
1571 pt(track_start + track_length + 200.0, 0.0),
1572 buttons_mask_left(),
1573 );
1574 match &events[1] {
1575 UiEvent::ScrollOffsetChanged { new_offset, .. } => {
1576 assert_eq!(*new_offset, max_scroll);
1577 }
1578 other => panic!("expected ScrollOffsetChanged, got {other:?}"),
1579 }
1580 }
1581
1582 #[test]
1583 fn dispatch_mouse_up_carries_modal_widget_if_over() {
1584 let mut stack = ModalStack::new();
1585 stack.push(id("palette"), rect(0.0, 0.0, 100.0, 100.0));
1586 let mut drag = DragState::new();
1587 let events = dispatch_mouse_up(&stack, &mut drag, pt(50.0, 50.0), MouseButton::Left);
1588 match &events[0] {
1589 UiEvent::MouseUp { widget, .. } => {
1590 assert_eq!(widget.as_ref().unwrap(), &id("palette"));
1591 }
1592 _ => panic!(),
1593 }
1594 }
1595
1596 fn delta(dy: f32) -> ScrollDelta {
1599 ScrollDelta::new(0.0, dy)
1600 }
1601
1602 #[test]
1603 fn scroll_inside_modal_routes_to_modal() {
1604 let mut stack = ModalStack::new();
1605 stack.push(id("picker"), rect(10.0, 10.0, 100.0, 100.0));
1606 let events = dispatch_scroll(&stack, &[], pt(50.0, 50.0), delta(-3.0));
1607 assert_eq!(events.len(), 1);
1608 match &events[0] {
1609 UiEvent::Scroll {
1610 widget, delta: d, ..
1611 } => {
1612 assert_eq!(widget.as_ref().unwrap(), &id("picker"));
1613 assert_eq!(d.y, -3.0);
1614 }
1615 other => panic!("expected Scroll, got {:?}", other),
1616 }
1617 }
1618
1619 #[test]
1620 fn scroll_outside_open_modal_is_swallowed() {
1621 let mut stack = ModalStack::new();
1622 stack.push(id("dialog"), rect(10.0, 10.0, 50.0, 50.0));
1623 let events = dispatch_scroll(&stack, &[], pt(500.0, 500.0), delta(-1.0));
1624 assert!(events.is_empty(), "scroll behind modal should be swallowed");
1625 }
1626
1627 #[test]
1628 fn scroll_routes_to_topmost_surface() {
1629 let stack = ModalStack::new();
1630 let surfaces = vec![
1631 ScrollSurface {
1632 id: id("editor"),
1633 bounds: rect(0.0, 0.0, 200.0, 200.0),
1634 scrollbar: None,
1635 },
1636 ScrollSurface {
1637 id: id("sidebar"),
1638 bounds: rect(0.0, 0.0, 50.0, 200.0),
1639 scrollbar: None,
1640 },
1641 ];
1642 let events = dispatch_scroll(&stack, &surfaces, pt(25.0, 100.0), delta(-1.0));
1645 assert_eq!(events.len(), 1);
1646 match &events[0] {
1647 UiEvent::Scroll { widget, .. } => {
1648 assert_eq!(widget.as_ref().unwrap(), &id("sidebar"));
1649 }
1650 other => panic!("expected Scroll, got {:?}", other),
1651 }
1652 }
1653
1654 #[test]
1655 fn scroll_on_non_overlapping_surface() {
1656 let stack = ModalStack::new();
1657 let surfaces = vec![
1658 ScrollSurface {
1659 id: id("editor"),
1660 bounds: rect(50.0, 0.0, 150.0, 200.0),
1661 scrollbar: None,
1662 },
1663 ScrollSurface {
1664 id: id("sidebar"),
1665 bounds: rect(0.0, 0.0, 50.0, 200.0),
1666 scrollbar: None,
1667 },
1668 ];
1669 let events = dispatch_scroll(&stack, &surfaces, pt(100.0, 100.0), delta(2.0));
1671 match &events[0] {
1672 UiEvent::Scroll { widget, .. } => {
1673 assert_eq!(widget.as_ref().unwrap(), &id("editor"));
1674 }
1675 other => panic!("expected Scroll, got {:?}", other),
1676 }
1677 }
1678
1679 #[test]
1680 fn scroll_with_no_surfaces_emits_no_widget() {
1681 let stack = ModalStack::new();
1682 let events = dispatch_scroll(&stack, &[], pt(50.0, 50.0), delta(-1.0));
1683 assert_eq!(events.len(), 1);
1684 match &events[0] {
1685 UiEvent::Scroll { widget, .. } => {
1686 assert!(widget.is_none());
1687 }
1688 other => panic!("expected Scroll, got {:?}", other),
1689 }
1690 }
1691
1692 #[test]
1693 fn scroll_outside_all_surfaces_emits_no_widget() {
1694 let stack = ModalStack::new();
1695 let surfaces = vec![ScrollSurface {
1696 id: id("panel"),
1697 bounds: rect(0.0, 0.0, 50.0, 50.0),
1698 scrollbar: None,
1699 }];
1700 let events = dispatch_scroll(&stack, &surfaces, pt(100.0, 100.0), delta(-1.0));
1701 match &events[0] {
1702 UiEvent::Scroll { widget, .. } => {
1703 assert!(widget.is_none());
1704 }
1705 other => panic!("expected Scroll, got {:?}", other),
1706 }
1707 }
1708
1709 fn surface_with_scrollbar() -> ScrollSurface {
1712 ScrollSurface {
1713 id: id("log"),
1714 bounds: rect(0.0, 0.0, 40.0, 30.0),
1715 scrollbar: Some(SurfaceScrollbar {
1716 axis: ScrollAxis::Vertical,
1717 track_bounds: rect(39.0, 0.0, 1.0, 30.0),
1718 thumb_bounds: rect(39.0, 20.0, 1.0, 8.0),
1719 total_items: 100,
1720 visible_items: 30,
1721 scroll_offset: 60,
1722 inverted: false,
1723 }),
1724 }
1725 }
1726
1727 #[test]
1728 fn click_thumb_starts_drag() {
1729 let stack = ModalStack::new();
1730 let surfaces = vec![surface_with_scrollbar()];
1731 let mut drag = DragState::new();
1732 let events = dispatch_click(
1733 &stack,
1734 &surfaces,
1735 &[],
1736 &mut drag,
1737 pt(39.5, 22.0),
1738 MouseButton::Left,
1739 Modifiers::default(),
1740 );
1741 assert_eq!(events.len(), 1);
1742 assert!(matches!(
1743 &events[0],
1744 UiEvent::MouseDown {
1745 widget: Some(w),
1746 ..
1747 } if w.as_str() == "log"
1748 ));
1749 assert!(drag.is_active());
1750 match drag.target().unwrap() {
1751 DragTarget::ScrollbarY {
1752 widget, max_scroll, ..
1753 } => {
1754 assert_eq!(widget.as_str(), "log");
1755 assert_eq!(*max_scroll, 70);
1756 }
1757 other => panic!("expected ScrollbarY, got {:?}", other),
1758 }
1759 }
1760
1761 #[test]
1762 fn click_track_before_pages_up() {
1763 let stack = ModalStack::new();
1764 let surfaces = vec![surface_with_scrollbar()];
1765 let mut drag = DragState::new();
1766 let events = dispatch_click(
1768 &stack,
1769 &surfaces,
1770 &[],
1771 &mut drag,
1772 pt(39.5, 5.0),
1773 MouseButton::Left,
1774 Modifiers::default(),
1775 );
1776 assert_eq!(events.len(), 1);
1777 match &events[0] {
1778 UiEvent::ScrollOffsetChanged { widget, new_offset } => {
1779 assert_eq!(widget.as_str(), "log");
1780 assert_eq!(*new_offset, 30);
1782 }
1783 other => panic!("expected ScrollOffsetChanged, got {:?}", other),
1784 }
1785 assert!(!drag.is_active());
1786 }
1787
1788 #[test]
1789 fn click_track_after_pages_down() {
1790 let stack = ModalStack::new();
1791 let surfaces = vec![surface_with_scrollbar()];
1792 let mut drag = DragState::new();
1793 let events = dispatch_click(
1795 &stack,
1796 &surfaces,
1797 &[],
1798 &mut drag,
1799 pt(39.5, 29.0),
1800 MouseButton::Left,
1801 Modifiers::default(),
1802 );
1803 assert_eq!(events.len(), 1);
1804 match &events[0] {
1805 UiEvent::ScrollOffsetChanged { widget, new_offset } => {
1806 assert_eq!(widget.as_str(), "log");
1807 assert_eq!(*new_offset, 70);
1809 }
1810 other => panic!("expected ScrollOffsetChanged, got {:?}", other),
1811 }
1812 }
1813
1814 #[test]
1815 fn click_body_not_scrollbar_emits_mousedown() {
1816 let stack = ModalStack::new();
1817 let surfaces = vec![surface_with_scrollbar()];
1818 let mut drag = DragState::new();
1819 let events = dispatch_click(
1821 &stack,
1822 &surfaces,
1823 &[],
1824 &mut drag,
1825 pt(10.0, 10.0),
1826 MouseButton::Left,
1827 Modifiers::default(),
1828 );
1829 assert_eq!(events.len(), 1);
1830 assert!(matches!(
1831 &events[0],
1832 UiEvent::MouseDown {
1833 widget: Some(w),
1834 ..
1835 } if w.as_str() == "log"
1836 ));
1837 assert!(!drag.is_active());
1838 }
1839
1840 #[test]
1841 fn click_no_surfaces_emits_no_widget() {
1842 let stack = ModalStack::new();
1843 let mut drag = DragState::new();
1844 let events = dispatch_click(
1845 &stack,
1846 &[],
1847 &[],
1848 &mut drag,
1849 pt(50.0, 50.0),
1850 MouseButton::Left,
1851 Modifiers::default(),
1852 );
1853 assert_eq!(events.len(), 1);
1854 assert!(matches!(
1855 &events[0],
1856 UiEvent::MouseDown { widget: None, .. }
1857 ));
1858 }
1859
1860 #[test]
1861 fn click_modal_takes_priority_over_surfaces() {
1862 let mut stack = ModalStack::new();
1863 stack.push(id("dialog"), rect(0.0, 0.0, 100.0, 100.0));
1864 let surfaces = vec![surface_with_scrollbar()];
1865 let mut drag = DragState::new();
1866 let events = dispatch_click(
1867 &stack,
1868 &surfaces,
1869 &[],
1870 &mut drag,
1871 pt(39.5, 22.0),
1872 MouseButton::Left,
1873 Modifiers::default(),
1874 );
1875 assert_eq!(events.len(), 1);
1877 assert!(matches!(
1878 &events[0],
1879 UiEvent::MouseDown {
1880 widget: Some(w),
1881 ..
1882 } if w.as_str() == "dialog"
1883 ));
1884 assert!(!drag.is_active());
1885 }
1886
1887 #[test]
1890 fn inverted_drag_flips_offset() {
1891 let mut drag = DragState::new();
1898 drag.begin(DragTarget::ScrollbarY {
1899 widget: id("term"),
1900 track_start: 100.0,
1901 track_length: 80.0,
1902 thumb_length: 16.0,
1903 max_scroll: 80,
1904 grab_offset: 0.0,
1905 inverted: true,
1906 });
1907 let events = dispatch_mouse_drag(&drag, pt(0.0, 100.0), buttons_mask_left());
1909 match &events[1] {
1910 UiEvent::ScrollOffsetChanged { new_offset, .. } => assert_eq!(*new_offset, 80),
1911 other => panic!("expected ScrollOffsetChanged, got {:?}", other),
1912 }
1913 let events = dispatch_mouse_drag(&drag, pt(0.0, 500.0), buttons_mask_left());
1915 match &events[1] {
1916 UiEvent::ScrollOffsetChanged { new_offset, .. } => assert_eq!(*new_offset, 0),
1917 other => panic!("expected ScrollOffsetChanged, got {:?}", other),
1918 }
1919 }
1920
1921 #[test]
1922 fn inverted_drag_halfway_matches_non_inverted() {
1923 let mut drag = DragState::new();
1926 drag.begin(DragTarget::ScrollbarY {
1927 widget: id("term"),
1928 track_start: 100.0,
1929 track_length: 80.0,
1930 thumb_length: 16.0,
1931 max_scroll: 80,
1932 grab_offset: 0.0,
1933 inverted: true,
1934 });
1935 let events = dispatch_mouse_drag(&drag, pt(0.0, 132.0), buttons_mask_left());
1936 match &events[1] {
1937 UiEvent::ScrollOffsetChanged { new_offset, .. } => assert_eq!(*new_offset, 40),
1938 other => panic!("expected ScrollOffsetChanged, got {:?}", other),
1939 }
1940 }
1941
1942 fn surface_with_inverted_scrollbar() -> ScrollSurface {
1943 ScrollSurface {
1944 id: id("term"),
1945 bounds: rect(0.0, 0.0, 40.0, 30.0),
1946 scrollbar: Some(SurfaceScrollbar {
1947 axis: ScrollAxis::Vertical,
1948 track_bounds: rect(39.0, 0.0, 1.0, 30.0),
1949 thumb_bounds: rect(39.0, 20.0, 1.0, 8.0),
1950 total_items: 100,
1951 visible_items: 30,
1952 scroll_offset: 60,
1953 inverted: true,
1954 }),
1955 }
1956 }
1957
1958 #[test]
1959 fn inverted_thumb_click_propagates_flag() {
1960 let stack = ModalStack::new();
1961 let surfaces = vec![surface_with_inverted_scrollbar()];
1962 let mut drag = DragState::new();
1963 dispatch_click(
1964 &stack,
1965 &surfaces,
1966 &[],
1967 &mut drag,
1968 pt(39.5, 22.0),
1969 MouseButton::Left,
1970 Modifiers::default(),
1971 );
1972 assert!(drag.is_active());
1973 match drag.target().unwrap() {
1974 DragTarget::ScrollbarY { inverted, .. } => assert!(*inverted),
1975 DragTarget::ScrollbarX { .. }
1976 | DragTarget::TextSelection { .. }
1977 | DragTarget::SplitDivider { .. } => {
1978 panic!("expected ScrollbarY")
1979 }
1980 }
1981 }
1982
1983 #[test]
1984 fn inverted_track_click_flips_page_direction() {
1985 let stack = ModalStack::new();
1986 let surfaces = vec![surface_with_inverted_scrollbar()];
1987 let mut drag = DragState::new();
1988 let events = dispatch_click(
1992 &stack,
1993 &surfaces,
1994 &[],
1995 &mut drag,
1996 pt(39.5, 5.0),
1997 MouseButton::Left,
1998 Modifiers::default(),
1999 );
2000 match &events[0] {
2001 UiEvent::ScrollOffsetChanged { new_offset, .. } => assert_eq!(*new_offset, 70),
2002 other => panic!("expected ScrollOffsetChanged, got {:?}", other),
2003 }
2004
2005 let events = dispatch_click(
2009 &stack,
2010 &surfaces,
2011 &[],
2012 &mut drag,
2013 pt(39.5, 29.0),
2014 MouseButton::Left,
2015 Modifiers::default(),
2016 );
2017 match &events[0] {
2018 UiEvent::ScrollOffsetChanged { new_offset, .. } => assert_eq!(*new_offset, 30),
2019 other => panic!("expected ScrollOffsetChanged, got {:?}", other),
2020 }
2021 }
2022
2023 fn surface_with_h_scrollbar() -> ScrollSurface {
2026 ScrollSurface {
2031 id: id("editor"),
2032 bounds: rect(0.0, 0.0, 400.0, 300.0),
2033 scrollbar: Some(SurfaceScrollbar {
2034 axis: ScrollAxis::Horizontal,
2035 track_bounds: rect(0.0, 296.0, 400.0, 4.0),
2036 thumb_bounds: rect(50.0, 296.0, 40.0, 4.0),
2037 total_items: 2000,
2038 visible_items: 200,
2039 scroll_offset: 250,
2040 inverted: false,
2041 }),
2042 }
2043 }
2044
2045 #[test]
2046 fn h_scrollbar_thumb_click_starts_scrollbar_x_drag() {
2047 let stack = ModalStack::new();
2048 let surfaces = vec![surface_with_h_scrollbar()];
2049 let mut drag = DragState::new();
2050 let events = dispatch_click(
2052 &stack,
2053 &surfaces,
2054 &[],
2055 &mut drag,
2056 pt(60.0, 297.0),
2057 MouseButton::Left,
2058 Modifiers::default(),
2059 );
2060 assert_eq!(events.len(), 1);
2061 assert!(
2062 matches!(&events[0], UiEvent::MouseDown { widget: Some(w), .. } if w.as_str() == "editor")
2063 );
2064 assert!(drag.is_active());
2065 match drag.target().unwrap() {
2066 DragTarget::ScrollbarX {
2067 widget,
2068 track_start,
2069 track_length,
2070 thumb_length,
2071 max_scroll,
2072 grab_offset,
2073 ..
2074 } => {
2075 assert_eq!(widget.as_str(), "editor");
2076 assert_eq!(*track_start, 0.0);
2077 assert_eq!(*track_length, 400.0);
2078 assert_eq!(*thumb_length, 40.0);
2079 assert_eq!(*max_scroll, 1800);
2080 assert!(*grab_offset > 9.0 && *grab_offset < 11.0); }
2082 DragTarget::ScrollbarY { .. }
2083 | DragTarget::TextSelection { .. }
2084 | DragTarget::SplitDivider { .. } => {
2085 panic!("expected ScrollbarX")
2086 }
2087 }
2088 }
2089
2090 #[test]
2091 fn h_scrollbar_track_click_left_of_thumb_pages_back() {
2092 let stack = ModalStack::new();
2093 let surfaces = vec![surface_with_h_scrollbar()];
2094 let mut drag = DragState::new();
2095 let events = dispatch_click(
2097 &stack,
2098 &surfaces,
2099 &[],
2100 &mut drag,
2101 pt(10.0, 297.0),
2102 MouseButton::Left,
2103 Modifiers::default(),
2104 );
2105 match &events[0] {
2106 UiEvent::ScrollOffsetChanged { widget, new_offset } => {
2107 assert_eq!(widget.as_str(), "editor");
2108 assert_eq!(*new_offset, 50);
2110 }
2111 other => panic!("expected ScrollOffsetChanged, got {:?}", other),
2112 }
2113 }
2114
2115 #[test]
2116 fn h_scrollbar_track_click_right_of_thumb_pages_forward() {
2117 let stack = ModalStack::new();
2118 let surfaces = vec![surface_with_h_scrollbar()];
2119 let mut drag = DragState::new();
2120 let events = dispatch_click(
2122 &stack,
2123 &surfaces,
2124 &[],
2125 &mut drag,
2126 pt(200.0, 297.0),
2127 MouseButton::Left,
2128 Modifiers::default(),
2129 );
2130 match &events[0] {
2131 UiEvent::ScrollOffsetChanged { widget, new_offset } => {
2132 assert_eq!(widget.as_str(), "editor");
2133 assert_eq!(*new_offset, 450);
2135 }
2136 other => panic!("expected ScrollOffsetChanged, got {:?}", other),
2137 }
2138 }
2139
2140 #[test]
2141 fn h_scrollbar_drag_produces_scroll_offset() {
2142 let stack = ModalStack::new();
2143 let surfaces = vec![surface_with_h_scrollbar()];
2144 let mut drag = DragState::new();
2145 dispatch_click(
2147 &stack,
2148 &surfaces,
2149 &[],
2150 &mut drag,
2151 pt(60.0, 297.0),
2152 MouseButton::Left,
2153 Modifiers::default(),
2154 );
2155 assert!(drag.is_active());
2156 let events = dispatch_mouse_drag(&drag, pt(370.0, 297.0), buttons_mask_left());
2158 assert_eq!(events.len(), 2);
2159 match &events[1] {
2160 UiEvent::ScrollOffsetChanged { widget, new_offset } => {
2161 assert_eq!(widget.as_str(), "editor");
2162 assert_eq!(*new_offset, 1800); }
2164 other => panic!("expected ScrollOffsetChanged, got {:?}", other),
2165 }
2166 }
2167
2168 #[test]
2171 fn click_text_region_starts_text_selection_drag() {
2172 let stack = ModalStack::new();
2173 let mut drag = DragState::new();
2174 let text_regions = vec![TextRegion {
2175 id: id("log:body"),
2176 bounds: rect(0.0, 0.0, 40.0, 30.0),
2177 lines: vec![],
2178 }];
2179 let events = dispatch_click(
2180 &stack,
2181 &[],
2182 &text_regions,
2183 &mut drag,
2184 pt(10.0, 5.0),
2185 MouseButton::Left,
2186 Modifiers::default(),
2187 );
2188 assert_eq!(events.len(), 1);
2189 assert!(matches!(
2190 &events[0],
2191 UiEvent::MouseDown {
2192 widget: Some(w), ..
2193 } if w.as_str() == "log:body"
2194 ));
2195 assert!(drag.is_active());
2196 match drag.target().unwrap() {
2197 DragTarget::TextSelection { region, anchor } => {
2198 assert_eq!(region.as_str(), "log:body");
2199 assert_eq!(*anchor, pt(10.0, 5.0));
2200 }
2201 other => panic!("expected TextSelection, got {:?}", other),
2202 }
2203 }
2204
2205 #[test]
2206 fn scrollbar_wins_over_text_region() {
2207 let stack = ModalStack::new();
2208 let surfaces = vec![surface_with_scrollbar()];
2209 let text_regions = vec![TextRegion {
2210 id: id("log:body"),
2211 bounds: rect(0.0, 0.0, 40.0, 30.0),
2213 lines: vec![],
2214 }];
2215 let mut drag = DragState::new();
2216 dispatch_click(
2218 &stack,
2219 &surfaces,
2220 &text_regions,
2221 &mut drag,
2222 pt(39.5, 22.0),
2223 MouseButton::Left,
2224 Modifiers::default(),
2225 );
2226 assert!(matches!(
2227 drag.target().unwrap(),
2228 DragTarget::ScrollbarY { .. }
2229 ));
2230 }
2231
2232 #[test]
2233 fn text_region_wins_over_surface_body() {
2234 let stack = ModalStack::new();
2235 let surfaces = vec![ScrollSurface {
2236 id: id("log"),
2237 bounds: rect(0.0, 0.0, 40.0, 30.0),
2238 scrollbar: None,
2239 }];
2240 let text_regions = vec![TextRegion {
2241 id: id("log:body"),
2242 bounds: rect(0.0, 0.0, 40.0, 30.0),
2243 lines: vec![],
2244 }];
2245 let mut drag = DragState::new();
2246 let events = dispatch_click(
2247 &stack,
2248 &surfaces,
2249 &text_regions,
2250 &mut drag,
2251 pt(10.0, 5.0),
2252 MouseButton::Left,
2253 Modifiers::default(),
2254 );
2255 assert!(matches!(
2256 &events[0],
2257 UiEvent::MouseDown { widget: Some(w), .. } if w.as_str() == "log:body"
2258 ));
2259 assert!(matches!(
2260 drag.target().unwrap(),
2261 DragTarget::TextSelection { .. }
2262 ));
2263 }
2264
2265 #[test]
2266 fn modal_wins_over_text_region() {
2267 let mut stack = ModalStack::new();
2268 stack.push(id("dialog"), rect(0.0, 0.0, 100.0, 100.0));
2269 let text_regions = vec![TextRegion {
2270 id: id("body"),
2271 bounds: rect(0.0, 0.0, 100.0, 100.0),
2272 lines: vec![],
2273 }];
2274 let mut drag = DragState::new();
2275 let events = dispatch_click(
2276 &stack,
2277 &[],
2278 &text_regions,
2279 &mut drag,
2280 pt(50.0, 50.0),
2281 MouseButton::Left,
2282 Modifiers::default(),
2283 );
2284 assert!(!drag.is_active());
2286 assert!(matches!(
2287 &events[0],
2288 UiEvent::MouseDown { widget: Some(w), .. } if w.as_str() == "dialog"
2289 ));
2290 }
2291
2292 #[test]
2293 fn dispatch_mouse_drag_text_selection_emits_changed() {
2294 let mut drag = DragState::new();
2295 drag.begin(DragTarget::TextSelection {
2296 region: id("log:body"),
2297 anchor: pt(5.0, 2.0),
2298 });
2299 let events = dispatch_mouse_drag(&drag, pt(10.0, 5.0), buttons_mask_left());
2300 assert_eq!(events.len(), 2);
2301 assert!(matches!(events[0], UiEvent::MouseMoved { .. }));
2302 match &events[1] {
2303 UiEvent::TextSelectionChanged {
2304 region,
2305 anchor,
2306 focus,
2307 } => {
2308 assert_eq!(region.as_str(), "log:body");
2309 assert_eq!(*anchor, pt(5.0, 2.0));
2310 assert_eq!(*focus, pt(10.0, 5.0));
2311 }
2312 other => panic!("expected TextSelectionChanged, got {:?}", other),
2313 }
2314 }
2315
2316 #[test]
2319 fn line_range_empty_when_anchor_equals_focus() {
2320 let bounds = rect(0.0, 0.0, 40.0, 10.0);
2321 let ranges = text_selection_line_range(pt(5.0, 3.0), pt(5.0, 3.0), bounds);
2322 assert!(ranges.is_empty(), "collapsed selection must be empty");
2323 }
2324
2325 #[test]
2326 fn line_range_single_row() {
2327 let bounds = rect(0.0, 0.0, 40.0, 10.0);
2329 let ranges = text_selection_line_range(pt(5.0, 3.0), pt(10.0, 3.0), bounds);
2330 assert_eq!(ranges, vec![(3, 5.0, 11.0)]);
2332 }
2333
2334 #[test]
2335 fn line_range_single_row_reversed() {
2336 let bounds = rect(0.0, 0.0, 40.0, 10.0);
2338 let ranges = text_selection_line_range(pt(10.0, 3.0), pt(5.0, 3.0), bounds);
2339 assert_eq!(ranges, vec![(3, 5.0, 11.0)]);
2340 }
2341
2342 #[test]
2343 fn line_range_multi_row() {
2344 let bounds = rect(0.0, 0.0, 40.0, 10.0);
2345 let ranges = text_selection_line_range(pt(2.0, 1.0), pt(5.0, 3.0), bounds);
2347 assert_eq!(ranges, vec![(1, 2.0, 40.0), (2, 0.0, 40.0), (3, 0.0, 6.0)]);
2351 }
2352
2353 #[test]
2354 fn line_range_multi_row_reversed() {
2355 let bounds = rect(0.0, 0.0, 40.0, 10.0);
2356 let ranges = text_selection_line_range(pt(5.0, 3.0), pt(2.0, 1.0), bounds);
2358 assert_eq!(ranges, vec![(1, 2.0, 40.0), (2, 0.0, 40.0), (3, 0.0, 6.0)]);
2359 }
2360
2361 #[test]
2362 fn line_range_clamped_to_bounds() {
2363 let bounds = rect(5.0, 2.0, 20.0, 5.0);
2365 let ranges = text_selection_line_range(pt(0.0, 0.0), pt(100.0, 100.0), bounds);
2366 assert_eq!(
2369 ranges[0],
2370 (2, 5.0, 25.0),
2371 "first row: anchor clamped to region x"
2372 );
2373 assert_eq!(
2374 *ranges.last().unwrap(),
2375 (6, 5.0, 25.0),
2376 "last row: focus clamped to region end"
2377 );
2378 for &(_, c_start, c_end) in &ranges[1..ranges.len() - 1] {
2380 assert_eq!(c_start, 5.0);
2381 assert_eq!(c_end, 25.0);
2382 }
2383 }
2384
2385 fn mouse_down(x: f32, y: f32) -> UiEvent {
2388 UiEvent::MouseDown {
2389 widget: None,
2390 button: MouseButton::Left,
2391 position: Point::new(x, y),
2392 modifiers: Modifiers::default(),
2393 }
2394 }
2395
2396 #[test]
2397 fn double_click_detected_on_same_position() {
2398 let mut det = DoubleClickDetector::new();
2399 let mut events = vec![mouse_down(5.0, 3.0)];
2400 det.process(&mut events);
2401 assert!(matches!(events[0], UiEvent::MouseDown { .. }));
2402
2403 let mut events = vec![mouse_down(5.0, 3.0)];
2404 det.process(&mut events);
2405 assert!(
2406 matches!(events[0], UiEvent::DoubleClick { .. }),
2407 "second click at same position should be DoubleClick"
2408 );
2409 }
2410
2411 #[test]
2412 fn no_double_click_at_different_position() {
2413 let mut det = DoubleClickDetector::new();
2414 let mut events = vec![mouse_down(5.0, 3.0)];
2415 det.process(&mut events);
2416
2417 let mut events = vec![mouse_down(20.0, 10.0)];
2418 det.process(&mut events);
2419 assert!(
2420 matches!(events[0], UiEvent::MouseDown { .. }),
2421 "click at different position should NOT be DoubleClick"
2422 );
2423 }
2424
2425 #[test]
2426 fn triple_click_resets_to_single() {
2427 let mut det = DoubleClickDetector::new();
2428 let mut events = vec![mouse_down(5.0, 3.0)];
2429 det.process(&mut events);
2430
2431 let mut events = vec![mouse_down(5.0, 3.0)];
2432 det.process(&mut events);
2433 assert!(matches!(events[0], UiEvent::DoubleClick { .. }));
2434
2435 let mut events = vec![mouse_down(5.0, 3.0)];
2437 det.process(&mut events);
2438 assert!(
2439 matches!(events[0], UiEvent::MouseDown { .. }),
2440 "third click should reset to MouseDown"
2441 );
2442 }
2443}