Files
micronomicon/backend/uframe/paint.py
2026-04-01 08:28:44 +02:00

398 lines
15 KiB
Python

"""Paint pass — write IR nodes into the CharGrid as characters.
Depth-first traversal: each node writes its content at its assigned
rect position. Containers recurse into children after drawing their
own structure (borders, etc.).
"""
from __future__ import annotations
import textwrap
from uframe.chars import (
BOX_CHARS, DIVIDER_CHARS, GAUGE_FILLED, GAUGE_EMPTY,
STATUS_CHARS, STATUS_COLORS, sparkline_chars,
)
from uframe.grid import CharGrid, CellStyle
from uframe.ir import (
IRNode, Page, Box, Row, Col, Spacer, Pad,
Heading, Text, Label, Divider, Link, ListNode, ListItem,
Gauge, Sparkline, Status, Table,
Form, Field, Password, Radio, Checkbox, FormButton,
HeadingLevel, DividerStyle, ListStyle, Align, BorderWeight,
)
def _align_text(text: str, width: int, align: Align) -> str:
"""Align text within a field of the given width."""
if len(text) >= width:
return text[:width]
if align == Align.CENTER:
return text.center(width)
elif align == Align.RIGHT:
return text.rjust(width)
return text.ljust(width)
def _style_from_node(node: IRNode) -> CellStyle:
"""Create a CellStyle from a node's style attributes."""
return CellStyle(
fg=node.style.fg,
bg=node.style.bg,
bold=node.style.bold,
italic=node.style.italic,
underline=node.style.underline,
)
def paint(node: IRNode, grid: CharGrid) -> None:
"""Recursively paint an IR node and its children into the grid."""
x, y, w = node.rect.x, node.rect.y, node.rect.w
# Ensure grid is tall enough
grid.grow_height(y + node.rect.h)
if isinstance(node, Page):
for child in node.children:
paint(child, grid)
elif isinstance(node, Box):
# Draw the border
title_style = CellStyle(bold=True, fg=node.style.fg)
grid.draw_border(x, y, w, node.rect.h,
weight=node.weight,
title=node.title,
title_style=title_style)
# Paint children inside the border
for child in node.children:
paint(child, grid)
elif isinstance(node, Row):
for child in node.children:
paint(child, grid)
elif isinstance(node, Col):
for child in node.children:
paint(child, grid)
elif isinstance(node, Spacer):
pass # Just empty space
elif isinstance(node, Pad):
for child in node.children:
paint(child, grid)
elif isinstance(node, Heading):
style = CellStyle(bold=True)
if node.level == HeadingLevel.H1:
style.fg = "0f0" # green
elif node.level == HeadingLevel.H2:
style.fg = "0cf" # cyan
elif node.level == HeadingLevel.H3:
style.fg = "88f" # light blue
# Underline-style heading
grid.put_text(x, y, node.text[:w], style=style)
elif isinstance(node, Text):
style = _style_from_node(node)
if node.spans and any(
s.bold or s.italic or s.underline or s.fg or s.bg
for s in node.spans
):
# Render with inline spans
col = x
row = y
for span in node.spans:
span_style = CellStyle(
fg=span.fg or style.fg,
bg=span.bg or style.bg,
bold=span.bold or style.bold,
italic=span.italic or style.italic,
underline=span.underline or style.underline,
)
for ch in span.text:
if col >= x + w:
col = x
row += 1
if grid.in_bounds(col, row):
grid.put(col, row, ch, style=span_style)
col += 1
else:
# Simple text with word wrapping
wrapped = textwrap.wrap(node.content, width=w) if node.content else [""]
for i, line in enumerate(wrapped):
if y + i < grid.height:
text = _align_text(line, w, node.style.align)
grid.put_text(x, y + i, text, style=style)
elif isinstance(node, Label):
style = _style_from_node(node)
key_style = CellStyle(bold=True, fg=style.fg, bg=style.bg)
# Key: value layout with padding
key_text = f"{node.key}:"
pad = max(1, 16 - len(key_text))
grid.put_text(x, y, key_text, style=key_style)
grid.put_text(x + len(key_text) + pad, y, node.value, style=style)
elif isinstance(node, Divider):
ds = node.divider_style
char = DIVIDER_CHARS.get(ds.name.lower(), "")
style = CellStyle(fg="555")
for col in range(x, x + w):
grid.put(col, y, char, style=style)
elif isinstance(node, Link):
style = CellStyle(fg="0cf", underline=True)
# In ASCII mode, display as [text]. In Micron, the emitter wraps with link syntax.
# Write just the display text — the link metadata goes on cells for Micron emission.
grid.put_text(x, y, node.display[:w], style=style, link=node.dest)
elif isinstance(node, ListNode):
for child in node.children:
paint(child, grid)
elif isinstance(node, ListItem):
style = _style_from_node(node)
# Parent determines bullet style — use a simple bullet for now
bullet = ""
grid.put_text(x - 2, y, bullet, style=CellStyle(fg="888"))
# Wrap content
wrapped = textwrap.wrap(node.content, width=w) if node.content else [""]
for i, line in enumerate(wrapped):
if y + i < grid.height:
grid.put_text(x, y + i, line, style=style)
elif isinstance(node, Gauge):
style = _style_from_node(node)
# Label
label_text = f"{node.label} "
grid.put_text(x, y, label_text, style=CellStyle(bold=True))
bar_x = x + len(label_text)
bar_w = min(node.bar_width, w - len(label_text) - 6)
if bar_w > 0:
pct = min(node.value / node.max_val, 1.0) if node.max_val > 0 else 0
filled = int(bar_w * pct)
# Determine color based on thresholds
fg = "0f0" # green
if node.crit is not None and node.value >= node.crit:
fg = "f00" # red
elif node.warn is not None and node.value >= node.warn:
fg = "ff0" # yellow
for i in range(bar_w):
if i < filled:
grid.put(bar_x + i, y, GAUGE_FILLED, style=CellStyle(fg=fg))
else:
grid.put(bar_x + i, y, GAUGE_EMPTY, style=CellStyle(fg="555"))
# Percentage
pct_text = f" {int(pct * 100)}%"
grid.put_text(bar_x + bar_w, y, pct_text, style=CellStyle(fg=fg))
elif isinstance(node, Sparkline):
style = _style_from_node(node)
label_text = f"{node.label} "
grid.put_text(x, y, label_text, style=CellStyle(bold=True))
spark_x = x + len(label_text)
chars = sparkline_chars(node.values, node.spark_width)
spark_style = CellStyle(fg="0cf")
for i, ch in enumerate(chars):
grid.put(spark_x + i, y, ch, style=spark_style)
elif isinstance(node, Status):
char = STATUS_CHARS.get(node.state, "")
color = STATUS_COLORS.get(node.state, "888")
grid.put(x, y, char, style=CellStyle(fg=color))
grid.put_text(x + 2, y, node.label)
elif isinstance(node, Table):
_paint_table(node, grid, x, y, w)
elif isinstance(node, Form):
for child in node.children:
paint(child, grid)
elif isinstance(node, Field):
label_style = CellStyle(fg="888")
field_style = CellStyle(fg="0cf")
label_text = f"{node.field_name}: "
grid.put_text(x, y, label_text, style=label_style)
# Draw [ placeholder_______ ]
fx = x + len(label_text)
fw = min(node.field_width, w - len(label_text) - 2)
grid.put(fx, y, "[", style=field_style)
placeholder = node.placeholder or node.field_name
inner = f" {placeholder}".ljust(fw - 1)[:fw - 1]
grid.put_text(fx + 1, y, inner, style=CellStyle(fg="555"))
grid.put(fx + fw, y, "]", style=field_style)
elif isinstance(node, Password):
label_style = CellStyle(fg="888")
field_style = CellStyle(fg="0cf")
label_text = f"{node.field_name}: "
grid.put_text(x, y, label_text, style=label_style)
fx = x + len(label_text)
fw = min(node.field_width, w - len(label_text) - 2)
grid.put(fx, y, "[", style=field_style)
inner = " " + "" * (fw - 2)
grid.put_text(fx + 1, y, inner[:fw - 1], style=CellStyle(fg="555"))
grid.put(fx + fw, y, "]", style=field_style)
elif isinstance(node, Radio):
label_style = CellStyle(fg="888")
label_text = f"{node.group}: "
grid.put_text(x, y, label_text, style=label_style)
rx = x + len(label_text)
for i, opt in enumerate(node.options):
dot = "(•)" if i == 0 else "( )"
opt_style = CellStyle(fg="0cf" if i == 0 else "888")
grid.put_text(rx, y, dot, style=opt_style)
rx += 4
grid.put_text(rx, y, opt, style=CellStyle())
rx += len(opt) + 2
elif isinstance(node, Checkbox):
check_style = CellStyle(fg="0cf")
box_char = "[✓]" if node.checked else "[ ]"
grid.put_text(x, y, box_char, style=check_style)
grid.put_text(x + 4, y, node.checkbox_label)
elif isinstance(node, FormButton):
btn_style = CellStyle(bold=True, fg="0f0")
btn_text = f"[ {node.button_label} ]"
grid.put_text(x, y, btn_text, style=btn_style, link=node.dest)
else:
# Generic: paint children
for child in node.children:
paint(child, grid)
def _paint_table(node: Table, grid: CharGrid, x: int, y: int, w: int) -> None:
"""Paint a box-drawn table with header and data rows."""
if not node.columns:
return
ch = BOX_CHARS[BorderWeight.LIGHT]
border_style = CellStyle()
header_style = CellStyle(bold=True)
num_cols = len(node.columns)
# Calculate column widths
# If columns have explicit widths, use them. Otherwise distribute evenly.
col_widths: list[int] = []
total_explicit = 0
auto_count = 0
for _, cw in node.columns:
if cw > 0:
col_widths.append(cw)
total_explicit += cw
else:
col_widths.append(0)
auto_count += 1
# Available inner width = total - borders (num_cols + 1 border chars)
inner_w = w - (num_cols + 1)
if auto_count > 0:
auto_each = max(1, (inner_w - total_explicit) // auto_count)
for i in range(len(col_widths)):
if col_widths[i] == 0:
col_widths[i] = auto_each
# Compute column x positions (after each left border)
col_x: list[int] = []
cx = x + 1 # after left border
for cw in col_widths:
col_x.append(cx)
cx += cw + 1 # +1 for separator
table_w = cx - x # total table width including right border
row_y = y
# ── Top border ──
grid.put(x, row_y, ch["tl"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
grid.put(x + table_w - 1, row_y, ch["tr"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
for ci, cw in enumerate(col_widths):
for j in range(cw):
grid.put(col_x[ci] + j, row_y, ch["h"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
# Column separator on top border
if ci < num_cols - 1:
grid.put(col_x[ci] + cw, row_y, ch["t_down"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
row_y += 1
# ── Header row ──
grid.put(x, row_y, ch["v"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
for ci, (col_name, _) in enumerate(node.columns):
text = col_name[:col_widths[ci]].ljust(col_widths[ci])
grid.put_text(col_x[ci], row_y, text, style=header_style)
sep_x = col_x[ci] + col_widths[ci]
grid.put(sep_x, row_y, ch["v"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
row_y += 1
# ── Header separator ──
grid.put(x, row_y, ch["t_right"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
grid.put(x + table_w - 1, row_y, ch["t_left"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
for ci, cw in enumerate(col_widths):
for j in range(cw):
grid.put(col_x[ci] + j, row_y, ch["h"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
if ci < num_cols - 1:
grid.put(col_x[ci] + cw, row_y, ch["cross"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
row_y += 1
# ── Data rows ──
cell_style = CellStyle()
for row_data in node.rows:
grid.put(x, row_y, ch["v"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
for ci in range(num_cols):
cell_text = row_data[ci] if ci < len(row_data) else ""
text = cell_text[:col_widths[ci]].ljust(col_widths[ci])
# Check for @color{hex}{text} modifiers in cell content
if "@" in cell_text:
spans = []
import re as _re
pattern = _re.compile(r"@color\{([0-9a-fA-F]{3})\}\{([^}]*)\}")
pos = 0
styled_parts: list[tuple[str, CellStyle]] = []
for m in pattern.finditer(cell_text):
if m.start() > pos:
styled_parts.append((cell_text[pos:m.start()], cell_style))
styled_parts.append((m.group(2), CellStyle(fg=m.group(1))))
pos = m.end()
if pos < len(cell_text):
styled_parts.append((cell_text[pos:], cell_style))
col_pos = col_x[ci]
for part_text, part_style in styled_parts:
for pch in part_text:
if col_pos < col_x[ci] + col_widths[ci]:
grid.put(col_pos, row_y, pch, style=part_style)
col_pos += 1
# Pad remaining
while col_pos < col_x[ci] + col_widths[ci]:
grid.put(col_pos, row_y, " ")
col_pos += 1
else:
grid.put_text(col_x[ci], row_y, text, style=cell_style)
sep_x = col_x[ci] + col_widths[ci]
grid.put(sep_x, row_y, ch["v"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
row_y += 1
# ── Bottom border ──
grid.put(x, row_y, ch["bl"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
grid.put(x + table_w - 1, row_y, ch["br"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
for ci, cw in enumerate(col_widths):
for j in range(cw):
grid.put(col_x[ci] + j, row_y, ch["h"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
if ci < num_cols - 1:
grid.put(col_x[ci] + cw, row_y, ch["t_up"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)