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

@@ -21,6 +21,7 @@ from uframe.ir import (
IRNode, Field, Password, Radio, Checkbox, FormButton, IRNode, Field, Password, Radio, Checkbox, FormButton,
Source, IfBlock, ForLoop, OnSubmit, StateDecl, CacheControl, Source, IfBlock, ForLoop, OnSubmit, StateDecl, CacheControl,
) )
from uframe.themes import get_theme, ThemeDef
@dataclass @dataclass
@@ -79,7 +80,7 @@ def _micron_form_line(node: IRNode) -> str:
return "" return ""
def compile(source: str, width: int = 64) -> CompileResult: def compile(source: str, width: int = 64, theme: str = "") -> CompileResult:
"""Compile a µFrame .uf source string into ASCII and Micron output. """Compile a µFrame .uf source string into ASCII and Micron output.
Args: Args:
@@ -102,6 +103,10 @@ def compile(source: str, width: int = 64) -> CompileResult:
w = page.width w = page.width
# 1b. Resolve theme (CLI flag overrides source directive)
theme_name = theme or page.theme_name or "default"
theme_def = get_theme(theme_name)
# 2. Measure # 2. Measure
measure(page, w) measure(page, w)
@@ -110,7 +115,7 @@ def compile(source: str, width: int = 64) -> CompileResult:
# 4. Create grid and paint # 4. Create grid and paint
grid = CharGrid(w, max(total_h, 1)) grid = CharGrid(w, max(total_h, 1))
paint(page, grid) paint(page, grid, theme_def)
# 5. Merge borders # 5. Merge borders
merge_borders(grid) merge_borders(grid)

View File

@@ -23,7 +23,7 @@ def cmd_render(args: argparse.Namespace) -> int:
source = Path(args.file).read_text(encoding="utf-8") source = Path(args.file).read_text(encoding="utf-8")
try: try:
result = uframe.compile(source, width=args.width) result = uframe.compile(source, width=args.width, theme=getattr(args, 'theme', ''))
except UFrameError as e: except UFrameError as e:
print(f"Error: {e}", file=sys.stderr) print(f"Error: {e}", file=sys.stderr)
return 1 return 1
@@ -52,7 +52,7 @@ def cmd_compile(args: argparse.Namespace) -> int:
source = source_path.read_text(encoding="utf-8") source = source_path.read_text(encoding="utf-8")
try: try:
result = uframe.compile(source, width=args.width) result = uframe.compile(source, width=args.width, theme=getattr(args, 'theme', ''))
except UFrameError as e: except UFrameError as e:
print(f"Error: {e}", file=sys.stderr) print(f"Error: {e}", file=sys.stderr)
return 1 return 1
@@ -75,7 +75,7 @@ def cmd_check(args: argparse.Namespace) -> int:
source = Path(args.file).read_text(encoding="utf-8") source = Path(args.file).read_text(encoding="utf-8")
try: try:
result = uframe.compile(source, width=args.width) result = uframe.compile(source, width=args.width, theme=getattr(args, 'theme', ''))
except UFrameError as e: except UFrameError as e:
print(f"Error: {e}", file=sys.stderr) print(f"Error: {e}", file=sys.stderr)
return 1 return 1
@@ -97,7 +97,7 @@ def cmd_deploy(args: argparse.Namespace) -> int:
dest_dir = Path(args.dest or os.path.expanduser("~/.nomadnetwork/storage/pages")) dest_dir = Path(args.dest or os.path.expanduser("~/.nomadnetwork/storage/pages"))
try: try:
result = uframe.compile(source, width=args.width) result = uframe.compile(source, width=args.width, theme=getattr(args, 'theme', ''))
except UFrameError as e: except UFrameError as e:
print(f"Error: {e}", file=sys.stderr) print(f"Error: {e}", file=sys.stderr)
return 1 return 1
@@ -130,12 +130,14 @@ def main() -> int:
p_render.add_argument("--ascii", action="store_true", help="Output ASCII only") p_render.add_argument("--ascii", action="store_true", help="Output ASCII only")
p_render.add_argument("--micron", action="store_true", help="Output Micron only") p_render.add_argument("--micron", action="store_true", help="Output Micron only")
p_render.add_argument("--width", type=int, default=64, help="Page width (default: 64)") p_render.add_argument("--width", type=int, default=64, help="Page width (default: 64)")
p_render.add_argument("--theme", default="", help="Theme name (default, nouveau, gothic, bamboo, circuit, brutalist)")
# compile # compile
p_compile = sub.add_parser("compile", help="Compile to .mu file") p_compile = sub.add_parser("compile", help="Compile to .mu file")
p_compile.add_argument("file", help="Path to .uf source file") p_compile.add_argument("file", help="Path to .uf source file")
p_compile.add_argument("--out", help="Output file path (default: <name>.mu)") p_compile.add_argument("--out", help="Output file path (default: <name>.mu)")
p_compile.add_argument("--width", type=int, default=64, help="Page width") p_compile.add_argument("--width", type=int, default=64, help="Page width")
p_compile.add_argument("--theme", default="", help="Theme name")
# check # check
p_check = sub.add_parser("check", help="Validate a .uf file") p_check = sub.add_parser("check", help="Validate a .uf file")
@@ -147,6 +149,7 @@ def main() -> int:
p_deploy.add_argument("file", help="Path to .uf source file") p_deploy.add_argument("file", help="Path to .uf source file")
p_deploy.add_argument("--dest", help="Destination directory (default: ~/.nomadnetwork/storage/pages)") p_deploy.add_argument("--dest", help="Destination directory (default: ~/.nomadnetwork/storage/pages)")
p_deploy.add_argument("--width", type=int, default=64, help="Page width") p_deploy.add_argument("--width", type=int, default=64, help="Page width")
p_deploy.add_argument("--theme", default="", help="Theme name")
args = parser.parse_args() args = parser.parse_args()

View File

@@ -106,7 +106,9 @@ class CharGrid:
def draw_border(self, x: int, y: int, w: int, h: int, def draw_border(self, x: int, y: int, w: int, h: int,
weight: BorderWeight = BorderWeight.LIGHT, weight: BorderWeight = BorderWeight.LIGHT,
title: str = "", title: str = "",
title_style: CellStyle | None = None) -> None: 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. """Draw a box border. Interior is not cleared.
Args: Args:
@@ -122,7 +124,7 @@ class CharGrid:
self._border_counter += 1 self._border_counter += 1
bid = self._border_counter bid = self._border_counter
ch = BOX_CHARS[weight] ch = border_chars or BOX_CHARS[weight]
border_style = CellStyle() border_style = CellStyle()
# Corners # Corners
@@ -143,12 +145,14 @@ class CharGrid:
# Title in top border # Title in top border
if title and w > 4: if title and w > 4:
title_text = f" {title} " lc = title_caps[0] if title_caps else " "
max_title = w - 4 # leave room for corners + padding rc = title_caps[1] if title_caps else " "
title_text = f"{lc}{title}{rc}"
max_title = w - 4
if len(title_text) > max_title: if len(title_text) > max_title:
title_text = title_text[:max_title] title_text = title_text[:max_title]
start_x = x + 2 start_x = x + 1
ts = title_style or CellStyle(bold=True) ts = title_style or CellStyle(bold=True)
self.put_text(start_x, y, title_text, style=ts) self.put_text(start_x, y, title_text, style=ts)

View File

@@ -118,6 +118,7 @@ class Page(IRNode):
"""Root container. One per .uf file.""" """Root container. One per .uf file."""
title: str = "" title: str = ""
width: int = 64 width: int = 64
theme_name: str = ""
@dataclass @dataclass

View File

@@ -11,8 +11,7 @@ import re
import textwrap import textwrap
from uframe.chars import ( from uframe.chars import (
BOX_CHARS, DIVIDER_CHARS, GAUGE_FILLED, GAUGE_EMPTY, BOX_CHARS, DIVIDER_CHARS, sparkline_chars,
STATUS_CHARS, STATUS_COLORS, sparkline_chars,
) )
from uframe.grid import CharGrid, CellStyle from uframe.grid import CharGrid, CellStyle
from uframe.ir import ( from uframe.ir import (
@@ -22,6 +21,7 @@ from uframe.ir import (
Form, Field, Password, Radio, Checkbox, FormButton, Form, Field, Password, Radio, Checkbox, FormButton,
HeadingLevel, DividerStyle, ListStyle, Align, BorderWeight, HeadingLevel, DividerStyle, ListStyle, Align, BorderWeight,
) )
from uframe.themes import ThemeDef, THEME_DEFAULT
def _align_text(text: str, width: int, align: Align) -> str: 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.""" """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 x, y, w = node.rect.x, node.rect.y, node.rect.w
# Ensure grid is tall enough # Ensure grid is tall enough
@@ -55,46 +56,48 @@ def paint(node: IRNode, grid: CharGrid) -> None:
if isinstance(node, Page): if isinstance(node, Page):
for child in node.children: for child in node.children:
paint(child, grid) paint(child, grid, th)
elif isinstance(node, Box): elif isinstance(node, Box):
# Draw the border # Draw the border with themed characters
title_style = CellStyle(bold=True, fg=node.style.fg) title_style = CellStyle(bold=True, fg=node.style.fg or th.palette.accent)
grid.draw_border(x, y, w, node.rect.h, grid.draw_border(x, y, w, node.rect.h,
weight=node.weight, weight=node.weight,
title=node.title, 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 # Propagate box alignment/color to children that don't have their own
for child in node.children: for child in node.children:
if node.style.align != Align.LEFT and child.style.align == Align.LEFT: if node.style.align != Align.LEFT and child.style.align == Align.LEFT:
child.style.align = node.style.align child.style.align = node.style.align
if node.style.fg and not child.style.fg: if node.style.fg and not child.style.fg:
child.style.fg = node.style.fg child.style.fg = node.style.fg
paint(child, grid) paint(child, grid, th)
elif isinstance(node, Row): elif isinstance(node, Row):
for child in node.children: for child in node.children:
paint(child, grid) paint(child, grid, th)
elif isinstance(node, Col): elif isinstance(node, Col):
for child in node.children: for child in node.children:
paint(child, grid) paint(child, grid, th)
elif isinstance(node, Spacer): elif isinstance(node, Spacer):
pass # Just empty space pass # Just empty space
elif isinstance(node, Pad): elif isinstance(node, Pad):
for child in node.children: for child in node.children:
paint(child, grid) paint(child, grid, th)
elif isinstance(node, Heading): elif isinstance(node, Heading):
style = CellStyle(bold=True) style = CellStyle(bold=True)
if node.level == HeadingLevel.H1: if node.level == HeadingLevel.H1:
style.fg = "0f0" # green style.fg = th.palette.accent
elif node.level == HeadingLevel.H2: elif node.level == HeadingLevel.H2:
style.fg = "0cf" # cyan style.fg = th.palette.accent2
elif node.level == HeadingLevel.H3: elif node.level == HeadingLevel.H3:
style.fg = "88f" # light blue style.fg = th.palette.accent3
# Underline-style heading # Underline-style heading
grid.put_text(x, y, node.text[:w], style=style) 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): elif isinstance(node, Divider):
ds = node.divider_style ds = node.divider_style
char = DIVIDER_CHARS.get(ds.name.lower(), "") char = getattr(th.dividers, ds.name.lower(), th.dividers.light)
style = CellStyle(fg="555") style = CellStyle(fg=th.palette.muted)
for col in range(x, x + w): for col in range(x, x + w):
grid.put(col, y, char, style=style) grid.put(col, y, char, style=style)
elif isinstance(node, Link): elif isinstance(node, Link):
style = CellStyle(fg="0cf", underline=True) style = CellStyle(fg=th.palette.info, 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) grid.put_text(x, y, node.display[:w], style=style, link=node.dest)
elif isinstance(node, ListNode): elif isinstance(node, ListNode):
for child in node.children: for child in node.children:
paint(child, grid) paint(child, grid, th)
elif isinstance(node, ListItem): elif isinstance(node, ListItem):
style = _style_from_node(node) style = _style_from_node(node)
# Bullet to the left of the content (safe: put_text clips to bounds) # Bullet to the left of the content (safe: put_text clips to bounds)
bullet_x = max(0, x - 2) 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 # Wrap content
wrapped = textwrap.wrap(node.content, width=w) if node.content else [""] wrapped = textwrap.wrap(node.content, width=w) if node.content else [""]
for i, line in enumerate(wrapped): for i, line in enumerate(wrapped):
@@ -183,17 +184,17 @@ def paint(node: IRNode, grid: CharGrid) -> None:
filled = int(bar_w * pct) filled = int(bar_w * pct)
# Determine color based on thresholds # Determine color based on thresholds
fg = "0f0" # green fg = th.palette.success
if node.crit is not None and node.value >= node.crit: 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: elif node.warn is not None and node.value >= node.warn:
fg = "ff0" # yellow fg = th.palette.warning
for i in range(bar_w): for i in range(bar_w):
if i < filled: 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: 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 # Percentage
pct_text = f" {int(pct * 100)}%" pct_text = f" {int(pct * 100)}%"
@@ -206,13 +207,16 @@ def paint(node: IRNode, grid: CharGrid) -> None:
spark_x = x + len(label_text) spark_x = x + len(label_text)
chars = sparkline_chars(node.values, node.spark_width) 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): for i, ch in enumerate(chars):
grid.put(spark_x + i, y, ch, style=spark_style) grid.put(spark_x + i, y, ch, style=spark_style)
elif isinstance(node, Status): elif isinstance(node, Status):
char = STATUS_CHARS.get(node.state, "") char = getattr(th.indicators, node.state, th.indicators.unknown)
color = STATUS_COLORS.get(node.state, "888") 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(x, y, char, style=CellStyle(fg=color))
grid.put_text(x + 2, y, node.label) grid.put_text(x + 2, y, node.label)
@@ -221,62 +225,65 @@ def paint(node: IRNode, grid: CharGrid) -> None:
elif isinstance(node, Form): elif isinstance(node, Form):
for child in node.children: for child in node.children:
paint(child, grid) paint(child, grid, th)
elif isinstance(node, Field): elif isinstance(node, Field):
label_style = CellStyle(fg="888") label_style = CellStyle(fg=th.palette.label)
field_style = CellStyle(fg="0cf") field_style = CellStyle(fg=th.palette.form)
label_text = f"{node.field_name}: " label_text = f"{node.field_name}: "
grid.put_text(x, y, label_text, style=label_style) 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) fx = x + len(label_text)
fw = min(node.field_width, w - len(label_text) - 2) fw = min(node.field_width, w - len(label_text) - len(fl) - len(fr))
grid.put(fx, y, "[", style=field_style) grid.put_text(fx, y, fl, style=field_style)
placeholder = node.placeholder or node.field_name placeholder = node.placeholder or node.field_name
inner = f" {placeholder}".ljust(fw - 1)[:fw - 1] inner = placeholder.ljust(fw)[:fw]
grid.put_text(fx + 1, y, inner, style=CellStyle(fg="555")) grid.put_text(fx + len(fl), y, inner, style=CellStyle(fg=th.palette.muted))
grid.put(fx + fw, y, "]", style=field_style) grid.put_text(fx + len(fl) + fw, y, fr, style=field_style)
elif isinstance(node, Password): elif isinstance(node, Password):
label_style = CellStyle(fg="888") label_style = CellStyle(fg=th.palette.label)
field_style = CellStyle(fg="0cf") field_style = CellStyle(fg=th.palette.form)
label_text = f"{node.field_name}: " label_text = f"{node.field_name}: "
grid.put_text(x, y, label_text, style=label_style) 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) fx = x + len(label_text)
fw = min(node.field_width, w - len(label_text) - 2) fw = min(node.field_width, w - len(label_text) - len(fl) - len(fr))
grid.put(fx, y, "[", style=field_style) grid.put_text(fx, y, fl, style=field_style)
inner = " " + "" * (fw - 2) inner = "" * fw
grid.put_text(fx + 1, y, inner[:fw - 1], style=CellStyle(fg="555")) grid.put_text(fx + len(fl), y, inner[:fw], style=CellStyle(fg=th.palette.muted))
grid.put(fx + fw, y, "]", style=field_style) grid.put_text(fx + len(fl) + fw, y, fr, style=field_style)
elif isinstance(node, Radio): elif isinstance(node, Radio):
label_style = CellStyle(fg="888") label_style = CellStyle(fg=th.palette.label)
label_text = f"{node.group}: " label_text = f"{node.group}: "
grid.put_text(x, y, label_text, style=label_style) grid.put_text(x, y, label_text, style=label_style)
rx = x + len(label_text) rx = x + len(label_text)
for i, opt in enumerate(node.options): for i, opt in enumerate(node.options):
dot = "(•)" if i == 0 else "( )" dot = th.form.radio_on if i == 0 else th.form.radio_off
opt_style = CellStyle(fg="0cf" if i == 0 else "888") opt_style = CellStyle(fg=th.palette.form if i == 0 else th.palette.label)
grid.put_text(rx, y, dot, style=opt_style) grid.put_text(rx, y, dot, style=opt_style)
rx += 4 rx += len(dot) + 1
grid.put_text(rx, y, opt, style=CellStyle()) grid.put_text(rx, y, opt, style=CellStyle())
rx += len(opt) + 2 rx += len(opt) + 2
elif isinstance(node, Checkbox): elif isinstance(node, Checkbox):
check_style = CellStyle(fg="0cf") check_style = CellStyle(fg=th.palette.form)
box_char = "[✓]" if node.checked else "[ ]" 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, 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): 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} ]" btn_text = f"[ {node.button_label} ]"
grid.put_text(x, y, btn_text, style=btn_style, link=node.dest) grid.put_text(x, y, btn_text, style=btn_style, link=node.dest)
else: else:
# Generic: paint children # Generic: paint children
for child in node.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: def _paint_table(node: Table, grid: CharGrid, x: int, y: int, w: int) -> None:

View File

@@ -426,6 +426,11 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
content = " ".join([keyword] + args) content = " ".join([keyword] + args)
return Text(content=content, source_line=line_num) return Text(content=content, source_line=line_num)
elif keyword == "theme":
# theme "name" — sets the page theme (handled as _StyleDirective on Page)
theme_name = args[0] if args else "default"
return _ThemeDirective(theme_name, line_num)
elif keyword == "component": elif keyword == "component":
# component name(arg1, arg2) # component name(arg1, arg2)
raw = " ".join(args) raw = " ".join(args)
@@ -450,6 +455,13 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
return ComponentUse(comp_name=keyword, args=args, source_line=line_num) return ComponentUse(comp_name=keyword, args=args, source_line=line_num)
class _ThemeDirective(IRNode):
"""Temporary node — sets theme_name on the Page during tree building."""
def __init__(self, theme_name: str, line_num: int):
super().__init__(source_line=line_num)
self.theme_name = theme_name
class _UseDirective(IRNode): class _UseDirective(IRNode):
"""Temporary node — triggers library loading during tree building.""" """Temporary node — triggers library loading during tree building."""
def __init__(self, lib_path: str, line_num: int): def __init__(self, lib_path: str, line_num: int):
@@ -756,12 +768,17 @@ def parse(source: str, components: dict[str, ComponentDef] | None = None) -> Pag
stack.pop() stack.pop()
if isinstance(node, _StyleDirective): if isinstance(node, _StyleDirective):
# Apply style directive to the current top of stack (parent)
if stack: if stack:
parent = stack[-1][1] parent = stack[-1][1]
setattr(parent.style, node.attr, node.value) setattr(parent.style, node.attr, node.value)
continue continue
if isinstance(node, _ThemeDirective):
# Set theme on the root Page
if root and isinstance(root, Page):
root.theme_name = node.theme_name
continue
# Table children: columns and rows are absorbed by the Table node # Table children: columns and rows are absorbed by the Table node
if isinstance(node, _TableColumns): if isinstance(node, _TableColumns):
if stack and isinstance(stack[-1][1], Table): if stack and isinstance(stack[-1][1], Table):

241
backend/uframe/themes.py Normal file
View File

@@ -0,0 +1,241 @@
"""µFrame Theme System — decorative styles for rich terminal UIs.
A theme maps abstract UI elements to concrete character sets and color
palettes. The same .uf source renders with different visual character
when a different theme is applied.
"""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class BorderChars:
tl: str = ""; t: str = ""; tr: str = ""
l: str = ""; r: str = ""
bl: str = ""; b: str = ""; br: str = ""
@dataclass
class Indicators:
online: str = ""
offline: str = ""
degraded: str = ""
unknown: str = ""
alert: str = ""
@dataclass
class GaugeChars:
filled: str = ""
empty: str = ""
@dataclass
class FormChars:
field_l: str = "[ "
field_r: str = " ]"
radio_on: str = "(•)"
radio_off: str = "( )"
check_on: str = "[✓]"
check_off: str = "[ ]"
@dataclass
class Ornaments:
bullet: str = ""
header: str = ""
separator: str = ""
footer: str = ""
@dataclass
class TitleCaps:
left: str = ""
right: str = ""
@dataclass
class Palette:
accent: str = "0f0" # headings, primary highlights
accent2: str = "0cf" # secondary (H2, links)
accent3: str = "88f" # tertiary (H3)
muted: str = "555" # dividers, empty gauge
border: str = "" # border color (empty = no color)
success: str = "0f0" # online, gauge ok
warning: str = "ff0" # degraded, gauge warn
danger: str = "f00" # offline, gauge crit
info: str = "0cf" # links, sparklines
form: str = "0cf" # form element accents
label: str = "888" # labels, field names
button: str = "0f0" # form buttons
@dataclass
class DividerChars:
light: str = ""
heavy: str = ""
double: str = ""
dash: str = ""
dot: str = ""
@dataclass
class ThemeDef:
name: str = "default"
description: str = "Clean engineering — standard box-drawing"
borders_light: BorderChars = field(default_factory=BorderChars)
borders_heavy: BorderChars = field(default_factory=lambda: BorderChars(
tl="", t="", tr="", l="", r="", bl="", b="", br=""))
borders_double: BorderChars = field(default_factory=lambda: BorderChars(
tl="", t="", tr="", l="", r="", bl="", b="", br=""))
borders_rounded: BorderChars = field(default_factory=lambda: BorderChars(
tl="", t="", tr="", l="", r="", bl="", b="", br=""))
dividers: DividerChars = field(default_factory=DividerChars)
title_caps: TitleCaps = field(default_factory=TitleCaps)
indicators: Indicators = field(default_factory=Indicators)
gauge: GaugeChars = field(default_factory=GaugeChars)
form: FormChars = field(default_factory=FormChars)
ornaments: Ornaments = field(default_factory=Ornaments)
palette: Palette = field(default_factory=Palette)
def border_chars(self, weight_name: str) -> BorderChars:
return {
"light": self.borders_light,
"heavy": self.borders_heavy,
"double": self.borders_double,
"rounded": self.borders_rounded,
}.get(weight_name, self.borders_light)
def border_dict(self, weight_name: str) -> dict[str, str]:
"""Return BOX_CHARS-compatible dict for a border weight."""
bc = self.border_chars(weight_name)
return {
"tl": bc.tl, "tr": bc.tr, "bl": bc.bl, "br": bc.br,
"h": bc.t, "v": bc.l,
"t_down": bc.t, "t_up": bc.b, "t_right": bc.l, "t_left": bc.r,
"cross": bc.t,
}
# ---------------------------------------------------------------------------
# Built-in themes
# ---------------------------------------------------------------------------
THEME_DEFAULT = ThemeDef()
THEME_NOUVEAU = ThemeDef(
name="nouveau",
description="Art Nouveau — organic flowing ornament",
borders_heavy=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
borders_light=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
borders_double=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
borders_rounded=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
dividers=DividerChars(light="", heavy="", double="", dash="", dot=""),
title_caps=TitleCaps(left="✾─── ", right=" ───✾"),
indicators=Indicators(online="", offline="", degraded="", unknown="", alert=""),
gauge=GaugeChars(filled="", empty=""),
form=FormChars(field_l=" ", field_r=" ", radio_on="", radio_off="",
check_on="", check_off=""),
ornaments=Ornaments(bullet="", header="─✾──────✾─", separator="☙━━━━━━━━━━━━━❧"),
palette=Palette(accent="da5", accent2="8b5", accent3="886", muted="886",
border="a85", success="6b4", warning="da5", danger="a33",
info="68a", form="da5", label="886", button="6b4"),
)
THEME_GOTHIC = ThemeDef(
name="gothic",
description="Gothic — heavy blackletter, monumental",
borders_heavy=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
borders_light=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
borders_double=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
borders_rounded=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
dividers=DividerChars(light="", heavy="", double="", dash="", dot=""),
title_caps=TitleCaps(left="═══╡ ", right=" ╞═══"),
indicators=Indicators(online="", offline="", degraded="", unknown="", alert=""),
gauge=GaugeChars(filled="", empty=""),
form=FormChars(field_l="", field_r="", radio_on="", radio_off="",
check_on="", check_off=""),
ornaments=Ornaments(bullet="", header="═══╡══════╞═══"),
palette=Palette(accent="cc8", accent2="a66", accent3="888", muted="666",
border="888", success="8a8", warning="cc8", danger="a44",
info="8ac", form="cc8", label="888", button="cc8"),
)
THEME_BAMBOO = ThemeDef(
name="bamboo",
description="Bamboo — East Asian minimalism, light brush strokes",
borders_heavy=BorderChars(tl="", t=" ", tr="", l=" ", r=" ", bl=" ", b=" ", br=" "),
borders_light=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
borders_double=BorderChars(tl="", t=" ", tr="", l=" ", r=" ", bl=" ", b=" ", br=" "),
borders_rounded=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
dividers=DividerChars(light="", heavy="", double="", dash="", dot=""),
title_caps=TitleCaps(left="┄┄┄ ", right=" ┄┄┄"),
indicators=Indicators(online="", offline="", degraded="", unknown="", alert=""),
gauge=GaugeChars(filled="", empty=" "),
form=FormChars(field_l="", field_r="", radio_on="", radio_off="",
check_on="", check_off=""),
ornaments=Ornaments(bullet=""),
palette=Palette(accent="bca", accent2="ab9", accent3="998", muted="998",
border="776", success="8b8", warning="cc9", danger="b77",
info="9ab", form="bca", label="998", button="8b8"),
)
THEME_CIRCUIT = ThemeDef(
name="circuit",
description="Circuit — digital, technical, neon",
borders_heavy=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
borders_light=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
borders_double=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
borders_rounded=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
dividers=DividerChars(light="", heavy="", double="", dash="", dot=""),
title_caps=TitleCaps(left="══[ ", right=" ]═══"),
indicators=Indicators(online="", offline="", degraded="", unknown="", alert=""),
gauge=GaugeChars(filled="", empty=""),
form=FormChars(field_l=">_ [ ", field_r=" ]", radio_on="[▰]", radio_off="[▱]",
check_on="[▰]", check_off="[▱]"),
ornaments=Ornaments(bullet=""),
palette=Palette(accent="0ff", accent2="f0f", accent3="0af", muted="555",
border="0aa", success="0f0", warning="ff0", danger="f00",
info="0ff", form="0ff", label="0aa", button="0f0"),
)
THEME_BRUTALIST = ThemeDef(
name="brutalist",
description="Brutalist — raw blocks, monochrome, anti-decorative",
borders_heavy=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
borders_light=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
borders_double=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
borders_rounded=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
dividers=DividerChars(light="", heavy="", double="", dash="", dot=""),
title_caps=TitleCaps(left="", right=""),
indicators=Indicators(online="", offline="", degraded="", unknown="", alert="!"),
gauge=GaugeChars(filled="", empty=" "),
form=FormChars(field_l="[", field_r="]", radio_on="", radio_off="",
check_on="", check_off=""),
ornaments=Ornaments(bullet=""),
palette=Palette(accent="fff", accent2="fff", accent3="ccc", muted="888",
border="fff", success="fff", warning="fff", danger="fff",
info="fff", form="fff", label="aaa", button="fff"),
)
# ---------------------------------------------------------------------------
# Theme registry
# ---------------------------------------------------------------------------
BUILTIN_THEMES: dict[str, ThemeDef] = {
"default": THEME_DEFAULT,
"nouveau": THEME_NOUVEAU,
"gothic": THEME_GOTHIC,
"bamboo": THEME_BAMBOO,
"circuit": THEME_CIRCUIT,
"brutalist": THEME_BRUTALIST,
}
def get_theme(name: str) -> ThemeDef:
"""Get a built-in theme by name. Returns default if not found."""
return BUILTIN_THEMES.get(name.lower(), THEME_DEFAULT)

View File

@@ -278,6 +278,35 @@ export const EXAMPLES: Example[] = [
peer_status "Node Gamma" degraded peer_status "Node Gamma" degraded
divider heavy divider heavy
link "Home" "/page/index.mu"`,
},
{
name: "Themed Page",
description: "Same layout with different visual themes (try: nouveau, gothic, bamboo, circuit, brutalist)",
source: `page "Node Status" 50
theme nouveau
box heavy "Relay Alpha-7"
align center
text "Reticulum Network Node"
spacer
heading 1 "Resources"
gauge "CPU" 62 100 24 warn=75 crit=90
gauge "MEM" 84 100 24 warn=80 crit=95
spacer
heading 2 "Peers"
status "East Relay" online
status "South Bridge" online
status "Node Gamma" degraded
divider heavy
link "Home" "/page/index.mu"`, link "Home" "/page/index.mu"`,
}, },
{ {

View File

@@ -155,6 +155,44 @@ const COMMANDS: CmdEntry[] = [
), ),
}, },
// Themes — type /theme to filter all 6
{
label: "theme_default",
detail: "clean box-drawing ┌─┐●█░",
section: "Theme",
apply: insert("theme default"),
},
{
label: "theme_nouveau",
detail: "flowing ornament ☙❧❀▐",
section: "Theme",
apply: insert("theme nouveau"),
},
{
label: "theme_gothic",
detail: "blackletter ╬═║⚑▓",
section: "Theme",
apply: insert("theme gothic"),
},
{
label: "theme_bamboo",
detail: "minimal brush 〔〕◉┄",
section: "Theme",
apply: insert("theme bamboo"),
},
{
label: "theme_circuit",
detail: "digital neon ╒▰◈⚡",
section: "Theme",
apply: insert("theme circuit"),
},
{
label: "theme_brutalist",
detail: "raw blocks █▌■□",
section: "Theme",
apply: insert("theme brutalist"),
},
// Templates // Templates
{ {
label: "dashboard", label: "dashboard",

View File

@@ -40,6 +40,8 @@ export default function EditorView() {
autocompletion({ autocompletion({
override: [uframeCommandSource], override: [uframeCommandSource],
icons: false, icons: false,
activateOnTyping: true,
maxOptions: 50,
}), }),
], ],
[], [],