Skip to main content

quadraui/
dispatch.rs

1//! Cross-backend mouse + scroll dispatch.
2//!
3//! Backends call into these free functions with raw (platform-translated)
4//! mouse and scroll events. Quadraui consults the [`ModalStack`] first,
5//! decides which widget — if any — should receive the event, and returns
6//! a `Vec<UiEvent>` the backend pushes onto its per-frame event queue.
7//!
8//! The key guarantee: **events landing inside an open modal cannot fall
9//! through to widgets behind it**. This centralizes modal event handling
10//! across backends, eliminating bugs where modals are added to one backend
11//! but forgotten in another.
12//!
13//! # What's here
14//!
15//! [`dispatch_mouse_down`], [`dispatch_mouse_drag`], [`dispatch_mouse_up`],
16//! [`dispatch_scroll`], and [`dispatch_click`] — the full event-routing
17//! pipeline for modal-aware mouse and scroll handling.
18//!
19//! # What's explicitly not here
20//!
21//! - Inner hit refinement within a modal — `Palette`, `Dialog`, etc.
22//!   have their own `*Layout::hit_test` for that. The dispatcher
23//!   identifies the topmost modal under the cursor; the app still
24//!   calls the primitive's hit test afterward if it needs an
25//!   item-level target.
26//! - Base-layer hit testing — the editor, sidebar, tabs, and so on.
27//!   Those events still go through each backend's existing mouse
28//!   handlers, which are already per-backend. A future change could
29//!   route them through here too.
30
31use 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// ─── Drag state ─────────────────────────────────────────────────────────────
40
41/// What's being dragged, if anything. Backends hold one [`DragState`]
42/// (typically on the same struct that owns the [`ModalStack`]) and
43/// update it from the click / drag / release handlers via
44/// [`DragState::begin`] / [`DragState::end`]. The dispatch functions
45/// below consult it to decide which primitive-specific event to emit.
46#[derive(Debug, Clone, PartialEq)]
47pub enum DragTarget {
48    /// A vertical scrollbar drag. `track_start` and `track_length`
49    /// are in the backend's native units (pixels for GTK; cells for
50    /// TUI) and define the track region the thumb can traverse.
51    /// `max_scroll` is supplied directly by the caller (typically
52    /// `total - visible`) so the dispatcher doesn't need to know
53    /// the unit system. The dispatcher maps a drag
54    /// point's y to a scroll offset via linear interpolation:
55    ///
56    /// ```text
57    /// rel    = (y - track_start) / track_length   (clamped 0..=1)
58    /// offset = round(rel * max_scroll)
59    /// ```
60    ScrollbarY {
61        /// Which widget's scrollbar is being dragged. Used to route
62        /// the resulting event back to the right primitive.
63        widget: WidgetId,
64        /// Track top in the backend's native y-coordinate.
65        track_start: f32,
66        /// Track length in the backend's native units. Must be > 0.
67        track_length: f32,
68        /// Actual painted thumb length in the same units as
69        /// `track_length`. The dispatcher uses this directly for
70        /// `effective_track = track_length - thumb_length` — no
71        /// recomputation from visible/total counts.
72        thumb_length: f32,
73        /// Maximum scroll offset the drag can produce. The caller
74        /// supplies this directly (typically `total - visible`)
75        /// so the dispatcher doesn't need to know the unit system.
76        max_scroll: usize,
77        /// Where on the thumb the cursor was at click-down time, as
78        /// the cursor's offset from the thumb's top in the backend's
79        /// native units. The dispatcher subtracts this from
80        /// `position.y` before computing `rel`, so the cursor stays
81        /// at the same relative spot on the thumb during the drag —
82        /// the standard "thumb doesn't jump when you grab it" UX
83        /// every native scrollbar provides. Set to `0.0` for clicks
84        /// on the track *outside* the thumb (jump-to-position
85        /// behavior — the thumb hops so its top lands at the cursor).
86        grab_offset: f32,
87        /// When true, offset 0 means "at the bottom" (scrollback
88        /// style). The dispatcher flips the ratio so the thumb sits
89        /// at the bottom when offset is 0 and at the top when fully
90        /// scrolled back.
91        inverted: bool,
92    },
93    /// A horizontal scrollbar drag. Same shape as [`DragTarget::ScrollbarY`]
94    /// but operates on the x-axis: `track_start` is the leftmost x of
95    /// the track, `track_length` is the track width, and the dispatcher
96    /// reads `position.x` (not `position.y`) to compute the offset.
97    ScrollbarX {
98        widget: WidgetId,
99        /// Track left in the backend's native x-coordinate.
100        track_start: f32,
101        /// Track length in the backend's native units. Must be > 0.
102        track_length: f32,
103        /// Actual painted thumb length. See
104        /// [`DragTarget::ScrollbarY::thumb_length`].
105        thumb_length: f32,
106        /// Maximum scroll offset. See
107        /// [`DragTarget::ScrollbarY::max_scroll`].
108        max_scroll: usize,
109        /// Cursor's x-offset from the thumb's left at click-down. See
110        /// [`DragTarget::ScrollbarY`] for why this matters.
111        grab_offset: f32,
112        /// When true, offset 0 means "at the right edge". See
113        /// [`DragTarget::ScrollbarY::inverted`].
114        inverted: bool,
115    },
116    /// A text-selection drag. `region` is the id of the
117    /// [`TextRegion`] where the drag started; `anchor` is the screen
118    /// position at click-down. The anchor is fixed for the lifetime of
119    /// the drag; [`dispatch_mouse_drag`] reads it to emit
120    /// [`UiEvent::TextSelectionChanged`] with the current cursor as
121    /// the `focus`.
122    TextSelection {
123        /// Which text region is being selected.
124        region: WidgetId,
125        /// Screen position where the drag started.
126        anchor: Point,
127    },
128    /// A [`crate::SplitTree`] divider drag. `tree` identifies which
129    /// tree this divider belongs to — apps hosting more than one split
130    /// tree (e.g. vimcode's per-window-group tree and its own in-group
131    /// vim-window tree) use this to route the resulting event to the
132    /// right state. `split_index` is the divider's pre-order index
133    /// (see [`crate::SplitTreeDivider::split_index`]).
134    ///
135    /// `axis_start` / `axis_size` are the *parent* rect's extent along
136    /// the split axis — copied from
137    /// [`crate::SplitTreeDivider::axis_start`] /
138    /// [`crate::SplitTreeDivider::axis_size`] at drag-start so
139    /// [`dispatch_mouse_drag`] can convert a cursor position directly
140    /// into a ratio without re-running the tree's `layout()` on every
141    /// mouse-move:
142    ///
143    /// ```text
144    /// ratio = (cursor_axis - axis_start) / axis_size   (clamped 0..=1)
145    /// ```
146    ///
147    /// `direction` selects which cursor coordinate feeds `cursor_axis`
148    /// (`x` for [`SplitDirection::Horizontal`], `y` for
149    /// [`SplitDirection::Vertical`] — matching
150    /// [`crate::SplitTree::layout`]'s axis convention).
151    SplitDivider {
152        tree: WidgetId,
153        split_index: usize,
154        direction: SplitDirection,
155        axis_start: f32,
156        axis_size: f32,
157    },
158}
159
160/// One drag in progress, or none. Backends hold one instance; call
161/// [`Self::begin`] on mouse-down over a draggable region and
162/// [`Self::end`] on mouse-up. The dispatch functions here read it to
163/// decide whether a mouse-move should produce a drag-update event.
164#[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    /// Start tracking a drag. Overwrites any previous state —
175    /// backends are expected to call [`Self::end`] on mouse-up before
176    /// beginning the next drag, but overwriting is the safer default
177    /// than panicking (spurious duplicate begins do happen in
178    /// gesture-heavy paths).
179    pub fn begin(&mut self, target: DragTarget) {
180        self.current = Some(target);
181    }
182
183    /// Clear the drag. No-op if nothing is in progress.
184    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
197// ─── Dispatch functions ─────────────────────────────────────────────────────
198
199/// Translate a raw mouse-down event into a `Vec<UiEvent>`, consulting
200/// the modal stack first.
201///
202/// # Returns
203///
204/// Three cases:
205///
206/// 1. **Click landed on an open modal.** Emits
207///    `[UiEvent::MouseDown { widget: Some(id), .. }]`. The app's
208///    dispatch matches on the widget id and routes to the modal's
209///    inner hit-test if it needs finer resolution.
210/// 2. **Click landed outside every open modal, but a modal is open.**
211///    Emits `[UiEvent::MouseDown { widget: None, .. }, UiEvent::Palette(id, Closed)]`
212///    where `id` is the topmost modal (the one the backdrop click
213///    dismisses). This is the "click outside to close" convention every
214///    desktop platform follows. **Base-layer widgets must not receive
215///    the event** — the event vec doesn't include a second
216///    `MouseDown` for a base widget, and the caller should consume on
217///    the emission of `PaletteEvent::Closed`.
218/// 3. **No modal open.** Emits `[UiEvent::MouseDown { widget: None, .. }]`
219///    with no primitive event; the backend's existing base-layer
220///    mouse handlers deal with it as they did before the pilot.
221///
222/// Callers that only care about case 1 (e.g. a GTK event handler that
223/// wants to stop the drag from leaking to the editor) can check the
224/// returned vec:
225///
226/// ```ignore
227/// let events = dispatch_mouse_down(&stack, pos, button, mods);
228/// if events.iter().any(|e| matches!(e, UiEvent::MouseDown { widget: Some(_), .. })
229///     || matches!(e, UiEvent::Palette(_, PaletteEvent::Closed)))
230/// {
231///     // modal consumed the click — don't run base-layer dispatch
232///     return;
233/// }
234/// ```
235///
236/// # Note on `PaletteEvent::Closed`
237///
238/// Today this dispatcher always emits [`PaletteEvent::Closed`] for
239/// backdrop clicks regardless of which primitive type is topmost on
240/// the stack. That's a pilot-scope simplification — the palette is
241/// the only consumer wired up in commit 1. When a second modal type
242/// needs the backdrop-dismiss behaviour (e.g. Dialog), we'll generalise
243/// to a `ModalDismissed(WidgetId)` event or per-primitive variants.
244pub fn dispatch_mouse_down(
245    stack: &ModalStack,
246    position: Point,
247    button: MouseButton,
248    modifiers: Modifiers,
249) -> Vec<UiEvent> {
250    // Case 1: click landed inside an open modal.
251    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    // Case 2: modal(s) open but click was outside them → dismiss topmost.
261    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    // Case 3: no modals open. Event belongs to the base layer.
274    vec![UiEvent::MouseDown {
275        widget: None,
276        button,
277        position,
278        modifiers,
279    }]
280}
281
282/// Translate a mouse-move event. When no drag is in progress, emits a
283/// plain [`UiEvent::MouseMoved`]. When a [`DragTarget::ScrollbarY`]
284/// drag is active, additionally emits a generic
285/// [`UiEvent::ScrollOffsetChanged { widget, new_offset }`] with the
286/// derived scroll offset. The app's dispatch matches on the event
287/// (and switches on `widget` to route to the right scroll-state
288/// field) without needing the track geometry — this function owns
289/// the translation.
290///
291/// # How the offset is computed
292///
293/// `ratio = ((point.y - track_start) / track_length).clamp(0, 1)`
294/// `new_offset = round(ratio * max_scroll)` where `max_scroll` is
295/// supplied directly by the `DragTarget`.
296///
297/// This mirrors the math TUI already uses in `mouse.rs`'s
298/// `dragging_picker_sb` branch, extended to f32 so it works for
299/// pixel-unit backends (GTK, macOS) as well as cell-unit backends
300/// (TUI).
301pub 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
378/// Translate a mouse-up event. Always emits [`UiEvent::MouseUp`] and
379/// clears any active drag state. If the click landed inside a modal,
380/// the `MouseUp` carries the modal's `widget` id — matching
381/// [`dispatch_mouse_down`]'s precedence — so apps can treat a drag
382/// that crosses the modal boundary atomically.
383pub 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// ─── Text region ─────────────────────────────────────────────────────────────
399
400/// A selectable text region registered during paint. Apps push one
401/// entry per selectable text area each frame;
402/// [`dispatch_click`] hit-tests the list to begin a
403/// [`DragTarget::TextSelection`] drag when the user clicks inside.
404///
405/// Push in paint order (back-to-front). The dispatcher walks
406/// last-to-first so the topmost-painted region wins on overlap.
407/// Text regions take priority over [`ScrollSurface`] *bodies* (not
408/// scrollbars) — a text region registered inside a scroll surface body
409/// will capture clicks first.
410///
411/// # Relationship with `ScrollSurface`
412///
413/// A text region is typically co-located with a scroll surface: the
414/// scroll surface owns the scrollbar; the text region owns the content
415/// body. Register them both, and the precedence chain
416/// (scrollbar thumb/track → text region → surface body → none) falls
417/// out automatically.
418///
419/// # `lines` field
420///
421/// Optional text content — one `String` per display row. Backends that
422/// cannot read text back from a cell buffer (GTK, macOS, win) use this to
423/// extract the selected substring when the user copies (Ctrl-C). TUI
424/// ignores this field and reads cells from its ratatui buffer instead.
425/// Leave as `vec![]` for TUI-only apps or when text extraction is not
426/// needed.
427#[derive(Debug, Clone, PartialEq)]
428pub struct TextRegion {
429    pub id: WidgetId,
430    pub bounds: crate::event::Rect,
431    /// Source text lines for pixel-based backends (GTK, macOS, win).
432    /// TUI ignores this (reads from the ratatui cell buffer).
433    /// Empty means no text content is available for extraction.
434    pub lines: Vec<String>,
435}
436
437/// Compute the screen-cell ranges covered by a line-wise text selection.
438///
439/// Given `anchor` and `focus` in screen coordinates (TUI: cells;
440/// GTK/macOS: pixels) and the `bounds` of the [`TextRegion`], returns
441/// a `Vec` of `(row, col_start, col_end)` tuples (half-open:
442/// `col_start..col_end`) in document order (top-to-bottom).
443///
444/// **Line-wise stream semantics** (same as most terminal selections):
445///
446/// | Position | Range |
447/// |---|---|
448/// | First row | `anchor_col .. region_end_col` |
449/// | Middle rows | `region_start_col .. region_end_col` |
450/// | Last row | `region_start_col .. focus_col + 1` |
451/// | Single row | `min_col .. max_col + 1` |
452///
453/// Coordinates are clamped to `bounds`. Returns an empty `Vec` when
454/// `anchor == focus` (selection collapsed to a point — plain click).
455///
456/// # Units
457///
458/// `row` is a discrete line/cell-row index (it indexes
459/// [`TextRegion::lines`] and is always integral) and stays `u16`.
460/// `col_start` and `col_end` are `f32` to match other native-unit
461/// spans in this crate ([`Point`], [`crate::event::Rect`]); callers
462/// pass pre-quantized cell-space coordinates (the "+1" is one *unit*,
463/// i.e. one character cell, not one pixel).
464pub fn text_selection_line_range(
465    anchor: Point,
466    focus: Point,
467    bounds: crate::event::Rect,
468) -> Vec<(u16, f32, f32)> {
469    // Empty when anchor == focus (no movement → collapsed click).
470    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    // Put in document order (top-to-bottom, left-to-right).
479    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    // Clamp start/end rows and cols to region.
491    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    // End col is inclusive → add one unit for the half-open range, then clamp.
495    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    // First row: from start_col to region end.
506    ranges.push((start_row, start_col, region_end_x));
507    // Middle rows: full region width.
508    for row in (start_row + 1)..end_row {
509        ranges.push((row, region_x, region_end_x));
510    }
511    // Last row: from region start to end_col.
512    ranges.push((end_row, region_x, end_col));
513    ranges
514}
515
516// ─── Scroll dispatch ──────────────────────────────────────────────────────
517
518/// A scrollable surface registered during paint. The consumer pushes
519/// one entry per scrollable widget each frame; [`dispatch_scroll`]
520/// hit-tests the list to route wheel events to the right widget.
521///
522/// Push in paint order (back-to-front). The dispatcher walks
523/// last-to-first so the topmost-painted surface wins on overlap —
524/// same semantics as [`ModalStack`].
525#[derive(Debug, Clone, PartialEq)]
526pub struct ScrollSurface {
527    pub id: WidgetId,
528    pub bounds: crate::event::Rect,
529    /// Optional scrollbar geometry. When present, [`dispatch_click`]
530    /// can route thumb clicks (auto-start drag) and track clicks
531    /// (page up/down) without per-backend code.
532    pub scrollbar: Option<SurfaceScrollbar>,
533}
534
535/// Translate a raw scroll-wheel event into a `Vec<UiEvent>`, consulting
536/// the modal stack first, then the registered scroll surfaces.
537///
538/// # Scroll delta sign convention
539///
540/// Positive `delta.y` = scroll content **up** (toward the top of the
541/// document). Backends normalise their native direction before
542/// constructing [`ScrollDelta`] — see [`ScrollDelta`] for the
543/// canonical definition. Compose helpers ([`TreeController`],
544/// [`SidebarSystem`]) consume the delta directly without per-call-site
545/// negation.
546///
547/// # Returns
548///
549/// Three cases:
550///
551/// 1. **Scroll landed inside an open modal.** Emits
552///    `[UiEvent::Scroll { widget: Some(modal_id), delta, position }]`.
553///    The app routes to the modal's inner scroll handler.
554/// 2. **Modal(s) open but scroll was outside them.** Emits nothing —
555///    scroll behind an open modal is swallowed (you can't scroll the
556///    editor while a dialog is up).
557/// 3. **No modal open.** Hit-tests `scroll_surfaces` last-to-first
558///    (topmost-painted wins). If a surface matches, emits
559///    `[UiEvent::Scroll { widget: Some(surface_id), delta, position }]`.
560///    If no surface matches, emits
561///    `[UiEvent::Scroll { widget: None, delta, position }]` for
562///    base-layer handling.
563pub fn dispatch_scroll(
564    stack: &ModalStack,
565    scroll_surfaces: &[ScrollSurface],
566    position: Point,
567    delta: ScrollDelta,
568) -> Vec<UiEvent> {
569    // Case 1: scroll inside an open modal.
570    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    // Case 2: modal(s) open but scroll was outside → swallow.
579    if !stack.is_empty() {
580        return Vec::new();
581    }
582
583    // Case 3: no modals. Hit-test scroll surfaces (last-to-first).
584    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    // No surface matched — base-layer event.
599    vec![UiEvent::Scroll {
600        widget: None,
601        delta,
602        position,
603    }]
604}
605
606// ─── Click dispatch with scrollbar awareness ──────────────────────────────
607
608/// Scrollbar geometry for a [`ScrollSurface`]. Registered during paint
609/// so [`dispatch_click`] can route scrollbar clicks (thumb drag, track
610/// page) without per-backend code. Supports both vertical and
611/// horizontal scrollbars via the `axis` field.
612#[derive(Debug, Clone, PartialEq)]
613pub struct SurfaceScrollbar {
614    /// Vertical or horizontal. Determines which coordinate axis
615    /// `dispatch_click` uses for grab offset, track-click paging,
616    /// and which `DragTarget` variant is created.
617    pub axis: ScrollAxis,
618    /// Full scrollbar track bounds (gutter area).
619    pub track_bounds: crate::event::Rect,
620    /// Current thumb bounds within the track.
621    pub thumb_bounds: crate::event::Rect,
622    /// Total number of scrollable items (lines, rows, cols, etc.).
623    pub total_items: usize,
624    /// Number of items visible in the viewport.
625    pub visible_items: usize,
626    /// Current scroll offset (index of the first visible item).
627    pub scroll_offset: usize,
628    /// When true, offset 0 means "at the bottom" (scrollback style).
629    /// Propagated to the `DragTarget`'s `inverted` field on thumb
630    /// click and used to flip track-click page direction.
631    pub inverted: bool,
632}
633
634/// Translate a raw mouse-down event, consulting the modal stack first,
635/// then the registered scroll surfaces (including their scrollbar
636/// regions) and text regions. Supersedes [`dispatch_mouse_down`] for
637/// consumers that register scroll surfaces and/or text regions.
638///
639/// # Returns
640///
641/// Priority order:
642///
643/// 1. **Click inside an open modal** → `MouseDown { widget: Some(modal_id) }`.
644/// 2. **Click outside every modal, but modal open** → dismiss topmost
645///    (same as [`dispatch_mouse_down`]).
646/// 3. **Click on a scroll surface's scrollbar thumb** → starts a
647///    [`DragTarget::ScrollbarY`] or [`DragTarget::ScrollbarX`] drag
648///    (depending on `SurfaceScrollbar::axis`), emits
649///    `MouseDown { widget: Some(surface_id) }`.
650/// 4. **Click on a scroll surface's scrollbar track** (above or below
651///    thumb) → emits `ScrollOffsetChanged { widget, new_offset }`
652///    with the offset paged up or down by `visible_items`.
653/// 5. **Click inside a registered [`TextRegion`]** → starts a
654///    [`DragTarget::TextSelection`] drag, emits
655///    `MouseDown { widget: Some(region_id) }`. Text regions take
656///    priority over scroll surface bodies (not scrollbars).
657/// 6. **Click on a scroll surface body** (not scrollbar, not text
658///    region) → `MouseDown { widget: Some(surface_id) }`.
659/// 7. **No surface or region matched** → `MouseDown { widget: None }`.
660pub 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    // Case 1: click inside an open modal.
670    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    // Case 2: modal open but click outside → dismiss topmost.
680    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    // Case 3–5: no modals. Hit-test scroll surfaces (last-to-first).
693    for surface in scroll_surfaces.iter().rev() {
694        if !surface.bounds.contains(position) {
695            continue;
696        }
697
698        // Check scrollbar regions first (if present).
699        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                // Track click → page forward or back. For vertical,
742                // "before" = above thumb; for horizontal, "before" =
743                // left of thumb. When inverted, the direction flips.
744                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        // Case 5: Check text regions before falling through to body click.
767        // Walk last-to-first so the topmost-painted region wins on overlap.
768        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        // Case 6: body click (not on scrollbar, not on text region).
784        return vec![UiEvent::MouseDown {
785            widget: Some(surface.id),
786            button,
787            position,
788            modifiers,
789        }];
790    }
791
792    // No scroll surface hit. Still check standalone text regions
793    // (not nested inside any scroll surface).
794    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    // Case 7: no surface or region matched.
810    vec![UiEvent::MouseDown {
811        widget: None,
812        button,
813        position,
814        modifiers,
815    }]
816}
817
818// ─── One-shot pointer routing ──────────────────────────────────────────────
819
820/// The `MouseDown` / `MouseMoved` / `MouseUp` routing sequence every
821/// runner (`gtk::run`, `gtk::testing`, `macos::run`, `win::run`,
822/// `tui::backend`) carried inline as its own copy: call the matching
823/// `dispatch_*` function above, and — on `MouseDown` — additionally track
824/// the newly-focused text region so Ctrl-A can resolve it even before the
825/// first drag-move fires a `TextSelectionChanged` event. Any other
826/// `UiEvent` variant passes straight through unchanged (a one-element
827/// vec), so a caller can route every event through this function
828/// unconditionally instead of matching the variant itself first.
829///
830/// `widget` on the incoming `MouseDown`/`MouseUp` is ignored — every
831/// `dispatch_*` function recomputes it from `stack`/the registered
832/// regions, exactly as the 7 inlined copies this replaces did (none of
833/// them read the field either).
834///
835/// # Scroll surfaces
836///
837/// Always passes `&[]` for [`dispatch_click`]'s `scroll_surfaces` — every
838/// one of the 7 copies did the same, because none of the four runners
839/// (GTK, macOS, Windows, TUI) register a [`ScrollSurface`] anywhere in
840/// the tree today. That makes the scrollbar-click arbitration above
841/// (`ScrollSurface`/[`SurfaceScrollbar`]) unreachable *from quadraui's own
842/// runners* — but not dead: `vimcode` (this crate's primary consumer)
843/// calls [`dispatch_click`] directly with its own non-empty
844/// `scroll_surfaces` list (`src/app.rs`), bypassing quadraui's runners
845/// entirely, so the parameter is load-bearing for that caller even though
846/// no in-tree caller populates it. Wiring per-frame `ScrollSurface`
847/// registration into the four runners (so `dispatch_click`'s scrollbar
848/// path becomes reachable in-tree too) is a real per-backend feature, not
849/// a dedup — left as follow-up work, not folded into this consolidation.
850///
851/// `#[cfg(...)]`-gated to mirror `crate::text_selection`'s own gate — this
852/// module is compiled unconditionally (`dispatch` has no toolkit
853/// dependency of its own) but `TextSelectionState` only exists when at
854/// least one backend that embeds it is being built.
855#[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
896// ─── Double-click synthesis ────────────────────────────────────────────────
897//
898// Backend-agnostic: relocated here from `tui::events` so
899// `macos::run` can reuse it without pulling in the `tui` feature. TUI's
900// `TuiBackend` batches translated events and calls
901// [`DoubleClickDetector::process`] on the whole slice each poll; macOS
902// dispatches synchronously per `NSEvent`, so `macos::backend::MacBackend`
903// wraps a single event in a one-element array before calling `process`.
904
905use std::time::{Duration, Instant};
906
907const DOUBLE_CLICK_MS: u64 = 400;
908/// Tuned for TUI's **character-cell** coordinate grid, where adjacent
909/// cells are a whole unit apart — a 1.5-cell tolerance comfortably
910/// covers "same cell" while rejecting "next cell over". Backends whose
911/// `MouseDown` positions are in a different unit (e.g. macOS's
912/// point-precision `NSEvent` coordinates) must not inherit this value;
913/// see [`DoubleClickDetector::with_radius`] and
914/// `quadraui/docs/LESSONS.md`'s "No hardcoded px / cell / DIP
915/// constants" rule — constants belong on the backend, not shared code.
916const DOUBLE_CLICK_RADIUS: f32 = 1.5;
917
918/// Detects double-clicks from repeated `MouseDown` events within a
919/// time window at the same position. Stateful — lives on the backend
920/// (`TuiBackend::double_click`, `MacBackend::double_click`).
921pub struct DoubleClickDetector {
922    last_click_time: Option<Instant>,
923    last_click_pos: Point,
924    last_click_button: MouseButton,
925    /// Position tolerance, in whatever unit the caller's `MouseDown`
926    /// positions use. Defaults to [`DOUBLE_CLICK_RADIUS`] (TUI's
927    /// character-cell tolerance) via [`Self::new`] /
928    /// [`Self::default`]; callers in a different coordinate system
929    /// should use [`Self::with_radius`] instead.
930    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    /// Same detector, but with a caller-supplied position-tolerance
950    /// `radius` instead of [`DOUBLE_CLICK_RADIUS`]. Use this whenever
951    /// the consumer's `MouseDown` positions aren't TUI's character-cell
952    /// grid — e.g. `MacBackend` wraps native `NSEvent` coordinates,
953    /// which are point-precision, so a 1.5-point tolerance (the raw
954    /// cell-tuned constant) is far tighter than two real clicks can
955    /// reliably land within.
956    pub fn with_radius(radius: f32) -> Self {
957        Self {
958            radius,
959            ..Self::default()
960        }
961    }
962
963    /// Process a batch of translated events. Replaces `MouseDown` with
964    /// `DoubleClick` when the same button clicks at the same position
965    /// within the time window.
966    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        // Click well outside the palette's bounds.
1045        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        // First event: MouseDown with widget None (backdrop click).
1053        assert!(matches!(
1054            &events[0],
1055            UiEvent::MouseDown { widget: None, .. }
1056        ));
1057        // Second event: palette Closed.
1058        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        // Palette open; then a dialog on top of it. Click in the
1085        // overlap region should target the dialog, not the palette.
1086        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        // Click lands in palette's bounds but outside the dialog on top.
1106        // The lower-modal id is what should be reported — the click is
1107        // still inside a modal, so no backdrop-dismiss.
1108        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    // ── Drag tests ────────────────────────────────────────────────────
1127
1128    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        // Track 80 units from y=100; 100 items, viewport shows 20.
1171        // thumb_ratio = 20/100 = 0.2 → thumb_length = 16
1172        // effective_track = 64; max_scroll = 80
1173        // Mouse at y=100+32 (halfway through effective_track) → offset 40.
1174        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        // Same geometry as above: max_scroll = 80, effective_track = 64.
1199        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        // Above track: offset = 0.
1210        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        // Below effective track: clamped to max_scroll = 80.
1218        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        // Pathological input — no track. Should emit only MouseMoved.
1230        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    // ── SplitDivider drag tests ─────────────────────────────────────
1265
1266    #[test]
1267    fn split_divider_drag_horizontal_computes_ratio() {
1268        // Parent bounds 41 wide starting at x=0 (matches a SplitTree
1269        // divider's axis_start/axis_size for a Horizontal split).
1270        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        // Cursor at x=30 → ratio 0.75.
1279        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        // Cursor at y=125 → (125-100)/50 = 0.5. x is irrelevant for Vertical.
1307        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        // Cursor well before axis_start → clamps to 0.0.
1332        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        // Cursor well past axis_start + axis_size → clamps to 1.0.
1338        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        // Same geometry as the basic test: track 80 from y=100, viewport 20,
1378        // total 100 → effective_track 64, max_scroll 80. With grab_offset=8
1379        // the cursor at y=140 sits 8 units below the thumb's top, so the
1380        // math sees effective y=132 → rel=32/64=0.5 → offset 40 (matches
1381        // the no-grab base test where cursor was at y=132 directly).
1382        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        // Mirror of the basic Y-axis test on the X axis. Track 80 from x=100;
1402        // 100 cols total, viewport shows 20. thumb_length=16, effective=64,
1403        // max_scroll=80. Cursor at x=132 (halfway through effective) → 40.
1404        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        // Same geometry as horizontal_scrollbar test; with grab_offset=8 the
1428        // cursor at x=140 maps to effective x=132 → 40.
1429        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        // Track 400px, 20 visible out of 4000 total.
1449        // Painted thumb = 20px → effective = 380; max_scroll = 3980.
1450        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        // Cursor at x = 100 + 380 (end of effective track) → ratio 1.0 → 3980
1461        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        // Halfway: x = 100 + 190 = 290 → ratio 0.5 → 1990
1467        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    // ── Round-trip: fit_thumb ↔ dispatch agreement ─────────────────
1475    //
1476    // The rasteriser calls `fit_thumb` to position the thumb. The
1477    // dispatcher inverts that position back to a scroll offset.
1478    // These tests prove the two agree for every scroll offset
1479    // including 0 and max_scroll.
1480
1481    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        // thumb dominates: min_thumb = 40 on 100px track → effective = 60
1546        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        // Cursor WAY past track end — must still clamp to max_scroll
1569        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    // ── Scroll dispatch tests ─────────────────────────────────────────
1597
1598    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        // Point (25, 100) is inside both surfaces. Sidebar was pushed
1643        // last (painted on top) → wins.
1644        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        // Point (100, 100) is inside editor only.
1670        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    // ── dispatch_click tests ──────────────────────────────────────────
1710
1711    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        // Click above the thumb (thumb starts at y=20).
1767        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                // scroll_offset=60, page up by visible_items=30 → 30.
1781                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        // Click below the thumb (thumb ends at y=28).
1794        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                // scroll_offset=60, page down by 30, max=70 → 70.
1808                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        // Click in the body area (not on the scrollbar gutter).
1820        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        // Should route to the modal, not the scrollbar.
1876        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    // ── Inverted scrollbar tests ─────────────────────────────────────
1888
1889    #[test]
1890    fn inverted_drag_flips_offset() {
1891        // Same geometry as the basic drag test: track 80 from y=100,
1892        // viewport 20, total 100. effective_track=64, max_scroll=80.
1893        // Normal: cursor at y=132 (halfway) → offset 40.
1894        // Inverted: same position → offset 80 - 40 = 40… wait:
1895        //   rel = 32/64 = 0.5, inverted → 1.0 - 0.5 = 0.5 → 40.
1896        // To see the flip, use the track top: y=100 → rel=0, inv→1.0 → 80.
1897        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        // Cursor at track top → normal offset 0, inverted offset 80.
1908        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        // Cursor at track bottom → normal offset 80, inverted offset 0.
1914        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        // At the exact midpoint, both normal and inverted produce the
1924        // same offset (symmetry check).
1925        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        // Click ABOVE the thumb (y=5 < thumb y=20).
1989        // Normal: page up → offset 60 - 30 = 30.
1990        // Inverted: above thumb pages FORWARD → offset 60 + 30 = 70 (capped at 70).
1991        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        // Click BELOW the thumb (y=29 > thumb bottom y=28).
2006        // Normal: page down → offset 60 + 30 = 70.
2007        // Inverted: below thumb pages BACK → offset 60 - 30 = 30.
2008        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    // ── Horizontal scroll surface tests ──────────────────────────────
2024
2025    fn surface_with_h_scrollbar() -> ScrollSurface {
2026        // Horizontal scrollbar at the bottom of a 400×300 surface.
2027        // Track spans x=0..400 at y=296, height=4.
2028        // Thumb at x=50..90 (40px wide).
2029        // 2000 total cols, 200 visible, offset 250.
2030        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        // Click on the thumb at x=60, y=297
2051        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); // 60.0 - 50.0 = 10.0
2081            }
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        // Click left of thumb (x=10 < thumb x=50)
2096        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                // page back: 250 - 200 = 50
2109                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        // Click right of thumb (x=200 > thumb right x=90)
2121        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                // page forward: 250 + 200 = 450
2134                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        // Click thumb to start drag
2146        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        // Drag cursor to x=370 (near track end)
2157        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); // max_scroll
2163            }
2164            other => panic!("expected ScrollOffsetChanged, got {:?}", other),
2165        }
2166    }
2167
2168    // ── TextRegion / text-selection tests ─────────────────────────────
2169
2170    #[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            // Covers the whole surface including the scrollbar column.
2212            bounds: rect(0.0, 0.0, 40.0, 30.0),
2213            lines: vec![],
2214        }];
2215        let mut drag = DragState::new();
2216        // Click on scrollbar thumb (x=39.5, y=22) — scrollbar must win.
2217        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        // Modal wins — no TextSelection drag started.
2285        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    // ── text_selection_line_range tests ──────────────────────────────────
2317
2318    #[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        // Same row, anchor before focus.
2328        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        // Half-open: 5..11 (focus_col 10 is inclusive → +1 = 11).
2331        assert_eq!(ranges, vec![(3, 5.0, 11.0)]);
2332    }
2333
2334    #[test]
2335    fn line_range_single_row_reversed() {
2336        // Same row, focus before anchor — must swap to document order.
2337        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        // anchor at (2,1), focus at (5,3).
2346        let ranges = text_selection_line_range(pt(2.0, 1.0), pt(5.0, 3.0), bounds);
2347        // Row 1: 2..40 (start_col to region_end)
2348        // Row 2: 0..40 (full width)
2349        // Row 3: 0..6  (region_start to focus_col+1)
2350        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        // Swap anchor and focus — must produce same result in document order.
2357        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        // Region starts at x=5, y=2; anchor/focus way outside — must clamp.
2364        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        // start row clamped to 2, end row clamped to 6 (2+5-1),
2367        // start_col clamped to 5, end_col clamped to 25.
2368        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        // All middle rows are full region width.
2379        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    // ── DoubleClickDetector tests ────────────────────────────────────
2386
2387    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        // Third click at same position — resets to single (no triple-click).
2436        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}