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}