diff --git a/backend/uframe/__init__.py b/backend/uframe/__init__.py index 4f7ec33..e74a53c 100644 --- a/backend/uframe/__init__.py +++ b/backend/uframe/__init__.py @@ -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) diff --git a/backend/uframe/cli.py b/backend/uframe/cli.py index e6c703c..4c688bf 100644 --- a/backend/uframe/cli.py +++ b/backend/uframe/cli.py @@ -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: .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() diff --git a/backend/uframe/grid.py b/backend/uframe/grid.py index a887e35..eda0e54 100644 --- a/backend/uframe/grid.py +++ b/backend/uframe/grid.py @@ -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) diff --git a/backend/uframe/ir.py b/backend/uframe/ir.py index 3007a99..c0ff10c 100644 --- a/backend/uframe/ir.py +++ b/backend/uframe/ir.py @@ -118,6 +118,7 @@ class Page(IRNode): """Root container. One per .uf file.""" title: str = "" width: int = 64 + theme_name: str = "" @dataclass diff --git a/backend/uframe/paint.py b/backend/uframe/paint.py index ec84f2d..6c04914 100644 --- a/backend/uframe/paint.py +++ b/backend/uframe/paint.py @@ -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: diff --git a/backend/uframe/parser.py b/backend/uframe/parser.py index bdb3edf..b975c9a 100644 --- a/backend/uframe/parser.py +++ b/backend/uframe/parser.py @@ -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): diff --git a/backend/uframe/themes.py b/backend/uframe/themes.py new file mode 100644 index 0000000..3accd07 --- /dev/null +++ b/backend/uframe/themes.py @@ -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) diff --git a/frontend/src/components/editor/examples.ts b/frontend/src/components/editor/examples.ts index acacb28..a9079ad 100644 --- a/frontend/src/components/editor/examples.ts +++ b/frontend/src/components/editor/examples.ts @@ -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"`, }, { diff --git a/frontend/src/components/editor/uframeCommands.ts b/frontend/src/components/editor/uframeCommands.ts index 4fe6533..9ddbea2 100644 --- a/frontend/src/components/editor/uframeCommands.ts +++ b/frontend/src/components/editor/uframeCommands.ts @@ -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", diff --git a/frontend/src/routes/EditorView.tsx b/frontend/src/routes/EditorView.tsx index a0396de..0d20e9a 100644 --- a/frontend/src/routes/EditorView.tsx +++ b/frontend/src/routes/EditorView.tsx @@ -40,6 +40,8 @@ export default function EditorView() { autocompletion({ override: [uframeCommandSource], icons: false, + activateOnTyping: true, + maxOptions: 50, }), ], [],