ailloli_ui_terminal_core/
line.rs

1//! Fixed-width terminal rows and in-row editing primitives.
2
3use serde::{Deserialize, Serialize};
4
5use crate::cell::{CellWidth, TerminalCell};
6use crate::style::TerminalStyle;
7
8/// One terminal row with explicit soft-wrap provenance.
9///
10/// Normal constructors keep `cells.len()` equal to the owning screen width.
11/// Public fields and deserialization can bypass that invariant and wide-cell
12/// pairing; editing helpers repair only invalid wide markers at row edges.
13///
14/// # Examples
15///
16/// ```
17/// use ailloli_ui_terminal_core::{TerminalLine, TerminalStyle};
18/// let line = TerminalLine::blank(3, TerminalStyle::default());
19/// assert_eq!((line.len(), line.wrapped_from_previous), (3, false));
20/// ```
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct TerminalLine {
23    /// Ordered grid cells; normally one per screen column.
24    pub cells: Vec<TerminalCell>,
25    /// Whether this physical row continues a preceding soft-wrapped row.
26    #[serde(default)]
27    pub wrapped_from_previous: bool,
28}
29
30impl TerminalLine {
31    /// Creates `cols` narrow spaces with identical style and no wrap marker.
32    ///
33    /// Zero columns creates an empty line.
34    ///
35    /// # Examples
36    ///
37    /// ```
38    /// use ailloli_ui_terminal_core::{TerminalLine, TerminalStyle};
39    /// assert!(TerminalLine::blank(0, TerminalStyle::default()).is_empty());
40    /// ```
41    pub fn blank(cols: usize, style: TerminalStyle) -> Self {
42        Self {
43            cells: vec![TerminalCell::blank(style); cols],
44            wrapped_from_previous: false,
45        }
46    }
47
48    /// Returns the number of retained cells/columns.
49    ///
50    /// # Examples
51    ///
52    /// ```
53    /// use ailloli_ui_terminal_core::{TerminalLine, TerminalStyle};
54    /// assert_eq!(TerminalLine::blank(80, TerminalStyle::default()).len(), 80);
55    /// ```
56    pub fn len(&self) -> usize {
57        self.cells.len()
58    }
59
60    /// Returns whether the row retains zero cells.
61    ///
62    /// # Examples
63    ///
64    /// ```
65    /// use ailloli_ui_terminal_core::{TerminalLine, TerminalStyle};
66    /// assert!(!TerminalLine::blank(1, TerminalStyle::default()).is_empty());
67    /// ```
68    pub fn is_empty(&self) -> bool {
69        self.cells.is_empty()
70    }
71
72    /// Borrows a zero-based cell, or returns `None` when out of range.
73    ///
74    /// # Examples
75    ///
76    /// ```
77    /// use ailloli_ui_terminal_core::{TerminalLine, TerminalStyle};
78    /// let line = TerminalLine::blank(2, TerminalStyle::default());
79    /// assert!(line.cell(1).is_some() && line.cell(2).is_none());
80    /// ```
81    pub fn cell(&self, col: usize) -> Option<&TerminalCell> {
82        self.cells.get(col)
83    }
84
85    /// Mutably borrows a zero-based cell, or returns `None` when out of range.
86    ///
87    /// Direct mutation can break wide-cell invariants.
88    ///
89    /// # Examples
90    ///
91    /// ```
92    /// use ailloli_ui_terminal_core::{TerminalLine, TerminalStyle};
93    /// let mut line = TerminalLine::blank(1, TerminalStyle::default());
94    /// line.cell_mut(0).unwrap().text = "A".into();
95    /// assert_eq!(line.plain_text(), "A");
96    /// ```
97    pub fn cell_mut(&mut self, col: usize) -> Option<&mut TerminalCell> {
98        self.cells.get_mut(col)
99    }
100
101    /// Replaces every retained cell with a styled narrow space and clears the
102    /// soft-wrap marker without changing line length.
103    ///
104    /// # Examples
105    ///
106    /// ```
107    /// use ailloli_ui_terminal_core::{TerminalLine, TerminalStyle};
108    /// let mut line = TerminalLine::blank(2, TerminalStyle::default());
109    /// line.wrapped_from_previous = true; line.cell_mut(0).unwrap().text = "x".into();
110    /// line.clear(TerminalStyle::default());
111    /// assert_eq!((line.plain_text(), line.wrapped_from_previous), ("  ".into(), false));
112    /// ```
113    pub fn clear(&mut self, style: TerminalStyle) {
114        for cell in &mut self.cells {
115            *cell = TerminalCell::blank(style);
116        }
117        self.wrapped_from_previous = false;
118    }
119
120    /// Replaces up to `count` cells from `col` with styled narrow spaces.
121    ///
122    /// Zero count, an empty line, or an out-of-range column is a no-op. The
123    /// operation does not normalize a partially erased wide pair.
124    ///
125    /// # Panics
126    ///
127    /// In debug builds, `col + count` panics on `usize` overflow after `col`
128    /// has passed bounds validation; optimized builds wrap and may erase no
129    /// cells. Ordinary terminal-sized counts do not approach this limit.
130    ///
131    /// # Examples
132    ///
133    /// ```
134    /// use ailloli_ui_terminal_core::{TerminalCell, TerminalLine, TerminalStyle};
135    /// let style = TerminalStyle::default();
136    /// let mut line = TerminalLine { cells: vec![TerminalCell::narrow("a", style), TerminalCell::narrow("b", style)], wrapped_from_previous: false };
137    /// line.erase_chars(1, 9, style);
138    /// assert_eq!(line.plain_text(), "a ");
139    /// ```
140    pub fn erase_chars(&mut self, col: usize, count: usize, style: TerminalStyle) {
141        if self.cells.is_empty() || count == 0 || col >= self.cells.len() {
142            return;
143        }
144        let end = (col + count).min(self.cells.len());
145        for idx in col..end {
146            self.cells[idx] = TerminalCell::blank(style);
147        }
148    }
149
150    /// Deletes up to `count` cells at `col`, shifts the suffix left, and pads
151    /// the right edge with styled blanks.
152    ///
153    /// Line length is preserved. Zero count and out-of-range columns are no-ops;
154    /// invalid leading/trailing wide markers at row edges are blanked.
155    ///
156    /// # Examples
157    ///
158    /// ```
159    /// use ailloli_ui_terminal_core::{TerminalCell, TerminalLine, TerminalStyle};
160    /// let style = TerminalStyle::default();
161    /// let mut line = TerminalLine { cells: "abc".chars().map(|c| TerminalCell::narrow(c.to_string(), style)).collect(), wrapped_from_previous: false };
162    /// line.delete_chars(1, 1, style);
163    /// assert_eq!(line.plain_text(), "ac ");
164    /// ```
165    pub fn delete_chars(&mut self, col: usize, count: usize, style: TerminalStyle) {
166        if self.cells.is_empty() || count == 0 || col >= self.cells.len() {
167            return;
168        }
169        let count = count.min(self.cells.len() - col);
170        for _ in 0..count {
171            self.cells.remove(col);
172            self.cells.push(TerminalCell::blank(style));
173        }
174        self.normalize_wide_edges(style);
175    }
176
177    /// Inserts up to `count` styled blanks at `col`, shifts cells right, and
178    /// discards the same number from the right edge.
179    ///
180    /// Line length is preserved. Zero count and out-of-range columns are no-ops;
181    /// invalid leading/trailing wide markers at row edges are blanked.
182    ///
183    /// # Examples
184    ///
185    /// ```
186    /// use ailloli_ui_terminal_core::{TerminalCell, TerminalLine, TerminalStyle};
187    /// let style = TerminalStyle::default();
188    /// let mut line = TerminalLine { cells: "abc".chars().map(|c| TerminalCell::narrow(c.to_string(), style)).collect(), wrapped_from_previous: false };
189    /// line.insert_blank_chars(1, 1, style);
190    /// assert_eq!(line.plain_text(), "a b");
191    /// ```
192    pub fn insert_blank_chars(&mut self, col: usize, count: usize, style: TerminalStyle) {
193        if self.cells.is_empty() || count == 0 || col >= self.cells.len() {
194            return;
195        }
196        let count = count.min(self.cells.len() - col);
197        for _ in 0..count {
198            self.cells.insert(col, TerminalCell::blank(style));
199            let _ = self.cells.pop();
200        }
201        self.normalize_wide_edges(style);
202    }
203
204    /// Resizes the row to exactly `cols`, padding with styled blanks or
205    /// truncating at the right edge.
206    ///
207    /// Zero is accepted. A leading trailing-half or final leading-half wide
208    /// marker is blanked after resizing.
209    ///
210    /// # Examples
211    ///
212    /// ```
213    /// use ailloli_ui_terminal_core::{TerminalLine, TerminalStyle};
214    /// let mut line = TerminalLine::blank(2, TerminalStyle::default());
215    /// line.resize(4, TerminalStyle::default());
216    /// assert_eq!(line.len(), 4);
217    /// ```
218    pub fn resize(&mut self, cols: usize, style: TerminalStyle) {
219        self.cells.resize_with(cols, || TerminalCell::blank(style));
220        self.normalize_wide_edges(style);
221    }
222
223    /// Concatenates cell text while omitting [`CellWidth::WideTrailing`] cells.
224    ///
225    /// Spaces and other blank padding are retained; malformed cell text is
226    /// returned verbatim.
227    ///
228    /// # Examples
229    ///
230    /// ```
231    /// use ailloli_ui_terminal_core::{TerminalCell, TerminalLine, TerminalStyle};
232    /// let style = TerminalStyle::default();
233    /// let line = TerminalLine { cells: vec![TerminalCell::wide_leading("界", style), TerminalCell::wide_trailing(style)], wrapped_from_previous: false };
234    /// assert_eq!(line.plain_text(), "界");
235    /// ```
236    pub fn plain_text(&self) -> String {
237        let mut text = String::new();
238        for cell in &self.cells {
239            if cell.width != CellWidth::WideTrailing {
240                text.push_str(&cell.text);
241            }
242        }
243        text
244    }
245
246    /// Blanks orphaned wide markers only at the first and last cell.
247    fn normalize_wide_edges(&mut self, style: TerminalStyle) {
248        if self.cells.is_empty() {
249            return;
250        }
251
252        if self.cells[0].width == CellWidth::WideTrailing {
253            self.cells[0] = TerminalCell::blank(style);
254        }
255
256        let last = self.cells.len() - 1;
257        if self.cells[last].width == CellWidth::WideLeading {
258            self.cells[last] = TerminalCell::blank(style);
259        }
260    }
261}