Skip to main content

tui_math/
mathbox.rs

1//! MathBox - A 2D character grid for math rendering
2
3use unicode_segmentation::UnicodeSegmentation;
4use unicode_width::UnicodeWidthStr;
5
6/// Represents a box of grapheme clusters for rendering math expressions.
7/// Uses a 2D grid with baseline tracking for proper vertical alignment.
8/// Each cell holds a grapheme cluster (base char + combining marks).
9#[derive(Clone, Debug)]
10pub struct MathBox {
11    content: Vec<Vec<String>>,
12    pub width: usize,
13    pub height: usize,
14    /// The baseline row (0-indexed from top)
15    pub baseline: usize,
16}
17
18impl MathBox {
19    /// Create a MathBox from a single-line string
20    pub fn from_text(text: &str) -> Self {
21        let graphemes: Vec<String> = text.graphemes(true).map(|g| g.to_string()).collect();
22        let width = text.width();
23
24        // Pad to match display width (handles wide chars)
25        let mut cells = Vec::with_capacity(width);
26        for g in graphemes {
27            let g_width = g.width();
28            cells.push(g);
29            // Add empty cells for wide characters
30            for _ in 1..g_width {
31                cells.push(String::new());
32            }
33        }
34        // Ensure we have exactly 'width' cells
35        while cells.len() < width {
36            cells.push(" ".to_string());
37        }
38
39        Self {
40            content: vec![cells],
41            width,
42            height: 1,
43            baseline: 0,
44        }
45    }
46
47    /// Create an empty MathBox with specified dimensions
48    pub fn empty(width: usize, height: usize, baseline: usize) -> Self {
49        Self {
50            content: vec![vec![" ".to_string(); width]; height],
51            width,
52            height,
53            baseline,
54        }
55    }
56
57    /// Create a MathBox from multiple lines
58    pub fn from_lines(lines: Vec<String>, baseline: usize) -> Self {
59        let height = lines.len();
60        let width = lines.iter().map(|l| l.width()).max().unwrap_or(0);
61        let mut content = vec![vec![" ".to_string(); width]; height];
62
63        for (y, line) in lines.iter().enumerate() {
64            let mut x = 0;
65            for g in line.graphemes(true) {
66                if x < width {
67                    let g_width = g.width();
68                    content[y][x] = g.to_string();
69                    // Mark continuation cells for wide chars
70                    for i in 1..g_width {
71                        if x + i < width {
72                            content[y][x + i] = String::new();
73                        }
74                    }
75                    x += g_width;
76                }
77            }
78        }
79
80        Self {
81            content,
82            width,
83            height,
84            baseline,
85        }
86    }
87
88    /// Get grapheme at position (returns space if out of bounds or empty)
89    pub fn get(&self, x: usize, y: usize) -> char {
90        if y < self.height && x < self.width {
91            self.content[y][x].chars().next().unwrap_or(' ')
92        } else {
93            ' '
94        }
95    }
96
97    /// Get full grapheme cluster at position
98    pub fn get_grapheme(&self, x: usize, y: usize) -> &str {
99        if y < self.height && x < self.width {
100            &self.content[y][x]
101        } else {
102            " "
103        }
104    }
105
106    /// Set character at position
107    pub fn set(&mut self, x: usize, y: usize, ch: char) {
108        if y < self.height && x < self.width {
109            self.content[y][x] = ch.to_string();
110        }
111    }
112
113    /// Set grapheme cluster at position
114    pub fn set_grapheme(&mut self, x: usize, y: usize, g: &str) {
115        if y < self.height && x < self.width {
116            self.content[y][x] = g.to_string();
117        }
118    }
119
120    /// Copy another MathBox into this one at the specified offset
121    pub fn blit(&mut self, other: &MathBox, x_offset: usize, y_offset: usize) {
122        for y in 0..other.height {
123            for x in 0..other.width {
124                let target_x = x_offset + x;
125                let target_y = y_offset + y;
126                if target_y < self.height && target_x < self.width {
127                    let g = other.get_grapheme(x, y);
128                    if !g.is_empty() && g != " " {
129                        self.set_grapheme(target_x, target_y, g);
130                    }
131                }
132            }
133        }
134    }
135
136    /// Concatenate horizontally, aligning by baseline
137    pub fn concat_horizontal(boxes: &[MathBox]) -> MathBox {
138        if boxes.is_empty() {
139            return MathBox::empty(0, 1, 0);
140        }
141
142        // Find max ascent (baseline) and max descent (height - baseline - 1)
143        let max_ascent = boxes.iter().map(|b| b.baseline).max().unwrap_or(0);
144        let max_descent = boxes
145            .iter()
146            .map(|b| b.height.saturating_sub(b.baseline + 1))
147            .max()
148            .unwrap_or(0);
149
150        let total_width: usize = boxes.iter().map(|b| b.width).sum();
151        let total_height = max_ascent + 1 + max_descent;
152
153        let mut result = MathBox::empty(total_width, total_height, max_ascent);
154        let mut x_pos = 0;
155
156        for b in boxes {
157            let y_offset = max_ascent - b.baseline;
158            result.blit(b, x_pos, y_offset);
159            x_pos += b.width;
160        }
161
162        result
163    }
164
165    /// Stack vertically, centered horizontally
166    pub fn stack_vertical(boxes: &[MathBox]) -> MathBox {
167        if boxes.is_empty() {
168            return MathBox::empty(0, 1, 0);
169        }
170
171        let max_width = boxes.iter().map(|b| b.width).max().unwrap_or(0);
172        let total_height: usize = boxes.iter().map(|b| b.height).sum();
173
174        let mut result = MathBox::empty(max_width, total_height, 0);
175        let mut y_pos = 0;
176
177        for b in boxes {
178            let x_offset = (max_width - b.width) / 2;
179            result.blit(b, x_offset, y_pos);
180            y_pos += b.height;
181        }
182
183        // Baseline at middle
184        result.baseline = total_height / 2;
185        result
186    }
187
188    /// Fill a row with a character
189    pub fn fill_row(&mut self, y: usize, ch: char) {
190        if y < self.height {
191            for x in 0..self.width {
192                self.set(x, y, ch);
193            }
194        }
195    }
196
197    /// Fill a column with a character
198    pub fn fill_col(&mut self, x: usize, ch: char) {
199        if x < self.width {
200            for y in 0..self.height {
201                self.set(x, y, ch);
202            }
203        }
204    }
205
206    /// Convert to string representation
207    pub fn to_string(&self) -> String {
208        self.content
209            .iter()
210            .map(|row| row.join("").trim_end().to_string())
211            .collect::<Vec<_>>()
212            .join("\n")
213    }
214
215    /// Get lines as vector of strings
216    pub fn to_lines(&self) -> Vec<String> {
217        self.content.iter().map(|row| row.join("")).collect()
218    }
219}
220
221impl Default for MathBox {
222    fn default() -> Self {
223        Self::empty(0, 1, 0)
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn test_from_text() {
233        let mb = MathBox::from_text("abc");
234        assert_eq!(mb.width, 3);
235        assert_eq!(mb.height, 1);
236        assert_eq!(mb.get(0, 0), 'a');
237        assert_eq!(mb.get(2, 0), 'c');
238    }
239
240    #[test]
241    fn test_combining_chars() {
242        // T with combining macron (TĚ„) should be width 1
243        let mb = MathBox::from_text("T\u{0304}");
244        assert_eq!(mb.width, 1);
245        assert_eq!(mb.get_grapheme(0, 0), "T\u{0304}");
246    }
247
248    #[test]
249    fn test_concat_horizontal() {
250        let a = MathBox::from_text("x");
251        let b = MathBox::from_text("+");
252        let c = MathBox::from_text("y");
253        let result = MathBox::concat_horizontal(&[a, b, c]);
254        assert_eq!(result.to_string(), "x+y");
255    }
256}