feat: dynamic mode

This commit is contained in:
2026-04-01 08:28:44 +02:00
parent df11705875
commit 619d3ec538
16 changed files with 1184 additions and 38 deletions

View File

@@ -17,6 +17,8 @@ class CompileRequest(BaseModel):
class CompileResponse(BaseModel):
ascii: str
micron: str
script: str
is_dynamic: bool
warnings: list[str]
@@ -28,6 +30,8 @@ async def compile_source(req: CompileRequest):
return CompileResponse(
ascii=result.ascii,
micron=result.micron,
script=result.script,
is_dynamic=result.is_dynamic,
warnings=[w.message for w in result.warnings],
)
except UFrameError as e:

View File

@@ -150,7 +150,16 @@ async def save_page(name: str, req: SaveRequest):
try:
result = uframe.compile(req.source)
mu_path = PAGES_DIR / f"{name}.mu"
mu_path.write_text(result.micron, encoding="utf-8")
if result.is_dynamic and result.script:
# Dynamic page: write executable Python script
mu_path.write_text(result.script, encoding="utf-8")
mu_path.chmod(0o755) # Set execute bit for NomadNet
else:
# Static page: write compiled Micron
mu_path.write_text(result.micron, encoding="utf-8")
# Remove execute bit if it was previously dynamic
mu_path.chmod(0o644)
except Exception as e:
raise HTTPException(
status_code=422,

View File

@@ -17,6 +17,10 @@ from uframe.borders import merge_borders
from uframe.grid import CharGrid
from uframe.emit_ascii import emit_ascii
from uframe.emit_micron import emit_micron
from uframe.ir import (
IRNode, Field, Password, Radio, Checkbox, FormButton,
Source, IfBlock, ForLoop, OnSubmit, StateDecl, CacheControl,
)
@dataclass
@@ -24,9 +28,57 @@ class CompileResult:
"""Result of compiling a .uf source."""
ascii: str = ""
micron: str = ""
script: str = "" # generated Python script (dynamic mode only)
is_dynamic: bool = False
warnings: list[CompileWarning] = field(default_factory=list)
def _has_dynamic_nodes(node: IRNode) -> bool:
"""Check if the IR tree contains any dynamic nodes."""
if isinstance(node, (Source, IfBlock, ForLoop, OnSubmit, StateDecl, CacheControl)):
return True
return any(_has_dynamic_nodes(child) for child in node.children)
def _collect_form_nodes(node: IRNode) -> dict[int, IRNode]:
"""Walk the IR tree and collect form nodes keyed by their y position."""
result: dict[int, IRNode] = {}
if isinstance(node, (Field, Password, Radio, Checkbox, FormButton)):
result[node.rect.y] = node
for child in node.children:
result.update(_collect_form_nodes(child))
return result
def _micron_form_line(node: IRNode) -> str:
"""Generate the Micron form tag for a form node."""
if isinstance(node, Field):
w = node.field_width
name = node.field_name
ph = node.placeholder or name
return f"{name}: `<{w}|{name}`{ph}>"
elif isinstance(node, Password):
w = node.field_width
name = node.field_name
ph = node.placeholder or name
return f"{name}: `<!{w}|{name}`{ph}>"
elif isinstance(node, Radio):
parts = []
for i, opt in enumerate(node.options):
val = opt.lower().replace(" ", "_")
checked = "|*" if i == 0 else ""
parts.append(f"`<^|{node.group}|{val}{checked}`{opt}>")
return f"{node.group}: {' '.join(parts)}"
elif isinstance(node, Checkbox):
name = node.field_name
label = node.checkbox_label
checked = "|*" if node.checked else ""
return f"`<?|{name}|yes{checked}`{label}>"
elif isinstance(node, FormButton):
return f"`[`!{node.button_label}`!`:{node.dest}]"
return ""
def compile(source: str, width: int = 64) -> CompileResult:
"""Compile a µFrame .uf source string into ASCII and Micron output.
@@ -67,8 +119,26 @@ def compile(source: str, width: int = 64) -> CompileResult:
ascii_out = emit_ascii(grid)
micron_out = emit_micron(grid, page_title=page.title)
# 7. Post-pass: replace form element lines in Micron output
form_nodes = _collect_form_nodes(page)
if form_nodes:
micron_lines = micron_out.split("\n")
for row_y, form_node in form_nodes.items():
if 0 <= row_y < len(micron_lines):
micron_lines[row_y] = _micron_form_line(form_node)
micron_out = "\n".join(micron_lines)
# 8. Check if this page has dynamic features
is_dynamic = _has_dynamic_nodes(page)
script = ""
if is_dynamic:
from uframe.codegen import compile_dynamic
script = compile_dynamic(page)
return CompileResult(
ascii=ascii_out,
micron=micron_out,
script=script,
is_dynamic=is_dynamic,
warnings=warnings,
)

322
backend/uframe/codegen.py Normal file
View File

@@ -0,0 +1,322 @@
"""Dynamic page compiler — generate executable Python scripts from µFrame IR.
Takes a parsed IR tree containing dynamic nodes (source, if, for, on_submit,
state) and generates a self-contained Python script that:
1. Sets shebang + cache header
2. Reads form data from environment variables
3. Executes source commands
4. Evaluates conditionals and loops
5. Renders the layout into a CharGrid
6. Emits Micron to stdout
"""
from __future__ import annotations
import textwrap
from pathlib import Path
from uframe.ir import (
IRNode, Page, Box, Row, Col, Spacer, Pad,
Heading, Text, Label, Divider, Link, ListNode, ListItem,
Gauge, Sparkline, Status, Table,
Form, Field, Password, Radio, Checkbox, FormButton,
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
SourceType, BorderWeight, HeadingLevel, DividerStyle, ListStyle,
)
def _indent(code: str, level: int = 1) -> str:
"""Indent a block of code."""
prefix = " " * level
return "\n".join(prefix + line if line.strip() else "" for line in code.split("\n"))
def _resolve_vars(text: str) -> str:
"""Convert $var references to Python f-string expressions."""
import re
# Replace $var.attr.attr with {var_attr_attr} and simple $var with {var}
def replace_var(m: re.Match) -> str:
var = m.group(1)
# Replace dots with underscores for Python variable names
py_var = var.replace(".", "_")
return "{" + py_var + "}"
return re.sub(r'\$([a-zA-Z_][\w.]*)', replace_var, text)
def _emit_node(node: IRNode, indent_level: int = 0) -> list[str]:
"""Generate Python code lines for a single IR node."""
lines: list[str] = []
ind = " " * indent_level
if isinstance(node, Let):
val = node.var_value
# Try to detect numeric values
try:
float(val)
lines.append(f"{ind}{node.var_name} = {val}")
except ValueError:
if "," in val:
# Comma-separated list
lines.append(f"{ind}{node.var_name} = [{val}]")
else:
lines.append(f"{ind}{node.var_name} = {val!r}")
elif isinstance(node, Source):
var = node.var_name
if node.source_type == SourceType.SHELL:
lines.append(f"{ind}{var} = _shell({node.command!r}, timeout={node.timeout})")
elif node.source_type == SourceType.FILE:
lines.append(f"{ind}{var} = _read_file({node.command!r})")
elif node.source_type == SourceType.JSON:
lines.append(f"{ind}{var} = _read_json({node.command!r})")
elif node.source_type == SourceType.PYTHON:
lines.append(f"{ind}{var} = eval({node.command!r})")
elif node.source_type == SourceType.PARAM:
lines.append(f"{ind}{var} = _get_param({node.command!r})")
elif node.source_type == SourceType.RNS:
lines.append(f"{ind}{var} = _shell('rnstatus {node.command}', timeout={node.timeout})")
elif isinstance(node, CacheControl):
lines.append(f"{ind}_cache_seconds = {node.seconds}")
elif isinstance(node, StateDecl):
lines.append(f"{ind}{node.state_name} = _load_state({node.path!r})")
elif isinstance(node, IfBlock):
cond = _resolve_vars(node.condition)
# Convert simple comparisons
cond = cond.replace("&&", " and ").replace("||", " or ")
lines.append(f"{ind}if {cond}:")
if node.children:
for child in node.children:
lines.extend(_emit_node(child, indent_level + 1))
else:
lines.append(f"{ind} pass")
for elif_cond, elif_children in node.elif_branches:
ec = _resolve_vars(elif_cond).replace("&&", " and ").replace("||", " or ")
lines.append(f"{ind}elif {ec}:")
if elif_children:
for child in elif_children:
lines.extend(_emit_node(child, indent_level + 1))
else:
lines.append(f"{ind} pass")
if node.else_children:
lines.append(f"{ind}else:")
for child in node.else_children:
lines.extend(_emit_node(child, indent_level + 1))
elif isinstance(node, ForLoop):
iterable = _resolve_vars(node.iterable)
lines.append(f"{ind}for {node.var_name} in _iter({iterable}):")
if node.children:
for child in node.children:
lines.extend(_emit_node(child, indent_level + 1))
else:
lines.append(f"{ind} pass")
elif isinstance(node, OnSubmit):
lines.append(f"{ind}if _get_field({node.form_name!r}, ''):")
lines.append(f"{ind} # Form '{node.form_name}' was submitted")
for child in node.children:
lines.extend(_emit_node(child, indent_level + 1))
elif isinstance(node, Page):
lines.append(f"{ind}_page_title = {node.title!r}")
lines.append(f"{ind}_page_width = {node.width}")
lines.append(f"{ind}_uf_source_parts = []")
for child in node.children:
lines.extend(_emit_node(child, indent_level))
# Content nodes — emit as µFrame source that gets compiled
elif isinstance(node, Heading):
level = node.level.value
text = _resolve_vars(node.text)
lines.append(f"{ind}_uf_source_parts.append(f'heading {level} \"{text}\"')")
elif isinstance(node, Text):
text = _resolve_vars(node.content)
lines.append(f"{ind}_uf_source_parts.append(f'text \"{text}\"')")
elif isinstance(node, Label):
key = _resolve_vars(node.key)
val = _resolve_vars(node.value)
lines.append(f"{ind}_uf_source_parts.append(f'label \"{key}\" \"{val}\"')")
elif isinstance(node, Gauge):
label = _resolve_vars(node.label)
value = _resolve_vars(str(node.value)) if "$" in str(node.value) else str(node.value)
extra = ""
if node.warn is not None:
extra += f" warn={node.warn}"
if node.crit is not None:
extra += f" crit={node.crit}"
lines.append(f"{ind}_uf_source_parts.append(f'gauge \"{label}\" {value} {node.max_val} {node.bar_width}{extra}')")
elif isinstance(node, Status):
label = _resolve_vars(node.label)
state = _resolve_vars(node.state)
lines.append(f"{ind}_uf_source_parts.append(f'status \"{label}\" {state}')")
elif isinstance(node, Box):
weight = node.weight.name.lower()
title = _resolve_vars(node.title)
lines.append(f"{ind}_uf_source_parts.append(f'box {weight} \"{title}\"')")
for child in node.children:
# Indent children for the box
child_lines = _emit_node(child, indent_level)
for cl in child_lines:
if "_uf_source_parts.append" in cl:
# Add 2-space indent to the µFrame source
cl = cl.replace(".append(f'", ".append(f' ")
cl = cl.replace(".append('", ".append(' ")
lines.append(cl)
elif isinstance(node, Divider):
style = node.divider_style.name.lower()
lines.append(f"{ind}_uf_source_parts.append('divider {style}')")
elif isinstance(node, Spacer):
lines.append(f"{ind}_uf_source_parts.append('spacer {node.lines}')")
elif isinstance(node, Link):
display = _resolve_vars(node.display)
dest = _resolve_vars(node.dest)
lines.append(f"{ind}_uf_source_parts.append(f'link \"{display}\" \"{dest}\"')")
elif isinstance(node, Field):
lines.append(f"{ind}_uf_source_parts.append('field \"{node.field_name}\" {node.field_width} \"{node.placeholder}\"')")
elif isinstance(node, FormButton):
lines.append(f"{ind}_uf_source_parts.append('button \"{node.button_label}\" \"{node.dest}\"')")
elif isinstance(node, Form):
lines.append(f"{ind}_uf_source_parts.append('form \"{node.form_name}\"')")
for child in node.children:
child_lines = _emit_node(child, indent_level)
for cl in child_lines:
if "_uf_source_parts.append" in cl:
cl = cl.replace(".append(f'", ".append(f' ")
cl = cl.replace(".append('", ".append(' ")
lines.append(cl)
else:
# Generic: emit children
for child in node.children:
lines.extend(_emit_node(child, indent_level))
return lines
# ---------------------------------------------------------------------------
# Runtime template embedded in generated scripts
# ---------------------------------------------------------------------------
def _build_script(uframe_import: str, page_logic: str, page_title: str, page_width: int) -> str:
"""Build the dynamic script from parts (avoids str.format brace issues)."""
lines = [
"#!/usr/bin/env python3",
"# Auto-generated by uFrame",
"# Do not edit — regenerate with: uframe compile <source>.uf",
"",
"import os, sys, json, subprocess, datetime, secrets, shlex",
"",
"# ─── Runtime Helpers ─────────────────────────────────────────",
"",
'def _shell(cmd, timeout=5):',
' """Execute shell command, return stdout."""',
' try:',
' return subprocess.check_output(cmd, shell=True, timeout=timeout).decode().strip()',
' except Exception:',
' return ""',
"",
'def _read_file(path):',
' """Read file contents."""',
' try:',
' return open(path).read().strip()',
' except Exception:',
' return ""',
"",
'def _read_json(path):',
' """Read and parse JSON file."""',
' try:',
' with open(path) as f:',
' return json.load(f)',
' except Exception:',
' return {}',
"",
'def _get_field(name, default=""):',
' """Read submitted form field from environment."""',
' return os.environ.get(f"FIELD_{name}", default)',
"",
'def _get_param(name, default=""):',
' """Read URL parameter."""',
' return os.environ.get(f"PARAM_{name}",',
' os.environ.get(f"var_{name}", default))',
"",
'def _load_state(path):',
' """Load state from JSON file."""',
' try:',
' with open(path) as f:',
' return json.load(f)',
' except Exception:',
' return {}',
"",
'def _save_state(path, data):',
' """Save state to JSON file."""',
' os.makedirs(os.path.dirname(path), exist_ok=True)',
' with open(path, "w") as f:',
' json.dump(data, f, indent=2)',
"",
'def _iter(val):',
' """Make a value iterable for for-loops."""',
' if isinstance(val, (list, tuple)):',
' return val',
' if isinstance(val, dict):',
' return [val]',
' if isinstance(val, str):',
' return val.strip().splitlines()',
' return []',
"",
f"# ─── µFrame Compile ──────────────────────────────────────────",
"",
uframe_import,
"",
"# ─── Page Logic ──────────────────────────────────────────────",
"",
"_cache_seconds = 0",
"",
page_logic,
"",
"# ─── Render & Output ─────────────────────────────────────────",
"",
f'_uf_source = f\'\'\'page "{page_title}" {page_width}',
"''' + \"\\n\".join(_uf_source_parts)",
"",
f"result = uframe.compile(_uf_source, width={page_width})",
"",
"if _cache_seconds >= 0:",
' print(f"#!c={_cache_seconds}")',
"print(result.micron)",
]
return "\n".join(lines)
def compile_dynamic(page: Page) -> str:
"""Generate a self-contained executable Python script from an IR tree.
The generated script imports uframe at runtime and compiles the
dynamically-built .uf source into Micron output.
"""
logic_lines = _emit_node(page, indent_level=0)
page_logic = "\n".join(logic_lines)
uframe_import = "import uframe"
return _build_script(
uframe_import=uframe_import,
page_logic=page_logic,
page_title=page.title,
page_width=page.width,
)

View File

@@ -237,7 +237,121 @@ class Status(IRNode):
@dataclass
class Table(IRNode):
"""Box-drawn table (Phase 4)."""
"""Box-drawn table."""
title: str = ""
columns: list[tuple[str, int]] = field(default_factory=list) # (name, width)
rows: list[list[str]] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Form nodes
# ---------------------------------------------------------------------------
@dataclass
class Form(IRNode):
"""Form container grouping interactive fields."""
form_name: str = ""
@dataclass
class Field(IRNode):
"""Text input field."""
field_name: str = ""
field_width: int = 24
placeholder: str = ""
@dataclass
class Password(IRNode):
"""Masked password field."""
field_name: str = ""
field_width: int = 24
placeholder: str = ""
@dataclass
class Radio(IRNode):
"""Radio button group — options separated by |."""
group: str = ""
options: list[str] = field(default_factory=list)
@dataclass
class Checkbox(IRNode):
"""Checkbox field."""
field_name: str = ""
checkbox_label: str = ""
checked: bool = False
@dataclass
class FormButton(IRNode):
"""Submit button — clickable link in Micron."""
button_label: str = ""
dest: str = ""
# ---------------------------------------------------------------------------
# Dynamic nodes (Phase 7)
# ---------------------------------------------------------------------------
class SourceType(Enum):
SHELL = auto()
FILE = auto()
JSON = auto()
PYTHON = auto()
RNS = auto()
PARAM = auto()
@dataclass
class Let(IRNode):
"""Variable assignment: let name = "value" or let name = 1,2,3."""
var_name: str = ""
var_value: str = ""
@dataclass
class Source(IRNode):
"""Data source resolved at render time (dynamic pages only)."""
var_name: str = ""
source_type: SourceType = SourceType.SHELL
command: str = ""
timeout: int = 5
@dataclass
class IfBlock(IRNode):
"""Conditional block: if $var > threshold."""
condition: str = ""
# children = the "then" branch
elif_branches: list[tuple[str, list[IRNode]]] = field(default_factory=list)
else_children: list[IRNode] = field(default_factory=list)
@dataclass
class ForLoop(IRNode):
"""Iteration: for item in $collection."""
var_name: str = ""
iterable: str = ""
# children = loop body
@dataclass
class CacheControl(IRNode):
"""Cache header: cache 0 (never cache) or cache 300 (5 min)."""
seconds: int = 0
@dataclass
class OnSubmit(IRNode):
"""Form submission handler: on_submit "form_name"."""
form_name: str = ""
# children = handler body
@dataclass
class StateDecl(IRNode):
"""State persistence: state "name" "/path.json"."""
state_name: str = ""
path: str = ""

View File

@@ -6,6 +6,8 @@ from uframe.ir import (
IRNode, Page, Box, Row, Col, Spacer, Pad, Rect,
Heading, Text, Label, Divider, Link, ListNode, ListItem,
Gauge, Sparkline, Status, Table,
Form, Field, Password, Radio, Checkbox, FormButton,
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
)
@@ -111,8 +113,29 @@ def layout(node: IRNode, x: int, y: int, w: int, h: int) -> int:
node.rect.h = (cursor_y - y) + node.bottom
return node.rect.h
elif isinstance(node, Form):
cursor_y = y
for child in node.children:
child_h = layout(child, x, cursor_y, w, h - (cursor_y - y))
cursor_y += child_h
node.rect.h = cursor_y - y
return node.rect.h
elif isinstance(node, (Let, Source, CacheControl, StateDecl)):
node.rect.h = 0
return 0
elif isinstance(node, (IfBlock, ForLoop, OnSubmit)):
cursor_y = y
for child in node.children:
child_h = layout(child, x, cursor_y, w, h - (cursor_y - y))
cursor_y += child_h
node.rect.h = cursor_y - y
return node.rect.h
elif isinstance(node, (Heading, Text, Label, Divider, Link, ListItem,
Gauge, Sparkline, Status, Table)):
Gauge, Sparkline, Status, Table,
Field, Password, Radio, Checkbox, FormButton)):
node.rect.h = node.pref_height
return node.pref_height

View File

@@ -6,6 +6,8 @@ from uframe.ir import (
IRNode, Page, Box, Row, Col, Spacer, Pad,
Heading, Text, Label, Divider, Link, ListNode, ListItem,
Gauge, Sparkline, Status, Table,
Form, Field, Password, Radio, Checkbox, FormButton,
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
)
@@ -195,13 +197,70 @@ def measure(node: IRNode, available_width: int) -> None:
node.min_height = 1
elif isinstance(node, Table):
# Height = header border + header row + separator + data rows + bottom border
num_rows = len(node.rows)
node.pref_width = available_width
node.min_width = len(node.columns) * 3 + 1 # minimum 3 chars per col + borders
node.pref_height = num_rows + 4 # top border + header + separator + rows + bottom border
node.min_width = len(node.columns) * 3 + 1
node.pref_height = num_rows + 4
node.min_height = 4
elif isinstance(node, Form):
total_h = 0
for child in node.children:
measure(child, available_width)
total_h += child.pref_height
node.pref_width = available_width
node.min_width = 10
node.pref_height = total_h
node.min_height = total_h
elif isinstance(node, Field):
node.pref_width = available_width
node.min_width = len(node.field_name) + node.field_width + 6
node.pref_height = 1
node.min_height = 1
elif isinstance(node, Password):
node.pref_width = available_width
node.min_width = len(node.field_name) + node.field_width + 6
node.pref_height = 1
node.min_height = 1
elif isinstance(node, Radio):
node.pref_width = available_width
node.min_width = len(node.group) + sum(len(o) + 6 for o in node.options)
node.pref_height = 1
node.min_height = 1
elif isinstance(node, Checkbox):
node.pref_width = available_width
node.min_width = len(node.checkbox_label) + 6
node.pref_height = 1
node.min_height = 1
elif isinstance(node, FormButton):
node.pref_width = available_width
node.min_width = len(node.button_label) + 6
node.pref_height = 1
node.min_height = 1
elif isinstance(node, (Let, Source, CacheControl, StateDecl)):
# Zero-height metadata nodes — no visual output
node.pref_width = 0
node.min_width = 0
node.pref_height = 0
node.min_height = 0
elif isinstance(node, (IfBlock, ForLoop, OnSubmit)):
# Container nodes — height = sum of children
total_h = 0
for child in node.children:
measure(child, available_width)
total_h += child.pref_height
node.pref_width = available_width
node.min_width = 1
node.pref_height = total_h
node.min_height = 0
else:
# Generic: just measure children
total_h = 0

View File

@@ -18,6 +18,7 @@ from uframe.ir import (
IRNode, Page, Box, Row, Col, Spacer, Pad,
Heading, Text, Label, Divider, Link, ListNode, ListItem,
Gauge, Sparkline, Status, Table,
Form, Field, Password, Radio, Checkbox, FormButton,
HeadingLevel, DividerStyle, ListStyle, Align, BorderWeight,
)
@@ -213,6 +214,60 @@ def paint(node: IRNode, grid: CharGrid) -> None:
elif isinstance(node, Table):
_paint_table(node, grid, x, y, w)
elif isinstance(node, Form):
for child in node.children:
paint(child, grid)
elif isinstance(node, Field):
label_style = CellStyle(fg="888")
field_style = CellStyle(fg="0cf")
label_text = f"{node.field_name}: "
grid.put_text(x, y, label_text, style=label_style)
# Draw [ placeholder_______ ]
fx = x + len(label_text)
fw = min(node.field_width, w - len(label_text) - 2)
grid.put(fx, y, "[", 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)
elif isinstance(node, Password):
label_style = CellStyle(fg="888")
field_style = CellStyle(fg="0cf")
label_text = f"{node.field_name}: "
grid.put_text(x, y, label_text, style=label_style)
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)
elif isinstance(node, Radio):
label_style = CellStyle(fg="888")
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")
grid.put_text(rx, y, dot, style=opt_style)
rx += 4
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 "[ ]"
grid.put_text(x, y, box_char, style=check_style)
grid.put_text(x + 4, y, node.checkbox_label)
elif isinstance(node, FormButton):
btn_style = CellStyle(bold=True, fg="0f0")
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:

View File

@@ -18,6 +18,9 @@ from uframe.ir import (
IRNode, Page, Box, Row, Col, Spacer, Pad,
Heading, Text, Label, Divider, Link, ListNode, ListItem,
Gauge, Sparkline, Status, Table, TextSpan,
Form, Field, Password, Radio, Checkbox, FormButton,
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
SourceType,
BorderWeight, HeadingLevel, DividerStyle, ListStyle, Align, Style,
)
@@ -245,9 +248,19 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
# Phase 4 placeholders
elif keyword == "gauge":
label = args[0] if args else ""
value = float(args[1]) if len(args) > 1 else 0
max_val = float(args[2]) if len(args) > 2 else 100
bar_width = int(args[3]) if len(args) > 3 else 28
val_str = args[1] if len(args) > 1 else "0"
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
except ValueError:
max_val = 100
try:
bar_width = int(args[3]) if len(args) > 3 else 28
except ValueError:
bar_width = 28
# Parse warn=N crit=N from remaining args
warn = crit = None
for a in args[4:]:
@@ -289,10 +302,139 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
cols.append((col_name, col_w))
return _TableColumns(cols, line_num)
# Forms
elif keyword == "form":
form_name = args[0] if args else ""
return Form(form_name=form_name, source_line=line_num)
elif keyword == "field":
name = args[0] if args else ""
width = int(args[1]) if len(args) > 1 else 24
placeholder = args[2] if len(args) > 2 else ""
return Field(field_name=name, field_width=width, placeholder=placeholder,
source_line=line_num)
elif keyword == "password":
name = args[0] if args else ""
width = int(args[1]) if len(args) > 1 else 24
placeholder = args[2] if len(args) > 2 else ""
return Password(field_name=name, field_width=width, placeholder=placeholder,
source_line=line_num)
elif keyword == "radio":
group = args[0] if args else ""
raw = " ".join(args[1:]) if len(args) > 1 else ""
options = [o.strip().strip('"') for o in raw.split("|")] if raw else []
return Radio(group=group, options=options, source_line=line_num)
elif keyword == "checkbox":
name = args[0] if args else ""
label_text = args[1] if len(args) > 1 else ""
return Checkbox(field_name=name, checkbox_label=label_text, source_line=line_num)
elif keyword == "button":
label_text = args[0] if args else ""
dest = args[1] if len(args) > 1 else ""
return FormButton(button_label=label_text, dest=dest, source_line=line_num)
# Dynamic features
elif keyword == "let":
# let name = "value" or let name = 1,2,3
raw = " ".join(args)
eq = raw.find("=")
if eq != -1:
var_name = raw[:eq].strip()
var_value = raw[eq + 1:].strip().strip('"')
else:
var_name = args[0] if args else ""
var_value = args[1] if len(args) > 1 else ""
return Let(var_name=var_name, var_value=var_value, source_line=line_num)
elif keyword == "source":
# source cpu : shell "grep 'cpu' /proc/stat"
# source name : type "command"
raw = " ".join(args)
colon = raw.find(":")
if colon != -1:
var_name = raw[:colon].strip()
rest = raw[colon + 1:].strip()
parts = _split_args(rest)
src_type_str = parts[0] if parts else "shell"
command = parts[1] if len(parts) > 1 else ""
src_type = {
"shell": SourceType.SHELL,
"file": SourceType.FILE,
"json": SourceType.JSON,
"python": SourceType.PYTHON,
"rns": SourceType.RNS,
"param": SourceType.PARAM,
}.get(src_type_str.lower(), SourceType.SHELL)
# Parse optional timeout
timeout = 5
for p in parts[2:]:
if p.startswith("timeout"):
try:
timeout = int(p.split("=")[1]) if "=" in p else int(parts[parts.index(p) + 1])
except (ValueError, IndexError):
pass
return Source(var_name=var_name, source_type=src_type,
command=command, timeout=timeout, source_line=line_num)
else:
return Source(var_name=args[0] if args else "", source_line=line_num)
elif keyword == "if":
condition = " ".join(args)
return IfBlock(condition=condition, source_line=line_num)
elif keyword == "elif":
condition = " ".join(args)
return _ElifBranch(condition, line_num)
elif keyword == "else":
return _ElseBranch(line_num)
elif keyword == "for":
# for item in $collection
var_name = args[0] if args else "item"
# Skip "in" keyword
iterable = args[2] if len(args) > 2 else (args[1] if len(args) > 1 else "")
return ForLoop(var_name=var_name, iterable=iterable, source_line=line_num)
elif keyword == "cache":
seconds = int(args[0]) if args else 0
return CacheControl(seconds=seconds, source_line=line_num)
elif keyword == "on_submit":
form_name = args[0] if args else ""
return OnSubmit(form_name=form_name, source_line=line_num)
elif keyword == "state":
state_name = args[0] if args else ""
path = args[1] if len(args) > 1 else ""
return StateDecl(state_name=state_name, path=path, source_line=line_num)
elif keyword in ("set", "append", "prepend"):
# State operations — store as text nodes with metadata for the compiler
content = " ".join([keyword] + args)
return Text(content=content, source_line=line_num)
else:
raise ParseError(f"Unknown keyword: {keyword!r}", line=line_num)
class _ElifBranch(IRNode):
"""Temporary node — absorbed by parent IfBlock during tree building."""
def __init__(self, condition: str, line_num: int):
super().__init__(source_line=line_num)
self.condition = condition
class _ElseBranch(IRNode):
"""Temporary node — absorbed by parent IfBlock during tree building."""
def __init__(self, line_num: int):
super().__init__(source_line=line_num)
class _TableColumns(IRNode):
"""Temporary node — absorbed by parent Table during tree building."""
def __init__(self, columns: list[tuple[str, int]], line_num: int):
@@ -377,6 +519,24 @@ def parse(source: str) -> Page:
stack[-1][1].rows.append(node.cells)
continue
# elif/else branches are absorbed by the nearest IfBlock ancestor
if isinstance(node, _ElifBranch):
# Find the IfBlock in the stack
for si in range(len(stack) - 1, -1, -1):
if isinstance(stack[si][1], IfBlock):
# Collect subsequent children under this elif
stack[si][1].elif_branches.append((node.condition, []))
break
continue
if isinstance(node, _ElseBranch):
# Find the IfBlock in the stack — mark it for else collection
for si in range(len(stack) - 1, -1, -1):
if isinstance(stack[si][1], IfBlock):
stack[si][1].else_children = [] # will be filled by subsequent children
break
continue
# Attach to parent
if stack:
parent = stack[-1][1]

View File

@@ -199,6 +199,61 @@ def test_table_with_color():
assert "`F0f0" in result.micron # green color tag
def test_form_field():
source = '''page "Demo" 50
form "test"
field "name" 20 "Enter name..."'''
result = uframe.compile(source)
assert "name:" in result.ascii
assert "[" in result.ascii
assert "]" in result.ascii
# Micron should have form tag
assert "`<" in result.micron or "<" in result.micron
def test_form_radio():
source = '''page "Demo" 50
form "test"
radio "mode" "Ping" | "Trace" | "Page"'''
result = uframe.compile(source)
assert "(•)" in result.ascii # first option selected
assert "( )" in result.ascii # other options unselected
assert "Ping" in result.ascii
assert "Trace" in result.ascii
def test_form_checkbox():
source = '''page "Demo" 50
form "test"
checkbox "agree" "I agree to terms"'''
result = uframe.compile(source)
assert "[ ]" in result.ascii
assert "I agree to terms" in result.ascii
def test_form_button():
source = '''page "Demo" 50
form "test"
button "Submit" "/page/submit.mu"'''
result = uframe.compile(source)
assert "Submit" in result.ascii
assert "submit.mu" in result.micron
def test_form_complete():
source = '''page "Search" 50
form "search"
field "query" 24 "Search term..."
radio "scope" "Local" | "Network"
checkbox "cache" "Include cached"
button "Go" "/page/search.mu"'''
result = uframe.compile(source)
assert "query:" in result.ascii
assert "(•)" in result.ascii
assert "[ ]" in result.ascii
assert "Go" in result.ascii
def test_full_dashboard():
"""Integration test: a realistic dashboard layout."""
source = '''page "Node Status" 60

View File

@@ -0,0 +1,124 @@
"""Tests for dynamic page features — source, if/for, codegen."""
import uframe
def test_static_page_not_dynamic():
result = uframe.compile('page "Test" 40\n heading 1 "Hello"')
assert not result.is_dynamic
assert result.script == ""
def test_source_makes_dynamic():
source = '''page "Test" 40
source cpu : shell "echo 42"
heading 1 "CPU: $cpu"'''
result = uframe.compile(source)
assert result.is_dynamic
assert result.script != ""
assert "#!/usr/bin/env python3" in result.script
assert "_shell" in result.script
def test_cache_control():
source = '''page "Test" 40
cache 0
heading 1 "Live"'''
result = uframe.compile(source)
assert result.is_dynamic
assert "_cache_seconds = 0" in result.script
def test_if_block():
source = '''page "Test" 40
source val : shell "echo 50"
if $val > 90
text "Critical"'''
result = uframe.compile(source)
assert result.is_dynamic
assert "if {val} > 90:" in result.script
def test_for_loop():
source = '''page "Test" 40
source items : shell "echo hello"
for item in $items
text "$item"'''
result = uframe.compile(source)
assert result.is_dynamic
assert "for item in _iter({items}):" in result.script
def test_let_variable():
"""let + source makes it dynamic; let alone is static."""
source = '''page "Test" 40
let name = "Relay Alpha"
source ts : python "datetime.now().isoformat()"
heading 1 "$name"'''
result = uframe.compile(source)
assert result.is_dynamic
assert "name = 'Relay Alpha'" in result.script
def test_state_declaration():
source = '''page "Test" 40
state "counter" "/tmp/counter.json"
heading 1 "Visits"'''
result = uframe.compile(source)
assert result.is_dynamic
assert "_load_state" in result.script
assert "/tmp/counter.json" in result.script
def test_on_submit():
source = '''page "Test" 40
form "search"
field "query" 20 "Search..."
button "Go" "/page/test.mu"
on_submit "search"
text "Results for $query"'''
result = uframe.compile(source)
assert result.is_dynamic
assert "_get_field" in result.script
def test_codegen_has_runtime():
source = '''page "Test" 40
source data : shell "echo ok"
text "$data"'''
result = uframe.compile(source)
script = result.script
# Verify the runtime helpers are included
assert "def _shell" in script
assert "def _get_field" in script
assert "def _load_state" in script
assert "def _iter" in script
assert "import uframe" in script
assert "result.micron" in script
def test_codegen_complete_dashboard():
source = '''page "Dashboard" 60
cache 0
source cpu : shell "echo 42"
source mem : shell "echo 67"
box double "Node Status"
text "System Monitor"
gauge "CPU" $cpu 100 28 warn=75 crit=90
gauge "MEM" $mem 100 28 warn=80 crit=95
if $cpu > 90
text "ALERT: CPU critical"
divider heavy
link "Home" "/page/index.mu"'''
result = uframe.compile(source)
assert result.is_dynamic
script = result.script
assert "#!/usr/bin/env python3" in script
assert "_cache_seconds = 0" in script
assert "_shell" in script
assert "if {cpu} > 90:" in script
assert "uframe.compile" in script

View File

@@ -1,23 +1,25 @@
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { useEditorStore } from "@/stores/editorStore";
import { renderMicron } from "./micronRenderer";
import { cn } from "@/lib/utils";
type PreviewMode = "ascii" | "micron" | "raw";
type PreviewMode = "ascii" | "micron" | "raw" | "script";
export default function PreviewPane() {
const previewMode = useEditorStore((s) => s.previewMode);
const setPreviewMode = useEditorStore((s) => s.setPreviewMode);
const compiledAscii = useEditorStore((s) => s.compiledAscii);
const compiledMicron = useEditorStore((s) => s.compiledMicron);
const compiledScript = useEditorStore((s) => s.compiledScript);
const isDynamic = useEditorStore((s) => s.isDynamic);
const isCompiling = useEditorStore((s) => s.isCompiling);
const compileError = useEditorStore((s) => s.compileError);
const tabs: { value: PreviewMode; label: string }[] = [
{ value: "ascii", label: "ASCII" },
{ value: "micron", label: "Micron" },
{ value: "raw", label: "Raw" },
const tabs: { value: PreviewMode; label: string; show: boolean }[] = [
{ value: "ascii", label: "ASCII", show: true },
{ value: "micron", label: "Micron", show: true },
{ value: "raw", label: "Raw", show: true },
{ value: "script", label: "Script", show: isDynamic },
];
return (
@@ -25,6 +27,11 @@ export default function PreviewPane() {
<div className="flex items-center px-3 py-1.5 border-b shrink-0 gap-2">
<span className="text-xs text-muted-foreground flex-1">
Preview
{isDynamic && (
<span className="ml-1.5 text-amber-400" title="This page has dynamic features (source, if, for)">
dynamic
</span>
)}
{isCompiling && (
<span className="ml-2 text-yellow-500 animate-pulse">
compiling
@@ -37,20 +44,22 @@ export default function PreviewPane() {
)}
</span>
<div className="flex gap-0.5 bg-muted rounded-md p-0.5">
{tabs.map((tab) => (
<button
key={tab.value}
onClick={() => setPreviewMode(tab.value)}
className={cn(
"text-xs px-2 py-0.5 rounded transition-colors",
previewMode === tab.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{tab.label}
</button>
))}
{tabs
.filter((t) => t.show)
.map((tab) => (
<button
key={tab.value}
onClick={() => setPreviewMode(tab.value)}
className={cn(
"text-xs px-2 py-0.5 rounded transition-colors",
previewMode === tab.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{tab.label}
</button>
))}
</div>
</div>
<ScrollArea className="flex-1 bg-background">
@@ -77,6 +86,10 @@ export default function PreviewPane() {
</span>
</div>
)
) : previewMode === "script" ? (
<pre className="p-4 font-mono text-xs whitespace-pre-wrap break-words text-amber-200/80 leading-relaxed">
{compiledScript || "No dynamic script generated."}
</pre>
) : (
<pre className="p-4 font-mono text-sm whitespace-pre-wrap break-words text-muted-foreground">
{compiledMicron || "Raw Micron output will appear here…"}

View File

@@ -227,4 +227,72 @@ export const EXAMPLES: Example[] = [
col 30
link "Settings" "/page/settings.mu"`,
},
{
name: "Interactive Form",
description: "Text fields, radio buttons, checkboxes, and submit",
source: `page "Search" 56
box rounded "Node Search"
align center
text "Find peers and pages on the mesh"
spacer
form "search"
field "query" 30 "Enter search term..."
radio "scope" "Local" | "Network" | "All"
checkbox "cache" "Include cached results"
spacer
button "Search" "/page/search.mu"
divider light
heading 2 "Quick Actions"
form "ping"
field "target" 30 "Destination hash..."
radio "mode" "Ping" | "Trace" | "Page"
checkbox "verbose" "Verbose output"
spacer
button "Execute" "/page/action.mu"`,
},
{
name: "Dynamic Dashboard",
description: "Live data sources, conditionals, and cache control",
source: `page "Live Status" 60
cache 0
source cpu_pct : shell "grep 'cpu ' /proc/stat | awk '{print int(($2+$4)*100/($2+$4+$5))}'"
source mem_pct : shell "free | awk '/Mem/{print int($3/$2*100)}'"
source uptime : shell "uptime -p"
source timestamp : python "datetime.now().strftime('%H:%M:%S')"
box double "Node Monitor"
align center
text "Live System Dashboard"
text "Updated: $timestamp"
spacer
heading 1 "Resources"
gauge "CPU" $cpu_pct 100 28 warn=75 crit=90
gauge "MEM" $mem_pct 100 28 warn=80 crit=95
spacer
if $cpu_pct > 90
box heavy "ALERT"
color f00
text "CPU critical! Immediate action required."
elif $cpu_pct > 75
text "@color{ff0}{Warning: CPU usage elevated}"
spacer
label "Uptime" "$uptime"
divider heavy
text "Press Ctrl+R to refresh"`,
},
];

View File

@@ -68,6 +68,31 @@ function renderInline(raw: string): string {
} else if (code === "l") {
out += `<span style="display:block;text-align:left">`;
openTags.push("</span>"); i += 2; continue;
} else if (code === "<") {
// Form element: `<...> or `<!...> or `<?...> or `<^...>
const closeAngle = raw.indexOf(">", i + 2);
if (closeAngle !== -1) {
const inner = raw.slice(i + 2, closeAngle);
out += renderFormTag(inner);
i = closeAngle + 1;
continue;
}
} else if (code === "[") {
// Link with inline formatting: `[`!Label`!`:/dest]
const closeBracket = raw.indexOf("]", i + 2);
if (closeBracket !== -1) {
const inner = raw.slice(i + 2, closeBracket);
const colonIdx = inner.indexOf("`:");
if (colonIdx !== -1) {
const labelRaw = inner.slice(0, colonIdx);
const dest = inner.slice(colonIdx + 2);
// Strip formatting tags from label for display
const label = labelRaw.replace(/`[!*_]/g, "");
out += `<a href="${escapeHtml(dest)}" style="color:#7dc4e4;text-decoration:underline">${escapeHtml(label)}</a>`;
i = closeBracket + 1;
continue;
}
}
}
}
@@ -95,6 +120,46 @@ function renderInline(raw: string): string {
return out;
}
/** Render a Micron form tag `<...> as styled HTML. */
function renderFormTag(inner: string): string {
const esc = escapeHtml;
// Text field: width|name`placeholder or name`placeholder
// Password: !width|name`placeholder
// Checkbox: ?|name|value`label or ?|name|value|*`label
// Radio: ^|group|value`label or ^|group|value|*`label
if (inner.startsWith("?")) {
// Checkbox
const backtick = inner.indexOf("`");
const label = backtick !== -1 ? inner.slice(backtick + 1) : "";
const checked = inner.includes("|*");
const box = checked ? "☑" : "☐";
return `<span style="color:#d4a8f8">${box} ${esc(label)}</span>`;
}
if (inner.startsWith("^")) {
// Radio button
const backtick = inner.indexOf("`");
const label = backtick !== -1 ? inner.slice(backtick + 1) : "";
const selected = inner.includes("|*");
const dot = selected ? "◉" : "○";
return `<span style="color:#d4a8f8">${dot} ${esc(label)}</span>`;
}
if (inner.startsWith("!")) {
// Password field
const backtick = inner.indexOf("`");
const placeholder = backtick !== -1 ? inner.slice(backtick + 1) : "";
return `<span style="color:#d4a8f8;border:1px solid rgba(212,168,248,0.3);border-radius:3px;padding:0 4px">🔒 ${esc(placeholder || "••••••")}</span>`;
}
// Regular text field: width|name`placeholder or name`placeholder
const backtick = inner.indexOf("`");
const placeholder = backtick !== -1 ? inner.slice(backtick + 1) : "";
return `<span style="color:#d4a8f8;border:1px solid rgba(212,168,248,0.3);border-radius:3px;padding:0 4px">${esc(placeholder || "...")}</span>`;
}
/** Render a form element line as a styled badge. */
function renderForm(line: string): string {
const inner = escapeHtml(line);
@@ -145,9 +210,6 @@ export function renderMicron(source: string): string {
// Empty line
} else if (line.trim() === "") {
htmlLines.push("");
// Form elements on their own line
} else if (/^`?<[^>]+>$/.test(line)) {
htmlLines.push(renderForm(line));
} else {
htmlLines.push(renderInline(line));
}

View File

@@ -19,7 +19,7 @@ export function useCompile() {
const compile = useCallback(
async (source: string) => {
if (!source.trim()) {
setCompileResult("", "", []);
setCompileResult("", "", "", false, []);
return;
}
@@ -45,7 +45,7 @@ export function useCompile() {
}
const data = await res.json();
setCompileResult(data.ascii, data.micron, data.warnings || []);
setCompileResult(data.ascii, data.micron, data.script || "", data.is_dynamic || false, data.warnings || []);
} catch (e: unknown) {
if (e instanceof DOMException && e.name === "AbortError") return;
setCompileError(e instanceof Error ? e.message : "Compile failed");

View File

@@ -18,21 +18,23 @@ interface EditorStore {
// Compiled output
compiledAscii: string;
compiledMicron: string;
compiledScript: string;
isDynamic: boolean;
compileWarnings: string[];
isCompiling: boolean;
compileError: string | null;
// Preview
previewMode: "ascii" | "micron" | "raw";
previewMode: "ascii" | "micron" | "raw" | "script";
// Actions
setSource: (s: string) => void;
setCurrentPage: (p: PageMeta | null) => void;
setDirty: (v: boolean) => void;
setCompileResult: (ascii: string, micron: string, warnings: string[]) => void;
setCompileResult: (ascii: string, micron: string, script: string, isDynamic: boolean, warnings: string[]) => void;
setCompiling: (v: boolean) => void;
setCompileError: (e: string | null) => void;
setPreviewMode: (mode: "ascii" | "micron" | "raw") => void;
setPreviewMode: (mode: "ascii" | "micron" | "raw" | "script") => void;
reset: () => void;
}
@@ -43,6 +45,8 @@ export const useEditorStore = create<EditorStore>((set) => ({
compiledAscii: "",
compiledMicron: "",
compiledScript: "",
isDynamic: false,
compileWarnings: [],
isCompiling: false,
compileError: null,
@@ -52,10 +56,12 @@ export const useEditorStore = create<EditorStore>((set) => ({
setSource: (s) => set({ ufSource: s, isDirty: true }),
setCurrentPage: (p) => set({ currentPage: p }),
setDirty: (v) => set({ isDirty: v }),
setCompileResult: (ascii, micron, warnings) =>
setCompileResult: (ascii, micron, script, isDynamic, warnings) =>
set({
compiledAscii: ascii,
compiledMicron: micron,
compiledScript: script,
isDynamic,
compileWarnings: warnings,
isCompiling: false,
compileError: null,
@@ -70,6 +76,8 @@ export const useEditorStore = create<EditorStore>((set) => ({
currentPage: null,
compiledAscii: "",
compiledMicron: "",
compiledScript: "",
isDynamic: false,
compileWarnings: [],
isCompiling: false,
compileError: null,