Source code for erbsland.ansi_convert._screen

#  Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
#  SPDX-License-Identifier: Apache-2.0


"""
Virtual screen and scroll-back buffer implementation.
"""

from __future__ import annotations

from collections import deque
from dataclasses import dataclass

from ._model import Character, CharacterStyle


@dataclass(slots=True)
class ScreenSnapshot:
    """Serializable snapshot for screen save/restore operations."""

    rows: list[list[Character]]
    history: list[list[Character]]


[docs] class ScreenBuffer: """A visible screen with an attached scroll-back buffer.""" def __init__(self, width: int, height: int, back_buffer_height: int) -> None: if width <= 0: raise ValueError("width must be >= 1") if height <= 0: raise ValueError("height must be >= 1") if back_buffer_height < 0: raise ValueError("back_buffer_height must be >= 0") self.width = width self.height = height self.back_buffer_height = back_buffer_height self._history: deque[list[Character]] = deque(maxlen=back_buffer_height) self._rows: list[list[Character]] = [self._new_blank_row() for _ in range(height)] @property def history(self) -> tuple[list[Character], ...]: """Return the current scroll-back history rows.""" return tuple(self._history) @property def rows(self) -> tuple[list[Character], ...]: """Return the visible rows.""" return tuple(self._rows)
[docs] def all_rows(self) -> list[list[Character]]: """Return history followed by visible screen rows.""" return [*self._history, *self._rows]
[docs] def snapshot(self) -> ScreenSnapshot: """Create a deep copy snapshot of this screen state.""" return ScreenSnapshot( rows=[[self._clone_cell(cell) for cell in row] for row in self._rows], history=[[self._clone_cell(cell) for cell in row] for row in self._history], )
[docs] def restore(self, snapshot: ScreenSnapshot) -> None: """Restore a previously captured snapshot.""" self._rows = [[self._clone_cell(cell) for cell in row] for row in snapshot.rows] self._history = deque( ([self._clone_cell(cell) for cell in row] for row in snapshot.history), maxlen=self.back_buffer_height, ) while len(self._rows) < self.height: self._rows.append(self._new_blank_row()) if len(self._rows) > self.height: self._rows = self._rows[: self.height] for row in self._rows: if len(row) < self.width: row.extend(Character.blank() for _ in range(self.width - len(row))) elif len(row) > self.width: del row[self.width :]
[docs] def clear(self) -> None: """Clear visible rows and keep history unchanged.""" self._rows = [self._new_blank_row() for _ in range(self.height)]
[docs] def clear_history(self) -> None: """Clear the scroll-back history.""" self._history.clear()
[docs] def scroll_up(self, lines: int = 1) -> None: """Scroll visible content up and append blank lines at the bottom.""" if lines <= 0: return for _ in range(lines): if self._rows: self._history.append(self._rows.pop(0)) self._rows.append(self._new_blank_row())
[docs] def reverse_index(self) -> None: """ESC M behavior for a screen at row 0 (scroll down one line).""" if not self._rows: return self._rows = [self._new_blank_row(), *self._rows[:-1]]
[docs] def set_character(self, x: int, y: int, character: str, style: CharacterStyle, width: int = 1) -> None: """Write one character at the given visible coordinates.""" if not self._is_in_bounds(x, y): return if width not in (1, 2): raise ValueError("width must be 1 or 2") row = self._rows[y] self._set_blank_in_row(row, x) if width == 2 and x + 1 < self.width: self._set_blank_in_row(row, x + 1) row[x] = Character(text=character, style=style, width=2, continuation=False) row[x + 1] = Character(text="", style=style, width=0, continuation=True) return row[x] = Character(text=character, style=style, width=1, continuation=False)
[docs] def append_combining(self, x: int, y: int, combining_mark: str) -> None: """Append a combining mark to the base cell at the given coordinates.""" if not self._is_in_bounds(x, y): return row = self._rows[y] cell = row[x] if cell.continuation: return if cell.text == " ": return row[x] = Character( text=cell.text + combining_mark, style=cell.style, width=cell.width, continuation=False, )
[docs] def erase_in_display(self, mode: int, cursor_x: int, cursor_y: int) -> None: """Execute CSI J erase behavior.""" if mode == 0: self.erase_in_line(0, cursor_x, cursor_y) for y in range(cursor_y + 1, self.height): self.erase_in_line(2, 0, y) return if mode == 1: for y in range(0, cursor_y): self.erase_in_line(2, 0, y) self.erase_in_line(1, cursor_x, cursor_y) return if mode == 2: for y in range(self.height): self.erase_in_line(2, 0, y) return if mode == 3: self.clear_history()
[docs] def erase_in_line(self, mode: int, cursor_x: int, cursor_y: int) -> None: """Execute CSI K erase behavior.""" if not (0 <= cursor_y < self.height): return if mode == 0: start = max(0, min(cursor_x, self.width - 1)) for x in range(start, self.width): self._set_blank(cursor_y, x) return if mode == 1: end = max(0, min(cursor_x + 1, self.width)) for x in range(0, end): self._set_blank(cursor_y, x) return if mode == 2: self._rows[cursor_y] = self._new_blank_row()
def _set_blank(self, y: int, x: int) -> None: row = self._rows[y] self._set_blank_in_row(row, x) def _set_blank_in_row(self, row: list[Character], x: int) -> None: if not (0 <= x < self.width): return current = row[x] if current.continuation and x > 0: row[x - 1] = Character.blank() if current.width == 2 and x + 1 < self.width and row[x + 1].continuation: row[x + 1] = Character.blank() if x > 0 and row[x - 1].width == 2 and row[x].continuation: row[x - 1] = Character.blank() row[x] = Character.blank() def _new_blank_row(self) -> list[Character]: return [Character.blank() for _ in range(self.width)] @staticmethod def _clone_cell(cell: Character) -> Character: return Character( text=cell.text, style=cell.style, width=cell.width, continuation=cell.continuation, ) def _is_in_bounds(self, x: int, y: int) -> bool: return 0 <= x < self.width and 0 <= y < self.height