feat: themes

This commit is contained in:
2026-04-01 10:45:02 +02:00
parent 0316e50233
commit 01a3e0095c
10 changed files with 413 additions and 66 deletions

View File

@@ -11,8 +11,7 @@ import re
import textwrap
from uframe.chars import (
BOX_CHARS, DIVIDER_CHARS, GAUGE_FILLED, GAUGE_EMPTY,
STATUS_CHARS, STATUS_COLORS, sparkline_chars,
BOX_CHARS, DIVIDER_CHARS, sparkline_chars,
)
from uframe.grid import CharGrid, CellStyle
from uframe.ir import (
@@ -22,6 +21,7 @@ from uframe.ir import (
Form, Field, Password, Radio, Checkbox, FormButton,
HeadingLevel, DividerStyle, ListStyle, Align, BorderWeight,
)
from uframe.themes import ThemeDef, THEME_DEFAULT
def _align_text(text: str, width: int, align: Align) -> str:
@@ -46,8 +46,9 @@ def _style_from_node(node: IRNode) -> CellStyle:
)
def paint(node: IRNode, grid: CharGrid) -> None:
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
@@ -55,46 +56,48 @@ def paint(node: IRNode, grid: CharGrid) -> None:
if isinstance(node, Page):
for child in node.children:
paint(child, grid)
paint(child, grid, th)
elif isinstance(node, Box):
# Draw the border
title_style = CellStyle(bold=True, fg=node.style.fg)
# 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)
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)
paint(child, grid, th)
elif isinstance(node, Row):
for child in node.children:
paint(child, grid)
paint(child, grid, th)
elif isinstance(node, Col):
for child in node.children:
paint(child, grid)
paint(child, grid, th)
elif isinstance(node, Spacer):
pass # Just empty space
elif isinstance(node, Pad):
for child in node.children:
paint(child, grid)
paint(child, grid, th)
elif isinstance(node, Heading):
style = CellStyle(bold=True)
if node.level == HeadingLevel.H1:
style.fg = "0f0" # green
style.fg = th.palette.accent
elif node.level == HeadingLevel.H2:
style.fg = "0cf" # cyan
style.fg = th.palette.accent2
elif node.level == HeadingLevel.H3:
style.fg = "88f" # light blue
style.fg = th.palette.accent3
# Underline-style heading
grid.put_text(x, y, node.text[:w], style=style)
@@ -143,26 +146,24 @@ def paint(node: IRNode, grid: CharGrid) -> None:
elif isinstance(node, Divider):
ds = node.divider_style
char = DIVIDER_CHARS.get(ds.name.lower(), "")
style = CellStyle(fg="555")
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="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.
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)
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, " ", style=CellStyle(fg="888"))
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):
@@ -183,17 +184,17 @@ def paint(node: IRNode, grid: CharGrid) -> None:
filled = int(bar_w * pct)
# Determine color based on thresholds
fg = "0f0" # green
fg = th.palette.success
if node.crit is not None and node.value >= node.crit:
fg = "f00" # red
fg = th.palette.danger
elif node.warn is not None and node.value >= node.warn:
fg = "ff0" # yellow
fg = th.palette.warning
for i in range(bar_w):
if i < filled:
grid.put(bar_x + i, y, GAUGE_FILLED, style=CellStyle(fg=fg))
grid.put(bar_x + i, y, th.gauge.filled, style=CellStyle(fg=fg))
else:
grid.put(bar_x + i, y, GAUGE_EMPTY, style=CellStyle(fg="555"))
grid.put(bar_x + i, y, th.gauge.empty, style=CellStyle(fg=th.palette.muted))
# Percentage
pct_text = f" {int(pct * 100)}%"
@@ -206,13 +207,16 @@ def paint(node: IRNode, grid: CharGrid) -> None:
spark_x = x + len(label_text)
chars = sparkline_chars(node.values, node.spark_width)
spark_style = CellStyle(fg="0cf")
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 = STATUS_CHARS.get(node.state, "")
color = STATUS_COLORS.get(node.state, "888")
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)
@@ -221,62 +225,65 @@ def paint(node: IRNode, grid: CharGrid) -> None:
elif isinstance(node, Form):
for child in node.children:
paint(child, grid)
paint(child, grid, th)
elif isinstance(node, Field):
label_style = CellStyle(fg="888")
field_style = CellStyle(fg="0cf")
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)
# Draw [ placeholder_______ ]
fl = th.form.field_l
fr = th.form.field_r
fx = x + len(label_text)
fw = min(node.field_width, w - len(label_text) - 2)
grid.put(fx, y, "[", style=field_style)
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 = 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)
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="888")
field_style = CellStyle(fg="0cf")
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) - 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)
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="888")
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 = "(•)" if i == 0 else "( )"
opt_style = CellStyle(fg="0cf" if i == 0 else "888")
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 += 4
rx += len(dot) + 1
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 "[ ]"
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 + 4, y, node.checkbox_label)
grid.put_text(x + len(box_char) + 1, y, node.checkbox_label)
elif isinstance(node, FormButton):
btn_style = CellStyle(bold=True, fg="0f0")
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)
paint(child, grid, th)
def _paint_table(node: Table, grid: CharGrid, x: int, y: int, w: int) -> None: