feat: component

This commit is contained in:
2026-04-01 08:40:17 +02:00
parent 619d3ec538
commit 8776459ffb
6 changed files with 518 additions and 5 deletions

View File

@@ -0,0 +1,4 @@
"""Allow running µFrame as: python -m uframe render file.uf"""
from uframe.cli import main
import sys
sys.exit(main())

165
backend/uframe/cli.py Normal file
View File

@@ -0,0 +1,165 @@
"""µFrame CLI — render, compile, check, and deploy .uf files.
Usage:
python -m uframe.cli render <file.uf> [--ascii | --micron] [--width N]
python -m uframe.cli compile <file.uf> [--out <file.mu>] [--embed]
python -m uframe.cli check <file.uf>
python -m uframe.cli deploy <file.uf> [--dest <dir>]
"""
from __future__ import annotations
import argparse
import os
import stat
import sys
from pathlib import Path
import uframe
from uframe.errors import UFrameError
def cmd_render(args: argparse.Namespace) -> int:
"""Render a .uf file to ASCII and/or Micron."""
source = Path(args.file).read_text(encoding="utf-8")
try:
result = uframe.compile(source, width=args.width)
except UFrameError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
if args.ascii:
print(result.ascii)
elif args.micron:
print(result.micron)
else:
# Default: show ASCII
print(result.ascii)
if result.warnings:
for w in result.warnings:
print(f"Warning: {w.message}", file=sys.stderr)
if result.is_dynamic:
print(f"\n[Dynamic page — {len(result.script)} bytes of generated script]", file=sys.stderr)
return 0
def cmd_compile(args: argparse.Namespace) -> int:
"""Compile a .uf file to an executable .mu script (dynamic) or static .mu."""
source_path = Path(args.file)
source = source_path.read_text(encoding="utf-8")
try:
result = uframe.compile(source, width=args.width)
except UFrameError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
out_path = Path(args.out) if args.out else source_path.with_suffix(".mu")
if result.is_dynamic and result.script:
out_path.write_text(result.script, encoding="utf-8")
out_path.chmod(0o755)
print(f"Compiled dynamic: {out_path} ({len(result.script)} bytes, +x)")
else:
out_path.write_text(result.micron, encoding="utf-8")
print(f"Compiled static: {out_path} ({len(result.micron)} bytes)")
return 0
def cmd_check(args: argparse.Namespace) -> int:
"""Validate a .uf file without generating output."""
source = Path(args.file).read_text(encoding="utf-8")
try:
result = uframe.compile(source, width=args.width)
except UFrameError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
status = "dynamic" if result.is_dynamic else "static"
print(f"OK: {args.file} ({status}, {len(result.ascii)} chars ASCII, {len(result.micron)} chars Micron)")
if result.warnings:
for w in result.warnings:
print(f" Warning: {w.message}")
return 0
def cmd_deploy(args: argparse.Namespace) -> int:
"""Compile and deploy a .uf file to the NomadNet pages directory."""
source_path = Path(args.file)
source = source_path.read_text(encoding="utf-8")
dest_dir = Path(args.dest or os.path.expanduser("~/.nomadnetwork/storage/pages"))
try:
result = uframe.compile(source, width=args.width)
except UFrameError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
dest_dir.mkdir(parents=True, exist_ok=True)
out_path = dest_dir / f"{source_path.stem}.mu"
if result.is_dynamic and result.script:
out_path.write_text(result.script, encoding="utf-8")
out_path.chmod(0o755)
print(f"Deployed dynamic: {out_path}")
else:
out_path.write_text(result.micron, encoding="utf-8")
out_path.chmod(0o644)
print(f"Deployed static: {out_path}")
return 0
def main() -> int:
parser = argparse.ArgumentParser(
prog="uframe",
description="µFrame — A DSL for rich terminal UIs rendered as ASCII and Micron",
)
sub = parser.add_subparsers(dest="command", required=True)
# render
p_render = sub.add_parser("render", help="Render a .uf file")
p_render.add_argument("file", help="Path to .uf source file")
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)")
# 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")
# check
p_check = sub.add_parser("check", help="Validate a .uf file")
p_check.add_argument("file", help="Path to .uf source file")
p_check.add_argument("--width", type=int, default=64, help="Page width")
# deploy
p_deploy = sub.add_parser("deploy", help="Compile and deploy to NomadNet")
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")
args = parser.parse_args()
commands = {
"render": cmd_render,
"compile": cmd_compile,
"check": cmd_check,
"deploy": cmd_deploy,
}
return commands[args.command](args)
if __name__ == "__main__":
sys.exit(main())

View File

@@ -355,3 +355,22 @@ class StateDecl(IRNode):
"""State persistence: state "name" "/path.json"."""
state_name: str = ""
path: str = ""
# ---------------------------------------------------------------------------
# Components (Phase 8)
# ---------------------------------------------------------------------------
@dataclass
class ComponentDef(IRNode):
"""Component definition: component name(arg1, arg2)."""
comp_name: str = ""
params: list[str] = field(default_factory=list)
# children = the component body template
@dataclass
class ComponentUse(IRNode):
"""Component instantiation: name "arg1" "arg2"."""
comp_name: str = ""
args: list[str] = field(default_factory=list)

View File

@@ -20,6 +20,7 @@ from uframe.ir import (
Gauge, Sparkline, Status, Table, TextSpan,
Form, Field, Password, Radio, Checkbox, FormButton,
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
ComponentDef, ComponentUse,
SourceType,
BorderWeight, HeadingLevel, DividerStyle, ListStyle, Align, Style,
)
@@ -249,16 +250,18 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
elif keyword == "gauge":
label = args[0] if args else ""
val_str = args[1] if len(args) > 1 else "0"
max_str = args[2] if len(args) > 2 else "100"
bw_str = args[3] if len(args) > 3 else "28"
try:
value = float(val_str)
except ValueError:
value = 0 # $variable — resolved at runtime
try:
max_val = float(args[2]) if len(args) > 2 else 100
max_val = float(max_str)
except ValueError:
max_val = 100
try:
bar_width = int(args[3]) if len(args) > 3 else 28
bar_width = int(bw_str)
except ValueError:
bar_width = 28
# Parse warn=N crit=N from remaining args
@@ -268,9 +271,15 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
warn = float(a[5:])
elif a.startswith("crit="):
crit = float(a[5:])
return Gauge(label=label, value=value, max_val=max_val,
node = Gauge(label=label, value=value, max_val=max_val,
bar_width=bar_width, warn=warn, crit=crit,
source_line=line_num)
# Store raw strings for deferred component expansion
if "$" in val_str:
node._raw_value = val_str # type: ignore[attr-defined]
if "$" in max_str:
node._raw_max_val = max_str # type: ignore[attr-defined]
return node
elif keyword == "sparkline":
label = args[0] if args else ""
@@ -418,8 +427,35 @@ 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 == "component":
# component name(arg1, arg2)
raw = " ".join(args)
paren = raw.find("(")
if paren != -1:
comp_name = raw[:paren].strip()
params_str = raw[paren + 1:].rstrip(")")
params = [p.strip() for p in params_str.split(",") if p.strip()]
else:
comp_name = args[0] if args else ""
params = []
return ComponentDef(comp_name=comp_name, params=params, source_line=line_num)
elif keyword == "use":
# use std/dashboard — load a library (handled at parse level)
lib_path = args[0] if args else ""
return _UseDirective(lib_path, line_num)
else:
raise ParseError(f"Unknown keyword: {keyword!r}", line=line_num)
# Try as component invocation: name "arg1" "arg2"
# Only if keyword isn't a known keyword — handled by the tree builder
return ComponentUse(comp_name=keyword, args=args, source_line=line_num)
class _UseDirective(IRNode):
"""Temporary node — triggers library loading during tree building."""
def __init__(self, lib_path: str, line_num: int):
super().__init__(source_line=line_num)
self.lib_path = lib_path
class _ElifBranch(IRNode):
@@ -465,17 +501,187 @@ class _StyleDirective(IRNode):
self.value = value
# ---------------------------------------------------------------------------
# Component expansion
# ---------------------------------------------------------------------------
def _expand_component(comp_def: ComponentDef, args: list[str]) -> list[IRNode]:
"""Expand a component use into a list of IR nodes by substituting $params.
Clones the component body and replaces $param references with provided args.
"""
import copy
# Build param → arg mapping
param_map: dict[str, str] = {}
for i, param in enumerate(comp_def.params):
param_map[param] = args[i] if i < len(args) else ""
# Deep copy the children and substitute
expanded: list[IRNode] = []
for child in comp_def.children:
clone = copy.deepcopy(child)
_substitute_vars(clone, param_map)
expanded.append(clone)
return expanded
def _substitute_vars(node: IRNode, var_map: dict[str, str]) -> None:
"""Recursively substitute $param references in an IR node tree."""
# Substitute in string fields
for attr_name in ("text", "content", "title", "label", "key", "value",
"display", "dest", "field_name", "placeholder",
"button_label", "checkbox_label", "var_name", "var_value",
"condition", "iterable", "command", "form_name",
"state_name", "path", "group", "state", "comp_name"):
val = getattr(node, attr_name, None)
if isinstance(val, str) and "$" in val:
for param, arg in var_map.items():
val = val.replace(f"${param}", arg)
setattr(node, attr_name, val)
# Substitute in list fields
for attr_name in ("options", "args"):
val = getattr(node, attr_name, None)
if isinstance(val, list):
for i, item in enumerate(val):
if isinstance(item, str) and "$" in item:
for param, arg in var_map.items():
item = item.replace(f"${param}", arg)
val[i] = item
# Substitute in TextSpan list
spans = getattr(node, "spans", None)
if isinstance(spans, list):
for span in spans:
if hasattr(span, "text") and "$" in span.text:
for param, arg in var_map.items():
span.text = span.text.replace(f"${param}", arg)
# After string substitution, resolve deferred numeric fields
for num_attr in ("value", "max_val", "bar_width"):
raw = getattr(node, f"_raw_{num_attr}", None)
if raw is not None:
for param, arg in var_map.items():
raw = raw.replace(f"${param}", arg)
try:
setattr(node, num_attr, float(raw) if "." in raw else int(raw))
except (ValueError, TypeError):
pass
# Recurse
for child in node.children:
_substitute_vars(child, var_map)
# ---------------------------------------------------------------------------
# Standard library loader
# ---------------------------------------------------------------------------
# Built-in component libraries
_STD_LIBRARIES: dict[str, str] = {
"std/dashboard": '''\
component banner(title, subtitle)
box double "$title"
align center
text "$subtitle"
component resources(cpu, mem)
heading 1 "Resources"
gauge "CPU" $cpu 100 28 warn=75 crit=90
gauge "MEM" $mem 100 28 warn=80 crit=95
component peer_status(name, state)
status "$name" $state
''',
"std/status-bar": '''\
component status_bar(label, value, max)
gauge "$label" $value $max 28
component status_item(name, state)
status "$name" $state
''',
"std/nav": '''\
component nav_link(label, dest)
link "$label" "$dest"
component nav_divider()
divider light
''',
}
def _load_library(lib_path: str) -> dict[str, ComponentDef]:
"""Load a standard library and return its component definitions."""
lib_source = _STD_LIBRARIES.get(lib_path, "")
if not lib_source:
return {}
# Parse the library source to extract ComponentDef nodes
comps: dict[str, ComponentDef] = {}
# Use a mini-parse: just extract component definitions
lines = lib_source.split("\n")
current_comp: ComponentDef | None = None
comp_indent = 0
for raw_line in lines:
stripped = raw_line.strip()
if not stripped or stripped.startswith("#"):
continue
indent = _indent_level(raw_line)
parts = stripped.split(None, 1)
keyword = parts[0].lower()
arg_str = parts[1] if len(parts) > 1 else ""
args = _split_args(arg_str)
if keyword == "component":
raw = " ".join(args)
paren = raw.find("(")
if paren != -1:
comp_name = raw[:paren].strip()
params_str = raw[paren + 1:].rstrip(")")
params = [p.strip() for p in params_str.split(",") if p.strip()]
else:
comp_name = args[0] if args else ""
params = []
current_comp = ComponentDef(comp_name=comp_name, params=params)
comp_indent = indent
comps[comp_name] = current_comp
elif current_comp and indent > comp_indent:
# Parse child node and attach to current component
try:
child = _parse_line(keyword, args, 0)
if not isinstance(child, (_StyleDirective, _TableColumns, _TableRow,
_ElifBranch, _ElseBranch, _UseDirective)):
current_comp.children.append(child)
except ParseError:
pass
else:
current_comp = None
return comps
# ---------------------------------------------------------------------------
# Tree builder
# ---------------------------------------------------------------------------
def parse(source: str) -> Page:
def parse(source: str, components: dict[str, ComponentDef] | None = None) -> Page:
"""Parse a .uf source string into an IR tree rooted at a Page node.
Args:
source: the .uf DSL source text
components: optional pre-loaded component registry (from `use` directives)
Returns the Page node with all children attached.
"""
lines = source.split("\n")
# Component registry: name → ComponentDef (with children as template)
comp_registry: dict[str, ComponentDef] = dict(components or {})
# Stack: list of (indent_level, node)
stack: list[tuple[int, IRNode]] = []
root: Page | None = None
@@ -537,6 +743,31 @@ def parse(source: str) -> Page:
break
continue
# Use directive — load standard library components
if isinstance(node, _UseDirective):
lib_comps = _load_library(node.lib_path)
comp_registry.update(lib_comps)
continue
# Component definition — register in the component registry
if isinstance(node, ComponentDef):
comp_registry[node.comp_name] = node
stack.append((indent, node)) # push so children attach to it
continue
# Component use — expand inline by cloning the template with args substituted
if isinstance(node, ComponentUse) and node.comp_name in comp_registry:
comp_def = comp_registry[node.comp_name]
expanded = _expand_component(comp_def, node.args)
if stack:
parent = stack[-1][1]
parent.children.extend(expanded)
continue
elif isinstance(node, ComponentUse) and node.comp_name not in comp_registry:
# Unknown component — treat as unknown keyword error
# But be lenient: just skip it with a warning
continue
# Attach to parent
if stack:
parent = stack[-1][1]

View File

@@ -0,0 +1,70 @@
"""Tests for components and standard library."""
import uframe
def test_inline_component():
source = '''page "Test" 50
component greeting(name)
heading 1 "Hello $name"
text "Welcome, $name!"
greeting "Alice"
greeting "Bob"'''
result = uframe.compile(source)
assert "Hello Alice" in result.ascii
assert "Welcome, Alice!" in result.ascii
assert "Hello Bob" in result.ascii
assert "Welcome, Bob!" in result.ascii
def test_component_with_gauge():
source = '''page "Test" 50
component stat(label, value, max)
gauge "$label" $value $max 20
stat "CPU" 62 100
stat "MEM" 84 100'''
result = uframe.compile(source)
assert "CPU" in result.ascii
assert "MEM" in result.ascii
assert "" in result.ascii
def test_use_std_dashboard():
source = '''page "Test" 60
use std/dashboard
banner "My Node" "Mesh Network"'''
result = uframe.compile(source)
assert "My Node" in result.ascii
assert "Mesh Network" in result.ascii
assert "" in result.ascii # double box from banner
def test_use_std_dashboard_resources():
source = '''page "Test" 60
use std/dashboard
resources 42 67'''
result = uframe.compile(source)
assert "CPU" in result.ascii
assert "MEM" in result.ascii
assert "Resources" in result.ascii
def test_use_std_nav():
source = '''page "Test" 50
use std/nav
nav_link "Home" "/page/index.mu"
nav_divider
nav_link "About" "/page/about.mu"'''
result = uframe.compile(source)
assert "Home" in result.ascii
assert "About" in result.ascii
def test_unknown_component_ignored():
source = '''page "Test" 50
heading 1 "Hello"
nonexistent_thing "arg"'''
result = uframe.compile(source)
assert "Hello" in result.ascii