tastty-core 0.1.0

Sans-IO core of the tastty terminal session library: VT parser, screen buffer, and byte encoders.
#[derive(Clone, Debug)]
pub(crate) struct Row {
    cells: Vec<crate::cell::Cell>,
    wrapped: bool,
    dirty: bool,
    semantic_prompt: Option<crate::screen::SemanticPrompt>,
    /// One past the highest column ever written. Cells at indices
    /// `>= occ` are guaranteed to be in default state (no content,
    /// default `Attrs`, no hyperlink, no wide flags). `clear` uses
    /// this to skip the already-default tail when repainting with
    /// default attrs.
    occ: u16,
}

impl Row {
    pub(crate) fn new(cols: u16) -> Self {
        Self {
            cells: vec![crate::cell::Cell::new(); usize::from(cols)],
            wrapped: false,
            dirty: false,
            semantic_prompt: None,
            occ: 0,
        }
    }

    pub(crate) fn clear(&mut self, attrs: crate::attrs::Attrs) {
        if attrs == crate::attrs::Attrs::default() {
            for cell in &mut self.cells[..usize::from(self.occ)] {
                cell.clear(attrs);
            }
            self.occ = 0;
        } else {
            for cell in &mut self.cells {
                cell.clear(attrs);
            }
            self.occ = self.cols();
        }
        self.wrapped = false;
        self.dirty = true;
        self.semantic_prompt = None;
    }

    #[cfg_attr(
        not(test),
        expect(
            dead_code,
            reason = "row-level OSC 133 accessor; part of the row API surface"
        )
    )]
    pub(crate) fn semantic_prompt(&self) -> Option<crate::screen::SemanticPrompt> {
        self.semantic_prompt
    }

    pub(crate) fn set_semantic_prompt(&mut self, mark: crate::screen::SemanticPrompt) {
        self.semantic_prompt = Some(mark);
    }

    pub(crate) fn get(&self, col: u16) -> Option<&crate::cell::Cell> {
        self.cells.get(usize::from(col))
    }

    pub(crate) fn get_mut(&mut self, col: u16) -> Option<&mut crate::cell::Cell> {
        // Pessimistic mark: callers only request a `&mut Cell` when they
        // intend to mutate it. Tracking dirty here keeps the cell-write hot
        // path single-write rather than threading a per-call mark through
        // every screen-op site.
        let cell = self.cells.get_mut(usize::from(col));
        if cell.is_some() {
            self.dirty = true;
            self.occ = self.occ.max(col.saturating_add(1));
        }
        cell
    }

    pub(crate) fn insert(&mut self, i: u16, cell: crate::cell::Cell) {
        self.cells.insert(usize::from(i), cell);
        self.wrapped = false;
        self.dirty = true;
        // Pessimistic: insertion shifts the tail and the inserted cell
        // may be non-default. CSI ICH (the only caller) truncates back
        // to size.cols immediately, restoring a precise occ.
        self.occ = self.cols();
    }

    pub(crate) fn remove(&mut self, i: u16) {
        self.clear_wide(i);
        self.cells.remove(usize::from(i));
        self.wrapped = false;
        self.dirty = true;
        // Pessimistic: removal shifts the tail. CSI DCH (the only caller)
        // resizes back to size.cols with a default cell immediately,
        // restoring a precise occ.
        self.occ = self.cols();
    }

    pub(crate) fn erase(&mut self, i: u16, attrs: crate::attrs::Attrs) {
        let wide = self.cells[usize::from(i)].is_wide();
        self.clear_wide(i);
        self.cells[usize::from(i)].clear(attrs);
        let cols = self.cols();
        let last = cols.saturating_sub(if wide { 2 } else { 1 });
        if i == last {
            self.wrapped = false;
        }
        self.dirty = true;
        self.occ = self.occ.max(i.saturating_add(1));
    }

    pub(crate) fn truncate(&mut self, len: u16) {
        self.cells.truncate(usize::from(len));
        self.wrapped = false;
        self.dirty = true;
        self.occ = self.occ.min(len);
        if len == 0 {
            return;
        }
        let last_cell = &mut self.cells[usize::from(len) - 1];
        if last_cell.is_wide() {
            last_cell.clear(*last_cell.attrs());
        }
    }

    pub(crate) fn resize(&mut self, len: u16, cell: crate::cell::Cell) {
        let old_len = self.cells.len();
        let new_len = usize::from(len);
        let grow_with_non_default = new_len > old_len && cell != crate::cell::Cell::new();
        self.cells.resize(new_len, cell);
        self.wrapped = false;
        self.dirty = true;
        if new_len < old_len && new_len > 0 {
            let last = &mut self.cells[new_len - 1];
            if last.is_wide() {
                last.clear(*last.attrs());
            }
        }
        self.occ = if grow_with_non_default {
            len
        } else {
            self.occ.min(len)
        };
    }

    pub(crate) fn wrap(&mut self, wrap: bool) {
        if self.wrapped != wrap {
            self.dirty = true;
        }
        self.wrapped = wrap;
    }

    pub(crate) fn is_dirty(&self) -> bool {
        self.dirty
    }

    pub(crate) fn mark_dirty(&mut self) {
        self.dirty = true;
    }

    pub(crate) fn clear_dirty(&mut self) {
        self.dirty = false;
    }

    pub(crate) fn wrapped(&self) -> bool {
        self.wrapped
    }

    /// Return the text content of this row with trailing whitespace trimmed.
    pub(crate) fn text_contents(&self) -> String {
        let mut text = String::new();
        for cell in &self.cells {
            if cell.is_wide_continuation() {
                continue;
            }
            let c = cell.contents();
            if c.is_empty() {
                text.push(' ');
            } else {
                text.push_str(c);
            }
        }
        text.truncate(text.trim_end().len());
        text
    }

    fn cols(&self) -> u16 {
        self.cells
            .len()
            .try_into()
            .expect("row width bounded by u16::MAX at TerminalSize construction")
    }

    pub(crate) fn clear_wide(&mut self, col: u16) {
        let col_idx = usize::from(col);
        let cell = &self.cells[col_idx];
        if cell.is_wide() {
            if let Some(other) = self.cells.get_mut(col_idx + 1) {
                other.clear(*other.attrs());
                self.dirty = true;
            }
        } else if cell.is_wide_continuation() && col > 0 {
            let other = &mut self.cells[col_idx - 1];
            other.clear(*other.attrs());
            self.dirty = true;
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::attrs::Attrs;

    #[test]
    fn new_row() {
        let row = Row::new(80);
        assert!(!row.wrapped());
        assert!(row.get(0).is_some());
        assert!(row.get(79).is_some());
        assert!(row.get(80).is_none());
    }

    #[test]
    fn clear_row() {
        let mut row = Row::new(10);
        row.get_mut(0).unwrap().set('A', Attrs::default());
        row.clear(Attrs::default());
        assert!(!row.get(0).unwrap().has_contents());
    }

    #[test]
    fn insert_and_truncate() {
        let mut row = Row::new(5);
        row.get_mut(0).unwrap().set('A', Attrs::default());
        row.insert(0, crate::cell::Cell::new());
        assert!(!row.get(0).unwrap().has_contents());
        assert_eq!(row.get(1).unwrap().contents(), "A");
        row.truncate(5);
    }

    #[test]
    fn wrap_flag() {
        let mut row = Row::new(10);
        assert!(!row.wrapped());
        row.wrap(true);
        assert!(row.wrapped());
        row.wrap(false);
        assert!(!row.wrapped());
    }

    #[test]
    fn resize_clears_orphaned_wide_char() {
        let mut row = Row::new(10);
        // Place a wide character at column 4, continuation at column 5
        row.get_mut(4).unwrap().set('', Attrs::default());
        row.get_mut(4).unwrap().set_wide(true);
        row.get_mut(5).unwrap().set_wide_continuation(true);
        assert!(row.get(4).unwrap().is_wide());
        assert!(row.get(5).unwrap().is_wide_continuation());

        // Shrink to 5 columns: continuation cell at col 5 is dropped
        row.resize(5, crate::cell::Cell::new());
        // The orphaned wide cell at col 4 must be cleared
        assert!(!row.get(4).unwrap().is_wide());
        assert!(!row.get(4).unwrap().has_contents());
    }

    #[test]
    fn truncate_zero_does_not_panic() {
        let mut row = Row::new(5);
        row.truncate(0);
        assert_eq!(row.get(0), None);
    }

    #[test]
    fn clear_wide_at_last_col() {
        let mut row = Row::new(5);
        // Wide char at last valid pair position (col 3-4)
        row.get_mut(3).unwrap().set('', Attrs::default());
        row.get_mut(3).unwrap().set_wide(true);
        row.get_mut(4).unwrap().set_wide_continuation(true);
        // Clearing the wide head should clear the continuation
        row.clear_wide(3);
        assert!(!row.get(4).unwrap().is_wide_continuation());
    }

    #[test]
    fn clear_wide_continuation_at_col_zero() {
        // A continuation at col 0 would be a bug state, but clear_wide
        // must not underflow when col == 0.
        let mut row = Row::new(5);
        row.get_mut(0).unwrap().set_wide_continuation(true);
        row.clear_wide(0); // must not panic
    }

    #[test]
    fn new_row_is_clean() {
        let row = Row::new(10);
        assert!(!row.is_dirty());
    }

    #[test]
    fn cell_write_marks_dirty() {
        let mut row = Row::new(10);
        row.clear_dirty();
        assert!(!row.is_dirty());
        row.get_mut(3).unwrap().set('A', Attrs::default());
        assert!(row.is_dirty());
    }

    #[test]
    fn out_of_bounds_get_mut_does_not_mark_dirty() {
        let mut row = Row::new(10);
        row.clear_dirty();
        assert!(row.get_mut(99).is_none());
        assert!(!row.is_dirty());
    }

    #[test]
    fn clear_marks_dirty() {
        let mut row = Row::new(10);
        row.clear_dirty();
        row.clear(Attrs::default());
        assert!(row.is_dirty());
    }

    #[test]
    fn wrap_change_marks_dirty() {
        let mut row = Row::new(10);
        row.clear_dirty();
        row.wrap(false); // no change
        assert!(!row.is_dirty());
        row.wrap(true);
        assert!(row.is_dirty());
    }

    #[test]
    fn clear_dirty_resets() {
        let mut row = Row::new(10);
        row.get_mut(0).unwrap().set('A', Attrs::default());
        assert!(row.is_dirty());
        row.clear_dirty();
        assert!(!row.is_dirty());
    }

    #[test]
    fn resize_preserves_complete_wide_char() {
        let mut row = Row::new(10);
        row.get_mut(3).unwrap().set('', Attrs::default());
        row.get_mut(3).unwrap().set_wide(true);
        row.get_mut(4).unwrap().set_wide_continuation(true);

        // Shrink to 6 columns: both wide cell and continuation are preserved
        row.resize(6, crate::cell::Cell::new());
        assert!(row.get(3).unwrap().is_wide());
        assert_eq!(row.get(3).unwrap().contents(), "");
        assert!(row.get(4).unwrap().is_wide_continuation());
    }
}