use crate::event::{MouseButton, Point, ScrollDelta, UiEvent};
use crate::modal_stack::ModalStack;
use crate::primitives::palette::PaletteEvent;
use crate::primitives::scrollbar::ScrollAxis;
use crate::primitives::split_tree::SplitDirection;
use crate::types::WidgetId;
use crate::Modifiers;
#[derive(Debug, Clone, PartialEq)]
pub enum DragTarget {
ScrollbarY {
widget: WidgetId,
track_start: f32,
track_length: f32,
thumb_length: f32,
max_scroll: usize,
grab_offset: f32,
inverted: bool,
},
ScrollbarX {
widget: WidgetId,
track_start: f32,
track_length: f32,
thumb_length: f32,
max_scroll: usize,
grab_offset: f32,
inverted: bool,
},
TextSelection {
region: WidgetId,
anchor: Point,
},
SplitDivider {
tree: WidgetId,
split_index: usize,
direction: SplitDirection,
axis_start: f32,
axis_size: f32,
},
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct DragState {
current: Option<DragTarget>,
}
impl DragState {
pub fn new() -> Self {
Self::default()
}
pub fn begin(&mut self, target: DragTarget) {
self.current = Some(target);
}
pub fn end(&mut self) {
self.current = None;
}
pub fn is_active(&self) -> bool {
self.current.is_some()
}
pub fn target(&self) -> Option<&DragTarget> {
self.current.as_ref()
}
}
pub fn dispatch_mouse_down(
stack: &ModalStack,
position: Point,
button: MouseButton,
modifiers: Modifiers,
) -> Vec<UiEvent> {
if let Some(widget_id) = stack.hit_test(position) {
return vec![UiEvent::MouseDown {
widget: Some(*widget_id),
button,
position,
modifiers,
}];
}
if let Some(top) = stack.top() {
return vec![
UiEvent::MouseDown {
widget: None,
button,
position,
modifiers,
},
UiEvent::Palette(top.id, PaletteEvent::Closed),
];
}
vec![UiEvent::MouseDown {
widget: None,
button,
position,
modifiers,
}]
}
pub fn dispatch_mouse_drag(
drag: &DragState,
position: Point,
buttons: crate::event::ButtonMask,
) -> Vec<UiEvent> {
let mut events = vec![UiEvent::MouseMoved { position, buttons }];
match drag.target() {
Some(DragTarget::ScrollbarY {
widget,
track_start,
track_length,
thumb_length,
max_scroll,
grab_offset,
inverted,
}) if *track_length > 0.0 && *max_scroll > 0 => {
let effective_track = (*track_length - *thumb_length).max(1.0);
let rel = (position.y - *track_start - *grab_offset) / effective_track;
let clamped = rel.clamp(0.0, 1.0);
let clamped = if *inverted { 1.0 - clamped } else { clamped };
let new_offset = (clamped * *max_scroll as f32).round() as usize;
events.push(UiEvent::ScrollOffsetChanged {
widget: *widget,
new_offset,
});
}
Some(DragTarget::ScrollbarX {
widget,
track_start,
track_length,
thumb_length,
max_scroll,
grab_offset,
inverted,
}) if *track_length > 0.0 && *max_scroll > 0 => {
let effective_track = (*track_length - *thumb_length).max(1.0);
let rel = (position.x - *track_start - *grab_offset) / effective_track;
let clamped = rel.clamp(0.0, 1.0);
let clamped = if *inverted { 1.0 - clamped } else { clamped };
let new_offset = (clamped * *max_scroll as f32).round() as usize;
events.push(UiEvent::ScrollOffsetChanged {
widget: *widget,
new_offset,
});
}
Some(DragTarget::TextSelection { region, anchor }) => {
events.push(UiEvent::TextSelectionChanged {
region: *region,
anchor: *anchor,
focus: position,
});
}
Some(DragTarget::SplitDivider {
tree,
split_index,
direction,
axis_start,
axis_size,
}) if *axis_size > 0.0 => {
let cursor_axis = match direction {
SplitDirection::Horizontal => position.x,
SplitDirection::Vertical => position.y,
};
let new_ratio = ((cursor_axis - *axis_start) / *axis_size).clamp(0.0, 1.0);
events.push(UiEvent::SplitDividerDragged {
tree: *tree,
split_index: *split_index,
new_ratio,
});
}
_ => {}
}
events
}
pub fn dispatch_mouse_up(
stack: &ModalStack,
drag: &mut DragState,
position: Point,
button: MouseButton,
) -> Vec<UiEvent> {
drag.end();
let widget = stack.hit_test(position).cloned();
vec![UiEvent::MouseUp {
widget,
button,
position,
}]
}
#[derive(Debug, Clone, PartialEq)]
pub struct TextRegion {
pub id: WidgetId,
pub bounds: crate::event::Rect,
pub lines: Vec<String>,
}
pub fn text_selection_line_range(
anchor: Point,
focus: Point,
bounds: crate::event::Rect,
) -> Vec<(u16, f32, f32)> {
let ax = anchor.x.round() as i32;
let ay = anchor.y.round() as i32;
let fx = focus.x.round() as i32;
let fy = focus.y.round() as i32;
if ax == fx && ay == fy {
return Vec::new();
}
let (start, end) = if ay < fy || (ay == fy && ax <= fx) {
(anchor, focus)
} else {
(focus, anchor)
};
let region_x = bounds.x.round();
let region_end_x = (bounds.x + bounds.width).round();
let region_y = bounds.y.round() as u16;
let region_end_y = (bounds.y + bounds.height).round() as u16;
let start_row = (start.y.round() as u16).clamp(region_y, region_end_y.saturating_sub(1));
let end_row = (end.y.round() as u16).clamp(region_y, region_end_y.saturating_sub(1));
let start_col = start.x.round().clamp(region_x, region_end_x);
let end_col = (end.x.round() + 1.0).clamp(region_x, region_end_x);
if start_row == end_row {
if start_col >= end_col {
return Vec::new();
}
return vec![(start_row, start_col, end_col)];
}
let mut ranges = Vec::with_capacity((end_row - start_row + 1) as usize);
ranges.push((start_row, start_col, region_end_x));
for row in (start_row + 1)..end_row {
ranges.push((row, region_x, region_end_x));
}
ranges.push((end_row, region_x, end_col));
ranges
}
#[derive(Debug, Clone, PartialEq)]
pub struct ScrollSurface {
pub id: WidgetId,
pub bounds: crate::event::Rect,
pub scrollbar: Option<SurfaceScrollbar>,
}
pub fn dispatch_scroll(
stack: &ModalStack,
scroll_surfaces: &[ScrollSurface],
position: Point,
delta: ScrollDelta,
) -> Vec<UiEvent> {
if let Some(widget_id) = stack.hit_test(position) {
return vec![UiEvent::Scroll {
widget: Some(*widget_id),
delta,
position,
}];
}
if !stack.is_empty() {
return Vec::new();
}
for surface in scroll_surfaces.iter().rev() {
if position.x >= surface.bounds.x
&& position.x < surface.bounds.x + surface.bounds.width
&& position.y >= surface.bounds.y
&& position.y < surface.bounds.y + surface.bounds.height
{
return vec![UiEvent::Scroll {
widget: Some(surface.id),
delta,
position,
}];
}
}
vec![UiEvent::Scroll {
widget: None,
delta,
position,
}]
}
#[derive(Debug, Clone, PartialEq)]
pub struct SurfaceScrollbar {
pub axis: ScrollAxis,
pub track_bounds: crate::event::Rect,
pub thumb_bounds: crate::event::Rect,
pub total_items: usize,
pub visible_items: usize,
pub scroll_offset: usize,
pub inverted: bool,
}
pub fn dispatch_click(
stack: &ModalStack,
scroll_surfaces: &[ScrollSurface],
text_regions: &[TextRegion],
drag: &mut DragState,
position: Point,
button: MouseButton,
modifiers: Modifiers,
) -> Vec<UiEvent> {
if let Some(widget_id) = stack.hit_test(position) {
return vec![UiEvent::MouseDown {
widget: Some(*widget_id),
button,
position,
modifiers,
}];
}
if let Some(top) = stack.top() {
return vec![
UiEvent::MouseDown {
widget: None,
button,
position,
modifiers,
},
UiEvent::Palette(top.id, PaletteEvent::Closed),
];
}
for surface in scroll_surfaces.iter().rev() {
if !surface.bounds.contains(position) {
continue;
}
if let Some(ref sb) = surface.scrollbar {
let in_track = sb.track_bounds.contains(position);
if in_track {
let in_thumb = sb.thumb_bounds.contains(position);
if in_thumb {
let max_scroll = sb.total_items.saturating_sub(sb.visible_items);
match sb.axis {
ScrollAxis::Vertical => {
let grab_offset = position.y - sb.thumb_bounds.y;
drag.begin(DragTarget::ScrollbarY {
widget: surface.id,
track_start: sb.track_bounds.y,
track_length: sb.track_bounds.height,
thumb_length: sb.thumb_bounds.height,
max_scroll,
grab_offset,
inverted: sb.inverted,
});
}
ScrollAxis::Horizontal => {
let grab_offset = position.x - sb.thumb_bounds.x;
drag.begin(DragTarget::ScrollbarX {
widget: surface.id,
track_start: sb.track_bounds.x,
track_length: sb.track_bounds.width,
thumb_length: sb.thumb_bounds.width,
max_scroll,
grab_offset,
inverted: sb.inverted,
});
}
}
return vec![UiEvent::MouseDown {
widget: Some(surface.id),
button,
position,
modifiers,
}];
}
let max_offset = sb.total_items.saturating_sub(sb.visible_items);
let before_thumb = match sb.axis {
ScrollAxis::Vertical => position.y < sb.thumb_bounds.y,
ScrollAxis::Horizontal => position.x < sb.thumb_bounds.x,
};
let page_back = if sb.inverted {
!before_thumb
} else {
before_thumb
};
let new_offset = if page_back {
sb.scroll_offset.saturating_sub(sb.visible_items)
} else {
(sb.scroll_offset + sb.visible_items).min(max_offset)
};
return vec![UiEvent::ScrollOffsetChanged {
widget: surface.id,
new_offset,
}];
}
}
for tr in text_regions.iter().rev() {
if tr.bounds.contains(position) {
drag.begin(DragTarget::TextSelection {
region: tr.id,
anchor: position,
});
return vec![UiEvent::MouseDown {
widget: Some(tr.id),
button,
position,
modifiers,
}];
}
}
return vec![UiEvent::MouseDown {
widget: Some(surface.id),
button,
position,
modifiers,
}];
}
for tr in text_regions.iter().rev() {
if tr.bounds.contains(position) {
drag.begin(DragTarget::TextSelection {
region: tr.id,
anchor: position,
});
return vec![UiEvent::MouseDown {
widget: Some(tr.id),
button,
position,
modifiers,
}];
}
}
vec![UiEvent::MouseDown {
widget: None,
button,
position,
modifiers,
}]
}
#[cfg(any(
feature = "tui",
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
pub(crate) fn route_pointer(
stack: &ModalStack,
drag: &mut DragState,
text_state: &mut crate::text_selection::TextSelectionState,
event: UiEvent,
) -> Vec<UiEvent> {
match event {
UiEvent::MouseDown {
button,
position,
modifiers,
..
} => {
let events = dispatch_click(
stack,
&[],
&text_state.text_regions,
drag,
position,
button,
modifiers,
);
if let Some(DragTarget::TextSelection { region, .. }) = drag.target() {
text_state.track_focused_text_region(*region);
}
events
}
UiEvent::MouseMoved { position, buttons } => dispatch_mouse_drag(drag, position, buttons),
UiEvent::MouseUp {
button, position, ..
} => dispatch_mouse_up(stack, drag, position, button),
other => vec![other],
}
}
use std::time::{Duration, Instant};
const DOUBLE_CLICK_MS: u64 = 400;
const DOUBLE_CLICK_RADIUS: f32 = 1.5;
pub struct DoubleClickDetector {
last_click_time: Option<Instant>,
last_click_pos: Point,
last_click_button: MouseButton,
radius: f32,
}
impl Default for DoubleClickDetector {
fn default() -> Self {
Self {
last_click_time: None,
last_click_pos: Point::default(),
last_click_button: MouseButton::default(),
radius: DOUBLE_CLICK_RADIUS,
}
}
}
impl DoubleClickDetector {
pub fn new() -> Self {
Self::default()
}
pub fn with_radius(radius: f32) -> Self {
Self {
radius,
..Self::default()
}
}
pub fn process(&mut self, events: &mut [UiEvent]) {
let now = Instant::now();
for ev in events.iter_mut() {
if let UiEvent::MouseDown {
button, position, ..
} = ev
{
let is_double = self
.last_click_time
.map(|t| now.duration_since(t) < Duration::from_millis(DOUBLE_CLICK_MS))
.unwrap_or(false)
&& *button == self.last_click_button
&& (position.x - self.last_click_pos.x).abs() <= self.radius
&& (position.y - self.last_click_pos.y).abs() <= self.radius;
if is_double {
*ev = UiEvent::DoubleClick {
widget: None,
position: *position,
};
self.last_click_time = None;
} else {
self.last_click_time = Some(now);
self.last_click_pos = *position;
self.last_click_button = *button;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::Rect;
use crate::types::WidgetId;
fn id(s: &str) -> WidgetId {
WidgetId::new(s)
}
fn pt(x: f32, y: f32) -> Point {
Point { x, y }
}
fn rect(x: f32, y: f32, w: f32, h: f32) -> Rect {
Rect {
x,
y,
width: w,
height: h,
}
}
#[test]
fn click_inside_modal_emits_single_mousedown_with_widget() {
let mut stack = ModalStack::new();
stack.push(id("palette"), rect(10.0, 10.0, 100.0, 100.0));
let events = dispatch_mouse_down(
&stack,
pt(50.0, 50.0),
MouseButton::Left,
Modifiers::default(),
);
assert_eq!(events.len(), 1);
match &events[0] {
UiEvent::MouseDown { widget, button, .. } => {
assert_eq!(widget.as_ref().unwrap(), &id("palette"));
assert_eq!(*button, MouseButton::Left);
}
_ => panic!("expected MouseDown, got {:?}", events[0]),
}
}
#[test]
fn click_outside_open_modal_dismisses_topmost() {
let mut stack = ModalStack::new();
stack.push(id("palette"), rect(10.0, 10.0, 50.0, 50.0));
let events = dispatch_mouse_down(
&stack,
pt(500.0, 500.0),
MouseButton::Left,
Modifiers::default(),
);
assert_eq!(events.len(), 2);
assert!(matches!(
&events[0],
UiEvent::MouseDown { widget: None, .. }
));
match &events[1] {
UiEvent::Palette(wid, PaletteEvent::Closed) => {
assert_eq!(wid, &id("palette"));
}
_ => panic!("expected Palette::Closed, got {:?}", events[1]),
}
}
#[test]
fn click_when_no_modal_open_emits_single_mousedown_with_no_widget() {
let stack = ModalStack::new();
let events = dispatch_mouse_down(
&stack,
pt(100.0, 100.0),
MouseButton::Left,
Modifiers::default(),
);
assert_eq!(events.len(), 1);
assert!(matches!(
&events[0],
UiEvent::MouseDown { widget: None, .. }
));
}
#[test]
fn stacked_modals_click_inside_top_targets_top() {
let mut stack = ModalStack::new();
stack.push(id("palette"), rect(0.0, 0.0, 200.0, 200.0));
stack.push(id("dialog"), rect(50.0, 50.0, 100.0, 100.0));
let events = dispatch_mouse_down(
&stack,
pt(100.0, 100.0),
MouseButton::Left,
Modifiers::default(),
);
match &events[0] {
UiEvent::MouseDown { widget, .. } => {
assert_eq!(widget.as_ref().unwrap(), &id("dialog"));
}
_ => panic!(),
}
}
#[test]
fn stacked_modals_click_inside_lower_targets_lower() {
let mut stack = ModalStack::new();
stack.push(id("palette"), rect(0.0, 0.0, 200.0, 200.0));
stack.push(id("dialog"), rect(50.0, 50.0, 100.0, 100.0));
let events = dispatch_mouse_down(
&stack,
pt(10.0, 10.0),
MouseButton::Left,
Modifiers::default(),
);
assert_eq!(events.len(), 1);
match &events[0] {
UiEvent::MouseDown { widget, .. } => {
assert_eq!(widget.as_ref().unwrap(), &id("palette"));
}
_ => panic!(),
}
}
fn buttons_mask_left() -> crate::event::ButtonMask {
crate::event::ButtonMask {
left: true,
..Default::default()
}
}
#[test]
fn drag_state_begin_and_end() {
let mut drag = DragState::new();
assert!(!drag.is_active());
drag.begin(DragTarget::ScrollbarY {
widget: id("picker"),
track_start: 100.0,
track_length: 200.0,
thumb_length: 16.0,
max_scroll: 40,
grab_offset: 0.0,
inverted: false,
});
assert!(drag.is_active());
match drag.target().unwrap() {
DragTarget::ScrollbarY { widget, .. } => assert_eq!(widget, &id("picker")),
DragTarget::ScrollbarX { .. } => panic!("expected ScrollbarY"),
DragTarget::TextSelection { .. } => panic!("expected ScrollbarY"),
DragTarget::SplitDivider { .. } => panic!("expected ScrollbarY"),
}
drag.end();
assert!(!drag.is_active());
assert!(drag.target().is_none());
}
#[test]
fn dispatch_mouse_drag_without_drag_emits_only_moved() {
let drag = DragState::new();
let events = dispatch_mouse_drag(&drag, pt(50.0, 50.0), buttons_mask_left());
assert_eq!(events.len(), 1);
assert!(matches!(events[0], UiEvent::MouseMoved { .. }));
}
#[test]
fn dispatch_mouse_drag_with_scrollbar_emits_scroll_offset_changed() {
let mut drag = DragState::new();
drag.begin(DragTarget::ScrollbarY {
widget: id("picker"),
track_start: 100.0,
track_length: 80.0,
thumb_length: 16.0,
max_scroll: 80,
grab_offset: 0.0,
inverted: false,
});
let events = dispatch_mouse_drag(&drag, pt(500.0, 132.0), buttons_mask_left());
assert_eq!(events.len(), 2);
assert!(matches!(events[0], UiEvent::MouseMoved { .. }));
match &events[1] {
UiEvent::ScrollOffsetChanged { widget, new_offset } => {
assert_eq!(widget, &id("picker"));
assert_eq!(*new_offset, 40);
}
other => panic!("expected ScrollOffsetChanged, got {:?}", other),
}
}
#[test]
fn dispatch_mouse_drag_clamps_above_and_below_track() {
let mut drag = DragState::new();
drag.begin(DragTarget::ScrollbarY {
widget: id("p"),
track_start: 100.0,
track_length: 80.0,
thumb_length: 16.0,
max_scroll: 80,
grab_offset: 0.0,
inverted: false,
});
let events = dispatch_mouse_drag(&drag, pt(0.0, 50.0), buttons_mask_left());
match &events[1] {
UiEvent::ScrollOffsetChanged { new_offset, .. } => {
assert_eq!(*new_offset, 0);
}
_ => panic!(),
}
let events = dispatch_mouse_drag(&drag, pt(0.0, 500.0), buttons_mask_left());
match &events[1] {
UiEvent::ScrollOffsetChanged { new_offset, .. } => {
assert_eq!(*new_offset, 80);
}
_ => panic!(),
}
}
#[test]
fn dispatch_mouse_drag_with_zero_track_does_not_crash_or_emit() {
let mut drag = DragState::new();
drag.begin(DragTarget::ScrollbarY {
widget: id("p"),
track_start: 0.0,
track_length: 0.0,
thumb_length: 0.0,
max_scroll: 90,
grab_offset: 0.0,
inverted: false,
});
let events = dispatch_mouse_drag(&drag, pt(0.0, 0.0), buttons_mask_left());
assert_eq!(events.len(), 1);
assert!(matches!(events[0], UiEvent::MouseMoved { .. }));
}
#[test]
fn dispatch_mouse_up_clears_drag_state() {
let mut drag = DragState::new();
drag.begin(DragTarget::ScrollbarY {
widget: id("p"),
track_start: 0.0,
track_length: 10.0,
thumb_length: 5.0,
max_scroll: 10,
grab_offset: 0.0,
inverted: false,
});
let stack = ModalStack::new();
let events = dispatch_mouse_up(&stack, &mut drag, pt(5.0, 5.0), MouseButton::Left);
assert_eq!(events.len(), 1);
assert!(matches!(events[0], UiEvent::MouseUp { .. }));
assert!(!drag.is_active());
}
#[test]
fn split_divider_drag_horizontal_computes_ratio() {
let mut drag = DragState::new();
drag.begin(DragTarget::SplitDivider {
tree: id("tree"),
split_index: 0,
direction: SplitDirection::Horizontal,
axis_start: 0.0,
axis_size: 40.0,
});
let events = dispatch_mouse_drag(&drag, pt(30.0, 5.0), buttons_mask_left());
assert_eq!(events.len(), 2);
assert!(matches!(events[0], UiEvent::MouseMoved { .. }));
match &events[1] {
UiEvent::SplitDividerDragged {
tree,
split_index,
new_ratio,
} => {
assert_eq!(tree, &id("tree"));
assert_eq!(*split_index, 0);
assert!((new_ratio - 0.75).abs() < 0.001);
}
other => panic!("expected SplitDividerDragged, got {:?}", other),
}
}
#[test]
fn split_divider_drag_vertical_reads_y_axis() {
let mut drag = DragState::new();
drag.begin(DragTarget::SplitDivider {
tree: id("tree"),
split_index: 2,
direction: SplitDirection::Vertical,
axis_start: 100.0,
axis_size: 50.0,
});
let events = dispatch_mouse_drag(&drag, pt(999.0, 125.0), buttons_mask_left());
match &events[1] {
UiEvent::SplitDividerDragged {
split_index,
new_ratio,
..
} => {
assert_eq!(*split_index, 2);
assert!((new_ratio - 0.5).abs() < 0.001);
}
other => panic!("expected SplitDividerDragged, got {:?}", other),
}
}
#[test]
fn split_divider_drag_clamps_beyond_bounds() {
let mut drag = DragState::new();
drag.begin(DragTarget::SplitDivider {
tree: id("tree"),
split_index: 0,
direction: SplitDirection::Horizontal,
axis_start: 0.0,
axis_size: 40.0,
});
let events = dispatch_mouse_drag(&drag, pt(-100.0, 5.0), buttons_mask_left());
match &events[1] {
UiEvent::SplitDividerDragged { new_ratio, .. } => assert_eq!(*new_ratio, 0.0),
other => panic!("expected SplitDividerDragged, got {:?}", other),
}
let events = dispatch_mouse_drag(&drag, pt(1000.0, 5.0), buttons_mask_left());
match &events[1] {
UiEvent::SplitDividerDragged { new_ratio, .. } => assert_eq!(*new_ratio, 1.0),
other => panic!("expected SplitDividerDragged, got {:?}", other),
}
}
#[test]
fn split_divider_drag_with_zero_axis_size_does_not_crash_or_emit() {
let mut drag = DragState::new();
drag.begin(DragTarget::SplitDivider {
tree: id("tree"),
split_index: 0,
direction: SplitDirection::Horizontal,
axis_start: 0.0,
axis_size: 0.0,
});
let events = dispatch_mouse_drag(&drag, pt(5.0, 5.0), buttons_mask_left());
assert_eq!(events.len(), 1);
assert!(matches!(events[0], UiEvent::MouseMoved { .. }));
}
#[test]
fn split_divider_drag_ends_on_mouse_up() {
let mut drag = DragState::new();
drag.begin(DragTarget::SplitDivider {
tree: id("tree"),
split_index: 0,
direction: SplitDirection::Horizontal,
axis_start: 0.0,
axis_size: 40.0,
});
let stack = ModalStack::new();
dispatch_mouse_up(&stack, &mut drag, pt(5.0, 5.0), MouseButton::Left);
assert!(!drag.is_active());
}
#[test]
fn dispatch_mouse_drag_with_grab_offset_keeps_thumb_relative_to_cursor() {
let mut drag = DragState::new();
drag.begin(DragTarget::ScrollbarY {
widget: id("p"),
track_start: 100.0,
track_length: 80.0,
thumb_length: 16.0,
max_scroll: 80,
grab_offset: 8.0,
inverted: false,
});
let events = dispatch_mouse_drag(&drag, pt(500.0, 140.0), buttons_mask_left());
match &events[1] {
UiEvent::ScrollOffsetChanged { new_offset, .. } => assert_eq!(*new_offset, 40),
other => panic!("expected ScrollOffsetChanged, got {:?}", other),
}
}
#[test]
fn dispatch_mouse_drag_horizontal_scrollbar() {
let mut drag = DragState::new();
drag.begin(DragTarget::ScrollbarX {
widget: id("editor:hsb"),
track_start: 100.0,
track_length: 80.0,
thumb_length: 16.0,
max_scroll: 80,
grab_offset: 0.0,
inverted: false,
});
let events = dispatch_mouse_drag(&drag, pt(132.0, 500.0), buttons_mask_left());
assert_eq!(events.len(), 2);
match &events[1] {
UiEvent::ScrollOffsetChanged { widget, new_offset } => {
assert_eq!(widget, &id("editor:hsb"));
assert_eq!(*new_offset, 40);
}
other => panic!("expected ScrollOffsetChanged, got {:?}", other),
}
}
#[test]
fn dispatch_mouse_drag_horizontal_grab_offset_preserves_relative() {
let mut drag = DragState::new();
drag.begin(DragTarget::ScrollbarX {
widget: id("editor:hsb"),
track_start: 100.0,
track_length: 80.0,
thumb_length: 16.0,
max_scroll: 80,
grab_offset: 8.0,
inverted: false,
});
let events = dispatch_mouse_drag(&drag, pt(140.0, 500.0), buttons_mask_left());
match &events[1] {
UiEvent::ScrollOffsetChanged { new_offset, .. } => assert_eq!(*new_offset, 40),
other => panic!("expected ScrollOffsetChanged, got {:?}", other),
}
}
#[test]
fn painted_thumb_length_controls_effective_track() {
let mut drag = DragState::new();
drag.begin(DragTarget::ScrollbarX {
widget: id("editor:hsb"),
track_start: 100.0,
track_length: 400.0,
thumb_length: 20.0,
max_scroll: 3980,
grab_offset: 0.0,
inverted: false,
});
let events = dispatch_mouse_drag(&drag, pt(480.0, 0.0), buttons_mask_left());
match &events[1] {
UiEvent::ScrollOffsetChanged { new_offset, .. } => assert_eq!(*new_offset, 3980),
other => panic!("expected ScrollOffsetChanged, got {:?}", other),
}
let events = dispatch_mouse_drag(&drag, pt(290.0, 0.0), buttons_mask_left());
match &events[1] {
UiEvent::ScrollOffsetChanged { new_offset, .. } => assert_eq!(*new_offset, 1990),
other => panic!("expected ScrollOffsetChanged, got {:?}", other),
}
}
fn assert_round_trip(
label: &str,
track_start: f32,
track_length: f32,
total: usize,
visible: usize,
min_thumb: f32,
scroll: usize,
) {
use crate::primitives::scrollbar::fit_thumb;
let max_scroll = total.saturating_sub(visible);
let (thumb_start, thumb_len) = fit_thumb(
scroll as f32,
total as f32,
visible as f32,
track_length,
min_thumb,
);
let grab = thumb_len / 2.0;
let cursor_x = track_start + thumb_start + grab;
let mut drag = DragState::new();
drag.begin(DragTarget::ScrollbarX {
widget: id("sb"),
track_start,
track_length,
thumb_length: thumb_len,
max_scroll,
grab_offset: grab,
inverted: false,
});
let events = dispatch_mouse_drag(&drag, pt(cursor_x, 0.0), buttons_mask_left());
match &events[1] {
UiEvent::ScrollOffsetChanged { new_offset, .. } => {
assert_eq!(
*new_offset, scroll,
"{label}: expected offset {scroll}, got {new_offset}"
);
}
other => panic!("{label}: expected ScrollOffsetChanged, got {other:?}"),
}
}
#[test]
fn round_trip_vimcode_numbers_at_max() {
assert_round_trip("3609 max", 50.0, 800.0, 3609, 137, 20.0, 3609 - 137);
}
#[test]
fn round_trip_vimcode_numbers_at_zero() {
assert_round_trip("3609 zero", 50.0, 800.0, 3609, 137, 20.0, 0);
}
#[test]
fn round_trip_vimcode_numbers_at_midpoint() {
assert_round_trip("3609 mid", 50.0, 800.0, 3609, 137, 20.0, 1736);
}
#[test]
fn round_trip_small_content_at_max() {
assert_round_trip("small max", 0.0, 100.0, 200, 50, 10.0, 150);
}
#[test]
fn round_trip_large_min_thumb_at_max() {
assert_round_trip("big thumb max", 0.0, 100.0, 5000, 50, 40.0, 4950);
}
#[test]
fn round_trip_cursor_past_track_end_reaches_max() {
use crate::primitives::scrollbar::fit_thumb;
let track_start = 50.0;
let track_length = 800.0;
let total = 3609_usize;
let visible = 137_usize;
let max_scroll = total - visible;
let (_, thumb_len) = fit_thumb(0.0, total as f32, visible as f32, track_length, 20.0);
let mut drag = DragState::new();
drag.begin(DragTarget::ScrollbarX {
widget: id("sb"),
track_start,
track_length,
thumb_length: thumb_len,
max_scroll,
grab_offset: 0.0,
inverted: false,
});
let events = dispatch_mouse_drag(
&drag,
pt(track_start + track_length + 200.0, 0.0),
buttons_mask_left(),
);
match &events[1] {
UiEvent::ScrollOffsetChanged { new_offset, .. } => {
assert_eq!(*new_offset, max_scroll);
}
other => panic!("expected ScrollOffsetChanged, got {other:?}"),
}
}
#[test]
fn dispatch_mouse_up_carries_modal_widget_if_over() {
let mut stack = ModalStack::new();
stack.push(id("palette"), rect(0.0, 0.0, 100.0, 100.0));
let mut drag = DragState::new();
let events = dispatch_mouse_up(&stack, &mut drag, pt(50.0, 50.0), MouseButton::Left);
match &events[0] {
UiEvent::MouseUp { widget, .. } => {
assert_eq!(widget.as_ref().unwrap(), &id("palette"));
}
_ => panic!(),
}
}
fn delta(dy: f32) -> ScrollDelta {
ScrollDelta::new(0.0, dy)
}
#[test]
fn scroll_inside_modal_routes_to_modal() {
let mut stack = ModalStack::new();
stack.push(id("picker"), rect(10.0, 10.0, 100.0, 100.0));
let events = dispatch_scroll(&stack, &[], pt(50.0, 50.0), delta(-3.0));
assert_eq!(events.len(), 1);
match &events[0] {
UiEvent::Scroll {
widget, delta: d, ..
} => {
assert_eq!(widget.as_ref().unwrap(), &id("picker"));
assert_eq!(d.y, -3.0);
}
other => panic!("expected Scroll, got {:?}", other),
}
}
#[test]
fn scroll_outside_open_modal_is_swallowed() {
let mut stack = ModalStack::new();
stack.push(id("dialog"), rect(10.0, 10.0, 50.0, 50.0));
let events = dispatch_scroll(&stack, &[], pt(500.0, 500.0), delta(-1.0));
assert!(events.is_empty(), "scroll behind modal should be swallowed");
}
#[test]
fn scroll_routes_to_topmost_surface() {
let stack = ModalStack::new();
let surfaces = vec![
ScrollSurface {
id: id("editor"),
bounds: rect(0.0, 0.0, 200.0, 200.0),
scrollbar: None,
},
ScrollSurface {
id: id("sidebar"),
bounds: rect(0.0, 0.0, 50.0, 200.0),
scrollbar: None,
},
];
let events = dispatch_scroll(&stack, &surfaces, pt(25.0, 100.0), delta(-1.0));
assert_eq!(events.len(), 1);
match &events[0] {
UiEvent::Scroll { widget, .. } => {
assert_eq!(widget.as_ref().unwrap(), &id("sidebar"));
}
other => panic!("expected Scroll, got {:?}", other),
}
}
#[test]
fn scroll_on_non_overlapping_surface() {
let stack = ModalStack::new();
let surfaces = vec![
ScrollSurface {
id: id("editor"),
bounds: rect(50.0, 0.0, 150.0, 200.0),
scrollbar: None,
},
ScrollSurface {
id: id("sidebar"),
bounds: rect(0.0, 0.0, 50.0, 200.0),
scrollbar: None,
},
];
let events = dispatch_scroll(&stack, &surfaces, pt(100.0, 100.0), delta(2.0));
match &events[0] {
UiEvent::Scroll { widget, .. } => {
assert_eq!(widget.as_ref().unwrap(), &id("editor"));
}
other => panic!("expected Scroll, got {:?}", other),
}
}
#[test]
fn scroll_with_no_surfaces_emits_no_widget() {
let stack = ModalStack::new();
let events = dispatch_scroll(&stack, &[], pt(50.0, 50.0), delta(-1.0));
assert_eq!(events.len(), 1);
match &events[0] {
UiEvent::Scroll { widget, .. } => {
assert!(widget.is_none());
}
other => panic!("expected Scroll, got {:?}", other),
}
}
#[test]
fn scroll_outside_all_surfaces_emits_no_widget() {
let stack = ModalStack::new();
let surfaces = vec![ScrollSurface {
id: id("panel"),
bounds: rect(0.0, 0.0, 50.0, 50.0),
scrollbar: None,
}];
let events = dispatch_scroll(&stack, &surfaces, pt(100.0, 100.0), delta(-1.0));
match &events[0] {
UiEvent::Scroll { widget, .. } => {
assert!(widget.is_none());
}
other => panic!("expected Scroll, got {:?}", other),
}
}
fn surface_with_scrollbar() -> ScrollSurface {
ScrollSurface {
id: id("log"),
bounds: rect(0.0, 0.0, 40.0, 30.0),
scrollbar: Some(SurfaceScrollbar {
axis: ScrollAxis::Vertical,
track_bounds: rect(39.0, 0.0, 1.0, 30.0),
thumb_bounds: rect(39.0, 20.0, 1.0, 8.0),
total_items: 100,
visible_items: 30,
scroll_offset: 60,
inverted: false,
}),
}
}
#[test]
fn click_thumb_starts_drag() {
let stack = ModalStack::new();
let surfaces = vec![surface_with_scrollbar()];
let mut drag = DragState::new();
let events = dispatch_click(
&stack,
&surfaces,
&[],
&mut drag,
pt(39.5, 22.0),
MouseButton::Left,
Modifiers::default(),
);
assert_eq!(events.len(), 1);
assert!(matches!(
&events[0],
UiEvent::MouseDown {
widget: Some(w),
..
} if w.as_str() == "log"
));
assert!(drag.is_active());
match drag.target().unwrap() {
DragTarget::ScrollbarY {
widget, max_scroll, ..
} => {
assert_eq!(widget.as_str(), "log");
assert_eq!(*max_scroll, 70);
}
other => panic!("expected ScrollbarY, got {:?}", other),
}
}
#[test]
fn click_track_before_pages_up() {
let stack = ModalStack::new();
let surfaces = vec![surface_with_scrollbar()];
let mut drag = DragState::new();
let events = dispatch_click(
&stack,
&surfaces,
&[],
&mut drag,
pt(39.5, 5.0),
MouseButton::Left,
Modifiers::default(),
);
assert_eq!(events.len(), 1);
match &events[0] {
UiEvent::ScrollOffsetChanged { widget, new_offset } => {
assert_eq!(widget.as_str(), "log");
assert_eq!(*new_offset, 30);
}
other => panic!("expected ScrollOffsetChanged, got {:?}", other),
}
assert!(!drag.is_active());
}
#[test]
fn click_track_after_pages_down() {
let stack = ModalStack::new();
let surfaces = vec![surface_with_scrollbar()];
let mut drag = DragState::new();
let events = dispatch_click(
&stack,
&surfaces,
&[],
&mut drag,
pt(39.5, 29.0),
MouseButton::Left,
Modifiers::default(),
);
assert_eq!(events.len(), 1);
match &events[0] {
UiEvent::ScrollOffsetChanged { widget, new_offset } => {
assert_eq!(widget.as_str(), "log");
assert_eq!(*new_offset, 70);
}
other => panic!("expected ScrollOffsetChanged, got {:?}", other),
}
}
#[test]
fn click_body_not_scrollbar_emits_mousedown() {
let stack = ModalStack::new();
let surfaces = vec![surface_with_scrollbar()];
let mut drag = DragState::new();
let events = dispatch_click(
&stack,
&surfaces,
&[],
&mut drag,
pt(10.0, 10.0),
MouseButton::Left,
Modifiers::default(),
);
assert_eq!(events.len(), 1);
assert!(matches!(
&events[0],
UiEvent::MouseDown {
widget: Some(w),
..
} if w.as_str() == "log"
));
assert!(!drag.is_active());
}
#[test]
fn click_no_surfaces_emits_no_widget() {
let stack = ModalStack::new();
let mut drag = DragState::new();
let events = dispatch_click(
&stack,
&[],
&[],
&mut drag,
pt(50.0, 50.0),
MouseButton::Left,
Modifiers::default(),
);
assert_eq!(events.len(), 1);
assert!(matches!(
&events[0],
UiEvent::MouseDown { widget: None, .. }
));
}
#[test]
fn click_modal_takes_priority_over_surfaces() {
let mut stack = ModalStack::new();
stack.push(id("dialog"), rect(0.0, 0.0, 100.0, 100.0));
let surfaces = vec![surface_with_scrollbar()];
let mut drag = DragState::new();
let events = dispatch_click(
&stack,
&surfaces,
&[],
&mut drag,
pt(39.5, 22.0),
MouseButton::Left,
Modifiers::default(),
);
assert_eq!(events.len(), 1);
assert!(matches!(
&events[0],
UiEvent::MouseDown {
widget: Some(w),
..
} if w.as_str() == "dialog"
));
assert!(!drag.is_active());
}
#[test]
fn inverted_drag_flips_offset() {
let mut drag = DragState::new();
drag.begin(DragTarget::ScrollbarY {
widget: id("term"),
track_start: 100.0,
track_length: 80.0,
thumb_length: 16.0,
max_scroll: 80,
grab_offset: 0.0,
inverted: true,
});
let events = dispatch_mouse_drag(&drag, pt(0.0, 100.0), buttons_mask_left());
match &events[1] {
UiEvent::ScrollOffsetChanged { new_offset, .. } => assert_eq!(*new_offset, 80),
other => panic!("expected ScrollOffsetChanged, got {:?}", other),
}
let events = dispatch_mouse_drag(&drag, pt(0.0, 500.0), buttons_mask_left());
match &events[1] {
UiEvent::ScrollOffsetChanged { new_offset, .. } => assert_eq!(*new_offset, 0),
other => panic!("expected ScrollOffsetChanged, got {:?}", other),
}
}
#[test]
fn inverted_drag_halfway_matches_non_inverted() {
let mut drag = DragState::new();
drag.begin(DragTarget::ScrollbarY {
widget: id("term"),
track_start: 100.0,
track_length: 80.0,
thumb_length: 16.0,
max_scroll: 80,
grab_offset: 0.0,
inverted: true,
});
let events = dispatch_mouse_drag(&drag, pt(0.0, 132.0), buttons_mask_left());
match &events[1] {
UiEvent::ScrollOffsetChanged { new_offset, .. } => assert_eq!(*new_offset, 40),
other => panic!("expected ScrollOffsetChanged, got {:?}", other),
}
}
fn surface_with_inverted_scrollbar() -> ScrollSurface {
ScrollSurface {
id: id("term"),
bounds: rect(0.0, 0.0, 40.0, 30.0),
scrollbar: Some(SurfaceScrollbar {
axis: ScrollAxis::Vertical,
track_bounds: rect(39.0, 0.0, 1.0, 30.0),
thumb_bounds: rect(39.0, 20.0, 1.0, 8.0),
total_items: 100,
visible_items: 30,
scroll_offset: 60,
inverted: true,
}),
}
}
#[test]
fn inverted_thumb_click_propagates_flag() {
let stack = ModalStack::new();
let surfaces = vec![surface_with_inverted_scrollbar()];
let mut drag = DragState::new();
dispatch_click(
&stack,
&surfaces,
&[],
&mut drag,
pt(39.5, 22.0),
MouseButton::Left,
Modifiers::default(),
);
assert!(drag.is_active());
match drag.target().unwrap() {
DragTarget::ScrollbarY { inverted, .. } => assert!(*inverted),
DragTarget::ScrollbarX { .. }
| DragTarget::TextSelection { .. }
| DragTarget::SplitDivider { .. } => {
panic!("expected ScrollbarY")
}
}
}
#[test]
fn inverted_track_click_flips_page_direction() {
let stack = ModalStack::new();
let surfaces = vec![surface_with_inverted_scrollbar()];
let mut drag = DragState::new();
let events = dispatch_click(
&stack,
&surfaces,
&[],
&mut drag,
pt(39.5, 5.0),
MouseButton::Left,
Modifiers::default(),
);
match &events[0] {
UiEvent::ScrollOffsetChanged { new_offset, .. } => assert_eq!(*new_offset, 70),
other => panic!("expected ScrollOffsetChanged, got {:?}", other),
}
let events = dispatch_click(
&stack,
&surfaces,
&[],
&mut drag,
pt(39.5, 29.0),
MouseButton::Left,
Modifiers::default(),
);
match &events[0] {
UiEvent::ScrollOffsetChanged { new_offset, .. } => assert_eq!(*new_offset, 30),
other => panic!("expected ScrollOffsetChanged, got {:?}", other),
}
}
fn surface_with_h_scrollbar() -> ScrollSurface {
ScrollSurface {
id: id("editor"),
bounds: rect(0.0, 0.0, 400.0, 300.0),
scrollbar: Some(SurfaceScrollbar {
axis: ScrollAxis::Horizontal,
track_bounds: rect(0.0, 296.0, 400.0, 4.0),
thumb_bounds: rect(50.0, 296.0, 40.0, 4.0),
total_items: 2000,
visible_items: 200,
scroll_offset: 250,
inverted: false,
}),
}
}
#[test]
fn h_scrollbar_thumb_click_starts_scrollbar_x_drag() {
let stack = ModalStack::new();
let surfaces = vec![surface_with_h_scrollbar()];
let mut drag = DragState::new();
let events = dispatch_click(
&stack,
&surfaces,
&[],
&mut drag,
pt(60.0, 297.0),
MouseButton::Left,
Modifiers::default(),
);
assert_eq!(events.len(), 1);
assert!(
matches!(&events[0], UiEvent::MouseDown { widget: Some(w), .. } if w.as_str() == "editor")
);
assert!(drag.is_active());
match drag.target().unwrap() {
DragTarget::ScrollbarX {
widget,
track_start,
track_length,
thumb_length,
max_scroll,
grab_offset,
..
} => {
assert_eq!(widget.as_str(), "editor");
assert_eq!(*track_start, 0.0);
assert_eq!(*track_length, 400.0);
assert_eq!(*thumb_length, 40.0);
assert_eq!(*max_scroll, 1800);
assert!(*grab_offset > 9.0 && *grab_offset < 11.0); }
DragTarget::ScrollbarY { .. }
| DragTarget::TextSelection { .. }
| DragTarget::SplitDivider { .. } => {
panic!("expected ScrollbarX")
}
}
}
#[test]
fn h_scrollbar_track_click_left_of_thumb_pages_back() {
let stack = ModalStack::new();
let surfaces = vec![surface_with_h_scrollbar()];
let mut drag = DragState::new();
let events = dispatch_click(
&stack,
&surfaces,
&[],
&mut drag,
pt(10.0, 297.0),
MouseButton::Left,
Modifiers::default(),
);
match &events[0] {
UiEvent::ScrollOffsetChanged { widget, new_offset } => {
assert_eq!(widget.as_str(), "editor");
assert_eq!(*new_offset, 50);
}
other => panic!("expected ScrollOffsetChanged, got {:?}", other),
}
}
#[test]
fn h_scrollbar_track_click_right_of_thumb_pages_forward() {
let stack = ModalStack::new();
let surfaces = vec![surface_with_h_scrollbar()];
let mut drag = DragState::new();
let events = dispatch_click(
&stack,
&surfaces,
&[],
&mut drag,
pt(200.0, 297.0),
MouseButton::Left,
Modifiers::default(),
);
match &events[0] {
UiEvent::ScrollOffsetChanged { widget, new_offset } => {
assert_eq!(widget.as_str(), "editor");
assert_eq!(*new_offset, 450);
}
other => panic!("expected ScrollOffsetChanged, got {:?}", other),
}
}
#[test]
fn h_scrollbar_drag_produces_scroll_offset() {
let stack = ModalStack::new();
let surfaces = vec![surface_with_h_scrollbar()];
let mut drag = DragState::new();
dispatch_click(
&stack,
&surfaces,
&[],
&mut drag,
pt(60.0, 297.0),
MouseButton::Left,
Modifiers::default(),
);
assert!(drag.is_active());
let events = dispatch_mouse_drag(&drag, pt(370.0, 297.0), buttons_mask_left());
assert_eq!(events.len(), 2);
match &events[1] {
UiEvent::ScrollOffsetChanged { widget, new_offset } => {
assert_eq!(widget.as_str(), "editor");
assert_eq!(*new_offset, 1800); }
other => panic!("expected ScrollOffsetChanged, got {:?}", other),
}
}
#[test]
fn click_text_region_starts_text_selection_drag() {
let stack = ModalStack::new();
let mut drag = DragState::new();
let text_regions = vec![TextRegion {
id: id("log:body"),
bounds: rect(0.0, 0.0, 40.0, 30.0),
lines: vec![],
}];
let events = dispatch_click(
&stack,
&[],
&text_regions,
&mut drag,
pt(10.0, 5.0),
MouseButton::Left,
Modifiers::default(),
);
assert_eq!(events.len(), 1);
assert!(matches!(
&events[0],
UiEvent::MouseDown {
widget: Some(w), ..
} if w.as_str() == "log:body"
));
assert!(drag.is_active());
match drag.target().unwrap() {
DragTarget::TextSelection { region, anchor } => {
assert_eq!(region.as_str(), "log:body");
assert_eq!(*anchor, pt(10.0, 5.0));
}
other => panic!("expected TextSelection, got {:?}", other),
}
}
#[test]
fn scrollbar_wins_over_text_region() {
let stack = ModalStack::new();
let surfaces = vec![surface_with_scrollbar()];
let text_regions = vec![TextRegion {
id: id("log:body"),
bounds: rect(0.0, 0.0, 40.0, 30.0),
lines: vec![],
}];
let mut drag = DragState::new();
dispatch_click(
&stack,
&surfaces,
&text_regions,
&mut drag,
pt(39.5, 22.0),
MouseButton::Left,
Modifiers::default(),
);
assert!(matches!(
drag.target().unwrap(),
DragTarget::ScrollbarY { .. }
));
}
#[test]
fn text_region_wins_over_surface_body() {
let stack = ModalStack::new();
let surfaces = vec![ScrollSurface {
id: id("log"),
bounds: rect(0.0, 0.0, 40.0, 30.0),
scrollbar: None,
}];
let text_regions = vec![TextRegion {
id: id("log:body"),
bounds: rect(0.0, 0.0, 40.0, 30.0),
lines: vec![],
}];
let mut drag = DragState::new();
let events = dispatch_click(
&stack,
&surfaces,
&text_regions,
&mut drag,
pt(10.0, 5.0),
MouseButton::Left,
Modifiers::default(),
);
assert!(matches!(
&events[0],
UiEvent::MouseDown { widget: Some(w), .. } if w.as_str() == "log:body"
));
assert!(matches!(
drag.target().unwrap(),
DragTarget::TextSelection { .. }
));
}
#[test]
fn modal_wins_over_text_region() {
let mut stack = ModalStack::new();
stack.push(id("dialog"), rect(0.0, 0.0, 100.0, 100.0));
let text_regions = vec![TextRegion {
id: id("body"),
bounds: rect(0.0, 0.0, 100.0, 100.0),
lines: vec![],
}];
let mut drag = DragState::new();
let events = dispatch_click(
&stack,
&[],
&text_regions,
&mut drag,
pt(50.0, 50.0),
MouseButton::Left,
Modifiers::default(),
);
assert!(!drag.is_active());
assert!(matches!(
&events[0],
UiEvent::MouseDown { widget: Some(w), .. } if w.as_str() == "dialog"
));
}
#[test]
fn dispatch_mouse_drag_text_selection_emits_changed() {
let mut drag = DragState::new();
drag.begin(DragTarget::TextSelection {
region: id("log:body"),
anchor: pt(5.0, 2.0),
});
let events = dispatch_mouse_drag(&drag, pt(10.0, 5.0), buttons_mask_left());
assert_eq!(events.len(), 2);
assert!(matches!(events[0], UiEvent::MouseMoved { .. }));
match &events[1] {
UiEvent::TextSelectionChanged {
region,
anchor,
focus,
} => {
assert_eq!(region.as_str(), "log:body");
assert_eq!(*anchor, pt(5.0, 2.0));
assert_eq!(*focus, pt(10.0, 5.0));
}
other => panic!("expected TextSelectionChanged, got {:?}", other),
}
}
#[test]
fn line_range_empty_when_anchor_equals_focus() {
let bounds = rect(0.0, 0.0, 40.0, 10.0);
let ranges = text_selection_line_range(pt(5.0, 3.0), pt(5.0, 3.0), bounds);
assert!(ranges.is_empty(), "collapsed selection must be empty");
}
#[test]
fn line_range_single_row() {
let bounds = rect(0.0, 0.0, 40.0, 10.0);
let ranges = text_selection_line_range(pt(5.0, 3.0), pt(10.0, 3.0), bounds);
assert_eq!(ranges, vec![(3, 5.0, 11.0)]);
}
#[test]
fn line_range_single_row_reversed() {
let bounds = rect(0.0, 0.0, 40.0, 10.0);
let ranges = text_selection_line_range(pt(10.0, 3.0), pt(5.0, 3.0), bounds);
assert_eq!(ranges, vec![(3, 5.0, 11.0)]);
}
#[test]
fn line_range_multi_row() {
let bounds = rect(0.0, 0.0, 40.0, 10.0);
let ranges = text_selection_line_range(pt(2.0, 1.0), pt(5.0, 3.0), bounds);
assert_eq!(ranges, vec![(1, 2.0, 40.0), (2, 0.0, 40.0), (3, 0.0, 6.0)]);
}
#[test]
fn line_range_multi_row_reversed() {
let bounds = rect(0.0, 0.0, 40.0, 10.0);
let ranges = text_selection_line_range(pt(5.0, 3.0), pt(2.0, 1.0), bounds);
assert_eq!(ranges, vec![(1, 2.0, 40.0), (2, 0.0, 40.0), (3, 0.0, 6.0)]);
}
#[test]
fn line_range_clamped_to_bounds() {
let bounds = rect(5.0, 2.0, 20.0, 5.0);
let ranges = text_selection_line_range(pt(0.0, 0.0), pt(100.0, 100.0), bounds);
assert_eq!(
ranges[0],
(2, 5.0, 25.0),
"first row: anchor clamped to region x"
);
assert_eq!(
*ranges.last().unwrap(),
(6, 5.0, 25.0),
"last row: focus clamped to region end"
);
for &(_, c_start, c_end) in &ranges[1..ranges.len() - 1] {
assert_eq!(c_start, 5.0);
assert_eq!(c_end, 25.0);
}
}
fn mouse_down(x: f32, y: f32) -> UiEvent {
UiEvent::MouseDown {
widget: None,
button: MouseButton::Left,
position: Point::new(x, y),
modifiers: Modifiers::default(),
}
}
#[test]
fn double_click_detected_on_same_position() {
let mut det = DoubleClickDetector::new();
let mut events = vec![mouse_down(5.0, 3.0)];
det.process(&mut events);
assert!(matches!(events[0], UiEvent::MouseDown { .. }));
let mut events = vec![mouse_down(5.0, 3.0)];
det.process(&mut events);
assert!(
matches!(events[0], UiEvent::DoubleClick { .. }),
"second click at same position should be DoubleClick"
);
}
#[test]
fn no_double_click_at_different_position() {
let mut det = DoubleClickDetector::new();
let mut events = vec![mouse_down(5.0, 3.0)];
det.process(&mut events);
let mut events = vec![mouse_down(20.0, 10.0)];
det.process(&mut events);
assert!(
matches!(events[0], UiEvent::MouseDown { .. }),
"click at different position should NOT be DoubleClick"
);
}
#[test]
fn triple_click_resets_to_single() {
let mut det = DoubleClickDetector::new();
let mut events = vec![mouse_down(5.0, 3.0)];
det.process(&mut events);
let mut events = vec![mouse_down(5.0, 3.0)];
det.process(&mut events);
assert!(matches!(events[0], UiEvent::DoubleClick { .. }));
let mut events = vec![mouse_down(5.0, 3.0)];
det.process(&mut events);
assert!(
matches!(events[0], UiEvent::MouseDown { .. }),
"third click should reset to MouseDown"
);
}
}