Skip to main content

lattice_host/
mouse.rs

1//! MO.2: editor-body mouse hit-testing.
2//!
3//! `ui.mouse` has existed since MO.1, but only modeline elements
4//! listened to it — the TUI's event handler returned early on anything
5//! that was not a left-press on a modeline zone, and the GPUI peer had
6//! hit-test primitives with no listener at all. This module is the
7//! shared half of giving the editor body scroll, click-to-position and
8//! drag-to-select.
9//!
10//! ## What lives here, and what does not
11//!
12//! Here: the pane hit map (which pane owns a screen cell, and where its
13//! text starts) and the semantic target a resolved gesture produces.
14//! Both are renderer-neutral.
15//!
16//! Not here: how a renderer arrives at a cell. The TUI reads
17//! `(column, row)` straight off a crossterm event; GPUI divides pixels
18//! by a glyph advance. That geometry belongs to each peer, and the
19//! `ModelineHitMap` beside this one draws the line in the same place.
20//!
21//! ## Recorded, not re-derived
22//!
23//! The zones are pushed by the renderer **during paint**, and cleared at
24//! the top of every frame — exactly like [`crate::modeline::ModelineHitMap`].
25//! A map rebuilt from layout inputs after the fact is a second
26//! implementation of the layout, free to disagree with the one on screen;
27//! the symptom of a disagreement is a click landing a pane away, which
28//! reads as a broken feature rather than as stale geometry. A pane that
29//! stops painting stops being clickable, because nothing pushed a zone
30//! for it.
31
32use lattice_core::BufferId;
33use lattice_core::ui::pane::PaneId;
34
35/// Vim's `mousescroll` default (`ver:3`): one wheel notch moves three
36/// lines. Named rather than inlined so the two call sites (wheel up and
37/// wheel down) cannot drift, and so the eventual option has an obvious
38/// thing to replace.
39pub const MOUSE_SCROLL_LINES: u32 = 3;
40
41/// One pane's painted body, recorded for hit-testing.
42///
43/// The rect is the pane's **content** area — the status footer is
44/// excluded, so a click on a status line is not a click in the buffer.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub struct PaneHitZone {
47    pub pane_id: PaneId,
48    pub buffer_id: BufferId,
49    pub x: u16,
50    pub y: u16,
51    pub width: u16,
52    pub height: u16,
53    /// Columns occupied by the gutter (line numbers, sign column, pad)
54    /// before the first text cell, relative to `x`.
55    ///
56    /// Recorded rather than recomputed because the gutter width depends
57    /// on the buffer's line count, `number` / `signcolumn` resolution
58    /// and the centring pad — four inputs the painter has already
59    /// resolved, and a click one cell off is exactly what a fifth
60    /// resolution of them produces.
61    pub text_left: u16,
62    /// The pane's first visible source line at paint time.
63    pub scroll: u32,
64    /// First visible display column (horizontal scroll). Always 0
65    /// under soft wrap, which is why the column arithmetic can add it
66    /// unconditionally.
67    pub leftcol: u32,
68}
69
70impl PaneHitZone {
71    /// Does this zone cover `(col, row)`?
72    pub fn covers(&self, col: u16, row: u16) -> bool {
73        col >= self.x
74            && col < self.x.saturating_add(self.width)
75            && row >= self.y
76            && row < self.y.saturating_add(self.height)
77    }
78}
79
80/// Which part of the buffer a painted row came from.
81///
82/// `segment` is the soft-wrap segment index: the second visual row of a
83/// wrapped line is `segment: 1`, and its columns start
84/// `segment * body_width` into the logical line. Without it every
85/// wrapped row would resolve to the line's first `body_width` columns,
86/// so clicking the tail of a wrapped paragraph would land near its
87/// start — wrong in a way that looks like an off-by-a-lot rather than
88/// like a missing concept.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub struct RowOrigin {
91    pub source_line: u32,
92    pub segment: u32,
93}
94
95/// Every pane body painted this frame.
96///
97/// Small (one entry per visible pane, so single digits) and walked
98/// linearly — a click is a human gesture, and an index would cost more
99/// to maintain than it saves.
100#[derive(Debug, Clone, Default)]
101pub struct PaneHitMap {
102    zones: Vec<PaneHitZone>,
103    /// Per-pane row → buffer origin, in painted order.
104    ///
105    /// Recorded by the compose loop rather than derived from scroll and
106    /// the fold list, because a painted row is not `scroll + n`: soft
107    /// wrap splits one line across several, closed folds skip interiors,
108    /// and virtual rows (sticky context, inline diagnostics) occupy rows
109    /// that mirror no line at all. Re-deriving all three is a second
110    /// implementation of the compose loop, and the one on screen is the
111    /// one that is right.
112    ///
113    /// `None` marks a row with no source line — a virtual row, or the
114    /// `~` filler past the end of the buffer.
115    rows: std::collections::HashMap<PaneId, Vec<Option<RowOrigin>>>,
116}
117
118impl PaneHitMap {
119    pub fn new() -> Self {
120        Self::default()
121    }
122
123    /// Drop every recorded zone. Called at the top of each frame; a
124    /// stale map would route clicks against a layout no longer painted.
125    pub fn clear(&mut self) {
126        self.zones.clear();
127        self.rows.clear();
128    }
129
130    /// Record what the compose loop painted into `pane`, row by row.
131    ///
132    /// Called from the composer rather than from the layout pass, which
133    /// is why it is a separate call from [`Self::push`]: the zone's rect
134    /// is known before the pane's contents are composed, and its rows
135    /// only after.
136    pub fn set_rows(&mut self, pane_id: PaneId, rows: Vec<Option<RowOrigin>>) {
137        self.rows.insert(pane_id, rows);
138    }
139
140    /// The buffer origin of `row_offset` within `pane`, if it has one.
141    ///
142    /// A row past the end of the buffer, or a virtual row, falls back to
143    /// the **last row above it that does** have an origin. Clicking the
144    /// blank space below a short buffer puts the cursor on its last line,
145    /// which is what every editor does and what a terminal makes the
146    /// common case — it reports a cell for every row on screen, not only
147    /// the ones with text under them.
148    pub fn origin_at(&self, pane_id: PaneId, row_offset: u16) -> Option<RowOrigin> {
149        let rows = self.rows.get(&pane_id)?;
150        let upto = rows.get(..=(row_offset as usize)).unwrap_or(rows);
151        upto.iter().rev().find_map(|r| *r)
152    }
153
154    pub fn push(&mut self, zone: PaneHitZone) {
155        // A zero-area pane can never be hit and would only lengthen the
156        // walk. Mirrors `ModelineHitMap::push`'s inverted-region guard.
157        if zone.width > 0 && zone.height > 0 {
158            self.zones.push(zone);
159        }
160    }
161
162    pub fn is_empty(&self) -> bool {
163        self.zones.is_empty()
164    }
165
166    pub fn len(&self) -> usize {
167        self.zones.len()
168    }
169
170    /// The pane under `(col, row)`, if any.
171    ///
172    /// Last match wins, so a pane painted over another — a popup body
173    /// above a document — takes the click. Panes are pushed in paint
174    /// order, which makes later-painted mean on-top without the map
175    /// needing a z-index.
176    pub fn hit(&self, col: u16, row: u16) -> Option<PaneHitZone> {
177        self.zones
178            .iter()
179            .rev()
180            .find(|z| z.covers(col, row))
181            .copied()
182    }
183
184    /// Resolve a screen cell to a position inside a pane's body.
185    ///
186    /// `None` when the cell is outside every pane. A cell on the gutter
187    /// resolves to its pane with `text_col: None` — the pane is still
188    /// the right scroll target for a wheel event there, and a click on a
189    /// line number is a real gesture with its own meaning (fold toggle,
190    /// eventually) rather than a miss.
191    pub fn resolve(&self, col: u16, row: u16) -> Option<BodyHit> {
192        let zone = self.hit(col, row)?;
193        let within = col - zone.x;
194        let row_offset = row - zone.y;
195        Some(BodyHit {
196            zone,
197            row_offset,
198            text_col: within.checked_sub(zone.text_left),
199            origin: self.origin_at(zone.pane_id, row_offset),
200        })
201    }
202}
203
204/// A screen cell resolved against the painted layout.
205///
206/// Still in *display* space: `row_offset` counts painted rows, which
207/// under soft wrap or a closed fold is not a source line, and `text_col`
208/// counts display columns, which inlays and conceals shift away from
209/// source positions. Turning those into a buffer position is the
210/// renderer's next step, and it does it by inverting the same forward
211/// maps the caret is drawn with — see `docs/dev/architecture/mouse.md`.
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213pub struct BodyHit {
214    pub zone: PaneHitZone,
215    /// Rows below the top of the pane's content area.
216    pub row_offset: u16,
217    /// Display columns right of the first text cell, or `None` when the
218    /// cell is on the gutter.
219    pub text_col: Option<u16>,
220    /// Which logical line (and wrap segment) this row was painted from.
221    /// `None` when the pane recorded no rows at all — a pane composed
222    /// before this frame's paint, or one whose content path does not go
223    /// through the shared composer.
224    pub origin: Option<RowOrigin>,
225}
226
227impl BodyHit {
228    /// The display column within the LOGICAL line, undoing soft wrap and
229    /// horizontal scroll.
230    ///
231    /// `None` on the gutter or on a row with no source line. The two
232    /// terms are exclusive in practice — `leftcol` is forced to 0 under
233    /// wrap — so adding both is correct rather than merely convenient.
234    pub fn logical_col(&self, body_width: u32) -> Option<u32> {
235        let text_col = self.text_col? as u32;
236        let origin = self.origin?;
237        Some(origin.segment * body_width + text_col + self.zone.leftcol)
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    #![allow(clippy::unwrap_used, clippy::panic)]
244    use super::*;
245
246    fn zone(pane: u32, x: u16, y: u16, w: u16, h: u16) -> PaneHitZone {
247        PaneHitZone {
248            pane_id: PaneId(pane),
249            buffer_id: BufferId(pane),
250            x,
251            y,
252            width: w,
253            height: h,
254            text_left: 4,
255            scroll: 0,
256            leftcol: 0,
257        }
258    }
259
260    #[test]
261    fn a_cell_outside_every_pane_resolves_to_nothing() {
262        let mut map = PaneHitMap::new();
263        map.push(zone(1, 0, 0, 40, 10));
264        assert!(map.resolve(50, 5).is_none(), "right of the pane");
265        assert!(map.resolve(10, 20).is_none(), "below the pane");
266    }
267
268    /// A vertical split: the column decides which pane takes the click.
269    /// This is the whole reason the map is keyed on a rect rather than
270    /// on "the active pane".
271    #[test]
272    fn side_by_side_panes_split_on_the_column() {
273        let mut map = PaneHitMap::new();
274        map.push(zone(1, 0, 0, 40, 10));
275        map.push(zone(2, 40, 0, 40, 10));
276
277        assert_eq!(map.resolve(10, 5).unwrap().zone.pane_id, PaneId(1));
278        assert_eq!(map.resolve(50, 5).unwrap().zone.pane_id, PaneId(2));
279        assert_eq!(
280            map.resolve(39, 5).unwrap().zone.pane_id,
281            PaneId(1),
282            "the boundary column belongs to the left pane"
283        );
284        assert_eq!(
285            map.resolve(40, 5).unwrap().zone.pane_id,
286            PaneId(2),
287            "…and the next one to the right pane"
288        );
289    }
290
291    /// The gutter is inside the pane but outside the text. It resolves
292    /// to the pane — a wheel event there still scrolls it — with no text
293    /// column, so a click cannot be mistaken for one on column 0.
294    #[test]
295    fn a_cell_on_the_gutter_has_no_text_column() {
296        let mut map = PaneHitMap::new();
297        map.push(zone(1, 0, 0, 40, 10));
298
299        let on_gutter = map.resolve(2, 3).unwrap();
300        assert_eq!(on_gutter.zone.pane_id, PaneId(1));
301        assert_eq!(on_gutter.text_col, None);
302
303        let first_text_cell = map.resolve(4, 3).unwrap();
304        assert_eq!(first_text_cell.text_col, Some(0));
305        assert_eq!(map.resolve(9, 3).unwrap().text_col, Some(5));
306    }
307
308    /// Row and column are both relative to the pane, not the screen —
309    /// a split pane's second row is row 1 of that pane.
310    #[test]
311    fn offsets_are_relative_to_the_pane_not_the_screen() {
312        let mut map = PaneHitMap::new();
313        map.push(zone(2, 40, 12, 40, 10));
314
315        let hit = map.resolve(48, 15).unwrap();
316        assert_eq!(hit.row_offset, 3);
317        assert_eq!(hit.text_col, Some(4));
318    }
319
320    /// A pane painted later sits on top, so it takes the click. That is
321    /// what makes a popup body clickable without the map carrying a
322    /// z-index.
323    #[test]
324    fn a_later_pane_wins_an_overlap() {
325        let mut map = PaneHitMap::new();
326        map.push(zone(1, 0, 0, 80, 24));
327        map.push(zone(2, 10, 5, 20, 8));
328
329        assert_eq!(map.resolve(15, 7).unwrap().zone.pane_id, PaneId(2));
330        assert_eq!(
331            map.resolve(5, 7).unwrap().zone.pane_id,
332            PaneId(1),
333            "outside the overlay, the pane underneath still answers"
334        );
335    }
336
337    /// Clearing is what makes a pane that stops painting stop being
338    /// clickable. Without it a closed split keeps taking clicks.
339    #[test]
340    fn clearing_makes_a_vanished_pane_unclickable() {
341        let mut map = PaneHitMap::new();
342        map.push(zone(1, 0, 0, 40, 10));
343        assert!(map.resolve(10, 5).is_some());
344
345        map.clear();
346
347        assert!(map.is_empty());
348        assert!(map.resolve(10, 5).is_none());
349    }
350
351    #[test]
352    fn a_zero_area_pane_is_not_recorded() {
353        let mut map = PaneHitMap::new();
354        map.push(zone(1, 0, 0, 0, 10));
355        map.push(zone(2, 0, 0, 40, 0));
356        assert_eq!(map.len(), 0);
357    }
358
359    // ── row origins ──────────────────────────────────────────────────
360
361    fn origin(line: u32, segment: u32) -> Option<RowOrigin> {
362        Some(RowOrigin {
363            source_line: line,
364            segment,
365        })
366    }
367
368    /// The straightforward case: one painted row per source line.
369    #[test]
370    fn a_row_resolves_to_the_line_painted_on_it() {
371        let mut map = PaneHitMap::new();
372        map.push(zone(1, 0, 0, 40, 4));
373        map.set_rows(
374            PaneId(1),
375            vec![origin(10, 0), origin(11, 0), origin(12, 0), origin(13, 0)],
376        );
377
378        assert_eq!(map.resolve(6, 2).unwrap().origin, origin(12, 0));
379    }
380
381    /// **Soft wrap: the second row of a wrapped line is the same line,
382    /// segment 1.** The segment is what puts a click on the tail of a
383    /// wrapped paragraph near its tail instead of near its head.
384    #[test]
385    fn a_wrapped_line_keeps_its_line_and_advances_its_segment() {
386        let mut map = PaneHitMap::new();
387        map.push(zone(1, 0, 0, 40, 4));
388        map.set_rows(
389            PaneId(1),
390            vec![origin(7, 0), origin(7, 1), origin(7, 2), origin(8, 0)],
391        );
392
393        assert_eq!(map.resolve(6, 0).unwrap().origin, origin(7, 0));
394        assert_eq!(map.resolve(6, 2).unwrap().origin, origin(7, 2));
395        assert_eq!(map.resolve(6, 3).unwrap().origin, origin(8, 0));
396    }
397
398    /// …and the column arithmetic uses it: segment 2 of a 30-column body
399    /// starts 60 columns into the logical line.
400    #[test]
401    fn the_logical_column_accounts_for_the_wrap_segment() {
402        let mut map = PaneHitMap::new();
403        map.push(zone(1, 0, 0, 34, 4));
404        map.set_rows(PaneId(1), vec![origin(7, 0), origin(7, 1), origin(7, 2)]);
405
406        // text_left is 4, so a click at screen column 9 is text column 5.
407        assert_eq!(map.resolve(9, 0).unwrap().logical_col(30), Some(5));
408        assert_eq!(map.resolve(9, 1).unwrap().logical_col(30), Some(35));
409        assert_eq!(map.resolve(9, 2).unwrap().logical_col(30), Some(65));
410    }
411
412    /// Horizontal scroll shifts the same way. `leftcol` is forced to 0
413    /// under wrap, so the two terms never both apply and adding both is
414    /// correct rather than merely convenient.
415    #[test]
416    fn the_logical_column_accounts_for_horizontal_scroll() {
417        let mut map = PaneHitMap::new();
418        let mut z = zone(1, 0, 0, 34, 4);
419        z.leftcol = 100;
420        map.push(z);
421        map.set_rows(PaneId(1), vec![origin(7, 0)]);
422
423        assert_eq!(map.resolve(9, 0).unwrap().logical_col(30), Some(105));
424    }
425
426    /// A virtual row — sticky context, an inline diagnostic — mirrors no
427    /// source line, so it falls back to the last row that does rather
428    /// than resolving to whatever line happens to be recorded next.
429    #[test]
430    fn a_virtual_row_falls_back_to_the_line_above_it() {
431        let mut map = PaneHitMap::new();
432        map.push(zone(1, 0, 0, 40, 4));
433        map.set_rows(
434            PaneId(1),
435            vec![origin(4, 0), None, origin(5, 0), origin(6, 0)],
436        );
437
438        assert_eq!(
439            map.resolve(6, 1).unwrap().origin,
440            origin(4, 0),
441            "clicking a virtual row acts on the line it is anchored below"
442        );
443    }
444
445    /// **A click below the end of the buffer lands on its last line.**
446    /// A terminal reports a cell for every row on screen, so clicking
447    /// the `~` filler is the common case, not a defensive one, and an
448    /// inert click there would read as the feature being broken.
449    #[test]
450    fn a_click_past_the_end_of_the_buffer_lands_on_the_last_line() {
451        let mut map = PaneHitMap::new();
452        map.push(zone(1, 0, 0, 40, 6));
453        map.set_rows(
454            PaneId(1),
455            vec![origin(0, 0), origin(1, 0), None, None, None, None],
456        );
457
458        assert_eq!(map.resolve(6, 5).unwrap().origin, origin(1, 0));
459    }
460
461    /// A pane whose rows were never recorded resolves its rect but no
462    /// origin — the scroll target is still right, and a click is inert
463    /// rather than landing somewhere invented.
464    #[test]
465    fn a_pane_with_no_recorded_rows_has_no_origin() {
466        let mut map = PaneHitMap::new();
467        map.push(zone(1, 0, 0, 40, 4));
468
469        let hit = map.resolve(6, 2).unwrap();
470        assert_eq!(hit.zone.pane_id, PaneId(1));
471        assert_eq!(hit.origin, None);
472        assert_eq!(hit.logical_col(30), None);
473    }
474
475    /// …and a buffer whose first rows are all virtual has nothing above
476    /// to fall back to, so it stays inert rather than guessing line 0.
477    #[test]
478    fn a_row_with_nothing_above_it_has_no_origin() {
479        let mut map = PaneHitMap::new();
480        map.push(zone(1, 0, 0, 40, 4));
481        map.set_rows(PaneId(1), vec![None, None, origin(0, 0)]);
482
483        assert_eq!(map.resolve(6, 1).unwrap().origin, None);
484    }
485
486    /// Rows are per-pane: one pane's listing must not answer for
487    /// another's, which is what keying on `PaneId` rather than on a
488    /// single vector buys.
489    #[test]
490    fn each_pane_keeps_its_own_rows() {
491        let mut map = PaneHitMap::new();
492        map.push(zone(1, 0, 0, 40, 4));
493        map.push(zone(2, 40, 0, 40, 4));
494        map.set_rows(PaneId(1), vec![origin(100, 0), origin(101, 0)]);
495        map.set_rows(PaneId(2), vec![origin(7, 0), origin(8, 0)]);
496
497        assert_eq!(map.resolve(6, 1).unwrap().origin, origin(101, 0));
498        assert_eq!(map.resolve(46, 1).unwrap().origin, origin(8, 0));
499    }
500
501    /// Clearing drops the rows with the zones, so a stale listing cannot
502    /// answer for a layout that is no longer painted.
503    #[test]
504    fn clearing_drops_the_recorded_rows_too() {
505        let mut map = PaneHitMap::new();
506        map.push(zone(1, 0, 0, 40, 4));
507        map.set_rows(PaneId(1), vec![origin(3, 0)]);
508
509        map.clear();
510
511        assert_eq!(map.origin_at(PaneId(1), 0), None);
512    }
513}