183 lines
6.6 KiB
Python
183 lines
6.6 KiB
Python
"""CharGrid — 2D character buffer with per-cell style annotations.
|
|
|
|
The CharGrid is the intermediate representation between layout and emission.
|
|
Both the ASCII and Micron emitters read from the same grid.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
from uframe.chars import BOX_CHARS
|
|
from uframe.ir import BorderWeight
|
|
|
|
|
|
@dataclass
|
|
class CellStyle:
|
|
"""Per-cell visual style for Micron emission."""
|
|
fg: str | None = None # 3-digit hex color
|
|
bg: str | None = None
|
|
bold: bool = False
|
|
italic: bool = False
|
|
underline: bool = False
|
|
|
|
def __eq__(self, other: object) -> bool:
|
|
if not isinstance(other, CellStyle):
|
|
return NotImplemented
|
|
return (self.fg == other.fg and self.bg == other.bg
|
|
and self.bold == other.bold and self.italic == other.italic
|
|
and self.underline == other.underline)
|
|
|
|
def __hash__(self) -> int:
|
|
return hash((self.fg, self.bg, self.bold, self.italic, self.underline))
|
|
|
|
|
|
@dataclass
|
|
class Cell:
|
|
"""A single cell in the CharGrid."""
|
|
char: str = " "
|
|
style: CellStyle = field(default_factory=CellStyle)
|
|
is_border: bool = False # True for box-drawing characters (for merge pass)
|
|
border_weight: BorderWeight | None = None
|
|
border_id: int = 0 # Identifies which box this border belongs to
|
|
link: str | None = None # Micron link destination
|
|
|
|
|
|
class CharGrid:
|
|
"""2D buffer of cells. Origin (0,0) is top-left."""
|
|
|
|
__slots__ = ("width", "height", "cells", "_border_counter")
|
|
|
|
def __init__(self, width: int, height: int):
|
|
self.width = width
|
|
self.height = height
|
|
self._border_counter = 0
|
|
self.cells: list[list[Cell]] = [
|
|
[Cell() for _ in range(width)]
|
|
for _ in range(height)
|
|
]
|
|
|
|
def in_bounds(self, x: int, y: int) -> bool:
|
|
return 0 <= x < self.width and 0 <= y < self.height
|
|
|
|
def put(self, x: int, y: int, char: str,
|
|
style: CellStyle | None = None,
|
|
is_border: bool = False,
|
|
border_weight: BorderWeight | None = None,
|
|
border_id: int = 0,
|
|
link: str | None = None) -> None:
|
|
"""Write a single character to the grid."""
|
|
if not self.in_bounds(x, y):
|
|
return
|
|
cell = self.cells[y][x]
|
|
cell.char = char
|
|
if style is not None:
|
|
cell.style = style
|
|
cell.is_border = is_border
|
|
cell.border_weight = border_weight
|
|
if border_id:
|
|
cell.border_id = border_id
|
|
if link is not None:
|
|
cell.link = link
|
|
|
|
def put_text(self, x: int, y: int, text: str,
|
|
style: CellStyle | None = None,
|
|
link: str | None = None) -> int:
|
|
"""Write a string horizontally starting at (x, y).
|
|
|
|
Returns the number of characters actually written.
|
|
"""
|
|
written = 0
|
|
for i, ch in enumerate(text):
|
|
px = x + i
|
|
if not self.in_bounds(px, y):
|
|
break
|
|
self.put(px, y, ch, style=style, link=link)
|
|
written += 1
|
|
return written
|
|
|
|
def fill_rect(self, x: int, y: int, w: int, h: int, char: str = " ",
|
|
style: CellStyle | None = None) -> None:
|
|
"""Fill a rectangular region with a character."""
|
|
for row in range(y, y + h):
|
|
for col in range(x, x + w):
|
|
self.put(col, row, char, style=style)
|
|
|
|
def draw_border(self, x: int, y: int, w: int, h: int,
|
|
weight: BorderWeight = BorderWeight.LIGHT,
|
|
title: str = "",
|
|
title_style: CellStyle | None = None,
|
|
border_chars: dict[str, str] | None = None,
|
|
title_caps: tuple[str, str] | None = None) -> None:
|
|
"""Draw a box border. Interior is not cleared.
|
|
|
|
Args:
|
|
x, y: top-left corner
|
|
w, h: outer dimensions (including border)
|
|
weight: border style
|
|
title: optional title inset in top border
|
|
title_style: style for the title text
|
|
"""
|
|
if w < 2 or h < 2:
|
|
return
|
|
|
|
self._border_counter += 1
|
|
bid = self._border_counter
|
|
|
|
ch = border_chars or BOX_CHARS[weight]
|
|
border_style = CellStyle()
|
|
|
|
# Corners
|
|
self.put(x, y, ch["tl"], border_style, is_border=True, border_weight=weight, border_id=bid)
|
|
self.put(x + w - 1, y, ch["tr"], border_style, is_border=True, border_weight=weight, border_id=bid)
|
|
self.put(x, y + h - 1, ch["bl"], border_style, is_border=True, border_weight=weight, border_id=bid)
|
|
self.put(x + w - 1, y + h - 1, ch["br"], border_style, is_border=True, border_weight=weight, border_id=bid)
|
|
|
|
# Top and bottom edges
|
|
for col in range(x + 1, x + w - 1):
|
|
self.put(col, y, ch["h"], border_style, is_border=True, border_weight=weight, border_id=bid)
|
|
self.put(col, y + h - 1, ch["h"], border_style, is_border=True, border_weight=weight, border_id=bid)
|
|
|
|
# Left and right edges
|
|
for row in range(y + 1, y + h - 1):
|
|
self.put(x, row, ch["v"], border_style, is_border=True, border_weight=weight, border_id=bid)
|
|
self.put(x + w - 1, row, ch["v"], border_style, is_border=True, border_weight=weight, border_id=bid)
|
|
|
|
# Title in top border
|
|
if title and w > 4:
|
|
lc = title_caps[0] if title_caps else " "
|
|
rc = title_caps[1] if title_caps else " "
|
|
title_text = f"{lc}{title}{rc}"
|
|
max_title = w - 4
|
|
if len(title_text) > max_title:
|
|
title_text = title_text[:max_title]
|
|
|
|
start_x = x + 1
|
|
ts = title_style or CellStyle(bold=True)
|
|
self.put_text(start_x, y, title_text, style=ts)
|
|
|
|
def grow_height(self, new_height: int) -> None:
|
|
"""Expand the grid vertically if needed."""
|
|
if new_height <= self.height:
|
|
return
|
|
for _ in range(new_height - self.height):
|
|
self.cells.append([Cell() for _ in range(self.width)])
|
|
self.height = new_height
|
|
|
|
def get_line(self, row: int) -> str:
|
|
"""Get a single row as a plain string (chars only)."""
|
|
if 0 <= row < self.height:
|
|
return "".join(cell.char for cell in self.cells[row])
|
|
return ""
|
|
|
|
def to_text(self) -> str:
|
|
"""Emit the entire grid as plain text (ASCII mode)."""
|
|
lines = []
|
|
for row in range(self.height):
|
|
line = self.get_line(row).rstrip()
|
|
lines.append(line)
|
|
# Strip trailing blank lines
|
|
while lines and not lines[-1]:
|
|
lines.pop()
|
|
return "\n".join(lines)
|