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}