Skip to main content

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}