Skip to main content

strop_core/
layout.rs

1//! The layout layer (0017): one line's byte↔display-cell maps and
2//! grapheme boundaries. Every visible-line consumer — renderer, cursor
3//! placement, selection overlays, diagnostics, mouse hit-testing —
4//! reads this instead of deriving positions by char index (the old
5//! `.chars().enumerate()` walk drifted after the first multibyte char).
6//!
7//! Storage stays byte-native (`ByteOffset` is canonical); this is the
8//! single translation seam.
9
10use unicode_segmentation::UnicodeSegmentation;
11use unicode_width::UnicodeWidthStr;
12
13/// One grapheme cluster's placement on the line.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub struct GraphemeSpan {
16    /// Byte offset of the cluster's start within the line.
17    pub byte: usize,
18    /// Display cell where the cluster starts.
19    pub cell: u16,
20    /// Display width in cells (0 for pure combining/zero-width, 2 for
21    /// CJK/emoji, tab expands to its stop).
22    pub width: u8,
23}
24
25/// A laid-out line: grapheme spans in order.
26#[derive(Debug, Clone, Default)]
27pub struct LineLayout {
28    spans: Vec<GraphemeSpan>,
29    /// The line's byte length (cursor-at-end needs it).
30    pub len_bytes: usize,
31    /// Total rendered width in cells.
32    pub width: u16,
33}
34
35impl LineLayout {
36    /// Lay out one line's text (no trailing newline). `tab` is the tab
37    /// stop; control chars render zero-width (terminals show them raw).
38    pub fn build(text: &str, tab: u16) -> Self {
39        let tab = tab.max(1);
40        let mut spans = Vec::with_capacity(text.len() / 2 + 4);
41        let mut cell: u16 = 0;
42        for (byte, g) in text.grapheme_indices(true) {
43            let w = if g == "\t" {
44                (tab - cell % tab) as u8
45            } else {
46                UnicodeWidthStr::width(g).min(u8::MAX as usize) as u8
47            };
48            spans.push(GraphemeSpan {
49                byte,
50                cell,
51                width: w,
52            });
53            cell = cell.saturating_add(w as u16);
54        }
55        LineLayout {
56            spans,
57            len_bytes: text.len(),
58            width: cell,
59        }
60    }
61
62    /// The grapheme spans, in order.
63    pub fn spans(&self) -> &[GraphemeSpan] {
64        &self.spans
65    }
66
67    /// Display cell where a byte offset renders (cursor placement).
68    /// A byte mid-cluster maps to the cluster's cell; at/past the end
69    /// maps to the line's end cell (vim's virtual cursor position).
70    pub fn cell_at_byte(&self, byte: usize) -> u16 {
71        if byte >= self.len_bytes {
72            return self.width;
73        }
74        self.spans
75            .iter()
76            .rev()
77            .find(|s| s.byte <= byte)
78            .map(|s| s.cell)
79            .unwrap_or(0)
80    }
81
82    /// Byte offset of the cluster at a display cell (mouse hit-testing,
83    /// desired-column). A cell inside a wide cluster maps to its start;
84    /// past the end maps to the line's byte length... caller clamps.
85    pub fn byte_at_cell(&self, cell: u16) -> usize {
86        match self.spans.iter().rev().find(|s| s.cell <= cell) {
87            Some(s) => s.byte,
88            None => 0,
89        }
90    }
91
92    /// Is the line free of wide/zero-width complications? Hot paths may
93    /// skip the layout for the common ASCII case.
94    pub fn is_ascii_fast(&self) -> bool {
95        self.spans.iter().all(|s| s.width == 1)
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn ascii_is_identity() {
105        let l = LineLayout::build("hello", 8);
106        assert_eq!(l.width, 5);
107        assert_eq!(l.cell_at_byte(3), 3);
108        assert_eq!(l.byte_at_cell(3), 3);
109        assert!(l.is_ascii_fast());
110    }
111
112    #[test]
113    fn cjk_is_two_cells() {
114        let l = LineLayout::build("a界b", 8);
115        assert_eq!(l.width, 4);
116        assert_eq!(l.cell_at_byte(1), 1); // 界 starts at cell 1
117        assert_eq!(l.cell_at_byte(4), 3); // b (byte 4) at cell 3
118        assert_eq!(l.byte_at_cell(3), 4);
119    }
120
121    #[test]
122    fn emoji_cluster_is_one_unit() {
123        let l = LineLayout::build("x\u{1F9D1}\u{200D}\u{1F680}y", 8); // 🧑‍🚀
124        assert_eq!(l.spans().len(), 3);
125        assert_eq!(l.spans()[1].width, 2);
126        assert_eq!(l.width, 4);
127    }
128
129    #[test]
130    fn tab_expands_to_its_stop() {
131        let l = LineLayout::build("ab\tc", 4);
132        assert_eq!(l.spans()[2].width, 2); // cell 2 → stop at 4
133        assert_eq!(l.cell_at_byte(3), 4); // c at cell 4
134        assert_eq!(l.width, 5);
135    }
136}