ailloli_ui_terminal_core/state.rs
1//! Mutable terminal model, cursor operations, screen switching, and reflow.
2//!
3//! [`TerminalState`] is a pure in-memory state machine. Public fields and
4//! derived deserialization make it possible to construct states that violate
5//! the dimensions, cursor, scroll-region, hyperlink, or soft-wrap invariants
6//! established by its constructors. Mutation methods assume constructor-valid
7//! state unless their documentation says otherwise.
8
9use std::ops::RangeInclusive;
10
11use serde::{Deserialize, Serialize};
12use unicode_width::UnicodeWidthChar;
13
14use crate::cell::{CellWidth, TerminalCell};
15use crate::cursor::TerminalCursor;
16use crate::damage::TerminalDamage;
17use crate::diagnostics::{
18 TerminalDiagnostic, TerminalOutputClassification, TerminalOutputClassifier,
19};
20use crate::hyperlink::{TerminalHyperlink, TerminalHyperlinkId};
21use crate::line::TerminalLine;
22use crate::mode::{TerminalModes, TerminalMouseTrackingMode};
23use crate::screen::TerminalScreen;
24use crate::scrollback::TerminalScrollback;
25use crate::security::TerminalSecurityPolicy;
26use crate::shell::{ShellExecutionState, ShellKind, TerminalProcessStatus, TerminalShellSnapshot};
27use crate::size::TerminalSize;
28use crate::style::TerminalStyle;
29use crate::warning::TerminalWarning;
30
31/// Screen buffer selected for rendering and mutation.
32///
33/// # Examples
34///
35/// ```
36/// use ailloli_ui_terminal_core::{ActiveScreen, TerminalState};
37/// assert_eq!(TerminalState::new().active_screen, ActiveScreen::Normal);
38/// ```
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40pub enum ActiveScreen {
41 /// Primary buffer whose top-region scrolls can feed scrollback.
42 Normal,
43 /// Ephemeral alternate buffer; its scrolled-off lines are discarded.
44 Alternate,
45}
46
47/// Policy used when reflowing the normal screen during resize.
48///
49/// # Examples
50///
51/// ```
52/// use ailloli_ui_terminal_core::TerminalResizePolicy;
53/// assert_eq!(TerminalResizePolicy::default(), TerminalResizePolicy::NormalReflow);
54/// ```
55#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
56pub enum TerminalResizePolicy {
57 /// Reflows every normal-screen logical line uniformly.
58 #[default]
59 NormalReflow,
60 /// Keeps the visible live prompt's soft-wrapped group together when possible.
61 ///
62 /// This special handling applies only while the normal screen is active, a
63 /// prompt is visible, and no command is running; otherwise it behaves as
64 /// [`Self::NormalReflow`].
65 LivePromptAwareReflow,
66}
67
68/// Construction settings for [`TerminalState`].
69///
70/// The size is clamped to at least one row and column by
71/// [`TerminalState::with_config`]. A zero scrollback limit is valid and discards
72/// every scrolled-off normal-screen line while still advancing its global count.
73///
74/// # Examples
75///
76/// ```
77/// use ailloli_ui_terminal_core::{TerminalConfig, TerminalSize, TerminalState};
78///
79/// let state = TerminalState::with_config(TerminalConfig {
80/// size: TerminalSize::new(10, 40),
81/// scrollback_limit: 0,
82/// ..TerminalConfig::default()
83/// });
84/// assert_eq!(state.screen.size(), TerminalSize::new(10, 40));
85/// assert_eq!(state.scrollback.limit(), 0);
86/// ```
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88pub struct TerminalConfig {
89 /// Initial rows and columns; zero dimensions are replaced with one.
90 pub size: TerminalSize,
91 /// Maximum retained normal-screen scrollback lines.
92 pub scrollback_limit: usize,
93 /// Permissions for security-sensitive terminal control sequences.
94 pub security: TerminalSecurityPolicy,
95}
96
97impl Default for TerminalConfig {
98 /// Returns a 24-by-80 terminal with 10,000 scrollback lines and secure defaults.
99 ///
100 /// # Examples
101 ///
102 /// ```
103 /// use ailloli_ui_terminal_core::{TerminalConfig, TerminalSize};
104 /// let config = TerminalConfig::default();
105 /// assert_eq!(config.size, TerminalSize::new(24, 80));
106 /// assert_eq!(config.scrollback_limit, 10_000);
107 /// ```
108 fn default() -> Self {
109 Self {
110 size: TerminalSize::default(),
111 scrollback_limit: 10_000,
112 security: TerminalSecurityPolicy::default(),
113 }
114 }
115}
116
117/// Complete mutable terminal state.
118///
119/// Both screen buffers share one cursor, style, mode set, hyperlink selection,
120/// shell state, warning list, and damage tracker. Switching buffers therefore
121/// does not preserve an independent alternate cursor. Normal-screen top-region
122/// scrolling contributes to scrollback; alternate-screen scrolling does not.
123///
124/// Public fields and deserialization do not validate rectangular screens,
125/// nonzero dimensions, cursor bounds, hyperlink references, scroll regions, or
126/// shell lifecycle consistency. Restore constructor invariants before invoking
127/// mutation methods on untrusted serialized state.
128///
129/// # Examples
130///
131/// ```
132/// use ailloli_ui_terminal_core::{ActiveScreen, TerminalState};
133///
134/// let mut state = TerminalState::new();
135/// state.write_str("hello");
136/// assert_eq!(state.active_screen, ActiveScreen::Normal);
137/// assert!(state.screen.lines[0].plain_text().starts_with("hello"));
138/// ```
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct TerminalState {
141 /// Primary screen buffer.
142 pub screen: TerminalScreen,
143 /// Ephemeral alternate screen buffer.
144 pub alternate_screen: TerminalScreen,
145 /// Buffer targeted by active-screen operations.
146 pub active_screen: ActiveScreen,
147 /// Shared cursor for whichever screen is active.
148 pub cursor: TerminalCursor,
149 /// Cursor saved by save-cursor operations, or `None` before the first save.
150 #[serde(default)]
151 pub saved_cursor: Option<TerminalCursor>,
152 /// Style assigned to newly written or erased cells.
153 pub current_style: TerminalStyle,
154 /// Retained lines removed from the top of the normal screen.
155 pub scrollback: TerminalScrollback,
156 /// Input/output mode flags, including the mirrored alternate-screen flag.
157 pub modes: TerminalModes,
158 /// Terminal title, or `None`; not intrinsically redacted.
159 pub title: Option<String>,
160 /// Current working-directory URI, or `None`; not intrinsically redacted.
161 pub cwd_uri: Option<String>,
162 /// Shell process, prompt, command history, and queued integration events.
163 #[serde(default)]
164 pub shell: ShellExecutionState,
165 /// Most recently stored output classification diagnostics.
166 #[serde(default)]
167 pub diagnostics: Vec<TerminalDiagnostic>,
168 /// Append-only hyperlink registry; no automatic deduplication or limit.
169 pub hyperlinks: Vec<TerminalHyperlink>,
170 /// Hyperlink assigned to newly written cells, or `None`.
171 pub active_hyperlink: Option<TerminalHyperlinkId>,
172 /// Rendering changes accumulated since the last external reset.
173 pub damage: TerminalDamage,
174 /// Append-only blocked/unsupported warning list.
175 pub warnings: Vec<TerminalWarning>,
176 /// Permissions consulted by title, hyperlink, and parser operations.
177 pub security: TerminalSecurityPolicy,
178 /// Whether the next cell mutation should break surrounding soft-wrap links.
179 #[serde(default)]
180 pub pending_cursor_addressed_write: bool,
181 /// Whether a carriage return may be the start of a live-prompt redraw.
182 #[serde(default)]
183 pub pending_prompt_carriage_return: bool,
184}
185
186/// One reconstructed logical line and its indexed physical segments.
187struct LogicalLine {
188 /// Original absolute physical index paired with each segment.
189 segments: Vec<(usize, TerminalLine)>,
190}
191
192impl TerminalState {
193 /// Creates a state from [`TerminalConfig::default`].
194 ///
195 /// # Examples
196 ///
197 /// ```
198 /// use ailloli_ui_terminal_core::{TerminalSize, TerminalState};
199 /// let state = TerminalState::new();
200 /// assert_eq!(state.screen.size(), TerminalSize::new(24, 80));
201 /// ```
202 pub fn new() -> Self {
203 Self::with_config(TerminalConfig::default())
204 }
205
206 /// Creates empty normal and alternate screens from `config`.
207 ///
208 /// Zero dimensions are independently clamped to one. The cursor begins at
209 /// `(0, 0)`, the normal screen is active, style/modes are defaults, damage is
210 /// initially full, and all optional text and append-only collections are empty.
211 ///
212 /// # Panics
213 ///
214 /// Allocating the two rectangular screen buffers can panic or abort if the
215 /// requested positive dimensions exceed available memory or their product
216 /// overflows an allocation layout.
217 ///
218 /// # Examples
219 ///
220 /// ```
221 /// use ailloli_ui_terminal_core::{TerminalConfig, TerminalSize, TerminalState};
222 /// let state = TerminalState::with_config(TerminalConfig {
223 /// size: TerminalSize { rows: 0, cols: 5 },
224 /// ..Default::default()
225 /// });
226 /// assert_eq!(state.screen.size(), TerminalSize::new(1, 5));
227 /// ```
228 pub fn with_config(config: TerminalConfig) -> Self {
229 let style = TerminalStyle::default();
230 let size = config.size.clamped();
231 Self {
232 screen: TerminalScreen::new(size, style),
233 alternate_screen: TerminalScreen::new(size, style),
234 active_screen: ActiveScreen::Normal,
235 cursor: TerminalCursor::default(),
236 saved_cursor: None,
237 current_style: style,
238 scrollback: TerminalScrollback::new(config.scrollback_limit),
239 modes: TerminalModes::default(),
240 title: None,
241 cwd_uri: None,
242 shell: ShellExecutionState::default(),
243 diagnostics: Vec::new(),
244 hyperlinks: Vec::new(),
245 active_hyperlink: None,
246 damage: TerminalDamage::default(),
247 warnings: Vec::new(),
248 security: config.security,
249 pending_cursor_addressed_write: false,
250 pending_prompt_carriage_return: false,
251 }
252 }
253
254 /// Borrows the buffer selected by [`Self::active_screen`].
255 ///
256 /// # Examples
257 ///
258 /// ```
259 /// use ailloli_ui_terminal_core::{ActiveScreen, TerminalState};
260 /// let mut state = TerminalState::new();
261 /// assert!(std::ptr::eq(state.active_screen(), &state.screen));
262 /// state.active_screen = ActiveScreen::Alternate;
263 /// assert!(std::ptr::eq(state.active_screen(), &state.alternate_screen));
264 /// ```
265 pub fn active_screen(&self) -> &TerminalScreen {
266 match self.active_screen {
267 ActiveScreen::Normal => &self.screen,
268 ActiveScreen::Alternate => &self.alternate_screen,
269 }
270 }
271
272 /// Moves the cursor up by at most `count` rows, saturating at row zero.
273 ///
274 /// During a recognized live-prompt carriage-return redraw, movement is also
275 /// clamped to the visible prompt start. Even a zero movement marks cursor
276 /// damage and makes the next cell write break surrounding soft-wrap links.
277 ///
278 /// # Examples
279 ///
280 /// ```
281 /// use ailloli_ui_terminal_core::TerminalState;
282 /// let mut state = TerminalState::new();
283 /// state.set_cursor_position(3, 0);
284 /// state.move_cursor_up(usize::MAX);
285 /// assert_eq!(state.cursor.row, 0);
286 /// ```
287 pub fn move_cursor_up(&mut self, count: usize) {
288 let mut target_row = self.cursor.row.saturating_sub(count);
289 if self.pending_prompt_carriage_return {
290 if let Some(prompt_start) = self.visible_live_prompt_start_row() {
291 if self.cursor.row >= prompt_start {
292 target_row = target_row.max(prompt_start);
293 }
294 }
295 }
296 self.cursor.row = target_row;
297 self.mark_cursor_addressed_write_pending();
298 self.damage.mark_cursor();
299 }
300
301 /// Moves down by `count`, clamped to the active screen's final row.
302 ///
303 /// Even a zero movement marks cursor damage and a cursor-addressed pending write.
304 ///
305 /// # Panics
306 ///
307 /// `cursor.row + count` uses ordinary `usize` arithmetic and can overflow for
308 /// an invalid/out-of-bounds public cursor or extreme count when checks are enabled.
309 ///
310 /// # Examples
311 ///
312 /// ```
313 /// use ailloli_ui_terminal_core::TerminalState;
314 /// let mut state = TerminalState::new();
315 /// state.move_cursor_down(usize::MAX / 2);
316 /// assert_eq!(state.cursor.row, state.screen.rows - 1);
317 /// ```
318 pub fn move_cursor_down(&mut self, count: usize) {
319 let rows = self.active_screen().rows;
320 self.cursor.row = (self.cursor.row + count).min(rows.saturating_sub(1));
321 self.mark_cursor_addressed_write_pending();
322 self.damage.mark_cursor();
323 }
324
325 /// Moves down by `count`, clamps to the last row, and sets column zero.
326 ///
327 /// # Panics
328 ///
329 /// The row addition can overflow under the same invalid/extreme inputs as
330 /// [`Self::move_cursor_down`].
331 ///
332 /// # Examples
333 ///
334 /// ```
335 /// use ailloli_ui_terminal_core::TerminalState;
336 /// let mut state = TerminalState::new();
337 /// state.set_cursor_position(0, 9);
338 /// state.move_cursor_next_line(2);
339 /// assert_eq!((state.cursor.row, state.cursor.col), (2, 0));
340 /// ```
341 pub fn move_cursor_next_line(&mut self, count: usize) {
342 let rows = self.active_screen().rows;
343 self.cursor.row = (self.cursor.row + count).min(rows.saturating_sub(1));
344 self.cursor.col = 0;
345 self.mark_cursor_addressed_write_pending();
346 self.damage.mark_cursor();
347 }
348
349 /// Moves up by `count` with saturation and sets column zero.
350 ///
351 /// # Examples
352 ///
353 /// ```
354 /// use ailloli_ui_terminal_core::TerminalState;
355 /// let mut state = TerminalState::new();
356 /// state.set_cursor_position(2, 9);
357 /// state.move_cursor_previous_line(1);
358 /// assert_eq!((state.cursor.row, state.cursor.col), (1, 0));
359 /// ```
360 pub fn move_cursor_previous_line(&mut self, count: usize) {
361 self.cursor.row = self.cursor.row.saturating_sub(count);
362 self.cursor.col = 0;
363 self.mark_cursor_addressed_write_pending();
364 self.damage.mark_cursor();
365 }
366
367 /// Moves right by `count`, clamped to the active screen's final column.
368 ///
369 /// # Panics
370 ///
371 /// `cursor.col + count` can overflow for invalid/extreme public values when
372 /// overflow checks are enabled.
373 ///
374 /// # Examples
375 ///
376 /// ```
377 /// use ailloli_ui_terminal_core::TerminalState;
378 /// let mut state = TerminalState::new();
379 /// state.move_cursor_forward(3);
380 /// assert_eq!(state.cursor.col, 3);
381 /// ```
382 pub fn move_cursor_forward(&mut self, count: usize) {
383 let cols = self.active_screen().cols;
384 self.cursor.col = (self.cursor.col + count).min(cols.saturating_sub(1));
385 self.mark_cursor_addressed_write_pending();
386 self.damage.mark_cursor();
387 }
388
389 /// Moves left by `count`, saturating at column zero.
390 ///
391 /// # Examples
392 ///
393 /// ```
394 /// use ailloli_ui_terminal_core::TerminalState;
395 /// let mut state = TerminalState::new();
396 /// state.set_cursor_position(0, 2);
397 /// state.move_cursor_back(9);
398 /// assert_eq!(state.cursor.col, 0);
399 /// ```
400 pub fn move_cursor_back(&mut self, count: usize) {
401 self.cursor.col = self.cursor.col.saturating_sub(count);
402 self.mark_cursor_addressed_write_pending();
403 self.damage.mark_cursor();
404 }
405
406 /// Sets a zero-based position, clamped to the active screen bounds.
407 ///
408 /// It also marks cursor damage and makes the next cell mutation break the
409 /// surrounding soft-wrap relationship.
410 ///
411 /// # Panics
412 ///
413 /// A publicly constructed/deserialized active screen with zero rows or
414 /// columns underflows the `size - 1` bound when checks are enabled.
415 ///
416 /// # Examples
417 ///
418 /// ```
419 /// use ailloli_ui_terminal_core::TerminalState;
420 /// let mut state = TerminalState::new();
421 /// state.set_cursor_position(usize::MAX, usize::MAX);
422 /// assert_eq!(state.cursor.row, state.screen.rows - 1);
423 /// assert_eq!(state.cursor.col, state.screen.cols - 1);
424 /// ```
425 pub fn set_cursor_position(&mut self, row: usize, col: usize) {
426 let size = self.active_screen().size();
427 self.cursor.row = row.min(size.rows - 1);
428 self.cursor.col = col.min(size.cols - 1);
429 self.mark_cursor_addressed_write_pending();
430 self.damage.mark_cursor();
431 }
432
433 /// Sets a one-based ANSI row/column, treating zero as the first cell.
434 ///
435 /// Values are converted with saturating subtraction and then clamped.
436 ///
437 /// # Examples
438 ///
439 /// ```
440 /// use ailloli_ui_terminal_core::TerminalState;
441 /// let mut state = TerminalState::new();
442 /// state.set_cursor_position_ansi(2, 3);
443 /// assert_eq!((state.cursor.row, state.cursor.col), (1, 2));
444 /// ```
445 pub fn set_cursor_position_ansi(&mut self, row: usize, col: usize) {
446 self.set_cursor_position(row.saturating_sub(1), col.saturating_sub(1));
447 }
448
449 /// Sets the one-based ANSI column while preserving the row.
450 ///
451 /// Zero is normalized to one before conversion.
452 ///
453 /// # Examples
454 ///
455 /// ```
456 /// use ailloli_ui_terminal_core::TerminalState;
457 /// let mut state = TerminalState::new();
458 /// state.set_cursor_column_ansi(4);
459 /// assert_eq!(state.cursor.col, 3);
460 /// ```
461 pub fn set_cursor_column_ansi(&mut self, col: usize) {
462 self.set_cursor_position(self.cursor.row, col.max(1).saturating_sub(1));
463 }
464
465 /// Sets the one-based ANSI row while preserving the column.
466 ///
467 /// Zero is normalized to one before conversion.
468 ///
469 /// # Examples
470 ///
471 /// ```
472 /// use ailloli_ui_terminal_core::TerminalState;
473 /// let mut state = TerminalState::new();
474 /// state.set_cursor_row_ansi(4);
475 /// assert_eq!(state.cursor.row, 3);
476 /// ```
477 pub fn set_cursor_row_ansi(&mut self, row: usize) {
478 self.set_cursor_position(row.max(1).saturating_sub(1), self.cursor.col);
479 }
480
481 /// Copies the shared current cursor into the single saved slot.
482 ///
483 /// Repeated calls replace the previous value and do not mark damage.
484 ///
485 /// # Examples
486 ///
487 /// ```
488 /// use ailloli_ui_terminal_core::TerminalState;
489 /// let mut state = TerminalState::new();
490 /// state.set_cursor_position(2, 3);
491 /// state.save_cursor();
492 /// assert_eq!(state.saved_cursor, Some(state.cursor));
493 /// ```
494 pub fn save_cursor(&mut self) {
495 self.saved_cursor = Some(self.cursor);
496 }
497
498 /// Restores and clamps the saved cursor, or does nothing when absent.
499 ///
500 /// A successful restore retains the saved value, marks cursor damage, and
501 /// makes the next write cursor-addressed.
502 ///
503 /// # Examples
504 ///
505 /// ```
506 /// use ailloli_ui_terminal_core::TerminalState;
507 /// let mut state = TerminalState::new();
508 /// state.set_cursor_position(2, 3); state.save_cursor();
509 /// state.set_cursor_position(0, 0); state.restore_cursor();
510 /// assert_eq!((state.cursor.row, state.cursor.col), (2, 3));
511 /// ```
512 pub fn restore_cursor(&mut self) {
513 let Some(mut cursor) = self.saved_cursor else {
514 return;
515 };
516 cursor.clamp_to(self.active_screen().size());
517 self.cursor = cursor;
518 self.mark_cursor_addressed_write_pending();
519 self.damage.mark_cursor();
520 }
521
522 /// Sets cursor visibility and marks cursor damage, even if unchanged.
523 ///
524 /// # Examples
525 ///
526 /// ```
527 /// use ailloli_ui_terminal_core::TerminalState;
528 /// let mut state = TerminalState::new();
529 /// state.set_cursor_visible(false);
530 /// assert!(!state.cursor.visible);
531 /// ```
532 pub fn set_cursor_visible(&mut self, visible: bool) {
533 self.cursor.visible = visible;
534 self.damage.mark_cursor();
535 }
536
537 /// Enables or disables automatic wrapping after the final column.
538 ///
539 /// This setter does not mark rendering damage.
540 ///
541 /// # Examples
542 ///
543 /// ```
544 /// use ailloli_ui_terminal_core::TerminalState;
545 /// let mut state = TerminalState::new(); state.set_wraparound_mode(false);
546 /// assert!(!state.modes.wraparound);
547 /// ```
548 pub fn set_wraparound_mode(&mut self, enabled: bool) {
549 self.modes.wraparound = enabled;
550 }
551
552 /// Sets application-cursor-key mode without marking damage.
553 ///
554 /// # Examples
555 ///
556 /// ```
557 /// use ailloli_ui_terminal_core::TerminalState;
558 /// let mut state = TerminalState::new(); state.set_application_cursor_mode(true);
559 /// assert!(state.modes.application_cursor);
560 /// ```
561 pub fn set_application_cursor_mode(&mut self, enabled: bool) {
562 self.modes.application_cursor = enabled;
563 }
564
565 /// Sets application-keypad mode without marking damage.
566 ///
567 /// # Examples
568 ///
569 /// ```
570 /// use ailloli_ui_terminal_core::TerminalState;
571 /// let mut state = TerminalState::new(); state.set_application_keypad_mode(true);
572 /// assert!(state.modes.application_keypad);
573 /// ```
574 pub fn set_application_keypad_mode(&mut self, enabled: bool) {
575 self.modes.application_keypad = enabled;
576 }
577
578 /// Sets bracketed-paste mode without marking damage.
579 ///
580 /// # Examples
581 ///
582 /// ```
583 /// use ailloli_ui_terminal_core::TerminalState;
584 /// let mut state = TerminalState::new(); state.set_bracketed_paste_mode(true);
585 /// assert!(state.modes.bracketed_paste);
586 /// ```
587 pub fn set_bracketed_paste_mode(&mut self, enabled: bool) {
588 self.modes.bracketed_paste = enabled;
589 }
590
591 /// Replaces the mouse-tracking mode without marking damage.
592 ///
593 /// # Examples
594 ///
595 /// ```
596 /// use ailloli_ui_terminal_core::{TerminalMouseTrackingMode, TerminalState};
597 /// let mut state = TerminalState::new();
598 /// state.set_mouse_tracking_mode(TerminalMouseTrackingMode::AnyMotion);
599 /// assert_eq!(state.modes.mouse_tracking, TerminalMouseTrackingMode::AnyMotion);
600 /// ```
601 pub fn set_mouse_tracking_mode(&mut self, mode: TerminalMouseTrackingMode) {
602 self.modes.mouse_tracking = mode;
603 }
604
605 /// Enables or disables SGR mouse-coordinate encoding without marking damage.
606 ///
607 /// # Examples
608 ///
609 /// ```
610 /// use ailloli_ui_terminal_core::TerminalState;
611 /// let mut state = TerminalState::new(); state.set_sgr_mouse_mode(true);
612 /// assert!(state.modes.sgr_mouse);
613 /// ```
614 pub fn set_sgr_mouse_mode(&mut self, enabled: bool) {
615 self.modes.sgr_mouse = enabled;
616 }
617
618 /// Applies Unicode text and four embedded control characters.
619 ///
620 /// `\n`, `\r`, `\t`, and backspace (`U+0008`) dispatch to their respective
621 /// state operations; every other scalar goes through [`Self::write_char`].
622 /// A newline does not imply a carriage return. This helper parses no ANSI or
623 /// OSC escape sequences; use a [`TerminalParser`](crate::TerminalParser) for bytes.
624 ///
625 /// # Examples
626 ///
627 /// ```
628 /// use ailloli_ui_terminal_core::TerminalState;
629 /// let mut state = TerminalState::new();
630 /// state.write_str("ab\rZ");
631 /// assert!(state.screen.lines[0].plain_text().starts_with("Zb"));
632 /// ```
633 pub fn write_str(&mut self, text: &str) {
634 for ch in text.chars() {
635 match ch {
636 '\n' => self.line_feed(),
637 '\r' => self.carriage_return(),
638 '\t' => self.tab(),
639 '\u{8}' => self.backspace(),
640 ch => self.write_char(ch),
641 }
642 }
643 }
644
645 /// Writes one Unicode scalar at the shared cursor using the current style/link.
646 ///
647 /// Width-zero scalars are appended to the preceding physical cell (or the
648 /// current top-left cell when no predecessor exists) without advancing.
649 /// Scalars wider than one cell occupy a leading/trailing pair when the screen
650 /// has at least two columns; on a one-column screen they are stored narrow.
651 /// A wide scalar that does not fit forces a soft wrap even when wraparound
652 /// mode is disabled. Filling the final column also wraps immediately when
653 /// wraparound is enabled.
654 ///
655 /// # Panics
656 ///
657 /// This method assumes a rectangular, nonzero active screen and an in-bounds
658 /// cursor. Publicly corrupted fields can cause indexing/subtraction panics.
659 /// Cursor-column additions can overflow for invalid extreme values when
660 /// overflow checks are enabled.
661 ///
662 /// # Examples
663 ///
664 /// ```
665 /// use ailloli_ui_terminal_core::{CellWidth, TerminalState};
666 /// let mut state = TerminalState::new();
667 /// state.write_char('界');
668 /// assert_eq!(state.screen.lines[0].cells[0].width, CellWidth::WideLeading);
669 /// assert_eq!(state.cursor.col, 2);
670 /// ```
671 pub fn write_char(&mut self, ch: char) {
672 let cols = self.active_screen().cols;
673 let width = UnicodeWidthChar::width(ch).unwrap_or(0);
674
675 if width == 0 {
676 let (row, col) = self.previous_cell_position();
677 self.append_combining(row, col, ch);
678 return;
679 }
680
681 self.before_cursor_addressed_mutation(self.cursor.row);
682
683 let cell_width = if width > 1 && cols > 1 { 2 } else { 1 };
684 if cell_width == 2 && self.cursor.col + 1 >= cols {
685 self.cursor.col = 0;
686 self.soft_wrap_line_feed();
687 }
688
689 let row = self.cursor.row;
690 let col = self.cursor.col;
691 if cell_width == 2 {
692 self.put_wide(row, col, ch);
693 } else {
694 self.put_narrow(row, col, ch);
695 }
696 self.advance_columns(cell_width);
697 }
698
699 /// Moves to column zero and arms live-prompt redraw detection.
700 ///
701 /// The row is unchanged. Cursor damage is marked and the next cell write is
702 /// considered cursor-addressed, which breaks surrounding soft-wrap links.
703 ///
704 /// # Examples
705 ///
706 /// ```
707 /// use ailloli_ui_terminal_core::TerminalState;
708 /// let mut state = TerminalState::new(); state.move_cursor_forward(5);
709 /// state.carriage_return();
710 /// assert_eq!(state.cursor.col, 0);
711 /// assert!(state.pending_prompt_carriage_return);
712 /// ```
713 pub fn carriage_return(&mut self) {
714 self.cursor.col = 0;
715 self.mark_cursor_addressed_write_pending();
716 self.pending_prompt_carriage_return = true;
717 self.damage.mark_cursor();
718 }
719
720 /// Performs a hard line feed without changing the cursor column.
721 ///
722 /// At or below the active scroll-region bottom it scrolls that region up and
723 /// keeps the cursor at the bottom; otherwise it increments the row. The new
724 /// line is marked as not soft-wrapped. On the normal screen, lines removed
725 /// from a top-anchored region can enter scrollback through [`Self::scroll_up`].
726 ///
727 /// # Examples
728 ///
729 /// ```
730 /// use ailloli_ui_terminal_core::TerminalState;
731 /// let mut state = TerminalState::new(); state.move_cursor_forward(3);
732 /// state.line_feed();
733 /// assert_eq!((state.cursor.row, state.cursor.col), (1, 3));
734 /// ```
735 pub fn line_feed(&mut self) {
736 self.pending_cursor_addressed_write = false;
737 self.pending_prompt_carriage_return = false;
738 self.line_feed_with_wrap(false);
739 }
740
741 /// Performs a line feed and marks the destination as a wrapped continuation.
742 fn soft_wrap_line_feed(&mut self) {
743 self.line_feed_with_wrap(true);
744 }
745
746 /// Shared hard/soft line-feed implementation.
747 fn line_feed_with_wrap(&mut self, wrapped_from_previous: bool) {
748 let scroll_bottom = self.active_screen().scroll_bottom;
749 if self.cursor.row >= scroll_bottom {
750 self.scroll_up(1);
751 self.cursor.row = scroll_bottom;
752 } else {
753 self.cursor.row += 1;
754 }
755 self.set_current_line_wrapped_from_previous(wrapped_from_previous);
756 self.damage.mark_cursor();
757 }
758
759 /// Moves left by one column without erasing; column zero is a no-op.
760 ///
761 /// A successful move marks cursor damage and a cursor-addressed pending write.
762 ///
763 /// # Examples
764 ///
765 /// ```
766 /// use ailloli_ui_terminal_core::TerminalState;
767 /// let mut state = TerminalState::new(); state.write_str("ab");
768 /// state.backspace();
769 /// assert_eq!(state.cursor.col, 1);
770 /// assert!(state.screen.lines[0].plain_text().starts_with("ab"));
771 /// ```
772 pub fn backspace(&mut self) {
773 if self.cursor.col > 0 {
774 self.cursor.col -= 1;
775 self.mark_cursor_addressed_write_pending();
776 self.damage.mark_cursor();
777 }
778 }
779
780 /// Writes spaces up to the next fixed eight-column tab stop.
781 ///
782 /// Stops early after a wrap resets the column to zero. Tabs therefore mutate
783 /// cells, styles, hyperlinks, cursor position, soft-wrap metadata, and damage;
784 /// there is no configurable tab-stop table.
785 ///
786 /// # Panics
787 ///
788 /// Computing the next multiple of eight can overflow for a publicly corrupted
789 /// cursor near `usize::MAX` when overflow checks are enabled.
790 ///
791 /// # Examples
792 ///
793 /// ```
794 /// use ailloli_ui_terminal_core::TerminalState;
795 /// let mut state = TerminalState::new(); state.write_char('x'); state.tab();
796 /// assert_eq!(state.cursor.col, 8);
797 /// assert!(state.screen.lines[0].plain_text().starts_with("x "));
798 /// ```
799 pub fn tab(&mut self) {
800 let next = ((self.cursor.col / 8) + 1) * 8;
801 while self.cursor.col < next {
802 self.write_char(' ');
803 if self.cursor.col == 0 {
804 break;
805 }
806 }
807 }
808
809 /// Fills the active screen with current-style blanks and breaks all soft wraps.
810 ///
811 /// Cursor position, scrollback, title, and shell state are unchanged. Screen
812 /// clearing marks full damage through the screen helper.
813 ///
814 /// # Examples
815 ///
816 /// ```
817 /// use ailloli_ui_terminal_core::TerminalState;
818 /// let mut state = TerminalState::new(); state.write_str("text"); state.clear_screen();
819 /// assert!(state.screen.lines[0].plain_text().trim().is_empty());
820 /// ```
821 pub fn clear_screen(&mut self) {
822 let style = self.current_style;
823 let rows = self.active_screen().rows;
824 self.break_soft_wrap_range(0, rows.saturating_sub(1));
825 match self.active_screen {
826 ActiveScreen::Normal => self.screen.clear_screen(style, &mut self.damage),
827 ActiveScreen::Alternate => self.alternate_screen.clear_screen(style, &mut self.damage),
828 }
829 }
830
831 /// Clears one zero-based active-screen row with current-style blanks.
832 ///
833 /// It breaks soft-wrap links immediately before and after `row`. An
834 /// out-of-range row performs no cell mutation but still clears the pending
835 /// cursor-addressed/prompt-redraw flags through wrap-breaking helpers.
836 ///
837 /// # Examples
838 ///
839 /// ```
840 /// use ailloli_ui_terminal_core::TerminalState;
841 /// let mut state = TerminalState::new(); state.write_str("text"); state.clear_line(0);
842 /// assert!(state.screen.lines[0].plain_text().trim().is_empty());
843 /// ```
844 pub fn clear_line(&mut self, row: usize) {
845 let style = self.current_style;
846 self.break_soft_wrap_around(row);
847 match self.active_screen {
848 ActiveScreen::Normal => self.screen.clear_line(row, style, &mut self.damage),
849 ActiveScreen::Alternate => {
850 self.alternate_screen
851 .clear_line(row, style, &mut self.damage)
852 }
853 }
854 }
855
856 /// Applies ANSI erase-in-display mode on the active screen.
857 ///
858 /// Mode `0` clears cursor-through-end, `1` clears start-through-cursor, and
859 /// `2` clears the entire screen. Other values do nothing. Valid modes break
860 /// affected soft-wrap links and fill with the current style; cursor and
861 /// scrollback remain unchanged.
862 ///
863 /// # Examples
864 ///
865 /// ```
866 /// use ailloli_ui_terminal_core::TerminalState;
867 /// let mut state = TerminalState::new(); state.write_str("abc");
868 /// state.set_cursor_position(0, 1); state.erase_display(0);
869 /// assert!(state.screen.lines[0].plain_text().starts_with("a "));
870 /// ```
871 pub fn erase_display(&mut self, mode: u16) {
872 let row = self.cursor.row;
873 let col = self.cursor.col;
874 let style = self.current_style;
875 let rows = self.active_screen().rows;
876 match mode {
877 0 => self.break_soft_wrap_range(row, rows.saturating_sub(1)),
878 1 => self.break_soft_wrap_range(0, row),
879 2 => self.break_soft_wrap_range(0, rows.saturating_sub(1)),
880 _ => {}
881 }
882 match self.active_screen {
883 ActiveScreen::Normal => {
884 Self::erase_display_on(&mut self.screen, row, col, mode, style, &mut self.damage);
885 }
886 ActiveScreen::Alternate => {
887 Self::erase_display_on(
888 &mut self.alternate_screen,
889 row,
890 col,
891 mode,
892 style,
893 &mut self.damage,
894 );
895 }
896 }
897 }
898
899 /// Applies ANSI erase-in-line mode at the cursor row.
900 ///
901 /// Mode `0` clears cursor-through-end, `1` clears start-through-cursor, and
902 /// `2` clears the full row. Other values do not erase. A pending live-prompt
903 /// carriage-return redraw may first clear its wrapped prompt range and move
904 /// the cursor to that range's start. The prompt-redraw flag is cleared for
905 /// every mode.
906 ///
907 /// # Examples
908 ///
909 /// ```
910 /// use ailloli_ui_terminal_core::TerminalState;
911 /// let mut state = TerminalState::new(); state.write_str("abc");
912 /// state.set_cursor_position(0, 1); state.erase_line(1);
913 /// assert!(state.screen.lines[0].plain_text().starts_with(" c"));
914 /// ```
915 pub fn erase_line(&mut self, mode: u16) {
916 let row = self.cursor.row;
917 let col = self.cursor.col;
918 let style = self.current_style;
919 self.clear_live_prompt_redraw_range(row, mode, style);
920 if matches!(mode, 0..=2) {
921 self.break_soft_wrap_around(row);
922 }
923 match self.active_screen {
924 ActiveScreen::Normal => {
925 Self::erase_line_on(&mut self.screen, row, col, mode, style, &mut self.damage);
926 }
927 ActiveScreen::Alternate => {
928 Self::erase_line_on(
929 &mut self.alternate_screen,
930 row,
931 col,
932 mode,
933 style,
934 &mut self.damage,
935 );
936 }
937 }
938 self.pending_prompt_carriage_return = false;
939 }
940
941 /// Sets the active screen's inclusive zero-based scroll region.
942 ///
943 /// Bounds clamp to the final row. If clamped `top > bottom`, the full-height
944 /// region is restored. Cursor and damage are unchanged.
945 ///
946 /// # Examples
947 ///
948 /// ```
949 /// use ailloli_ui_terminal_core::TerminalState;
950 /// let mut state = TerminalState::new(); state.set_scroll_region(2, 5);
951 /// assert_eq!((state.screen.scroll_top, state.screen.scroll_bottom), (2, 5));
952 /// ```
953 pub fn set_scroll_region(&mut self, top: usize, bottom: usize) {
954 match self.active_screen {
955 ActiveScreen::Normal => self.screen.set_scroll_region(top, bottom),
956 ActiveScreen::Alternate => self.alternate_screen.set_scroll_region(top, bottom),
957 }
958 }
959
960 /// Restores the active screen's scroll region to its full declared height.
961 ///
962 /// # Examples
963 ///
964 /// ```
965 /// use ailloli_ui_terminal_core::TerminalState;
966 /// let mut state = TerminalState::new(); state.set_scroll_region(2, 5);
967 /// state.reset_scroll_region();
968 /// assert_eq!((state.screen.scroll_top, state.screen.scroll_bottom), (0, 23));
969 /// ```
970 pub fn reset_scroll_region(&mut self) {
971 match self.active_screen {
972 ActiveScreen::Normal => self.screen.reset_scroll_region(),
973 ActiveScreen::Alternate => self.alternate_screen.reset_scroll_region(),
974 }
975 }
976
977 /// Inserts current-style blank rows at `row` within the active scroll region.
978 ///
979 /// The count is clamped to the region suffix, bottom rows are discarded, and
980 /// surrounding soft-wrap links are broken. A row outside the region is a
981 /// cell no-op; count zero still marks the valid suffix dirty in the screen.
982 ///
983 /// # Panics
984 ///
985 /// Can panic if public screen lines or scroll-region fields violate their
986 /// documented invariants.
987 ///
988 /// # Examples
989 ///
990 /// ```
991 /// use ailloli_ui_terminal_core::TerminalState;
992 /// let mut state = TerminalState::new(); state.write_str("x");
993 /// state.insert_lines(0, 1);
994 /// assert!(state.screen.lines[0].plain_text().trim().is_empty());
995 /// ```
996 pub fn insert_lines(&mut self, row: usize, count: usize) {
997 let style = self.current_style;
998 let end = self.active_screen().scroll_bottom;
999 self.break_soft_wrap_range(row, end);
1000 match self.active_screen {
1001 ActiveScreen::Normal => self
1002 .screen
1003 .insert_lines(row, count, style, &mut self.damage),
1004 ActiveScreen::Alternate => {
1005 self.alternate_screen
1006 .insert_lines(row, count, style, &mut self.damage)
1007 }
1008 }
1009 }
1010
1011 /// Deletes rows at `row` within the active scroll region and appends blanks.
1012 ///
1013 /// Removed rows are discarded rather than added to scrollback. The count is
1014 /// clamped to the suffix; zero still marks a valid suffix dirty. Soft-wrap
1015 /// links throughout the affected suffix are broken first.
1016 ///
1017 /// # Panics
1018 ///
1019 /// Can panic if public screen lines or scroll-region fields violate their
1020 /// documented invariants.
1021 ///
1022 /// # Examples
1023 ///
1024 /// ```
1025 /// use ailloli_ui_terminal_core::TerminalState;
1026 /// let mut state = TerminalState::new(); state.write_str("x");
1027 /// state.delete_lines(0, 1);
1028 /// assert!(state.screen.lines[0].plain_text().trim().is_empty());
1029 /// assert!(state.scrollback.is_empty());
1030 /// ```
1031 pub fn delete_lines(&mut self, row: usize, count: usize) {
1032 let style = self.current_style;
1033 let end = self.active_screen().scroll_bottom;
1034 self.break_soft_wrap_range(row, end);
1035 match self.active_screen {
1036 ActiveScreen::Normal => self
1037 .screen
1038 .delete_lines(row, count, style, &mut self.damage),
1039 ActiveScreen::Alternate => {
1040 self.alternate_screen
1041 .delete_lines(row, count, style, &mut self.damage)
1042 }
1043 }
1044 }
1045
1046 /// Blanks `count` cells from the cursor without shifting later cells.
1047 ///
1048 /// Intersecting wide pairs are cleared together, the current style fills
1049 /// blanks, and soft-wrap links around the row are broken. Count zero still
1050 /// marks an existing row dirty through the screen helper.
1051 ///
1052 /// # Panics
1053 ///
1054 /// `cursor.col + count` can overflow inside line erasure when checks are
1055 /// enabled, and malformed public screen state can cause indexing failures.
1056 ///
1057 /// # Examples
1058 ///
1059 /// ```
1060 /// use ailloli_ui_terminal_core::TerminalState;
1061 /// let mut state = TerminalState::new(); state.write_str("abcd");
1062 /// state.set_cursor_position(0, 1); state.erase_chars(2);
1063 /// assert!(state.screen.lines[0].plain_text().starts_with("a d"));
1064 /// ```
1065 pub fn erase_chars(&mut self, count: usize) {
1066 let style = self.current_style;
1067 let row = self.cursor.row;
1068 let col = self.cursor.col;
1069 self.break_soft_wrap_around(row);
1070 match self.active_screen {
1071 ActiveScreen::Normal => {
1072 self.screen
1073 .erase_chars(row, col, count, style, &mut self.damage)
1074 }
1075 ActiveScreen::Alternate => {
1076 self.alternate_screen
1077 .erase_chars(row, col, count, style, &mut self.damage)
1078 }
1079 }
1080 }
1081
1082 /// Deletes `count` cells from the cursor and shifts the row left.
1083 ///
1084 /// Current-style blanks pad the right edge, line width is preserved, wide
1085 /// pairs are repaired, and surrounding soft-wrap links are broken. Count
1086 /// zero still marks an existing row dirty.
1087 ///
1088 /// # Examples
1089 ///
1090 /// ```
1091 /// use ailloli_ui_terminal_core::TerminalState;
1092 /// let mut state = TerminalState::new(); state.write_str("abcd");
1093 /// state.set_cursor_position(0, 1); state.delete_chars(2);
1094 /// assert!(state.screen.lines[0].plain_text().starts_with("ad"));
1095 /// ```
1096 pub fn delete_chars(&mut self, count: usize) {
1097 let style = self.current_style;
1098 let row = self.cursor.row;
1099 let col = self.cursor.col;
1100 self.break_soft_wrap_around(row);
1101 match self.active_screen {
1102 ActiveScreen::Normal => {
1103 self.screen
1104 .delete_chars(row, col, count, style, &mut self.damage)
1105 }
1106 ActiveScreen::Alternate => {
1107 self.alternate_screen
1108 .delete_chars(row, col, count, style, &mut self.damage)
1109 }
1110 }
1111 }
1112
1113 /// Inserts `count` current-style blanks at the cursor and shifts right.
1114 ///
1115 /// Cells falling off the right edge are discarded, line width is preserved,
1116 /// wide pairs are repaired, and surrounding soft-wrap links are broken.
1117 /// Count zero still marks an existing row dirty.
1118 ///
1119 /// # Examples
1120 ///
1121 /// ```
1122 /// use ailloli_ui_terminal_core::TerminalState;
1123 /// let mut state = TerminalState::new(); state.write_str("ab");
1124 /// state.set_cursor_position(0, 1); state.insert_blank_chars(2);
1125 /// assert!(state.screen.lines[0].plain_text().starts_with("a b"));
1126 /// ```
1127 pub fn insert_blank_chars(&mut self, count: usize) {
1128 let style = self.current_style;
1129 let row = self.cursor.row;
1130 let col = self.cursor.col;
1131 self.break_soft_wrap_around(row);
1132 match self.active_screen {
1133 ActiveScreen::Normal => {
1134 self.screen
1135 .insert_blank_chars(row, col, count, style, &mut self.damage)
1136 }
1137 ActiveScreen::Alternate => {
1138 self.alternate_screen
1139 .insert_blank_chars(row, col, count, style, &mut self.damage)
1140 }
1141 }
1142 }
1143
1144 /// Scrolls the active inclusive region upward by at most its height.
1145 ///
1146 /// New bottom rows are current-style blanks. On the normal screen only, rows
1147 /// removed from a full-height region enter scrollback; partial-region and all
1148 /// alternate-screen removals are discarded. Count zero still marks the
1149 /// region dirty.
1150 ///
1151 /// # Panics
1152 ///
1153 /// Can panic or overflow if public screen/region fields violate invariants.
1154 ///
1155 /// # Examples
1156 ///
1157 /// ```
1158 /// use ailloli_ui_terminal_core::TerminalState;
1159 /// let mut state = TerminalState::new(); state.write_str("top"); state.scroll_up(1);
1160 /// assert_eq!(state.scrollback.len(), 1);
1161 /// assert!(state.scrollback.iter().next().unwrap().plain_text().starts_with("top"));
1162 /// ```
1163 pub fn scroll_up(&mut self, count: usize) {
1164 let style = self.current_style;
1165 match self.active_screen {
1166 ActiveScreen::Normal => {
1167 let removed = self.screen.scroll_up(count, style, &mut self.damage);
1168 for line in removed {
1169 self.scrollback.push(line);
1170 }
1171 }
1172 ActiveScreen::Alternate => {
1173 self.alternate_screen
1174 .scroll_up(count, style, &mut self.damage);
1175 }
1176 }
1177 }
1178
1179 /// Scrolls the active inclusive region downward by at most its height.
1180 ///
1181 /// Current-style blanks appear at the top and removed bottom rows are always
1182 /// discarded. Count zero still marks the region dirty.
1183 ///
1184 /// # Panics
1185 ///
1186 /// Can panic or overflow if public screen/region fields violate invariants.
1187 ///
1188 /// # Examples
1189 ///
1190 /// ```
1191 /// use ailloli_ui_terminal_core::TerminalState;
1192 /// let mut state = TerminalState::new(); state.write_str("top"); state.scroll_down(1);
1193 /// assert!(state.screen.lines[0].plain_text().trim().is_empty());
1194 /// assert!(state.scrollback.is_empty());
1195 /// ```
1196 pub fn scroll_down(&mut self, count: usize) {
1197 let style = self.current_style;
1198 match self.active_screen {
1199 ActiveScreen::Normal => self.screen.scroll_down(count, style, &mut self.damage),
1200 ActiveScreen::Alternate => {
1201 self.alternate_screen
1202 .scroll_down(count, style, &mut self.damage)
1203 }
1204 }
1205 }
1206
1207 /// Resizes with [`TerminalResizePolicy::NormalReflow`].
1208 ///
1209 /// # Examples
1210 ///
1211 /// ```
1212 /// use ailloli_ui_terminal_core::{TerminalSize, TerminalState};
1213 /// let mut state = TerminalState::new(); state.resize(TerminalSize::new(10, 40));
1214 /// assert_eq!(state.active_screen().size(), TerminalSize::new(10, 40));
1215 /// ```
1216 pub fn resize(&mut self, size: TerminalSize) {
1217 self.resize_with_policy(size, TerminalResizePolicy::NormalReflow);
1218 }
1219
1220 /// Resizes both buffers, reflowing the normal screen and its scrollback.
1221 ///
1222 /// Zero dimensions clamp to one. Normal-screen soft-wrapped physical rows are
1223 /// reconstructed into logical lines and wrapped to the new width; trailing
1224 /// blank cells/rows not needed by content or cursor may be discarded. The
1225 /// visible window tries to preserve the cursor's distance from the bottom.
1226 /// Reflow recomputes scrollback and its global counter with saturating
1227 /// arithmetic. The alternate buffer is resized top-left without reflow.
1228 ///
1229 /// When normal is active, its cursor is mapped through reflow; an alternate
1230 /// resize uses a discarded cursor copy. When alternate is active, normal is
1231 /// reflowed without a live cursor and the shared cursor is clamped by the
1232 /// alternate resize. The saved cursor, if present, is clamped. Both scroll
1233 /// regions reset, and full damage is marked.
1234 ///
1235 /// [`TerminalResizePolicy::LivePromptAwareReflow`] additionally tries to keep
1236 /// the current visible live prompt group out of scrollback and updates its
1237 /// global prompt line; see that variant for its activation conditions.
1238 ///
1239 /// # Panics
1240 ///
1241 /// Allocation can fail for extreme sizes. Publicly malformed screens or
1242 /// scrollback can also violate the indexing assumptions of reflow.
1243 ///
1244 /// # Examples
1245 ///
1246 /// ```
1247 /// use ailloli_ui_terminal_core::{TerminalResizePolicy, TerminalSize, TerminalState};
1248 /// let mut state = TerminalState::new(); state.write_str("abcdefgh");
1249 /// state.resize_with_policy(TerminalSize::new(4, 4), TerminalResizePolicy::NormalReflow);
1250 /// assert_eq!(state.screen.size(), TerminalSize::new(4, 4));
1251 /// assert_eq!(state.alternate_screen.size(), TerminalSize::new(4, 4));
1252 /// ```
1253 pub fn resize_with_policy(&mut self, size: TerminalSize, policy: TerminalResizePolicy) {
1254 let style = self.current_style;
1255 let size = size.clamped();
1256 match self.active_screen {
1257 ActiveScreen::Normal => {
1258 self.reflow_normal_screen(size, style, Some(self.cursor), policy);
1259 let mut alternate_cursor = self.cursor;
1260 self.alternate_screen
1261 .resize(size, style, &mut alternate_cursor, &mut self.damage);
1262 }
1263 ActiveScreen::Alternate => {
1264 self.reflow_normal_screen(size, style, None, TerminalResizePolicy::NormalReflow);
1265 self.alternate_screen
1266 .resize(size, style, &mut self.cursor, &mut self.damage);
1267 }
1268 }
1269 if let Some(saved_cursor) = &mut self.saved_cursor {
1270 saved_cursor.clamp_to(size);
1271 }
1272 }
1273
1274 /// Selects and clears the alternate screen, resetting the shared cursor.
1275 ///
1276 /// Every call clears alternate cells with the current style, even when it is
1277 /// already active. Normal cells and scrollback remain intact, but the former
1278 /// normal cursor is not saved automatically. The alternate mode flag is set
1279 /// and full damage is marked.
1280 ///
1281 /// # Examples
1282 ///
1283 /// ```
1284 /// use ailloli_ui_terminal_core::{ActiveScreen, TerminalState};
1285 /// let mut state = TerminalState::new(); state.write_str("normal");
1286 /// state.switch_to_alternate_screen(); state.write_str("alternate");
1287 /// assert_eq!(state.active_screen, ActiveScreen::Alternate);
1288 /// assert!(state.alternate_screen.lines[0].plain_text().starts_with("alternate"));
1289 /// assert!(state.screen.lines[0].plain_text().starts_with("normal"));
1290 /// ```
1291 pub fn switch_to_alternate_screen(&mut self) {
1292 self.active_screen = ActiveScreen::Alternate;
1293 self.modes.alternate_screen = true;
1294 self.cursor = TerminalCursor::default();
1295 self.alternate_screen
1296 .clear_screen(self.current_style, &mut self.damage);
1297 self.damage.mark_full();
1298 }
1299
1300 /// Selects the normal screen and clamps the shared cursor into its bounds.
1301 ///
1302 /// The alternate buffer is retained until the next alternate switch clears
1303 /// it. No earlier normal-screen cursor is restored automatically. The mode
1304 /// flag is cleared and full damage is marked.
1305 ///
1306 /// # Examples
1307 ///
1308 /// ```
1309 /// use ailloli_ui_terminal_core::{ActiveScreen, TerminalState};
1310 /// let mut state = TerminalState::new();
1311 /// state.switch_to_alternate_screen(); state.switch_to_normal_screen();
1312 /// assert_eq!(state.active_screen, ActiveScreen::Normal);
1313 /// assert!(!state.modes.alternate_screen);
1314 /// ```
1315 pub fn switch_to_normal_screen(&mut self) {
1316 self.active_screen = ActiveScreen::Normal;
1317 self.modes.alternate_screen = false;
1318 self.cursor.clamp_to(self.screen.size());
1319 self.damage.mark_full();
1320 }
1321
1322 /// Sets the title when allowed, otherwise appends a blocked warning.
1323 ///
1324 /// Allowed values are stored verbatim, including an empty string, and title
1325 /// damage is marked. A blocked call leaves the existing title and damage
1326 /// unchanged; warnings are not deduplicated or bounded.
1327 ///
1328 /// # Examples
1329 ///
1330 /// ```
1331 /// use ailloli_ui_terminal_core::TerminalState;
1332 /// let mut state = TerminalState::new(); state.security.allow_title_change = false;
1333 /// state.set_title("blocked");
1334 /// assert_eq!(state.title, None);
1335 /// assert_eq!(state.warnings.len(), 1);
1336 /// ```
1337 pub fn set_title(&mut self, title: impl Into<String>) {
1338 if self.security.allow_title_change {
1339 self.title = Some(title.into());
1340 self.damage.mark_title();
1341 } else {
1342 self.warnings.push(TerminalWarning::blocked_sequence(
1343 "OSC 0/1/2",
1344 "title change disabled by terminal security policy",
1345 ));
1346 }
1347 }
1348
1349 /// Stores a CWD URI and forwards it to shell execution state.
1350 ///
1351 /// The string is accepted verbatim, including empty or non-URI text. This
1352 /// method performs no security-policy check, marks no rendering damage, and
1353 /// the shell setter queues a CWD-changed event even if the value is unchanged.
1354 ///
1355 /// # Examples
1356 ///
1357 /// ```
1358 /// use ailloli_ui_terminal_core::TerminalState;
1359 /// let mut state = TerminalState::new(); state.set_cwd_uri("file:///workspace");
1360 /// assert_eq!(state.cwd_uri.as_deref(), Some("file:///workspace"));
1361 /// assert_eq!(state.shell.cwd_uri, state.cwd_uri);
1362 /// ```
1363 pub fn set_cwd_uri(&mut self, cwd_uri: impl Into<String>) {
1364 let cwd_uri = cwd_uri.into();
1365 self.cwd_uri = Some(cwd_uri.clone());
1366 self.shell.set_cwd_uri(cwd_uri);
1367 }
1368
1369 /// Returns the saturating scrollback-push count plus shared cursor row.
1370 ///
1371 /// The value identifies the current normal-screen line when state invariants
1372 /// hold. It is computed the same way while the alternate screen is active,
1373 /// so it is not an alternate-screen identity. On hypothetical targets where
1374 /// `usize` exceeds 64 bits, converting the cursor row truncates high bits.
1375 ///
1376 /// # Examples
1377 ///
1378 /// ```
1379 /// use ailloli_ui_terminal_core::TerminalState;
1380 /// let mut state = TerminalState::new(); state.move_cursor_down(2);
1381 /// assert_eq!(state.current_global_line_index(), 2);
1382 /// ```
1383 pub fn current_global_line_index(&self) -> u64 {
1384 self.scrollback
1385 .total_pushed()
1386 .saturating_add(self.cursor.row as u64)
1387 }
1388
1389 /// Marks a shell prompt at [`Self::current_global_line_index`].
1390 ///
1391 /// This delegates to shell state, which makes the prompt visible and queues
1392 /// a prompt-start event. It does not consult `security.allow_shell_integration`.
1393 /// Callers receiving untrusted private shell controls must enforce that policy.
1394 ///
1395 /// # Examples
1396 ///
1397 /// ```
1398 /// use ailloli_ui_terminal_core::TerminalState;
1399 /// let mut state = TerminalState::new(); state.mark_shell_prompt_start();
1400 /// assert!(state.shell.prompt_visible);
1401 /// assert_eq!(state.shell.last_prompt_line, Some(0));
1402 /// ```
1403 pub fn mark_shell_prompt_start(&mut self) {
1404 let line = self.current_global_line_index();
1405 self.shell.mark_prompt_start(line);
1406 }
1407
1408 /// Starts a tracked shell command at the current global line.
1409 ///
1410 /// A supplied CWD first becomes both the terminal and shell CWD, queuing its
1411 /// own change event, and is then stored on the command. `None` inherits the
1412 /// current terminal CWD. Starting while another command runs implicitly
1413 /// finishes the old command as unknown through [`ShellExecutionState`].
1414 /// Times are optional caller-domain milliseconds; strings may be empty.
1415 /// This method does not enforce `security.allow_shell_integration`.
1416 ///
1417 /// # Examples
1418 ///
1419 /// ```
1420 /// use ailloli_ui_terminal_core::{CommandStatus, TerminalState};
1421 /// let mut state = TerminalState::new();
1422 /// state.start_shell_command("cargo check", Some("file:///repo".into()), Some(10));
1423 /// let command = state.shell.current_command.as_ref().unwrap();
1424 /// assert_eq!(command.status, CommandStatus::Running);
1425 /// assert_eq!(command.cwd_uri.as_deref(), Some("file:///repo"));
1426 /// ```
1427 pub fn start_shell_command(
1428 &mut self,
1429 command_line: impl Into<String>,
1430 cwd_uri: Option<String>,
1431 started_at_ms: Option<u64>,
1432 ) {
1433 if let Some(cwd_uri) = cwd_uri.clone() {
1434 self.set_cwd_uri(cwd_uri);
1435 }
1436 let line = self.current_global_line_index();
1437 self.shell.start_command(
1438 command_line,
1439 cwd_uri.or_else(|| self.cwd_uri.clone()),
1440 line,
1441 started_at_ms,
1442 );
1443 }
1444
1445 /// Finishes the current shell command at the current global line.
1446 ///
1447 /// A signal yields interrupted status; otherwise exit zero succeeds,
1448 /// nonzero fails, and absent signal/code is unknown. The output end clamps
1449 /// no earlier than its start. A supplied duration wins; otherwise shell state
1450 /// derives one only when checked `ended - started` succeeds. With no current
1451 /// command this is a no-op. The shell-integration security flag is not checked.
1452 ///
1453 /// # Examples
1454 ///
1455 /// ```
1456 /// use ailloli_ui_terminal_core::{CommandStatus, TerminalState};
1457 /// let mut state = TerminalState::new();
1458 /// state.start_shell_command("true", None, Some(5));
1459 /// state.finish_shell_command(Some(0), None, Some(8), None);
1460 /// assert_eq!(state.shell.last_command.as_ref().unwrap().status, CommandStatus::Succeeded);
1461 /// assert_eq!(state.shell.last_command.as_ref().unwrap().duration_ms, Some(3));
1462 /// ```
1463 pub fn finish_shell_command(
1464 &mut self,
1465 exit_code: Option<i32>,
1466 signal: Option<i32>,
1467 ended_at_ms: Option<u64>,
1468 duration_ms: Option<u64>,
1469 ) {
1470 let line = self.current_global_line_index();
1471 self.shell
1472 .finish_command(exit_code, signal, line, ended_at_ms, duration_ms);
1473 }
1474
1475 /// Sets the shell family and queues a shell-kind event.
1476 ///
1477 /// This stores duplicate values/events and does not consult the shell-
1478 /// integration security flag.
1479 ///
1480 /// # Examples
1481 ///
1482 /// ```
1483 /// use ailloli_ui_terminal_core::{ShellKind, TerminalState};
1484 /// let mut state = TerminalState::new(); state.set_shell_kind(ShellKind::Fish);
1485 /// assert_eq!(state.shell.shell_kind, ShellKind::Fish);
1486 /// ```
1487 pub fn set_shell_kind(&mut self, shell_kind: ShellKind) {
1488 self.shell.set_shell_kind(shell_kind);
1489 }
1490
1491 /// Sets process status and queues an event without policy validation.
1492 ///
1493 /// Duplicate or lifecycle-inconsistent values are retained as supplied.
1494 ///
1495 /// # Examples
1496 ///
1497 /// ```
1498 /// use ailloli_ui_terminal_core::{TerminalProcessStatus, TerminalState};
1499 /// let mut state = TerminalState::new();
1500 /// state.set_shell_process_status(TerminalProcessStatus::Running);
1501 /// assert_eq!(state.shell.process_status, TerminalProcessStatus::Running);
1502 /// ```
1503 pub fn set_shell_process_status(&mut self, status: TerminalProcessStatus) {
1504 self.shell.set_process_status(status);
1505 }
1506
1507 /// Returns an owned, unredacted shell-state snapshot.
1508 ///
1509 /// Queued events are excluded, while current/last/history commands and their
1510 /// command lines/CWDs are cloned verbatim. Use [`crate::TerminalSnapshot`] for
1511 /// configured redaction.
1512 ///
1513 /// # Examples
1514 ///
1515 /// ```
1516 /// use ailloli_ui_terminal_core::TerminalState;
1517 /// let mut state = TerminalState::new(); state.set_cwd_uri("file:///repo");
1518 /// assert_eq!(state.shell_snapshot().cwd_uri.as_deref(), Some("file:///repo"));
1519 /// ```
1520 pub fn shell_snapshot(&self) -> TerminalShellSnapshot {
1521 self.shell.snapshot()
1522 }
1523
1524 /// Classifies normal-screen visual text and replaces stored diagnostics.
1525 ///
1526 /// Classification is deterministic and performs no I/O. The returned value
1527 /// includes diagnostic events; [`Self::diagnostics`] receives a clone of only
1528 /// its diagnostics. Existing stored diagnostics are replaced, including by
1529 /// an empty result. See [`TerminalOutputClassifier::classify`] for matching,
1530 /// indexing, complexity, and active-command correlation details.
1531 ///
1532 /// # Examples
1533 ///
1534 /// ```
1535 /// use ailloli_ui_terminal_core::TerminalState;
1536 /// let mut state = TerminalState::new(); state.write_str("error: build failed");
1537 /// let result = state.classify_terminal_output();
1538 /// assert_eq!(state.diagnostics, result.diagnostics);
1539 /// assert!(!result.diagnostics.is_empty());
1540 /// ```
1541 pub fn classify_terminal_output(&mut self) -> TerminalOutputClassification {
1542 let classification = TerminalOutputClassifier::new().classify(self);
1543 self.diagnostics = classification.diagnostics.clone();
1544 classification
1545 }
1546
1547 /// Removes all stored diagnostics without touching terminal text or damage.
1548 ///
1549 /// # Examples
1550 ///
1551 /// ```
1552 /// use ailloli_ui_terminal_core::TerminalState;
1553 /// let mut state = TerminalState::new(); state.write_str("error: failed");
1554 /// state.classify_terminal_output(); state.clear_terminal_diagnostics();
1555 /// assert!(state.diagnostics.is_empty());
1556 /// ```
1557 pub fn clear_terminal_diagnostics(&mut self) {
1558 self.diagnostics.clear();
1559 }
1560
1561 /// Clones all stored diagnostics in their current order.
1562 ///
1563 /// The returned vector is independent; modifying it does not affect state.
1564 /// No redaction or bounding is applied.
1565 ///
1566 /// # Examples
1567 ///
1568 /// ```
1569 /// use ailloli_ui_terminal_core::TerminalState;
1570 /// let state = TerminalState::new();
1571 /// assert!(state.terminal_diagnostics_snapshot().is_empty());
1572 /// ```
1573 pub fn terminal_diagnostics_snapshot(&self) -> Vec<TerminalDiagnostic> {
1574 self.diagnostics.clone()
1575 }
1576
1577 /// Applies the shell prompt heuristic to the current active-screen row.
1578 ///
1579 /// The full padded plain text and normal-based global line index are passed
1580 /// to shell state. If the cursor row has no corresponding line, this is a
1581 /// no-op. A detected prompt can finish an active command as unknown, mark a
1582 /// prompt visible, and queue events. No security flag is consulted.
1583 ///
1584 /// # Examples
1585 ///
1586 /// ```
1587 /// use ailloli_ui_terminal_core::TerminalState;
1588 /// let mut state = TerminalState::new(); state.write_str("user@host:~$ ");
1589 /// state.update_prompt_heuristic();
1590 /// assert!(state.shell.prompt_visible);
1591 /// ```
1592 pub fn update_prompt_heuristic(&mut self) {
1593 let line = self.current_global_line_index();
1594 let text = self
1595 .active_screen()
1596 .line(self.cursor.row)
1597 .map(|line| line.plain_text());
1598 if let Some(text) = text {
1599 self.shell.apply_prompt_heuristic(&text, line);
1600 }
1601 }
1602
1603 /// Registers and activates a hyperlink when permitted.
1604 ///
1605 /// Allowed strings are stored verbatim, including empty/invalid URIs. The ID
1606 /// is the pre-push registry length converted to `u64`; links are never
1607 /// deduplicated or removed. On a target with `usize` wider than 64 bits that
1608 /// conversion can truncate. When blocked, the active link is cleared and a
1609 /// warning is appended, leaving the registry unchanged.
1610 ///
1611 /// # Examples
1612 ///
1613 /// ```
1614 /// use ailloli_ui_terminal_core::{TerminalHyperlinkId, TerminalState};
1615 /// let mut state = TerminalState::new(); state.open_hyperlink("id=docs", "https://example.test");
1616 /// assert_eq!(state.active_hyperlink, Some(TerminalHyperlinkId(0)));
1617 /// state.write_char('x');
1618 /// assert_eq!(state.screen.lines[0].cells[0].hyperlink, Some(TerminalHyperlinkId(0)));
1619 /// ```
1620 pub fn open_hyperlink(&mut self, params: impl Into<String>, uri: impl Into<String>) {
1621 if !self.security.allow_hyperlinks {
1622 self.active_hyperlink = None;
1623 self.warnings.push(TerminalWarning::blocked_sequence(
1624 "OSC 8",
1625 "hyperlinks disabled by terminal security policy",
1626 ));
1627 return;
1628 }
1629
1630 let id = TerminalHyperlinkId(self.hyperlinks.len() as u64);
1631 self.hyperlinks
1632 .push(TerminalHyperlink::new(id, uri, params));
1633 self.active_hyperlink = Some(id);
1634 }
1635
1636 /// Clears the hyperlink assigned to future cells.
1637 ///
1638 /// Existing cell links and registry entries remain unchanged; repeated calls
1639 /// are no-ops and no damage is marked.
1640 ///
1641 /// # Examples
1642 ///
1643 /// ```
1644 /// use ailloli_ui_terminal_core::TerminalState;
1645 /// let mut state = TerminalState::new(); state.open_hyperlink("", "https://example.test");
1646 /// state.close_hyperlink();
1647 /// assert_eq!(state.active_hyperlink, None);
1648 /// assert_eq!(state.hyperlinks.len(), 1);
1649 /// ```
1650 pub fn close_hyperlink(&mut self) {
1651 self.active_hyperlink = None;
1652 }
1653
1654 /// Appends a warning without deduplication, redaction, or retention limit.
1655 ///
1656 /// # Examples
1657 ///
1658 /// ```
1659 /// use ailloli_ui_terminal_core::{TerminalState, TerminalWarning};
1660 /// let mut state = TerminalState::new();
1661 /// state.push_warning(TerminalWarning::unsupported_sequence("ESC Z"));
1662 /// assert_eq!(state.warnings.len(), 1);
1663 /// ```
1664 pub fn push_warning(&mut self, warning: TerminalWarning) {
1665 self.warnings.push(warning);
1666 }
1667
1668 /// Writes one scalar as a narrow cell on the selected buffer.
1669 ///
1670 /// Style and active hyperlink are copied; the screen helper clears any
1671 /// intersecting wide pair and marks line damage.
1672 fn put_narrow(&mut self, row: usize, col: usize, ch: char) {
1673 let style = self.current_style;
1674 match self.active_screen {
1675 ActiveScreen::Normal => {
1676 self.screen.put_narrow(
1677 row,
1678 col,
1679 ch.to_string(),
1680 style,
1681 self.active_hyperlink,
1682 &mut self.damage,
1683 );
1684 }
1685 ActiveScreen::Alternate => {
1686 self.alternate_screen.put_narrow(
1687 row,
1688 col,
1689 ch.to_string(),
1690 style,
1691 self.active_hyperlink,
1692 &mut self.damage,
1693 );
1694 }
1695 }
1696 }
1697
1698 /// Writes one scalar as a leading/trailing wide pair on the selected buffer.
1699 fn put_wide(&mut self, row: usize, col: usize, ch: char) {
1700 let style = self.current_style;
1701 match self.active_screen {
1702 ActiveScreen::Normal => {
1703 self.screen.put_wide(
1704 row,
1705 col,
1706 ch.to_string(),
1707 style,
1708 self.active_hyperlink,
1709 &mut self.damage,
1710 );
1711 }
1712 ActiveScreen::Alternate => {
1713 self.alternate_screen.put_wide(
1714 row,
1715 col,
1716 ch.to_string(),
1717 style,
1718 self.active_hyperlink,
1719 &mut self.damage,
1720 );
1721 }
1722 }
1723 }
1724
1725 /// Applies valid erase-in-display modes to one explicit screen and damage set.
1726 fn erase_display_on(
1727 screen: &mut TerminalScreen,
1728 row: usize,
1729 col: usize,
1730 mode: u16,
1731 style: TerminalStyle,
1732 damage: &mut TerminalDamage,
1733 ) {
1734 match mode {
1735 0 => {
1736 screen.clear_line_range(row, col, screen.cols.saturating_sub(1), style, damage);
1737 for line in row.saturating_add(1)..screen.rows {
1738 screen.clear_line(line, style, damage);
1739 }
1740 }
1741 1 => {
1742 for line in 0..row {
1743 screen.clear_line(line, style, damage);
1744 }
1745 screen.clear_line_range(row, 0, col, style, damage);
1746 }
1747 2 => screen.clear_screen(style, damage),
1748 _ => {}
1749 }
1750 }
1751
1752 /// Applies valid erase-in-line modes to one explicit screen and damage set.
1753 fn erase_line_on(
1754 screen: &mut TerminalScreen,
1755 row: usize,
1756 col: usize,
1757 mode: u16,
1758 style: TerminalStyle,
1759 damage: &mut TerminalDamage,
1760 ) {
1761 match mode {
1762 0 => screen.clear_line_range(row, col, screen.cols.saturating_sub(1), style, damage),
1763 1 => screen.clear_line_range(row, 0, col, style, damage),
1764 2 => screen.clear_line(row, style, damage),
1765 _ => {}
1766 }
1767 }
1768
1769 /// Breaks pending cursor-addressed wrapping and appends a scalar to one cell.
1770 fn append_combining(&mut self, row: usize, col: usize, ch: char) {
1771 self.before_cursor_addressed_mutation(row);
1772 match self.active_screen {
1773 ActiveScreen::Normal => self.screen.append_combining(row, col, ch, &mut self.damage),
1774 ActiveScreen::Alternate => {
1775 self.alternate_screen
1776 .append_combining(row, col, ch, &mut self.damage)
1777 }
1778 }
1779 }
1780
1781 /// Advances after a write, wrapping immediately or clamping at the right edge.
1782 ///
1783 /// Column addition is ordinary `usize` arithmetic and assumes a valid cursor.
1784 fn advance_columns(&mut self, amount: usize) {
1785 let cols = self.active_screen().cols;
1786 if self.cursor.col + amount >= cols {
1787 if self.modes.wraparound {
1788 self.cursor.col = 0;
1789 self.soft_wrap_line_feed();
1790 } else {
1791 self.cursor.col = cols - 1;
1792 self.damage.mark_cursor();
1793 }
1794 } else {
1795 self.cursor.col += amount;
1796 self.damage.mark_cursor();
1797 }
1798 }
1799
1800 /// Sets soft-wrap provenance on the selected buffer's cursor row.
1801 fn set_current_line_wrapped_from_previous(&mut self, wrapped: bool) {
1802 match self.active_screen {
1803 ActiveScreen::Normal => self
1804 .screen
1805 .set_line_wrapped_from_previous(self.cursor.row, wrapped),
1806 ActiveScreen::Alternate => self
1807 .alternate_screen
1808 .set_line_wrapped_from_previous(self.cursor.row, wrapped),
1809 }
1810 }
1811
1812 /// Arms soft-wrap breaking for the next cell mutation.
1813 fn mark_cursor_addressed_write_pending(&mut self) {
1814 self.pending_cursor_addressed_write = true;
1815 }
1816
1817 /// Breaks wraps when armed and always clears prompt-carriage-return state.
1818 fn before_cursor_addressed_mutation(&mut self, row: usize) {
1819 if self.pending_cursor_addressed_write {
1820 self.break_soft_wrap_around(row);
1821 }
1822 self.pending_prompt_carriage_return = false;
1823 }
1824
1825 /// Marks `row` as starting a logical line and clears both pending flags.
1826 fn break_soft_wrap_before(&mut self, row: usize) {
1827 match self.active_screen {
1828 ActiveScreen::Normal => {
1829 if let Some(line) = self.screen.lines.get_mut(row) {
1830 line.wrapped_from_previous = false;
1831 }
1832 }
1833 ActiveScreen::Alternate => {
1834 if let Some(line) = self.alternate_screen.lines.get_mut(row) {
1835 line.wrapped_from_previous = false;
1836 }
1837 }
1838 }
1839 self.pending_cursor_addressed_write = false;
1840 self.pending_prompt_carriage_return = false;
1841 }
1842
1843 /// Marks `row + 1` as starting a logical line and clears both pending flags.
1844 ///
1845 /// Saturating addition makes `usize::MAX` target itself before safe lookup.
1846 fn break_soft_wrap_after(&mut self, row: usize) {
1847 let next = row.saturating_add(1);
1848 match self.active_screen {
1849 ActiveScreen::Normal => {
1850 if let Some(line) = self.screen.lines.get_mut(next) {
1851 line.wrapped_from_previous = false;
1852 }
1853 }
1854 ActiveScreen::Alternate => {
1855 if let Some(line) = self.alternate_screen.lines.get_mut(next) {
1856 line.wrapped_from_previous = false;
1857 }
1858 }
1859 }
1860 self.pending_cursor_addressed_write = false;
1861 self.pending_prompt_carriage_return = false;
1862 }
1863
1864 /// Breaks logical-line joins immediately before and after `row`.
1865 fn break_soft_wrap_around(&mut self, row: usize) {
1866 self.break_soft_wrap_before(row);
1867 self.break_soft_wrap_after(row);
1868 }
1869
1870 /// Breaks every join touching an inclusive, bounds-clamped row range.
1871 ///
1872 /// Empty/invalid ranges clear the cursor-addressed flag; the zero-row early
1873 /// return intentionally leaves the prompt-carriage-return flag unchanged.
1874 fn break_soft_wrap_range(&mut self, start_row: usize, end_row: usize) {
1875 let rows = self.active_screen().rows;
1876 if rows == 0 || start_row >= rows {
1877 self.pending_cursor_addressed_write = false;
1878 return;
1879 }
1880
1881 let end_row = end_row.min(rows - 1);
1882 if start_row > end_row {
1883 self.pending_cursor_addressed_write = false;
1884 return;
1885 }
1886
1887 for row in start_row..=end_row {
1888 self.break_soft_wrap_before(row);
1889 }
1890 self.break_soft_wrap_after(end_row);
1891 }
1892
1893 /// Clears a recognized live-prompt redraw prefix and moves to its start.
1894 ///
1895 /// Returns whether special clearing ran. It requires a valid erase-line mode,
1896 /// pending carriage return, normal active screen, visible prompt, no current
1897 /// command, and a cursor row inside the selected prompt soft-wrap group.
1898 fn clear_live_prompt_redraw_range(
1899 &mut self,
1900 row: usize,
1901 mode: u16,
1902 style: TerminalStyle,
1903 ) -> bool {
1904 if !matches!(mode, 0..=2) || !self.pending_prompt_carriage_return {
1905 return false;
1906 }
1907 let Some(range) = self.active_prompt_screen_range() else {
1908 return false;
1909 };
1910 if !range.contains(&row) {
1911 return false;
1912 }
1913
1914 let start = *range.start();
1915 let end = row.min(*range.end());
1916 self.break_soft_wrap_range(start, end);
1917 for clear_row in start..=end {
1918 self.screen.clear_line(clear_row, style, &mut self.damage);
1919 }
1920 self.cursor.row = start;
1921 self.cursor.col = 0;
1922 self.damage.mark_cursor();
1923 self.pending_prompt_carriage_return = false;
1924 true
1925 }
1926
1927 /// Resolves the current live prompt's visible normal-screen soft-wrap group.
1928 ///
1929 /// It prefers the recorded global prompt line when visible and containing the
1930 /// cursor, otherwise falls back to the cursor's group.
1931 fn active_prompt_screen_range(&self) -> Option<RangeInclusive<usize>> {
1932 if self.active_screen != ActiveScreen::Normal
1933 || !self.shell.prompt_visible
1934 || self.shell.current_command.is_some()
1935 || self.screen.lines.is_empty()
1936 {
1937 return None;
1938 }
1939
1940 if let Some(prompt_row) = self
1941 .shell
1942 .last_prompt_line
1943 .and_then(|line| line.checked_sub(self.scrollback.total_pushed()))
1944 .and_then(|row| usize::try_from(row).ok())
1945 .filter(|row| *row < self.screen.lines.len())
1946 {
1947 let range = soft_wrap_group_range(&self.screen.lines, prompt_row);
1948 if range.contains(&self.cursor.row) {
1949 return Some(range);
1950 }
1951 }
1952
1953 (self.cursor.row < self.screen.lines.len())
1954 .then(|| soft_wrap_group_range(&self.screen.lines, self.cursor.row))
1955 }
1956
1957 /// Maps a live prompt's global start into a visible normal-screen row.
1958 fn visible_live_prompt_start_row(&self) -> Option<usize> {
1959 if self.active_screen != ActiveScreen::Normal
1960 || !self.shell.prompt_visible
1961 || self.shell.current_command.is_some()
1962 {
1963 return None;
1964 }
1965
1966 self.shell
1967 .last_prompt_line
1968 .and_then(|line| line.checked_sub(self.scrollback.total_pushed()))
1969 .and_then(|row| usize::try_from(row).ok())
1970 .filter(|row| *row < self.screen.lines.len())
1971 }
1972
1973 /// Reconstructs, rewraps, and repartitions normal scrollback/screen content.
1974 ///
1975 /// Trailing irrelevant blank physical rows are removed, except through the
1976 /// cursor. Logical lines derive from `wrapped_from_previous`. A supplied
1977 /// cursor maps through retained cells; without one, the old screen bottom is
1978 /// the anchor. Live-prompt-aware partitioning can exclude prompt output rows
1979 /// from scrollback. The screen is padded to the exact new height and full
1980 /// damage is marked.
1981 fn reflow_normal_screen(
1982 &mut self,
1983 size: TerminalSize,
1984 style: TerminalStyle,
1985 cursor: Option<TerminalCursor>,
1986 policy: TerminalResizePolicy,
1987 ) {
1988 let size = size.clamped();
1989 let old_scrollback_len = self.scrollback.len();
1990 let old_total_pushed = self.scrollback.total_pushed();
1991 let visual_base = old_total_pushed.saturating_sub(old_scrollback_len as u64);
1992 let old_screen_bottom =
1993 old_scrollback_len.saturating_add(self.screen.rows.saturating_sub(1));
1994 let cursor_abs = cursor
1995 .map(|cursor| old_scrollback_len.saturating_add(cursor.row))
1996 .unwrap_or(old_screen_bottom);
1997 let cursor_col = cursor.map(|cursor| cursor.col).unwrap_or(0);
1998 let distance_to_bottom = old_screen_bottom.saturating_sub(cursor_abs);
1999
2000 let mut visual_lines = self
2001 .scrollback
2002 .iter()
2003 .cloned()
2004 .chain(self.screen.lines.iter().cloned())
2005 .collect::<Vec<_>>();
2006 if !visual_lines.is_empty() {
2007 let last_nonblank = visual_lines
2008 .iter()
2009 .rposition(|line| terminal_line_reflow_len(line) > 0 || line.wrapped_from_previous)
2010 .unwrap_or(0);
2011 let last_relevant = last_nonblank.max(cursor_abs.min(visual_lines.len() - 1));
2012 visual_lines.truncate(last_relevant + 1);
2013 }
2014 let mut reflowed = Vec::new();
2015 let mut next_cursor = None;
2016 let mut live_prompt_out = None;
2017
2018 if let Some(active_range) =
2019 self.live_prompt_active_range(&visual_lines, visual_base, cursor_abs, policy)
2020 {
2021 reflow_indexed_lines_into(
2022 visual_lines[..*active_range.start()]
2023 .iter()
2024 .cloned()
2025 .enumerate()
2026 .collect(),
2027 size.cols,
2028 style,
2029 cursor_abs,
2030 cursor_col,
2031 &mut reflowed,
2032 &mut next_cursor,
2033 );
2034
2035 let active_start_out = reflowed.len();
2036 reflow_indexed_lines_into(
2037 visual_lines[*active_range.start()..=*active_range.end()]
2038 .iter()
2039 .cloned()
2040 .enumerate()
2041 .map(|(idx, line)| (*active_range.start() + idx, line))
2042 .collect(),
2043 size.cols,
2044 style,
2045 cursor_abs,
2046 cursor_col,
2047 &mut reflowed,
2048 &mut next_cursor,
2049 );
2050 if active_start_out < reflowed.len() {
2051 live_prompt_out = Some(active_start_out..=reflowed.len().saturating_sub(1));
2052 }
2053
2054 let after_start = active_range.end().saturating_add(1);
2055 reflow_indexed_lines_into(
2056 visual_lines[after_start..]
2057 .iter()
2058 .cloned()
2059 .enumerate()
2060 .map(|(idx, line)| (after_start + idx, line))
2061 .collect(),
2062 size.cols,
2063 style,
2064 cursor_abs,
2065 cursor_col,
2066 &mut reflowed,
2067 &mut next_cursor,
2068 );
2069 } else {
2070 reflow_indexed_lines_into(
2071 visual_lines.into_iter().enumerate().collect(),
2072 size.cols,
2073 style,
2074 cursor_abs,
2075 cursor_col,
2076 &mut reflowed,
2077 &mut next_cursor,
2078 );
2079 }
2080
2081 if reflowed.is_empty() {
2082 reflowed.push(TerminalLine::blank(size.cols, style));
2083 }
2084
2085 let (cursor_abs, cursor_col) = next_cursor.unwrap_or((reflowed.len().saturating_sub(1), 0));
2086 let desired_cursor_row = size
2087 .rows
2088 .saturating_sub(1)
2089 .saturating_sub(distance_to_bottom.min(size.rows.saturating_sub(1)));
2090 let max_screen_start = reflowed.len().saturating_sub(size.rows);
2091 let default_screen_start = cursor_abs
2092 .saturating_sub(desired_cursor_row)
2093 .min(max_screen_start);
2094 let screen_start = live_prompt_out
2095 .as_ref()
2096 .map(|range| {
2097 screen_start_for_live_prompt(
2098 default_screen_start,
2099 max_screen_start,
2100 size.rows,
2101 cursor_abs,
2102 range,
2103 )
2104 })
2105 .unwrap_or(default_screen_start);
2106 let screen_end = (screen_start + size.rows).min(reflowed.len());
2107
2108 let scrollback_lines = (0..screen_start)
2109 .filter(|idx| match &live_prompt_out {
2110 Some(range) => !range.contains(idx),
2111 None => true,
2112 })
2113 .map(|idx| reflowed[idx].clone())
2114 .collect::<Vec<_>>();
2115 let scrollback_total_pushed = visual_base.saturating_add(scrollback_lines.len() as u64);
2116 self.scrollback
2117 .replace_lines(scrollback_lines, scrollback_total_pushed);
2118
2119 let mut screen_lines = reflowed[screen_start..screen_end].to_vec();
2120 if let Some(range) = &live_prompt_out {
2121 if screen_start > *range.start() && screen_start <= *range.end() {
2122 if let Some(first) = screen_lines.first_mut() {
2123 first.wrapped_from_previous = false;
2124 }
2125 }
2126 }
2127 while screen_lines.len() < size.rows {
2128 screen_lines.push(TerminalLine::blank(size.cols, style));
2129 }
2130
2131 self.screen.rows = size.rows;
2132 self.screen.cols = size.cols;
2133 self.screen.lines = screen_lines;
2134 self.screen.reset_scroll_region();
2135
2136 if cursor.is_some() {
2137 self.cursor.row = cursor_abs
2138 .saturating_sub(screen_start)
2139 .min(size.rows.saturating_sub(1));
2140 self.cursor.col = cursor_col.min(size.cols.saturating_sub(1));
2141 self.cursor.clamp_to(size);
2142 }
2143
2144 if let Some(range) = live_prompt_out {
2145 if screen_end > *range.start() && screen_start <= *range.end() {
2146 let prompt_visible_start = (*range.start()).max(screen_start);
2147 let prompt_screen_row = prompt_visible_start.saturating_sub(screen_start);
2148 self.shell.last_prompt_line =
2149 Some(scrollback_total_pushed.saturating_add(prompt_screen_row as u64));
2150 }
2151 }
2152
2153 self.damage.mark_full();
2154 }
2155
2156 /// Selects the logical prompt range eligible for live-aware reflow.
2157 ///
2158 /// The recorded prompt group is preferred when it contains the cursor;
2159 /// otherwise the cursor's group is used. Ineligible state returns `None`.
2160 fn live_prompt_active_range(
2161 &self,
2162 lines: &[TerminalLine],
2163 visual_base: u64,
2164 cursor_abs: usize,
2165 policy: TerminalResizePolicy,
2166 ) -> Option<RangeInclusive<usize>> {
2167 if policy != TerminalResizePolicy::LivePromptAwareReflow
2168 || self.active_screen != ActiveScreen::Normal
2169 || !self.shell.prompt_visible
2170 || self.shell.current_command.is_some()
2171 || lines.is_empty()
2172 {
2173 return None;
2174 }
2175
2176 if let Some(prompt_line) = self.shell.last_prompt_line {
2177 if let Some(prompt_abs) = prompt_line
2178 .checked_sub(visual_base)
2179 .and_then(|line| usize::try_from(line).ok())
2180 .filter(|line| *line < lines.len())
2181 {
2182 let range = soft_wrap_group_range(lines, prompt_abs);
2183 if range.contains(&cursor_abs) {
2184 return Some(range);
2185 }
2186 }
2187 }
2188
2189 (cursor_abs < lines.len()).then(|| soft_wrap_group_range(lines, cursor_abs))
2190 }
2191
2192 /// Returns the preceding physical cell for a width-zero scalar.
2193 ///
2194 /// At column zero it chooses the preceding row's final column, except at the
2195 /// top-left where it returns the cursor itself. It assumes nonzero columns.
2196 fn previous_cell_position(&self) -> (usize, usize) {
2197 if self.cursor.col > 0 {
2198 (self.cursor.row, self.cursor.col - 1)
2199 } else if self.cursor.row > 0 {
2200 (self.cursor.row - 1, self.active_screen().cols - 1)
2201 } else {
2202 (self.cursor.row, self.cursor.col)
2203 }
2204 }
2205}
2206
2207/// Groups indexed physical rows into logical lines and appends rewrapped output.
2208///
2209/// The first matching cursor mapping wins because `cursor_out` is shared across
2210/// all appended logical lines.
2211fn reflow_indexed_lines_into(
2212 indexed_lines: Vec<(usize, TerminalLine)>,
2213 cols: usize,
2214 style: TerminalStyle,
2215 cursor_abs: usize,
2216 cursor_col: usize,
2217 out: &mut Vec<TerminalLine>,
2218 cursor_out: &mut Option<(usize, usize)>,
2219) {
2220 for logical in logical_lines_from_indexed_physical(indexed_lines) {
2221 let (cells, cursor_offset) = logical_line_cells(&logical, cursor_abs, cursor_col, style);
2222 wrap_logical_line(cells, cols, style, cursor_offset, out, cursor_out);
2223 }
2224}
2225
2226/// Groups each `wrapped_from_previous` row with its preceding logical line.
2227///
2228/// A leading continuation with no preceding input starts a new logical line.
2229fn logical_lines_from_indexed_physical(lines: Vec<(usize, TerminalLine)>) -> Vec<LogicalLine> {
2230 let mut logical_lines = Vec::<LogicalLine>::new();
2231 for (idx, line) in lines {
2232 if line.wrapped_from_previous {
2233 if let Some(current) = logical_lines.last_mut() {
2234 current.segments.push((idx, line));
2235 continue;
2236 }
2237 }
2238 logical_lines.push(LogicalLine {
2239 segments: vec![(idx, line)],
2240 });
2241 }
2242 logical_lines
2243}
2244
2245/// Finds the inclusive physical-row group connected to `row` by soft wraps.
2246///
2247/// `row` clamps to the final index. Callers must supply a non-empty slice;
2248/// otherwise indexing the clamped zero row panics.
2249fn soft_wrap_group_range(lines: &[TerminalLine], row: usize) -> RangeInclusive<usize> {
2250 let row = row.min(lines.len().saturating_sub(1));
2251 let mut start = row;
2252 while start > 0 && lines[start].wrapped_from_previous {
2253 start -= 1;
2254 }
2255
2256 let mut end = row;
2257 while end + 1 < lines.len() && lines[end + 1].wrapped_from_previous {
2258 end += 1;
2259 }
2260
2261 start..=end
2262}
2263
2264/// Chooses a screen window that contains a prompt group or at least its cursor.
2265///
2266/// A prompt no taller than the screen is kept entirely visible when bounds
2267/// permit. For taller prompts, only the cursor is constrained to the window.
2268fn screen_start_for_live_prompt(
2269 default_start: usize,
2270 max_screen_start: usize,
2271 rows: usize,
2272 cursor_abs: usize,
2273 prompt_range: &RangeInclusive<usize>,
2274) -> usize {
2275 let rows = rows.max(1);
2276 let prompt_start = *prompt_range.start();
2277 let prompt_end = *prompt_range.end();
2278 let prompt_height = prompt_end.saturating_sub(prompt_start).saturating_add(1);
2279
2280 if prompt_height <= rows {
2281 let lower = prompt_end.saturating_add(1).saturating_sub(rows);
2282 let upper = prompt_start;
2283 return clamp_screen_start(default_start, lower, upper, max_screen_start);
2284 }
2285
2286 let lower = cursor_abs.saturating_add(1).saturating_sub(rows);
2287 let upper = cursor_abs;
2288 clamp_screen_start(default_start, lower, upper, max_screen_start)
2289}
2290
2291/// Clamps a preferred screen start into ordered bounds and the maximum start.
2292///
2293/// When independently clamped bounds cross, the clamped lower bound wins.
2294fn clamp_screen_start(
2295 default_start: usize,
2296 lower: usize,
2297 upper: usize,
2298 max_screen_start: usize,
2299) -> usize {
2300 let lower = lower.min(max_screen_start);
2301 let upper = upper.min(max_screen_start);
2302 if lower > upper {
2303 return lower;
2304 }
2305 default_start.min(max_screen_start).max(lower).min(upper)
2306}
2307
2308/// Flattens one logical line, trims its tail, and maps an optional cursor offset.
2309///
2310/// Every non-final physical segment keeps its complete cell width; the final
2311/// segment drops trailing blank/wide-placeholder cells. A cursor segment forces
2312/// retention through its clamped column. Broken wide markers at the flattened
2313/// outer boundaries are replaced with current-style blanks.
2314fn logical_line_cells(
2315 logical: &LogicalLine,
2316 cursor_abs: usize,
2317 cursor_col: usize,
2318 style: TerminalStyle,
2319) -> (Vec<TerminalCell>, Option<usize>) {
2320 let mut cells = Vec::new();
2321 let mut cursor_offset = None;
2322
2323 for (idx, (abs_line, line)) in logical.segments.iter().enumerate() {
2324 let is_last_segment = idx + 1 == logical.segments.len();
2325 let segment_start = cells.len();
2326 let mut take = if is_last_segment {
2327 terminal_line_reflow_len(line)
2328 } else {
2329 line.cells.len()
2330 };
2331
2332 if *abs_line == cursor_abs {
2333 let cursor_col = cursor_col.min(line.cells.len().saturating_sub(1));
2334 take = take.max(cursor_col.saturating_add(1)).min(line.cells.len());
2335 cursor_offset = Some(segment_start.saturating_add(cursor_col));
2336 }
2337
2338 cells.extend(line.cells.iter().take(take).cloned());
2339 }
2340
2341 normalize_cells_for_reflow(&mut cells, style);
2342 (cells, cursor_offset)
2343}
2344
2345/// Returns one past the last nonblank, non-wide-trailing cell, or zero.
2346fn terminal_line_reflow_len(line: &TerminalLine) -> usize {
2347 line.cells
2348 .iter()
2349 .rposition(|cell| !cell.is_blank() && cell.width != CellWidth::WideTrailing)
2350 .map(|idx| idx + 1)
2351 .unwrap_or(0)
2352}
2353
2354/// Replaces an orphan outer trailing/leading wide half with a styled blank.
2355fn normalize_cells_for_reflow(cells: &mut [TerminalCell], style: TerminalStyle) {
2356 if cells.is_empty() {
2357 return;
2358 }
2359 if cells[0].width == CellWidth::WideTrailing {
2360 cells[0] = TerminalCell::blank(style);
2361 }
2362 let last = cells.len() - 1;
2363 if cells[last].width == CellWidth::WideLeading {
2364 cells[last] = TerminalCell::blank(style);
2365 }
2366}
2367
2368/// Splits flattened cells into fixed-width physical lines and maps the cursor.
2369///
2370/// Width clamps to at least one. For widths above one, a wide pair crossing a
2371/// boundary is moved together to the next row when possible. At width one an
2372/// existing two-cell pair cannot fit and its halves can occupy adjacent rows.
2373/// Empty logical content still emits one styled blank row.
2374fn wrap_logical_line(
2375 cells: Vec<TerminalCell>,
2376 cols: usize,
2377 style: TerminalStyle,
2378 cursor_offset: Option<usize>,
2379 out: &mut Vec<TerminalLine>,
2380 cursor_out: &mut Option<(usize, usize)>,
2381) {
2382 let cols = cols.max(1);
2383 if cells.is_empty() {
2384 let line_idx = out.len();
2385 out.push(TerminalLine::blank(cols, style));
2386 if cursor_offset.is_some() && cursor_out.is_none() {
2387 *cursor_out = Some((line_idx, 0));
2388 }
2389 return;
2390 }
2391
2392 let mut start = 0;
2393 let mut first = true;
2394 while start < cells.len() {
2395 let mut end = (start + cols).min(cells.len());
2396 if end < cells.len()
2397 && end > start + 1
2398 && cells[end - 1].width == CellWidth::WideLeading
2399 && cells[end].width == CellWidth::WideTrailing
2400 {
2401 end -= 1;
2402 }
2403
2404 let line_idx = out.len();
2405 if let Some(offset) = cursor_offset {
2406 if cursor_out.is_none() && offset >= start && offset < end {
2407 *cursor_out = Some((line_idx, offset.saturating_sub(start).min(cols - 1)));
2408 }
2409 }
2410
2411 let mut line = TerminalLine {
2412 cells: cells[start..end].to_vec(),
2413 wrapped_from_previous: !first,
2414 };
2415 line.resize(cols, style);
2416 out.push(line);
2417 first = false;
2418 start = end;
2419 }
2420}
2421
2422impl Default for TerminalState {
2423 /// Creates the same state as [`Self::new`].
2424 ///
2425 /// # Examples
2426 ///
2427 /// ```
2428 /// use ailloli_ui_terminal_core::{TerminalSize, TerminalState};
2429 /// let state = TerminalState::default();
2430 /// assert_eq!(state.screen.size(), TerminalSize::default());
2431 /// ```
2432 fn default() -> Self {
2433 Self::new()
2434 }
2435}