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}