ailloli_ui_terminal_core/
screen.rs

1//! Rectangular terminal screen buffer and bounded editing operations.
2
3use serde::{Deserialize, Serialize};
4
5use crate::cell::{CellWidth, TerminalCell};
6use crate::cursor::TerminalCursor;
7use crate::damage::TerminalDamage;
8use crate::hyperlink::TerminalHyperlinkId;
9use crate::line::TerminalLine;
10use crate::size::TerminalSize;
11use crate::style::TerminalStyle;
12
13/// Rectangular visible terminal buffer with an inclusive scroll region.
14///
15/// [`Self::new`] maintains `lines.len() == rows`, every line width equal to
16/// `cols`, nonzero dimensions, and `scroll_top <= scroll_bottom < rows`.
17/// Public fields and derived deserialization can bypass these invariants; most
18/// mutation methods index under them and may panic on inconsistent values.
19///
20/// # Examples
21///
22/// ```
23/// use ailloli_ui_terminal_core::{TerminalScreen, TerminalSize, TerminalStyle};
24/// let screen = TerminalScreen::new(TerminalSize::new(24, 80), TerminalStyle::default());
25/// assert_eq!((screen.rows, screen.cols, screen.lines.len()), (24, 80, 24));
26/// ```
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct TerminalScreen {
29    /// Visible row count; normally nonzero and equal to `lines.len()`.
30    pub rows: usize,
31    /// Visible column count; normally nonzero and equal to every line length.
32    pub cols: usize,
33    /// Visible rows in top-to-bottom order.
34    pub lines: Vec<TerminalLine>,
35    /// Inclusive first row of the active scroll region.
36    pub scroll_top: usize,
37    /// Inclusive last row of the active scroll region.
38    pub scroll_bottom: usize,
39}
40
41impl TerminalScreen {
42    /// Creates a blank rectangular screen and full-height scroll region.
43    ///
44    /// Zero dimensions are clamped to one. Every cell is a narrow styled space.
45    ///
46    /// # Examples
47    ///
48    /// ```
49    /// use ailloli_ui_terminal_core::{TerminalScreen, TerminalSize, TerminalStyle};
50    /// let screen = TerminalScreen::new(TerminalSize { rows: 0, cols: 0 }, TerminalStyle::default());
51    /// assert_eq!((screen.rows, screen.cols, screen.scroll_bottom), (1, 1, 0));
52    /// ```
53    pub fn new(size: TerminalSize, style: TerminalStyle) -> Self {
54        let size = size.clamped();
55        Self {
56            rows: size.rows,
57            cols: size.cols,
58            lines: vec![TerminalLine::blank(size.cols, style); size.rows],
59            scroll_top: 0,
60            scroll_bottom: size.rows - 1,
61        }
62    }
63
64    /// Returns dimensions with zero public-field values clamped to one.
65    ///
66    /// It does not inspect `lines` or repair an inconsistent screen.
67    ///
68    /// # Examples
69    ///
70    /// ```
71    /// use ailloli_ui_terminal_core::{TerminalScreen, TerminalSize, TerminalStyle};
72    /// let screen = TerminalScreen::new(TerminalSize::new(2, 3), TerminalStyle::default());
73    /// assert_eq!(screen.size(), TerminalSize::new(2, 3));
74    /// ```
75    pub fn size(&self) -> TerminalSize {
76        TerminalSize::new(self.rows, self.cols)
77    }
78
79    /// Borrows a zero-based physical row, or `None` outside `lines`.
80    ///
81    /// # Examples
82    ///
83    /// ```
84    /// use ailloli_ui_terminal_core::{TerminalScreen, TerminalSize, TerminalStyle};
85    /// let screen = TerminalScreen::new(TerminalSize::new(2, 3), TerminalStyle::default());
86    /// assert!(screen.line(1).is_some() && screen.line(2).is_none());
87    /// ```
88    pub fn line(&self, row: usize) -> Option<&TerminalLine> {
89        self.lines.get(row)
90    }
91
92    /// Borrows a zero-based cell, or `None` when either index is absent.
93    ///
94    /// # Examples
95    ///
96    /// ```
97    /// use ailloli_ui_terminal_core::{TerminalScreen, TerminalSize, TerminalStyle};
98    /// let screen = TerminalScreen::new(TerminalSize::new(2, 3), TerminalStyle::default());
99    /// assert!(screen.cell(1, 2).is_some() && screen.cell(2, 0).is_none());
100    /// ```
101    pub fn cell(&self, row: usize, col: usize) -> Option<&TerminalCell> {
102        self.lines.get(row).and_then(|line| line.cell(col))
103    }
104
105    /// Blanks every retained line and requests full-grid/cursor repaint.
106    ///
107    /// Line lengths are preserved, soft-wrap markers are cleared, and the
108    /// damage title flag is not changed.
109    ///
110    /// # Examples
111    ///
112    /// ```
113    /// use ailloli_ui_terminal_core::{TerminalDamage, TerminalScreen, TerminalSize, TerminalStyle};
114    /// let mut screen = TerminalScreen::new(TerminalSize::new(1, 2), TerminalStyle::default());
115    /// let mut damage = TerminalDamage::clean(); screen.clear_screen(TerminalStyle::default(), &mut damage);
116    /// assert!(damage.full && damage.cursor_dirty);
117    /// ```
118    pub fn clear_screen(&mut self, style: TerminalStyle, damage: &mut TerminalDamage) {
119        for line in &mut self.lines {
120            line.clear(style);
121        }
122        damage.mark_full();
123    }
124
125    /// Blanks one retained row and marks it dirty.
126    ///
127    /// An index outside `lines` is a no-op, even if below the public `rows`
128    /// field. The row's soft-wrap marker is cleared.
129    ///
130    /// # Examples
131    ///
132    /// ```
133    /// use ailloli_ui_terminal_core::{TerminalDamage, TerminalScreen, TerminalSize, TerminalStyle};
134    /// let mut screen = TerminalScreen::new(TerminalSize::new(1, 2), TerminalStyle::default());
135    /// let mut damage = TerminalDamage::clean(); screen.clear_line(0, TerminalStyle::default(), &mut damage);
136    /// assert_eq!(damage.dirty_lines, vec![0]);
137    /// ```
138    pub fn clear_line(&mut self, row: usize, style: TerminalStyle, damage: &mut TerminalDamage) {
139        if let Some(line) = self.lines.get_mut(row) {
140            line.clear(style);
141            damage.mark_line(row);
142        }
143    }
144
145    /// Sets an inclusive scroll region after clamping both bounds to the last row.
146    ///
147    /// If the clamped top exceeds the clamped bottom, the full-height region is
148    /// restored. No cursor movement or damage is produced. On an invalid
149    /// zero-row public state, both bounds become zero.
150    ///
151    /// # Examples
152    ///
153    /// ```
154    /// use ailloli_ui_terminal_core::{TerminalScreen, TerminalSize, TerminalStyle};
155    /// let mut screen = TerminalScreen::new(TerminalSize::new(5, 2), TerminalStyle::default());
156    /// screen.set_scroll_region(1, 3);
157    /// assert_eq!((screen.scroll_top, screen.scroll_bottom), (1, 3));
158    /// ```
159    pub fn set_scroll_region(&mut self, top: usize, bottom: usize) {
160        let max = self.rows.saturating_sub(1);
161        let top = top.min(max);
162        let bottom = bottom.min(max);
163        if top <= bottom {
164            self.scroll_top = top;
165            self.scroll_bottom = bottom;
166        } else {
167            self.reset_scroll_region();
168        }
169    }
170
171    /// Restores the inclusive scroll region to all declared rows.
172    ///
173    /// For `rows == 0` (possible only through public mutation/deserialization),
174    /// both bounds become zero.
175    ///
176    /// # Examples
177    ///
178    /// ```
179    /// use ailloli_ui_terminal_core::{TerminalScreen, TerminalSize, TerminalStyle};
180    /// let mut screen = TerminalScreen::new(TerminalSize::new(4, 2), TerminalStyle::default());
181    /// screen.set_scroll_region(1, 2); screen.reset_scroll_region();
182    /// assert_eq!((screen.scroll_top, screen.scroll_bottom), (0, 3));
183    /// ```
184    pub fn reset_scroll_region(&mut self) {
185        self.scroll_top = 0;
186        self.scroll_bottom = self.rows.saturating_sub(1);
187    }
188
189    /// Writes one narrow cell after clearing any intersecting wide pair.
190    ///
191    /// Text is stored verbatim without Unicode-width validation. Out-of-range
192    /// row/column values are no-ops and produce no damage.
193    ///
194    /// # Panics
195    ///
196    /// May panic if public fields/lines violate the screen invariants.
197    ///
198    /// # Examples
199    ///
200    /// ```
201    /// use ailloli_ui_terminal_core::{TerminalDamage, TerminalScreen, TerminalSize, TerminalStyle};
202    /// let style = TerminalStyle::default();
203    /// let mut screen = TerminalScreen::new(TerminalSize::new(1, 2), style);
204    /// screen.put_narrow(0, 1, "A", style, None, &mut TerminalDamage::clean());
205    /// assert_eq!(screen.cell(0, 1).unwrap().text, "A");
206    /// ```
207    pub fn put_narrow(
208        &mut self,
209        row: usize,
210        col: usize,
211        text: impl Into<String>,
212        style: TerminalStyle,
213        hyperlink: Option<TerminalHyperlinkId>,
214        damage: &mut TerminalDamage,
215    ) {
216        if row >= self.rows || col >= self.cols {
217            return;
218        }
219        self.clear_write_range(row, col, 1, style);
220        self.lines[row].cells[col] = TerminalCell::narrow(text, style).hyperlink(hyperlink);
221        damage.mark_line(row);
222    }
223
224    /// Writes a leading/trailing two-cell pair after clearing intersecting pairs.
225    ///
226    /// Text is stored verbatim without Unicode-width validation. A pair that
227    /// does not fit, including one starting at the last column, is a no-op.
228    /// Both cells receive the same optional hyperlink.
229    ///
230    /// # Panics
231    ///
232    /// In debug builds `col + 1` can panic on theoretical `usize` overflow.
233    /// The method can also panic if public screen fields violate invariants.
234    ///
235    /// # Examples
236    ///
237    /// ```
238    /// use ailloli_ui_terminal_core::{CellWidth, TerminalDamage, TerminalScreen, TerminalSize, TerminalStyle};
239    /// let style = TerminalStyle::default();
240    /// let mut screen = TerminalScreen::new(TerminalSize::new(1, 2), style);
241    /// screen.put_wide(0, 0, "界", style, None, &mut TerminalDamage::clean());
242    /// assert_eq!(screen.cell(0, 1).unwrap().width, CellWidth::WideTrailing);
243    /// ```
244    pub fn put_wide(
245        &mut self,
246        row: usize,
247        col: usize,
248        text: impl Into<String>,
249        style: TerminalStyle,
250        hyperlink: Option<TerminalHyperlinkId>,
251        damage: &mut TerminalDamage,
252    ) {
253        if row >= self.rows || col + 1 >= self.cols {
254            return;
255        }
256        self.clear_write_range(row, col, 2, style);
257        self.lines[row].cells[col] = TerminalCell::wide_leading(text, style).hyperlink(hyperlink);
258        self.lines[row].cells[col + 1] = TerminalCell::wide_trailing(style).hyperlink(hyperlink);
259        damage.mark_line(row);
260    }
261
262    /// Appends one Unicode scalar to a cell's text.
263    ///
264    /// If `col` names a wide trailing cell, the mark is appended to its leading
265    /// neighbor. An exact blank's space is removed first. The scalar is not
266    /// validated as a combining mark. Out-of-range coordinates are no-ops.
267    ///
268    /// # Panics
269    ///
270    /// May panic if public screen fields/lines violate invariants.
271    ///
272    /// # Examples
273    ///
274    /// ```
275    /// use ailloli_ui_terminal_core::{TerminalDamage, TerminalScreen, TerminalSize, TerminalStyle};
276    /// let mut screen = TerminalScreen::new(TerminalSize::new(1, 1), TerminalStyle::default());
277    /// screen.append_combining(0, 0, '\u{301}', &mut TerminalDamage::clean());
278    /// assert_eq!(screen.cell(0, 0).unwrap().text, "\u{301}");
279    /// ```
280    pub fn append_combining(
281        &mut self,
282        row: usize,
283        col: usize,
284        mark: char,
285        damage: &mut TerminalDamage,
286    ) {
287        if row >= self.rows || col >= self.cols {
288            return;
289        }
290
291        let mut target_col = col;
292        if self.lines[row].cells[target_col].width == CellWidth::WideTrailing && target_col > 0 {
293            target_col -= 1;
294        }
295
296        let cell = &mut self.lines[row].cells[target_col];
297        if cell.is_blank() {
298            cell.text.clear();
299        }
300        cell.text.push(mark);
301        damage.mark_line(row);
302    }
303
304    /// Sets one row's soft-wrap provenance without recording damage.
305    ///
306    /// An index outside `lines` is a no-op.
307    ///
308    /// # Examples
309    ///
310    /// ```
311    /// use ailloli_ui_terminal_core::{TerminalScreen, TerminalSize, TerminalStyle};
312    /// let mut screen = TerminalScreen::new(TerminalSize::new(2, 2), TerminalStyle::default());
313    /// screen.set_line_wrapped_from_previous(1, true);
314    /// assert!(screen.line(1).unwrap().wrapped_from_previous);
315    /// ```
316    pub fn set_line_wrapped_from_previous(&mut self, row: usize, wrapped: bool) {
317        if let Some(line) = self.lines.get_mut(row) {
318            line.wrapped_from_previous = wrapped;
319        }
320    }
321
322    /// Scrolls the inclusive region upward by at most its height.
323    ///
324    /// New bottom rows are styled blanks. Removed rows are returned only when
325    /// the scroll region spans the complete screen; partial-region rows are
326    /// discarded. Count zero still marks the whole region dirty.
327    ///
328    /// # Panics
329    ///
330    /// May panic or overflow if public screen/region fields violate invariants.
331    ///
332    /// # Examples
333    ///
334    /// ```
335    /// use ailloli_ui_terminal_core::{TerminalDamage, TerminalScreen, TerminalSize, TerminalStyle};
336    /// let style = TerminalStyle::default();
337    /// let mut screen = TerminalScreen::new(TerminalSize::new(2, 2), style);
338    /// let removed = screen.scroll_up(1, style, &mut TerminalDamage::clean());
339    /// assert_eq!(removed.len(), 1);
340    /// ```
341    pub fn scroll_up(
342        &mut self,
343        count: usize,
344        style: TerminalStyle,
345        damage: &mut TerminalDamage,
346    ) -> Vec<TerminalLine> {
347        let height = self.scroll_bottom - self.scroll_top + 1;
348        let count = count.min(height);
349        let full_region = self.scroll_top == 0 && self.scroll_bottom + 1 == self.rows;
350        let mut removed = Vec::new();
351
352        for _ in 0..count {
353            let line = self.lines.remove(self.scroll_top);
354            self.lines
355                .insert(self.scroll_bottom, TerminalLine::blank(self.cols, style));
356            if full_region {
357                removed.push(line);
358            }
359        }
360
361        damage.mark_range(self.scroll_top, self.scroll_bottom);
362        removed
363    }
364
365    /// Blanks an inclusive column range and both halves of intersecting wide cells.
366    ///
367    /// Bounds are clamped to the last declared column. An absent row, zero
368    /// columns, or a range whose clamped start exceeds end is a no-op.
369    ///
370    /// # Panics
371    ///
372    /// May panic if public fields/line widths violate screen invariants.
373    ///
374    /// # Examples
375    ///
376    /// ```
377    /// use ailloli_ui_terminal_core::{TerminalDamage, TerminalScreen, TerminalSize, TerminalStyle};
378    /// let style = TerminalStyle::default();
379    /// let mut screen = TerminalScreen::new(TerminalSize::new(1, 3), style);
380    /// screen.put_narrow(0, 1, "x", style, None, &mut TerminalDamage::clean());
381    /// screen.clear_line_range(0, 1, 99, style, &mut TerminalDamage::clean());
382    /// assert_eq!(screen.line(0).unwrap().plain_text(), "   ");
383    /// ```
384    pub fn clear_line_range(
385        &mut self,
386        row: usize,
387        start: usize,
388        end_inclusive: usize,
389        style: TerminalStyle,
390        damage: &mut TerminalDamage,
391    ) {
392        if row >= self.rows || self.cols == 0 {
393            return;
394        }
395        let start = start.min(self.cols - 1);
396        let end = end_inclusive.min(self.cols - 1);
397        if start > end {
398            return;
399        }
400        for col in start..=end {
401            self.clear_cell_and_wide_neighbors(row, col, style);
402        }
403        damage.mark_line(row);
404    }
405
406    /// Delegates in-row character erasure and marks an existing row dirty.
407    ///
408    /// A missing row is a no-op. A valid row is marked dirty even when `count`
409    /// is zero or `col` is out of range. See [`TerminalLine::erase_chars`] for
410    /// wide-cell and overflow behavior.
411    ///
412    /// # Examples
413    ///
414    /// ```
415    /// use ailloli_ui_terminal_core::{TerminalDamage, TerminalScreen, TerminalSize, TerminalStyle};
416    /// let style = TerminalStyle::default(); let mut damage = TerminalDamage::clean();
417    /// let mut screen = TerminalScreen::new(TerminalSize::new(1, 2), style);
418    /// screen.erase_chars(0, 0, 1, style, &mut damage);
419    /// assert_eq!(damage.dirty_lines, vec![0]);
420    /// ```
421    pub fn erase_chars(
422        &mut self,
423        row: usize,
424        col: usize,
425        count: usize,
426        style: TerminalStyle,
427        damage: &mut TerminalDamage,
428    ) {
429        if let Some(line) = self.lines.get_mut(row) {
430            line.erase_chars(col, count, style);
431            damage.mark_line(row);
432        }
433    }
434
435    /// Deletes cells within one row, pads its right edge, and marks it dirty.
436    ///
437    /// A missing row is a no-op. A valid row is marked even for an otherwise
438    /// no-op count/column. Line length is preserved.
439    ///
440    /// # Examples
441    ///
442    /// ```
443    /// use ailloli_ui_terminal_core::{TerminalDamage, TerminalScreen, TerminalSize, TerminalStyle};
444    /// let style = TerminalStyle::default(); let mut screen = TerminalScreen::new(TerminalSize::new(1, 2), style);
445    /// screen.put_narrow(0, 0, "x", style, None, &mut TerminalDamage::clean());
446    /// screen.delete_chars(0, 0, 1, style, &mut TerminalDamage::clean());
447    /// assert_eq!(screen.line(0).unwrap().plain_text(), "  ");
448    /// ```
449    pub fn delete_chars(
450        &mut self,
451        row: usize,
452        col: usize,
453        count: usize,
454        style: TerminalStyle,
455        damage: &mut TerminalDamage,
456    ) {
457        if let Some(line) = self.lines.get_mut(row) {
458            line.delete_chars(col, count, style);
459            damage.mark_line(row);
460        }
461    }
462
463    /// Inserts blanks within one row, discards its right edge, and marks it dirty.
464    ///
465    /// A missing row is a no-op. A valid row is marked even for an otherwise
466    /// no-op count/column. Line length is preserved.
467    ///
468    /// # Examples
469    ///
470    /// ```
471    /// use ailloli_ui_terminal_core::{TerminalDamage, TerminalScreen, TerminalSize, TerminalStyle};
472    /// let style = TerminalStyle::default(); let mut screen = TerminalScreen::new(TerminalSize::new(1, 2), style);
473    /// screen.put_narrow(0, 0, "x", style, None, &mut TerminalDamage::clean());
474    /// screen.insert_blank_chars(0, 0, 1, style, &mut TerminalDamage::clean());
475    /// assert_eq!(screen.line(0).unwrap().plain_text(), " x");
476    /// ```
477    pub fn insert_blank_chars(
478        &mut self,
479        row: usize,
480        col: usize,
481        count: usize,
482        style: TerminalStyle,
483        damage: &mut TerminalDamage,
484    ) {
485        if let Some(line) = self.lines.get_mut(row) {
486            line.insert_blank_chars(col, count, style);
487            damage.mark_line(row);
488        }
489    }
490
491    /// Scrolls the inclusive region downward by at most its height.
492    ///
493    /// Bottom rows are discarded and styled blank rows appear at the top.
494    /// Count zero still marks the complete region dirty; removed rows are not
495    /// returned for scrollback.
496    ///
497    /// # Panics
498    ///
499    /// May panic or overflow if public screen/region fields violate invariants.
500    ///
501    /// # Examples
502    ///
503    /// ```
504    /// use ailloli_ui_terminal_core::{TerminalDamage, TerminalScreen, TerminalSize, TerminalStyle};
505    /// let style = TerminalStyle::default(); let mut damage = TerminalDamage::clean();
506    /// let mut screen = TerminalScreen::new(TerminalSize::new(2, 2), style);
507    /// screen.scroll_down(1, style, &mut damage);
508    /// assert_eq!(damage.dirty_lines, vec![0, 1]);
509    /// ```
510    pub fn scroll_down(&mut self, count: usize, style: TerminalStyle, damage: &mut TerminalDamage) {
511        let height = self.scroll_bottom - self.scroll_top + 1;
512        let count = count.min(height);
513        for _ in 0..count {
514            self.lines.remove(self.scroll_bottom);
515            self.lines
516                .insert(self.scroll_top, TerminalLine::blank(self.cols, style));
517        }
518        damage.mark_range(self.scroll_top, self.scroll_bottom);
519    }
520
521    /// Inserts blank rows at `row` within the scroll region.
522    ///
523    /// Rows at the region bottom are discarded. Count is clamped to the suffix
524    /// height; zero still marks `row..=scroll_bottom` dirty. A row outside the
525    /// region is a no-op.
526    ///
527    /// # Panics
528    ///
529    /// May panic or overflow if public screen/region fields violate invariants.
530    ///
531    /// # Examples
532    ///
533    /// ```
534    /// use ailloli_ui_terminal_core::{TerminalDamage, TerminalScreen, TerminalSize, TerminalStyle};
535    /// let style = TerminalStyle::default(); let mut screen = TerminalScreen::new(TerminalSize::new(3, 2), style);
536    /// screen.insert_lines(1, 1, style, &mut TerminalDamage::clean());
537    /// assert_eq!(screen.lines.len(), 3);
538    /// ```
539    pub fn insert_lines(
540        &mut self,
541        row: usize,
542        count: usize,
543        style: TerminalStyle,
544        damage: &mut TerminalDamage,
545    ) {
546        if row < self.scroll_top || row > self.scroll_bottom {
547            return;
548        }
549        let count = count.min(self.scroll_bottom - row + 1);
550        for _ in 0..count {
551            self.lines
552                .insert(row, TerminalLine::blank(self.cols, style));
553            self.lines.remove(self.scroll_bottom + 1);
554        }
555        damage.mark_range(row, self.scroll_bottom);
556    }
557
558    /// Deletes rows at `row` within the scroll region and appends blank rows.
559    ///
560    /// Count is clamped to the region suffix; zero still marks the suffix dirty.
561    /// A row outside the active region is a no-op. Removed rows are discarded.
562    ///
563    /// # Panics
564    ///
565    /// May panic or overflow if public screen/region fields violate invariants.
566    ///
567    /// # Examples
568    ///
569    /// ```
570    /// use ailloli_ui_terminal_core::{TerminalDamage, TerminalScreen, TerminalSize, TerminalStyle};
571    /// let style = TerminalStyle::default(); let mut screen = TerminalScreen::new(TerminalSize::new(3, 2), style);
572    /// screen.delete_lines(1, usize::MAX, style, &mut TerminalDamage::clean());
573    /// assert_eq!(screen.lines.len(), 3);
574    /// ```
575    pub fn delete_lines(
576        &mut self,
577        row: usize,
578        count: usize,
579        style: TerminalStyle,
580        damage: &mut TerminalDamage,
581    ) {
582        if row < self.scroll_top || row > self.scroll_bottom {
583            return;
584        }
585        let count = count.min(self.scroll_bottom - row + 1);
586        for _ in 0..count {
587            self.lines.remove(row);
588            self.lines
589                .insert(self.scroll_bottom, TerminalLine::blank(self.cols, style));
590        }
591        damage.mark_range(row, self.scroll_bottom);
592    }
593
594    /// Resizes without reflow, preserving the old top-left rectangle.
595    ///
596    /// Zero dimensions clamp to one. Right/bottom cells are discarded, new
597    /// cells/rows are styled blanks, the scroll region resets to full height,
598    /// the cursor clamps into bounds, and full/cursor damage is requested.
599    /// Scrollback is not modified.
600    ///
601    /// # Examples
602    ///
603    /// ```
604    /// use ailloli_ui_terminal_core::{TerminalCursor, TerminalDamage, TerminalScreen, TerminalSize, TerminalStyle};
605    /// let style = TerminalStyle::default(); let mut screen = TerminalScreen::new(TerminalSize::new(2, 2), style);
606    /// let mut cursor = TerminalCursor { row: 9, col: 9, ..TerminalCursor::new() };
607    /// screen.resize(TerminalSize::new(1, 3), style, &mut cursor, &mut TerminalDamage::clean());
608    /// assert_eq!((screen.rows, screen.cols, cursor.row, cursor.col), (1, 3, 0, 2));
609    /// ```
610    pub fn resize(
611        &mut self,
612        size: TerminalSize,
613        style: TerminalStyle,
614        cursor: &mut TerminalCursor,
615        damage: &mut TerminalDamage,
616    ) {
617        let size = size.clamped();
618        let old_lines = std::mem::take(&mut self.lines);
619        let mut lines = Vec::with_capacity(size.rows);
620
621        for row in 0..size.rows {
622            if let Some(mut line) = old_lines.get(row).cloned() {
623                line.resize(size.cols, style);
624                lines.push(line);
625            } else {
626                lines.push(TerminalLine::blank(size.cols, style));
627            }
628        }
629
630        self.rows = size.rows;
631        self.cols = size.cols;
632        self.lines = lines;
633        self.reset_scroll_region();
634        cursor.clamp_to(size);
635        damage.mark_full();
636    }
637
638    /// Clears a write span plus intersecting wide-cell partners.
639    ///
640    /// Callers validate the row/column. `col + offset` uses ordinary `usize`
641    /// addition and can theoretically overflow for corrupted/extreme inputs.
642    fn clear_write_range(&mut self, row: usize, col: usize, width: usize, style: TerminalStyle) {
643        for offset in 0..width {
644            if col + offset < self.cols {
645                self.clear_cell_and_wide_neighbors(row, col + offset, style);
646            }
647        }
648    }
649
650    /// Blanks one cell and the paired half indicated by its width marker.
651    ///
652    /// The caller guarantees valid indices and rectangular screen invariants.
653    fn clear_cell_and_wide_neighbors(&mut self, row: usize, col: usize, style: TerminalStyle) {
654        let width = self.lines[row].cells[col].width;
655        match width {
656            CellWidth::WideLeading => {
657                self.lines[row].cells[col] = TerminalCell::blank(style);
658                if col + 1 < self.cols {
659                    self.lines[row].cells[col + 1] = TerminalCell::blank(style);
660                }
661            }
662            CellWidth::WideTrailing => {
663                if col > 0 {
664                    self.lines[row].cells[col - 1] = TerminalCell::blank(style);
665                }
666                self.lines[row].cells[col] = TerminalCell::blank(style);
667            }
668            CellWidth::Narrow => {
669                self.lines[row].cells[col] = TerminalCell::blank(style);
670            }
671        }
672    }
673}