# Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
# SPDX-License-Identifier: Apache-2.0
"""
Core data model objects for the ANSI terminal simulation library.
"""
from __future__ import annotations
import unicodedata
from dataclasses import dataclass, replace
SGR_FOREGROUND_CODES: dict[str, int] = {
"black": 30,
"red": 31,
"green": 32,
"yellow": 33,
"blue": 34,
"magenta": 35,
"cyan": 36,
"white": 37,
"bright-black": 90,
"bright-red": 91,
"bright-green": 92,
"bright-yellow": 93,
"bright-blue": 94,
"bright-magenta": 95,
"bright-cyan": 96,
"bright-white": 97,
}
SGR_BACKGROUND_CODES: dict[str, int] = {
"black": 40,
"red": 41,
"green": 42,
"yellow": 43,
"blue": 44,
"magenta": 45,
"cyan": 46,
"white": 47,
"bright-black": 100,
"bright-red": 101,
"bright-green": 102,
"bright-yellow": 103,
"bright-blue": 104,
"bright-magenta": 105,
"bright-cyan": 106,
"bright-white": 107,
}
SGR_CODE_TO_FOREGROUND: dict[int, str] = {value: key for key, value in SGR_FOREGROUND_CODES.items()}
SGR_CODE_TO_BACKGROUND: dict[int, str] = {value: key for key, value in SGR_BACKGROUND_CODES.items()}
[docs]
@dataclass(slots=True, frozen=True)
class CharacterStyle:
"""A complete style state for one terminal cell."""
bold: bool = False
dim: bool = False
italic: bool = False
underline: bool = False
blink: bool = False
reverse: bool = False
hidden: bool = False
strike: bool = False
foreground: str | None = None
background: str | None = None
[docs]
def with_updates(self, **kwargs: object) -> CharacterStyle:
"""Return a copied style with updated fields."""
return replace(self, **kwargs)
[docs]
def html_classes(self) -> list[str]:
"""Return a compact list of style class suffixes."""
classes: list[str] = []
if self.bold:
classes.append("bold")
if self.dim:
classes.append("dim")
if self.italic:
classes.append("italic")
if self.underline:
classes.append("underline")
if self.blink:
classes.append("blink")
if self.reverse:
classes.append("reverse")
if self.hidden:
classes.append("hidden")
if self.strike:
classes.append("strike")
if self.foreground is not None:
classes.append(self.foreground)
if self.background is not None:
classes.append(f"background-{self.background}")
return classes
DEFAULT_STYLE = CharacterStyle()
[docs]
@dataclass(slots=True)
class Character:
"""A single terminal cell character plus formatting state."""
text: str = " "
style: CharacterStyle = DEFAULT_STYLE
width: int = 1
continuation: bool = False
[docs]
@classmethod
def blank(cls) -> Character:
"""Create a blank cell with default style."""
return cls()
[docs]
@dataclass(slots=True)
class Cursor:
"""The active cursor and style state."""
x: int = 0
y: int = 0
style: CharacterStyle = DEFAULT_STYLE
visible: bool = True
[docs]
def copy(self) -> Cursor:
"""Return a shallow copy of this cursor state."""
return Cursor(self.x, self.y, self.style, self.visible)
def char_display_width(character: str) -> int:
"""Approximate terminal display width for one Unicode code point.
:return: 0 for control / combining marks, 1 for regular characters and 2 for
full-width East Asian characters.
"""
if not character:
return 0
code_point = ord(character)
if code_point < 0x20 or code_point == 0x7F:
return 0
if unicodedata.combining(character):
return 0
if unicodedata.east_asian_width(character) in {"F", "W"}:
return 2
return 1