468 lines
18 KiB
Python
468 lines
18 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 re
|
|
import textwrap
|
|
|
|
from uframe.chars import (
|
|
BOX_CHARS, DIVIDER_CHARS, 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,
|
|
BigTitle, ImageNode,
|
|
HeadingLevel, DividerStyle, ListStyle, Align, BorderWeight,
|
|
)
|
|
from uframe.themes import ThemeDef, THEME_DEFAULT
|
|
from uframe.fonts import render_big_text, get_text_width, FONT_HEIGHTS
|
|
from uframe.imaging import convert_image
|
|
|
|
|
|
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, theme: ThemeDef | None = None) -> None:
|
|
"""Recursively paint an IR node and its children into the grid."""
|
|
th = theme or THEME_DEFAULT
|
|
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, th)
|
|
|
|
elif isinstance(node, Box):
|
|
# Draw the border with themed characters
|
|
title_style = CellStyle(bold=True, fg=node.style.fg or th.palette.accent)
|
|
grid.draw_border(x, y, w, node.rect.h,
|
|
weight=node.weight,
|
|
title=node.title,
|
|
title_style=title_style,
|
|
border_chars=th.border_dict(node.weight.name.lower()),
|
|
title_caps=(th.title_caps.left, th.title_caps.right))
|
|
# Propagate box alignment/color to children that don't have their own
|
|
for child in node.children:
|
|
if node.style.align != Align.LEFT and child.style.align == Align.LEFT:
|
|
child.style.align = node.style.align
|
|
if node.style.fg and not child.style.fg:
|
|
child.style.fg = node.style.fg
|
|
paint(child, grid, th)
|
|
|
|
elif isinstance(node, Row):
|
|
for child in node.children:
|
|
paint(child, grid, th)
|
|
|
|
elif isinstance(node, Col):
|
|
for child in node.children:
|
|
paint(child, grid, th)
|
|
|
|
elif isinstance(node, Spacer):
|
|
pass # Just empty space
|
|
|
|
elif isinstance(node, Pad):
|
|
for child in node.children:
|
|
paint(child, grid, th)
|
|
|
|
elif isinstance(node, Heading):
|
|
style = CellStyle(bold=True)
|
|
if node.level == HeadingLevel.H1:
|
|
style.fg = th.palette.accent
|
|
elif node.level == HeadingLevel.H2:
|
|
style.fg = th.palette.accent2
|
|
elif node.level == HeadingLevel.H3:
|
|
style.fg = th.palette.accent3
|
|
|
|
# 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 = getattr(th.dividers, ds.name.lower(), th.dividers.light)
|
|
style = CellStyle(fg=th.palette.muted)
|
|
for col in range(x, x + w):
|
|
grid.put(col, y, char, style=style)
|
|
|
|
elif isinstance(node, Link):
|
|
style = CellStyle(fg=th.palette.info, underline=True)
|
|
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, th)
|
|
|
|
elif isinstance(node, ListItem):
|
|
style = _style_from_node(node)
|
|
# Bullet to the left of the content (safe: put_text clips to bounds)
|
|
bullet_x = max(0, x - 2)
|
|
grid.put_text(bullet_x, y, f"{th.ornaments.bullet} ", style=CellStyle(fg=th.palette.label))
|
|
# 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 = th.palette.success
|
|
if node.crit is not None and node.value >= node.crit:
|
|
fg = th.palette.danger
|
|
elif node.warn is not None and node.value >= node.warn:
|
|
fg = th.palette.warning
|
|
|
|
for i in range(bar_w):
|
|
if i < filled:
|
|
grid.put(bar_x + i, y, th.gauge.filled, style=CellStyle(fg=fg))
|
|
else:
|
|
grid.put(bar_x + i, y, th.gauge.empty, style=CellStyle(fg=th.palette.muted))
|
|
|
|
# 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=th.palette.info)
|
|
for i, ch in enumerate(chars):
|
|
grid.put(spark_x + i, y, ch, style=spark_style)
|
|
|
|
elif isinstance(node, Status):
|
|
char = getattr(th.indicators, node.state, th.indicators.unknown)
|
|
color_map = {"online": th.palette.success, "offline": th.palette.danger,
|
|
"degraded": th.palette.warning, "unknown": th.palette.label,
|
|
"alert": th.palette.danger}
|
|
color = color_map.get(node.state, th.palette.label)
|
|
grid.put(x, y, char, style=CellStyle(fg=color))
|
|
grid.put_text(x + 2, y, node.label)
|
|
|
|
elif isinstance(node, ImageNode):
|
|
style = CellStyle(fg=node.style.fg or th.palette.accent)
|
|
try:
|
|
lines = convert_image(node.path, node.mode, node.img_width,
|
|
node.dither, node.invert, node.use_color)
|
|
# Center if aligned
|
|
offset = 0
|
|
actual_w = len(lines[0]) if lines else 0
|
|
if node.style.align == Align.CENTER:
|
|
offset = max(0, (w - actual_w) // 2)
|
|
elif node.style.align == Align.RIGHT:
|
|
offset = max(0, w - actual_w)
|
|
|
|
for row_i, line in enumerate(lines):
|
|
if y + row_i < grid.height:
|
|
grid.put_text(x + offset, y + row_i, line, style=style)
|
|
|
|
# Caption
|
|
if node.caption and y + len(lines) < grid.height:
|
|
cap_style = CellStyle(fg=th.palette.label, italic=True)
|
|
grid.put_text(x + offset, y + len(lines), node.caption, style=cap_style)
|
|
except (ImportError, FileNotFoundError) as e:
|
|
# Render placeholder if image can't be loaded
|
|
grid.put_text(x, y, f"[image: {node.path}]", style=CellStyle(fg=th.palette.muted))
|
|
|
|
elif isinstance(node, BigTitle):
|
|
style = CellStyle(fg=node.style.fg or th.palette.accent, bold=True)
|
|
|
|
# Determine which font fits
|
|
font = node.font
|
|
tw = get_text_width(node.text, font)
|
|
if tw > w:
|
|
# Try fallback cascade
|
|
for fallback in ["pixel", "thin"]:
|
|
if get_text_width(node.text, fallback) <= w:
|
|
font = fallback
|
|
tw = get_text_width(node.text, fallback)
|
|
break
|
|
else:
|
|
# Final fallback: styled single line
|
|
styled = f"═══ {node.text.upper()} ═══"
|
|
grid.put_text(x, y, styled[:w], style=style)
|
|
return
|
|
|
|
lines = render_big_text(node.text, font)
|
|
|
|
# Center if align is set
|
|
offset = 0
|
|
if node.style.align == Align.CENTER:
|
|
offset = max(0, (w - tw) // 2)
|
|
elif node.style.align == Align.RIGHT:
|
|
offset = max(0, w - tw)
|
|
|
|
for row_i, line in enumerate(lines):
|
|
if y + row_i < grid.height:
|
|
grid.put_text(x + offset, y + row_i, line, style=style)
|
|
|
|
elif isinstance(node, Table):
|
|
_paint_table(node, grid, x, y, w)
|
|
|
|
elif isinstance(node, Form):
|
|
for child in node.children:
|
|
paint(child, grid, th)
|
|
|
|
elif isinstance(node, Field):
|
|
label_style = CellStyle(fg=th.palette.label)
|
|
field_style = CellStyle(fg=th.palette.form)
|
|
label_text = f"{node.field_name}: "
|
|
grid.put_text(x, y, label_text, style=label_style)
|
|
fl = th.form.field_l
|
|
fr = th.form.field_r
|
|
fx = x + len(label_text)
|
|
fw = min(node.field_width, w - len(label_text) - len(fl) - len(fr))
|
|
grid.put_text(fx, y, fl, style=field_style)
|
|
placeholder = node.placeholder or node.field_name
|
|
inner = placeholder.ljust(fw)[:fw]
|
|
grid.put_text(fx + len(fl), y, inner, style=CellStyle(fg=th.palette.muted))
|
|
grid.put_text(fx + len(fl) + fw, y, fr, style=field_style)
|
|
|
|
elif isinstance(node, Password):
|
|
label_style = CellStyle(fg=th.palette.label)
|
|
field_style = CellStyle(fg=th.palette.form)
|
|
label_text = f"{node.field_name}: "
|
|
grid.put_text(x, y, label_text, style=label_style)
|
|
fl = th.form.field_l
|
|
fr = th.form.field_r
|
|
fx = x + len(label_text)
|
|
fw = min(node.field_width, w - len(label_text) - len(fl) - len(fr))
|
|
grid.put_text(fx, y, fl, style=field_style)
|
|
inner = "•" * fw
|
|
grid.put_text(fx + len(fl), y, inner[:fw], style=CellStyle(fg=th.palette.muted))
|
|
grid.put_text(fx + len(fl) + fw, y, fr, style=field_style)
|
|
|
|
elif isinstance(node, Radio):
|
|
label_style = CellStyle(fg=th.palette.label)
|
|
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 = th.form.radio_on if i == 0 else th.form.radio_off
|
|
opt_style = CellStyle(fg=th.palette.form if i == 0 else th.palette.label)
|
|
grid.put_text(rx, y, dot, style=opt_style)
|
|
rx += len(dot) + 1
|
|
grid.put_text(rx, y, opt, style=CellStyle())
|
|
rx += len(opt) + 2
|
|
|
|
elif isinstance(node, Checkbox):
|
|
check_style = CellStyle(fg=th.palette.form)
|
|
box_char = th.form.check_on if node.checked else th.form.check_off
|
|
grid.put_text(x, y, box_char, style=check_style)
|
|
grid.put_text(x + len(box_char) + 1, y, node.checkbox_label)
|
|
|
|
elif isinstance(node, FormButton):
|
|
btn_style = CellStyle(bold=True, fg=th.palette.button)
|
|
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, th)
|
|
|
|
|
|
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:
|
|
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)
|