feat: themes
This commit is contained in:
@@ -21,6 +21,7 @@ from uframe.ir import (
|
||||
IRNode, Field, Password, Radio, Checkbox, FormButton,
|
||||
Source, IfBlock, ForLoop, OnSubmit, StateDecl, CacheControl,
|
||||
)
|
||||
from uframe.themes import get_theme, ThemeDef
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -79,7 +80,7 @@ def _micron_form_line(node: IRNode) -> str:
|
||||
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.
|
||||
|
||||
Args:
|
||||
@@ -102,6 +103,10 @@ def compile(source: str, width: int = 64) -> CompileResult:
|
||||
|
||||
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
|
||||
measure(page, w)
|
||||
|
||||
@@ -110,7 +115,7 @@ def compile(source: str, width: int = 64) -> CompileResult:
|
||||
|
||||
# 4. Create grid and paint
|
||||
grid = CharGrid(w, max(total_h, 1))
|
||||
paint(page, grid)
|
||||
paint(page, grid, theme_def)
|
||||
|
||||
# 5. Merge borders
|
||||
merge_borders(grid)
|
||||
|
||||
@@ -23,7 +23,7 @@ def cmd_render(args: argparse.Namespace) -> int:
|
||||
source = Path(args.file).read_text(encoding="utf-8")
|
||||
|
||||
try:
|
||||
result = uframe.compile(source, width=args.width)
|
||||
result = uframe.compile(source, width=args.width, theme=getattr(args, 'theme', ''))
|
||||
except UFrameError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
@@ -52,7 +52,7 @@ def cmd_compile(args: argparse.Namespace) -> int:
|
||||
source = source_path.read_text(encoding="utf-8")
|
||||
|
||||
try:
|
||||
result = uframe.compile(source, width=args.width)
|
||||
result = uframe.compile(source, width=args.width, theme=getattr(args, 'theme', ''))
|
||||
except UFrameError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
@@ -75,7 +75,7 @@ def cmd_check(args: argparse.Namespace) -> int:
|
||||
source = Path(args.file).read_text(encoding="utf-8")
|
||||
|
||||
try:
|
||||
result = uframe.compile(source, width=args.width)
|
||||
result = uframe.compile(source, width=args.width, theme=getattr(args, 'theme', ''))
|
||||
except UFrameError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
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"))
|
||||
|
||||
try:
|
||||
result = uframe.compile(source, width=args.width)
|
||||
result = uframe.compile(source, width=args.width, theme=getattr(args, 'theme', ''))
|
||||
except UFrameError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
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("--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("--theme", default="", help="Theme name (default, nouveau, gothic, bamboo, circuit, brutalist)")
|
||||
|
||||
# compile
|
||||
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("--out", help="Output file path (default: <name>.mu)")
|
||||
p_compile.add_argument("--width", type=int, default=64, help="Page width")
|
||||
p_compile.add_argument("--theme", default="", help="Theme name")
|
||||
|
||||
# check
|
||||
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("--dest", help="Destination directory (default: ~/.nomadnetwork/storage/pages)")
|
||||
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()
|
||||
|
||||
|
||||
@@ -106,7 +106,9 @@ class CharGrid:
|
||||
def draw_border(self, x: int, y: int, w: int, h: int,
|
||||
weight: BorderWeight = BorderWeight.LIGHT,
|
||||
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.
|
||||
|
||||
Args:
|
||||
@@ -122,7 +124,7 @@ class CharGrid:
|
||||
self._border_counter += 1
|
||||
bid = self._border_counter
|
||||
|
||||
ch = BOX_CHARS[weight]
|
||||
ch = border_chars or BOX_CHARS[weight]
|
||||
border_style = CellStyle()
|
||||
|
||||
# Corners
|
||||
@@ -143,12 +145,14 @@ class CharGrid:
|
||||
|
||||
# Title in top border
|
||||
if title and w > 4:
|
||||
title_text = f" {title} "
|
||||
max_title = w - 4 # leave room for corners + padding
|
||||
lc = title_caps[0] if title_caps else " "
|
||||
rc = title_caps[1] if title_caps else " "
|
||||
title_text = f"{lc}{title}{rc}"
|
||||
max_title = w - 4
|
||||
if len(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)
|
||||
self.put_text(start_x, y, title_text, style=ts)
|
||||
|
||||
|
||||
@@ -118,6 +118,7 @@ class Page(IRNode):
|
||||
"""Root container. One per .uf file."""
|
||||
title: str = ""
|
||||
width: int = 64
|
||||
theme_name: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -426,6 +426,11 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
|
||||
content = " ".join([keyword] + args)
|
||||
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":
|
||||
# component name(arg1, arg2)
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
"""Temporary node — triggers library loading during tree building."""
|
||||
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()
|
||||
|
||||
if isinstance(node, _StyleDirective):
|
||||
# Apply style directive to the current top of stack (parent)
|
||||
if stack:
|
||||
parent = stack[-1][1]
|
||||
setattr(parent.style, node.attr, node.value)
|
||||
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
|
||||
if isinstance(node, _TableColumns):
|
||||
if stack and isinstance(stack[-1][1], Table):
|
||||
|
||||
241
backend/uframe/themes.py
Normal file
241
backend/uframe/themes.py
Normal 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)
|
||||
@@ -278,6 +278,35 @@ export const EXAMPLES: Example[] = [
|
||||
peer_status "Node Gamma" degraded
|
||||
|
||||
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"`,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
label: "dashboard",
|
||||
|
||||
@@ -40,6 +40,8 @@ export default function EditorView() {
|
||||
autocompletion({
|
||||
override: [uframeCommandSource],
|
||||
icons: false,
|
||||
activateOnTyping: true,
|
||||
maxOptions: 50,
|
||||
}),
|
||||
],
|
||||
[],
|
||||
|
||||
Reference in New Issue
Block a user