Skip to main content

term_maths/
rendered_block.rs

1use std::fmt;
2use unicode_width::UnicodeWidthStr;
3
4/// A rectangular character grid with dimensional metadata.
5///
6/// This is the core data structure for 2D math rendering. Each cell contains
7/// a string (to handle multi-codepoint grapheme clusters). The baseline marks
8/// the row used for horizontal alignment when composing blocks side-by-side.
9#[derive(Debug, Clone)]
10pub struct RenderedBlock {
11    /// Rows of character cells. Each cell is a `String` occupying one terminal column.
12    cells: Vec<Vec<String>>,
13    /// Width in terminal columns (via unicode-width).
14    width: usize,
15    /// Height in rows.
16    height: usize,
17    /// Row index of the alignment baseline (0-indexed from top).
18    baseline: usize,
19}
20
21impl RenderedBlock {
22    /// Create a new block from rows of cell strings.
23    ///
24    /// Width is computed from the first row (all rows must have equal width).
25    /// The baseline defaults to `height / 2` if not specified.
26    pub fn new(cells: Vec<Vec<String>>, baseline: usize) -> Self {
27        let height = cells.len();
28        let width = cells.first().map_or(0, |row| {
29            row.iter().map(|c| UnicodeWidthStr::width(c.as_str())).sum()
30        });
31        Self {
32            cells,
33            width,
34            height,
35            baseline,
36        }
37    }
38
39    /// Create a block containing a single character.
40    pub fn from_char(ch: char) -> Self {
41        let s = ch.to_string();
42        let width = UnicodeWidthStr::width(s.as_str()).max(1);
43        Self {
44            cells: vec![vec![s]],
45            width,
46            height: 1,
47            baseline: 0,
48        }
49    }
50
51    /// Create a block from a string of text (single row).
52    pub fn from_text(text: &str) -> Self {
53        if text.is_empty() {
54            return Self::empty();
55        }
56        let cells: Vec<String> = text.chars().map(|c| c.to_string()).collect();
57        let width = UnicodeWidthStr::width(text);
58        Self {
59            cells: vec![cells],
60            width,
61            height: 1,
62            baseline: 0,
63        }
64    }
65
66    /// Create an empty block with zero dimensions.
67    pub fn empty() -> Self {
68        Self {
69            cells: vec![],
70            width: 0,
71            height: 0,
72            baseline: 0,
73        }
74    }
75
76    pub fn width(&self) -> usize {
77        self.width
78    }
79
80    pub fn height(&self) -> usize {
81        self.height
82    }
83
84    pub fn baseline(&self) -> usize {
85        self.baseline
86    }
87
88    pub fn cells(&self) -> &[Vec<String>] {
89        &self.cells
90    }
91
92    pub fn is_empty(&self) -> bool {
93        self.height == 0 || self.width == 0
94    }
95
96    /// Place two blocks side-by-side, aligned on baselines.
97    /// Pads the shorter block with empty rows above/below as needed.
98    pub fn beside(&self, other: &RenderedBlock) -> RenderedBlock {
99        if self.is_empty() {
100            return other.clone();
101        }
102        if other.is_empty() {
103            return self.clone();
104        }
105
106        let baseline = self.baseline.max(other.baseline);
107        let above_baseline = baseline;
108
109        let self_below = self.height.saturating_sub(self.baseline + 1);
110        let other_below = other.height.saturating_sub(other.baseline + 1);
111        let below_baseline = self_below.max(other_below);
112
113        let total_height = above_baseline + 1 + below_baseline;
114        let total_width = self.width + other.width;
115
116        let self_top_pad = above_baseline - self.baseline;
117        let other_top_pad = above_baseline - other.baseline;
118
119        let mut rows = Vec::with_capacity(total_height);
120        for row_idx in 0..total_height {
121            let mut row = Vec::new();
122
123            // Left block cells
124            let self_row = row_idx.checked_sub(self_top_pad);
125            if let Some(sr) = self_row {
126                if sr < self.height {
127                    row.extend(self.cells[sr].iter().cloned());
128                } else {
129                    row.extend(std::iter::repeat_n(" ".to_string(), self.width));
130                }
131            } else {
132                row.extend(std::iter::repeat_n(" ".to_string(), self.width));
133            }
134
135            // Right block cells
136            let other_row = row_idx.checked_sub(other_top_pad);
137            if let Some(or_idx) = other_row {
138                if or_idx < other.height {
139                    row.extend(other.cells[or_idx].iter().cloned());
140                } else {
141                    row.extend(std::iter::repeat_n(" ".to_string(), other.width));
142                }
143            } else {
144                row.extend(std::iter::repeat_n(" ".to_string(), other.width));
145            }
146
147            rows.push(row);
148        }
149
150        RenderedBlock {
151            cells: rows,
152            width: total_width,
153            height: total_height,
154            baseline,
155        }
156    }
157
158    /// Stack two blocks vertically. The baseline is set to `baseline_row`
159    /// (typically the dividing row between them, or top/bottom block's baseline).
160    pub fn above(
161        top: &RenderedBlock,
162        bottom: &RenderedBlock,
163        baseline_row: usize,
164    ) -> RenderedBlock {
165        let width = top.width.max(bottom.width);
166        let mut rows = Vec::with_capacity(top.height + bottom.height);
167
168        for r in 0..top.height {
169            rows.push(Self::pad_row_to_width(&top.cells[r], top.width, width));
170        }
171        for r in 0..bottom.height {
172            rows.push(Self::pad_row_to_width(
173                &bottom.cells[r],
174                bottom.width,
175                width,
176            ));
177        }
178
179        RenderedBlock {
180            cells: rows,
181            width,
182            height: top.height + bottom.height,
183            baseline: baseline_row,
184        }
185    }
186
187    /// Add empty space around a block.
188    pub fn pad(&self, left: usize, right: usize, top: usize, bottom: usize) -> RenderedBlock {
189        let new_width = left + self.width + right;
190        let new_height = top + self.height + bottom;
191
192        let mut rows = Vec::with_capacity(new_height);
193
194        // Top padding
195        for _ in 0..top {
196            rows.push(vec![" ".to_string(); new_width]);
197        }
198
199        // Content rows with left/right padding
200        for r in 0..self.height {
201            let mut row = Vec::with_capacity(new_width);
202            row.extend(std::iter::repeat_n(" ".to_string(), left));
203            row.extend(self.cells[r].iter().cloned());
204            row.extend(std::iter::repeat_n(" ".to_string(), right));
205            rows.push(row);
206        }
207
208        // Bottom padding
209        for _ in 0..bottom {
210            rows.push(vec![" ".to_string(); new_width]);
211        }
212
213        RenderedBlock {
214            cells: rows,
215            width: new_width,
216            height: new_height,
217            baseline: self.baseline + top,
218        }
219    }
220
221    /// Horizontally centre a block within a given width.
222    pub fn center_in(&self, target_width: usize) -> RenderedBlock {
223        if target_width <= self.width {
224            return self.clone();
225        }
226        let total_pad = target_width - self.width;
227        let left_pad = total_pad / 2;
228        let right_pad = total_pad - left_pad;
229        self.pad(left_pad, right_pad, 0, 0)
230    }
231
232    /// Helper: pad a row of cells to a target width by appending spaces.
233    fn pad_row_to_width(row: &[String], current_width: usize, target_width: usize) -> Vec<String> {
234        let mut result = row.to_vec();
235        let pad = target_width.saturating_sub(current_width);
236        result.extend(std::iter::repeat_n(" ".to_string(), pad));
237        result
238    }
239
240    /// Create a horizontal line of a given character and width.
241    pub fn hline(ch: char, width: usize) -> RenderedBlock {
242        let cells = vec![vec![ch.to_string(); width]];
243        RenderedBlock {
244            cells,
245            width,
246            height: 1,
247            baseline: 0,
248        }
249    }
250}
251
252impl fmt::Display for RenderedBlock {
253    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
254        for (i, row) in self.cells.iter().enumerate() {
255            if i > 0 {
256                writeln!(f)?;
257            }
258            for cell in row {
259                write!(f, "{}", cell)?;
260            }
261        }
262        Ok(())
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn test_from_char() {
272        let block = RenderedBlock::from_char('x');
273        assert_eq!(block.width(), 1);
274        assert_eq!(block.height(), 1);
275        assert_eq!(block.baseline(), 0);
276        assert_eq!(format!("{}", block), "x");
277    }
278
279    #[test]
280    fn test_from_text() {
281        let block = RenderedBlock::from_text("hello");
282        assert_eq!(block.width(), 5);
283        assert_eq!(block.height(), 1);
284        assert_eq!(format!("{}", block), "hello");
285    }
286
287    #[test]
288    fn test_beside_baseline_aligned() {
289        // Two single-row blocks
290        let a = RenderedBlock::from_text("ab");
291        let b = RenderedBlock::from_text("cd");
292        let result = a.beside(&b);
293        assert_eq!(result.width(), 4);
294        assert_eq!(result.height(), 1);
295        assert_eq!(format!("{}", result), "abcd");
296    }
297
298    #[test]
299    fn test_beside_different_heights() {
300        // a is 3 rows tall with baseline at row 1
301        let a = RenderedBlock::new(
302            vec![vec!["a".into()], vec!["b".into()], vec!["c".into()]],
303            1,
304        );
305        // d is 1 row tall with baseline at row 0
306        let d = RenderedBlock::from_char('d');
307        let result = a.beside(&d);
308        assert_eq!(result.height(), 3);
309        assert_eq!(result.baseline(), 1);
310        // d should be on the baseline row (row 1)
311        let output = format!("{}", result);
312        let lines: Vec<&str> = output.lines().collect();
313        assert_eq!(lines[0], "a ");
314        assert_eq!(lines[1], "bd");
315        assert_eq!(lines[2], "c ");
316    }
317
318    #[test]
319    fn test_center_in() {
320        let block = RenderedBlock::from_text("ab");
321        let centered = block.center_in(6);
322        assert_eq!(centered.width(), 6);
323        assert_eq!(format!("{}", centered), "  ab  ");
324    }
325
326    #[test]
327    fn test_above() {
328        let top = RenderedBlock::from_text("abc");
329        let bottom = RenderedBlock::from_text("de");
330        let result = RenderedBlock::above(&top, &bottom, 0);
331        assert_eq!(result.height(), 2);
332        assert_eq!(result.width(), 3);
333        let output = format!("{}", result);
334        let lines: Vec<&str> = output.lines().collect();
335        assert_eq!(lines[0], "abc");
336        assert_eq!(lines[1], "de ");
337    }
338
339    #[test]
340    fn test_pad() {
341        let block = RenderedBlock::from_char('x');
342        let padded = block.pad(1, 1, 1, 1);
343        assert_eq!(padded.width(), 3);
344        assert_eq!(padded.height(), 3);
345        assert_eq!(padded.baseline(), 1);
346        let output = format!("{}", padded);
347        let lines: Vec<&str> = output.lines().collect();
348        assert_eq!(lines[0], "   ");
349        assert_eq!(lines[1], " x ");
350        assert_eq!(lines[2], "   ");
351    }
352}