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