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}