Skip to main content

strop_core/
layout.rs

1//! The layout layer (0017, R6 in 0031): one line's byte↔display-cell maps
2//! and grapheme boundaries. Every visible-line consumer — renderer, cursor
3//! placement, selection overlays, diagnostics, mouse hit-testing — reads
4//! this instead of deriving positions by char index.
5//!
6//! Cells are `id::DisplayColumn` (unsaturated usize): positions past 65535
7//! columns stay exact; u16 exists only at the final terminal conversion in
8//! the renderer. Storage stays byte-native (`ByteOffset` is canonical); this
9//! is the single translation seam. `RopeGraphemes` streams clusters off a
10//! rope slice so long lines never pay a whole-line String or layout vector.
11
12use std::borrow::Cow;
13
14use ropey::RopeSlice;
15use unicode_segmentation::{GraphemeCursor, GraphemeIncomplete, UnicodeSegmentation};
16use unicode_width::UnicodeWidthStr;
17
18use crate::id::DisplayColumn;
19
20/// A terminal cell must contain printable text, never protocol bytes. Use the
21/// same one-cell replacement in layout and emission; tab expansion is separate.
22pub fn printable_grapheme(grapheme: &str) -> &str {
23    if grapheme.chars().any(char::is_control) {
24        "\u{fffd}"
25    } else {
26        grapheme
27    }
28}
29
30/// Printable metadata with the same grapheme policy as buffer emission.
31/// Preserve an already-owned label without copying when no control needs replacing.
32pub fn printable_text<'a>(text: impl Into<Cow<'a, str>>) -> Cow<'a, str> {
33    let text = text.into();
34    if !text.chars().any(char::is_control) {
35        return text;
36    }
37    let mut output = String::with_capacity(text.len());
38    for grapheme in text.graphemes(true) {
39        output.push_str(printable_grapheme(grapheme));
40    }
41    Cow::Owned(output)
42}
43
44/// One cluster's width: tabs expand to their stop from the ABSOLUTE cell;
45/// everything else is the printable form's terminal width.
46fn grapheme_width(text: &str, cell: DisplayColumn, tab: usize) -> usize {
47    let tab = tab.max(1);
48    if text == "\t" {
49        tab - cell.get() % tab
50    } else {
51        UnicodeWidthStr::width(printable_grapheme(text))
52    }
53}
54
55/// One grapheme cluster's placement on the line.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct GraphemeSpan {
58    /// Byte offset of the cluster's start within the line.
59    pub byte: usize,
60    /// Display cell where the cluster starts.
61    pub cell: DisplayColumn,
62    /// Display width in cells (0 for pure combining/zero-width, 2 for
63    /// CJK/emoji, tab expands to its stop).
64    pub width: usize,
65}
66
67/// Streaming grapheme iteration over a rope slice, chunk-aligned like
68/// ropey's own iteration: only a cluster crossing rope chunks needs an
69/// owned string. Carries the absolute cell so consumers never re-walk.
70pub struct RopeGraphemes<'a> {
71    text: RopeSlice<'a>,
72    cursor: GraphemeCursor,
73    chunk: &'a str,
74    chunk_start: usize,
75    cell: DisplayColumn,
76    tab: usize,
77}
78
79impl<'a> RopeGraphemes<'a> {
80    pub fn new(text: RopeSlice<'a>, tab: usize) -> Self {
81        Self::new_at(text, tab, DisplayColumn::new(0))
82    }
83
84    /// Start at an absolute cell — virtual EOL annotations continue the
85    /// line's tab stops instead of restarting at column zero.
86    pub fn new_at(text: RopeSlice<'a>, tab: usize, cell: DisplayColumn) -> Self {
87        let (chunk, chunk_start, _, _) = text.chunk_at_byte(0);
88        Self {
89            text,
90            cursor: GraphemeCursor::new(0, text.len_bytes(), true),
91            chunk,
92            chunk_start,
93            cell,
94            tab: tab.max(1),
95        }
96    }
97}
98
99impl<'a> Iterator for RopeGraphemes<'a> {
100    type Item = (GraphemeSpan, Cow<'a, str>);
101
102    fn next(&mut self) -> Option<Self::Item> {
103        let start = self.cursor.cur_cursor();
104        if start == self.text.len_bytes() {
105            return None;
106        }
107        let end = loop {
108            match self.cursor.next_boundary(self.chunk, self.chunk_start) {
109                Ok(Some(end)) => break end,
110                Ok(None) => return None,
111                Err(GraphemeIncomplete::NextChunk) => {
112                    let next = self.chunk_start + self.chunk.len();
113                    let (chunk, offset, _, _) = self.text.chunk_at_byte(next);
114                    self.chunk = chunk;
115                    self.chunk_start = offset;
116                }
117                Err(GraphemeIncomplete::PreContext(end)) => {
118                    let (chunk, offset, _, _) = self.text.chunk_at_byte(end - 1);
119                    self.cursor.provide_context(&chunk[..end - offset], offset);
120                }
121                Err(other) => unreachable!("forward grapheme traversal: {other:?}"),
122            }
123        };
124        let text = if start >= self.chunk_start && end <= self.chunk_start + self.chunk.len() {
125            // fast path: borrow straight out of the current chunk
126            Cow::Borrowed(&self.chunk[start - self.chunk_start..end - self.chunk_start])
127        } else {
128            let slice = self.text.byte_slice(start..end);
129            match slice.as_str() {
130                Some(text) => Cow::Borrowed(text),
131                None => Cow::Owned(slice.to_string()),
132            }
133        };
134        let width = grapheme_width(&text, self.cell, self.tab);
135        let span = GraphemeSpan {
136            byte: start,
137            cell: self.cell,
138            width,
139        };
140        self.cell += width;
141        Some((span, text))
142    }
143}
144
145/// A glyph's intersection with the visible cell window.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub struct CellClip {
148    /// Leftmost visible cell, relative to the window origin.
149    pub x: usize,
150    /// Visible cell count (a partially clipped wide glyph's cells).
151    pub width: usize,
152    /// The whole cluster is visible — false means the renderer must use
153    /// styled blanks, never half a glyph.
154    pub complete: bool,
155}
156
157/// Clip one grapheme span to `[origin, origin+width)`. `None` when the
158/// cluster occupies no cell in the window (zero-width or fully outside).
159pub fn clip(span: GraphemeSpan, origin: DisplayColumn, width: usize) -> Option<CellClip> {
160    let left = span.cell.get().max(origin.get());
161    let end = span.cell.get() + span.width;
162    let right = end.min(origin.get().saturating_add(width));
163    (left < right).then(|| CellClip {
164        x: left - origin.get(),
165        width: right - left,
166        complete: left == span.cell.get() && right == end,
167    })
168}
169
170/// A laid-out line: grapheme spans in order.
171#[derive(Debug, Clone, Default)]
172pub struct LineLayout {
173    spans: Vec<GraphemeSpan>,
174    /// The line's byte length (cursor-at-end needs it).
175    pub len_bytes: usize,
176    /// Total rendered width in cells.
177    pub width: DisplayColumn,
178}
179
180impl LineLayout {
181    /// Lay out one content line. Tabs expand to stops; control graphemes use
182    /// the same visible one-cell replacement as the renderer.
183    pub fn build(text: &str, tab: usize) -> Self {
184        let mut spans = Vec::new();
185        let mut cell = DisplayColumn::new(0);
186        for (byte, text) in text.grapheme_indices(true) {
187            let width = grapheme_width(text, cell, tab);
188            spans.push(GraphemeSpan { byte, cell, width });
189            cell += width;
190        }
191        Self {
192            spans,
193            len_bytes: text.len(),
194            width: cell,
195        }
196    }
197
198    /// The grapheme spans, in order.
199    pub fn spans(&self) -> &[GraphemeSpan] {
200        &self.spans
201    }
202
203    /// Display cell where a byte offset renders (cursor placement).
204    /// A byte mid-cluster maps to the cluster's cell; at/past the end
205    /// maps to the line's end cell (vim's virtual cursor position).
206    pub fn cell_at_byte(&self, byte: usize) -> DisplayColumn {
207        if byte >= self.len_bytes {
208            return self.width;
209        }
210        let next = self.spans.partition_point(|s| s.byte <= byte);
211        next.checked_sub(1)
212            .map_or(DisplayColumn::new(0), |i| self.spans[i].cell)
213    }
214
215    /// Byte offset of the cluster at a display cell (mouse hit-testing,
216    /// desired-column). A cell inside a wide cluster maps to its start;
217    /// past the end maps to the line's byte length... caller clamps.
218    pub fn byte_at_cell(&self, cell: DisplayColumn) -> usize {
219        if cell >= self.width {
220            return self.len_bytes;
221        }
222        let next = self.spans.partition_point(|s| s.cell <= cell);
223        next.checked_sub(1).map_or(0, |i| self.spans[i].byte)
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn absolute_tabs_and_unicode_cells() {
233        let text = "ab\t界e\u{301}\x1bZ";
234        for (tab, cells, width) in [(3, [0, 1, 2, 3, 5, 6, 7], 8), (4, [0, 1, 2, 4, 6, 7, 8], 9)] {
235            let layout = LineLayout::build(text, tab);
236            assert_eq!(
237                layout
238                    .spans()
239                    .iter()
240                    .map(|s| s.cell.get())
241                    .collect::<Vec<_>>(),
242                cells
243            );
244            assert_eq!(layout.width.get(), width);
245            assert_eq!(layout.cell_at_byte(8).get(), cells[4]);
246            assert_eq!(layout.byte_at_cell(DisplayColumn::new(cells[3] + 1)), 3);
247            assert_eq!(layout.byte_at_cell(DisplayColumn::new(width)), text.len());
248        }
249    }
250
251    #[test]
252    fn columns_do_not_alias_at_terminal_limit() {
253        let text = format!("{}\t界Z", "x".repeat(70_001));
254        let layout = LineLayout::build(&text, 4);
255        assert_eq!(layout.cell_at_byte(70_002).get(), 70_004);
256        assert_eq!(layout.cell_at_byte(70_005).get(), 70_006);
257        assert_eq!(layout.byte_at_cell(DisplayColumn::new(70_005)), 70_002);
258        assert_eq!(LineLayout::build("x\tZ", 300).cell_at_byte(2).get(), 300);
259    }
260
261    #[test]
262    fn rope_chunk_boundaries_preserve_extended_clusters() {
263        let text = format!("{}e{}\t界🧑‍🚀Z", "a".repeat(997), "\u{301}".repeat(2000));
264        let rope = ropey::Rope::from_str(&text);
265        let got = RopeGraphemes::new(rope.slice(..), 3).collect::<Vec<_>>();
266        assert_eq!(
267            got[997].0,
268            GraphemeSpan {
269                byte: 997,
270                cell: DisplayColumn::new(997),
271                width: 1
272            }
273        );
274        assert_eq!(got[997].1, format!("e{}", "\u{301}".repeat(2000)));
275        let tail = got
276            .iter()
277            .skip(998)
278            .map(|(s, t)| (s.cell.get(), s.width, t.as_ref()))
279            .collect::<Vec<_>>();
280        assert_eq!(
281            tail,
282            [
283                (998, 1, "\t"),
284                (999, 2, "界"),
285                (1001, 2, "🧑‍🚀"),
286                (1003, 1, "Z")
287            ]
288        );
289    }
290
291    #[test]
292    fn clipping_preserves_cells_not_partial_glyphs() {
293        let span = GraphemeSpan {
294            byte: 0,
295            cell: DisplayColumn::new(4),
296            width: 2,
297        };
298        assert_eq!(
299            clip(span, DisplayColumn::new(5), 4),
300            Some(CellClip {
301                x: 0,
302                width: 1,
303                complete: false
304            })
305        );
306        assert_eq!(
307            clip(span, DisplayColumn::new(3), 2),
308            Some(CellClip {
309                x: 1,
310                width: 1,
311                complete: false
312            })
313        );
314        assert_eq!(
315            clip(span, DisplayColumn::new(3), 3),
316            Some(CellClip {
317                x: 1,
318                width: 2,
319                complete: true
320            })
321        );
322    }
323}