lattice_host/visual.rs
1//! Visual-mode selection helpers — renderer-neutral computation
2//! of the highlighted range when `editor.modal == Visual(_)`.
3//!
4//! Phase 5.8.P: hoisted out of `lattice-ui-tui::render::visual_
5//! selection_range` so both renderer peers paint the same
6//! selection range. The TUI peer keeps its `apply_match_overlay`
7//! splice; the GPUI peer paints a flex_row cell with an inverted
8//! background. Both consume [`Editor::visual_selection_range`].
9
10use lattice_core::BufferKind;
11use lattice_protocol::position::{Position, Range};
12use lattice_protocol::selection::VisualMode;
13
14use crate::editor::Editor;
15use lattice_grammar::{ModalState, VisualKind};
16use lattice_terminal::VisualKind as TermVisualKind;
17
18/// Rectangle defined by a Blockwise Visual selection's
19/// `(anchor, head)` positions, normalised so that
20/// `start_line ≤ end_line` and `start_col ≤ end_col`. Byte
21/// columns, not display columns — renderers fold/inlay-expand at
22/// paint time.
23///
24/// Renderer-neutral; published in
25/// [`crate::render_state::ActiveDocumentRenderState`] so TUI and
26/// GPUI peers paint the block from the same data instead of each
27/// re-deriving it from `selections` + `modal`.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct BlockExtents {
30 pub start_line: u32,
31 pub end_line: u32,
32 pub start_col: u32,
33 pub end_col: u32,
34}
35
36/// Byte range a selection spans, folding the stored `sel.visual`
37/// kind. **Visual and Select share this geometry verbatim** —
38/// select-mode.md §2 — so both modes resolve their span through this
39/// one helper (no drift). Charwise / Blockwise include the HEAD byte
40/// (vim semantics; end = `head.byte + 1`); Linewise covers full lines
41/// with a `u32::MAX` end byte the caller clamps to the line length.
42/// The `(anchor, head)` pair is normalised so `start <= end`.
43pub(crate) fn selection_extent(sel: &lattice_protocol::selection::Selection) -> Range {
44 let (a, b) = if sel.anchor <= sel.head {
45 (sel.anchor, sel.head)
46 } else {
47 (sel.head, sel.anchor)
48 };
49 match sel.visual {
50 Some(VisualMode::Linewise) => {
51 Range::new(Position::new(a.line, 0), Position::new(b.line, u32::MAX))
52 }
53 Some(VisualMode::Charwise) | Some(VisualMode::Blockwise) | None => {
54 Range::new(a, Position::new(b.line, b.byte.saturating_add(1)))
55 }
56 }
57}
58
59impl Editor {
60 /// Half-open byte range covered by the active Visual selection,
61 /// or `None` outside Visual mode. Spans the primary selection's
62 /// anchor → head pair, normalised so `start <= end`.
63 ///
64 /// - **Linewise**: covers full lines from `start.line` to
65 /// `end.line`. The end byte is `u32::MAX` — callers should
66 /// clamp it to the actual line length when painting
67 /// (`match_overlay_range` in TUI; the GPUI peer's per-line
68 /// clamp likewise).
69 /// - **Charwise** (and `None` — uninitialised selections that
70 /// default to charwise): includes the HEAD byte (vim
71 /// semantics). End byte = `head.byte + 1`.
72 /// - **Blockwise**: returns the same linear span as Charwise,
73 /// but renderers ignore this value when the publisher's
74 /// `visual_block_extents` is `Some` (2026-05-27 — the
75 /// per-line column band lives there). Kept around so non-
76 /// renderer consumers (e.g. `Range::Selection` operator
77 /// resolution) still see *some* selection range.
78 ///
79 /// Renderer-neutral; the returned `Range` is the renderer-
80 /// agnostic [`lattice_protocol::position::Range`].
81 pub fn visual_selection_range(&self) -> Option<Range> {
82 // T-paint-1 (2026-05-28): terminal Visual lives on
83 // `t.visual` (grid-space) and its own modal flag stays
84 // `Normal`. Derive a doc-space `Range` from it via the
85 // SyntheticDoc's `origin_top_line`, so downstream
86 // document-grammar consumers (operator range resolution,
87 // future copy-to-register paths, etc.) see terminal
88 // Visual through the same publish surface as document
89 // Visual.
90 if matches!(self.active_buffer, BufferKind::Terminal) {
91 return self.terminal_visual_selection_range();
92 }
93 // Select mode (SN.3d) reuses Visual's selection geometry
94 // verbatim — same anchor/head, same `selection_extent`. The
95 // only difference is the typing semantics, not the painted
96 // span, so the render publish surface fires for both.
97 if !matches!(self.modal, ModalState::Visual(_) | ModalState::Select(_)) {
98 return None;
99 }
100 let sels = self.document.selections();
101 Some(selection_extent(sels.primary()))
102 }
103
104 /// Rectangular block of the active Visual selection when
105 /// `modal == Visual(Blockwise)`; `None` otherwise. Normalised
106 /// so `start_line ≤ end_line` and `start_col ≤ end_col`.
107 ///
108 /// 2026-05-27: hoisted from
109 /// `lattice-ui-tui::render::visual_block_extents` so both
110 /// renderer peers paint the same block. Charwise / Linewise
111 /// stay on [`visual_selection_range`] — Blockwise needs a
112 /// per-line column band that a linear `Range` can't express.
113 /// T-paint-1 (2026-05-28): also returns the block when the
114 /// active buffer is a Terminal with `t.visual.kind == Block`,
115 /// translating from grid-space to doc-space via
116 /// `synthetic.origin_top_line`.
117 pub fn visual_block_extents(&self) -> Option<BlockExtents> {
118 if matches!(self.active_buffer, BufferKind::Terminal) {
119 return self.terminal_visual_block_extents();
120 }
121 if !matches!(
122 self.modal,
123 ModalState::Visual(VisualKind::Blockwise) | ModalState::Select(VisualKind::Blockwise)
124 ) {
125 return None;
126 }
127 let sels = self.document.selections();
128 let sel = sels.primary();
129 Some(BlockExtents {
130 start_line: sel.anchor.line.min(sel.head.line),
131 end_line: sel.anchor.line.max(sel.head.line),
132 start_col: sel.anchor.byte.min(sel.head.byte),
133 end_col: sel.anchor.byte.max(sel.head.byte),
134 })
135 }
136
137 /// T-paint-1 (2026-05-28): doc-space derivation of the
138 /// terminal-Visual selection. Reads grid-space `(anchor, head)`
139 /// from `t.visual`, subtracts `origin_top_line` to get doc
140 /// lines, treats the grid column as a byte column (ASCII
141 /// assumption — wide-char handling is the §7 open question),
142 /// then folds Charwise / Linewise / Blockwise the same way as
143 /// the document path.
144 fn terminal_visual_selection_range(&self) -> Option<Range> {
145 let buf_id = self.active_pane_buffer_id();
146 self.buffers
147 .with_terminal(buf_id, |t| {
148 let visual = t.visual?;
149 let synthetic = t.synthetic.as_ref()?;
150 let origin = synthetic.origin_top_line;
151 let anchor_line = (visual.anchor_line - origin).max(0) as u32;
152 let head_line = (visual.head_line - origin).max(0) as u32;
153 let anchor = Position::new(anchor_line, visual.anchor_col as u32);
154 let head = Position::new(head_line, visual.head_col as u32);
155 let (a, b) = if anchor <= head {
156 (anchor, head)
157 } else {
158 (head, anchor)
159 };
160 Some(match visual.kind {
161 TermVisualKind::Line => {
162 Range::new(Position::new(a.line, 0), Position::new(b.line, u32::MAX))
163 }
164 TermVisualKind::Char => {
165 Range::new(a, Position::new(b.line, b.byte.saturating_add(1)))
166 }
167 TermVisualKind::Block => {
168 Range::new(a, Position::new(b.line, b.byte.saturating_add(1)))
169 }
170 })
171 })
172 .flatten()
173 }
174
175 /// T-paint-1 (2026-05-28): doc-space derivation of the
176 /// terminal-Visual block extents. Mirrors
177 /// [`Self::terminal_visual_selection_range`]; only fires for
178 /// `TermVisualKind::Block`.
179 fn terminal_visual_block_extents(&self) -> Option<BlockExtents> {
180 let buf_id = self.active_pane_buffer_id();
181 self.buffers
182 .with_terminal(buf_id, |t| {
183 let visual = t.visual?;
184 if !matches!(visual.kind, TermVisualKind::Block) {
185 return None;
186 }
187 let synthetic = t.synthetic.as_ref()?;
188 let origin = synthetic.origin_top_line;
189 let anchor_line = (visual.anchor_line - origin).max(0) as u32;
190 let head_line = (visual.head_line - origin).max(0) as u32;
191 let anchor_col = visual.anchor_col as u32;
192 let head_col = visual.head_col as u32;
193 Some(BlockExtents {
194 start_line: anchor_line.min(head_line),
195 end_line: anchor_line.max(head_line),
196 start_col: anchor_col.min(head_col),
197 end_col: anchor_col.max(head_col),
198 })
199 })
200 .flatten()
201 }
202}