# 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 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