feat: added a twist
This commit is contained in:
216
backend/uframe/paint.py
Normal file
216
backend/uframe/paint.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""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 (
|
||||
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,
|
||||
HeadingLevel, DividerStyle, ListStyle, Align,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
else:
|
||||
# Generic: paint children
|
||||
for child in node.children:
|
||||
paint(child, grid)
|
||||
Reference in New Issue
Block a user