feat: dynamic mode
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
322
backend/uframe/codegen.py
Normal 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,
|
||||
)
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
124
backend/uframe/tests/test_dynamic.py
Normal file
124
backend/uframe/tests/test_dynamic.py
Normal 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
|
||||
@@ -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…"}
|
||||
|
||||
@@ -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"`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user