"""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)