Source code for erbsland.ansi_convert._model

#  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