Files
micronomicon/backend/uframe/grid.py
2026-04-01 10:13:14 +02:00

179 lines
6.4 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) -> 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 = 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:
title_text = f" {title} "
max_title = w - 4 # leave room for corners + padding
if len(title_text) > max_title:
title_text = title_text[:max_title]
start_x = x + 2
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)