feat: added a twist

This commit is contained in:
2026-04-01 00:53:55 +02:00
parent 0b7deee59e
commit b40c6436cd
76 changed files with 15121 additions and 64 deletions

View File

@@ -1,28 +1,34 @@
"""µFrame compile endpoint — POST /api/compile."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import uframe
from uframe.errors import UFrameError
router = APIRouter()
class ConvertRequest(BaseModel):
markdown: str
width: int = 80
class CompileRequest(BaseModel):
source: str
width: int = 64
class ConvertResponse(BaseModel):
class CompileResponse(BaseModel):
ascii: str
micron: str
warnings: list[str]
@router.post("/convert", response_model=ConvertResponse)
async def convert(req: ConvertRequest):
@router.post("/compile", response_model=CompileResponse)
async def compile_source(req: CompileRequest):
"""Compile µFrame .uf source into ASCII and Micron output."""
try:
from md2txt import convert_markdown
result = convert_markdown(
req.markdown,
width=req.width,
renderer_name="micron",
result = uframe.compile(req.source, width=req.width)
return CompileResponse(
ascii=result.ascii,
micron=result.micron,
warnings=[w.message for w in result.warnings],
)
return ConvertResponse(micron=result)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Conversion failed: {e}")
except UFrameError as e:
raise HTTPException(status_code=422, detail=str(e))

View File

@@ -10,8 +10,8 @@ router = APIRouter()
SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources"))
PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages"))
# Matches markdown links: [text](slug) where slug has no protocol or path separators
_INTERNAL_LINK = re.compile(r"\[([^\]]+)\]\(([a-zA-Z0-9_-]+)\)")
# Matches Micron links: [label`slug] or [label`slug.mu]
_INTERNAL_LINK = re.compile(r'\[([^`\]]+)`([a-zA-Z0-9_-]+)(?:\.mu)?\]')
class GraphNode(BaseModel):
@@ -38,16 +38,16 @@ def _all_page_names() -> set[str]:
names.add(f.stem)
if SOURCES_DIR.is_dir():
for f in SOURCES_DIR.iterdir():
if f.suffix == ".md" and f.is_file():
if f.suffix == ".mu" and f.is_file():
names.add(f.stem)
return names
def _extract_title(markdown: str) -> str | None:
for line in markdown.splitlines():
def _extract_title(micron: str) -> str | None:
for line in micron.splitlines():
stripped = line.strip()
if stripped.startswith("# "):
return stripped[2:].strip()
if stripped.startswith(">") and not stripped.startswith(">>"):
return stripped[1:].strip()
return None
@@ -58,15 +58,14 @@ async def get_graph():
edges: list[GraphEdge] = []
for name in sorted(all_names):
md_path = SOURCES_DIR / f"{name}.md"
src_path = SOURCES_DIR / f"{name}.mu"
mu_path = PAGES_DIR / f"{name}.mu"
title = None
if md_path.is_file():
content = md_path.read_text(encoding="utf-8")
if src_path.is_file():
content = src_path.read_text(encoding="utf-8")
title = _extract_title(content)
# Parse internal links
for match in _INTERNAL_LINK.finditer(content):
target = match.group(2)
if target in all_names:

View File

@@ -4,12 +4,12 @@ from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from converter import router as converter_router
from pages import router as pages_router
from graph import router as graph_router
from docker_utils import router as docker_router
from converter import router as converter_router
app = FastAPI(title="Micron Page Editor")
app = FastAPI(title="µFrame Editor")
app.include_router(converter_router, prefix="/api")
app.include_router(pages_router, prefix="/api")

View File

@@ -1,9 +1,12 @@
import os
import shlex
from pathlib import Path
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import uframe
router = APIRouter()
PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages"))
@@ -21,20 +24,42 @@ class PageMeta(BaseModel):
class PageDetail(BaseModel):
name: str
markdown: str | None = None
micron: str | None = None
source: str | None = None
class SaveRequest(BaseModel):
markdown: str
source: str
publish: bool = False
def _extract_title(markdown: str) -> str | None:
for line in markdown.splitlines():
def _extract_title(source: str) -> str | None:
"""Extract title from µFrame source or legacy Micron."""
for line in source.splitlines():
stripped = line.strip()
if stripped.startswith("# "):
return stripped[2:].strip()
if not stripped or stripped.startswith("#"):
continue
# µFrame: page "Title" [width]
if stripped.lower().startswith("page "):
try:
parts = shlex.split(stripped)
if len(parts) >= 2:
return parts[1]
except ValueError:
pass
break
# µFrame: heading 1 "Title"
if stripped.lower().startswith("heading "):
try:
parts = shlex.split(stripped)
if len(parts) >= 3:
return parts[2]
except ValueError:
pass
break
# Legacy Micron: >Title
if stripped.startswith(">") and not stripped.startswith(">>"):
return stripped[1:].strip()
break
return None
@@ -46,18 +71,29 @@ def _list_all_page_names() -> set[str]:
names.add(f.stem)
if SOURCES_DIR.is_dir():
for f in SOURCES_DIR.iterdir():
if f.suffix == ".md" and f.is_file():
if f.suffix in (".uf", ".mu") and f.is_file():
names.add(f.stem)
return names
def _source_path(name: str) -> Path:
"""Get source file path, preferring .uf over legacy .mu."""
uf = SOURCES_DIR / f"{name}.uf"
if uf.is_file():
return uf
mu = SOURCES_DIR / f"{name}.mu"
return mu if mu.is_file() else uf # default to .uf for new files
def _page_meta(name: str) -> PageMeta:
src_path = _source_path(name)
mu_path = PAGES_DIR / f"{name}.mu"
md_path = SOURCES_DIR / f"{name}.md"
title = None
if md_path.is_file():
title = _extract_title(md_path.read_text(encoding="utf-8"))
if src_path.is_file():
title = _extract_title(src_path.read_text(encoding="utf-8"))
elif mu_path.is_file():
title = _extract_title(mu_path.read_text(encoding="utf-8"))
published = mu_path.is_file()
last_modified = mu_path.stat().st_mtime if published else None
@@ -67,7 +103,7 @@ def _page_meta(name: str) -> PageMeta:
name=name,
title=title,
published=published,
has_source=md_path.is_file(),
has_source=src_path.is_file(),
last_modified=last_modified,
size=size,
)
@@ -80,16 +116,19 @@ async def list_pages():
@router.get("/pages/{name}", response_model=PageDetail)
async def get_page(name: str):
md_path = SOURCES_DIR / f"{name}.md"
src_path = _source_path(name)
mu_path = PAGES_DIR / f"{name}.mu"
if not md_path.is_file() and not mu_path.is_file():
if not src_path.is_file() and not mu_path.is_file():
raise HTTPException(status_code=404, detail="Page not found")
markdown = md_path.read_text(encoding="utf-8") if md_path.is_file() else None
micron = mu_path.read_text(encoding="utf-8") if mu_path.is_file() else None
source = (
src_path.read_text(encoding="utf-8")
if src_path.is_file()
else mu_path.read_text(encoding="utf-8")
)
return PageDetail(name=name, markdown=markdown, micron=micron)
return PageDetail(name=name, source=source)
@router.post("/pages/{name}", response_model=PageMeta)
@@ -97,39 +136,40 @@ async def save_page(name: str, req: SaveRequest):
SOURCES_DIR.mkdir(parents=True, exist_ok=True)
PAGES_DIR.mkdir(parents=True, exist_ok=True)
# Always save markdown source
md_path = SOURCES_DIR / f"{name}.md"
md_path.write_text(req.markdown, encoding="utf-8")
# Save source as .uf
src_path = SOURCES_DIR / f"{name}.uf"
src_path.write_text(req.source, encoding="utf-8")
# Optionally publish
# Remove legacy .mu source if it exists
legacy_mu = SOURCES_DIR / f"{name}.mu"
if legacy_mu.is_file():
legacy_mu.unlink()
# Publish: compile .uf → .mu and write to pages dir
if req.publish:
try:
from md2txt import convert_markdown
micron = convert_markdown(
req.markdown,
width=80,
renderer_name="micron",
)
result = uframe.compile(req.source)
mu_path = PAGES_DIR / f"{name}.mu"
mu_path.write_text(result.micron, encoding="utf-8")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Conversion failed: {e}")
mu_path = PAGES_DIR / f"{name}.mu"
mu_path.write_text(micron, encoding="utf-8")
raise HTTPException(
status_code=422,
detail=f"Compile failed during publish: {e}",
)
return _page_meta(name)
@router.delete("/pages/{name}")
async def delete_page(name: str):
md_path = SOURCES_DIR / f"{name}.md"
src_path = _source_path(name)
mu_path = PAGES_DIR / f"{name}.mu"
if not md_path.is_file() and not mu_path.is_file():
if not src_path.is_file() and not mu_path.is_file():
raise HTTPException(status_code=404, detail="Page not found")
if md_path.is_file():
md_path.unlink()
if src_path.is_file():
src_path.unlink()
if mu_path.is_file():
mu_path.unlink()

View File

@@ -0,0 +1,74 @@
"""µFrame — A DSL for rich terminal UIs rendered as ASCII and Micron.
Public API:
compile(source, width=64) → CompileResult
"""
from __future__ import annotations
from dataclasses import dataclass, field
from uframe.errors import CompileWarning, UFrameError
from uframe.parser import parse
from uframe.measure import measure
from uframe.layout import layout
from uframe.paint import paint
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
@dataclass
class CompileResult:
"""Result of compiling a .uf source."""
ascii: str = ""
micron: str = ""
warnings: list[CompileWarning] = field(default_factory=list)
def compile(source: str, width: int = 64) -> CompileResult:
"""Compile a µFrame .uf source string into ASCII and Micron output.
Args:
source: the .uf DSL source text
width: page width in characters (default 64)
Returns:
CompileResult with ascii, micron, and any warnings
Raises:
ParseError: if the source cannot be parsed
LayoutError: if layout constraints fail
"""
warnings: list[CompileWarning] = []
# 1. Parse
page = parse(source)
if page.width == 64 and width != 64:
page.width = width
w = page.width
# 2. Measure
measure(page, w)
# 3. Layout
total_h = layout(page, 0, 0, w, page.pref_height + 100)
# 4. Create grid and paint
grid = CharGrid(w, max(total_h, 1))
paint(page, grid)
# 5. Merge borders
merge_borders(grid)
# 6. Emit
ascii_out = emit_ascii(grid)
micron_out = emit_micron(grid, page_title=page.title)
return CompileResult(
ascii=ascii_out,
micron=micron_out,
warnings=warnings,
)

148
backend/uframe/borders.py Normal file
View File

@@ -0,0 +1,148 @@
"""Border merging post-pass — fix junction characters where borders meet.
Scans the CharGrid for adjacent border cells and replaces with the
correct junction character (T-junctions, crosses, corners) from the
Unicode box-drawing set.
"""
from __future__ import annotations
from uframe.grid import CharGrid
from uframe.ir import BorderWeight
# ---------------------------------------------------------------------------
# Connection detection
# ---------------------------------------------------------------------------
# For each border cell, check which directions have adjacent borders.
# Direction flags:
UP = 1
DOWN = 2
LEFT = 4
RIGHT = 8
# Junction lookup: connections bitmask → character
# Only light weight for now (most common case)
_LIGHT_JUNCTIONS: dict[int, str] = {
UP | DOWN: "",
LEFT | RIGHT: "",
DOWN | RIGHT: "",
DOWN | LEFT: "",
UP | RIGHT: "",
UP | LEFT: "",
UP | DOWN | RIGHT: "",
UP | DOWN | LEFT: "",
LEFT | RIGHT | DOWN: "",
LEFT | RIGHT | UP: "",
UP | DOWN | LEFT | RIGHT: "",
RIGHT: "",
LEFT: "",
UP: "",
DOWN: "",
}
_HEAVY_JUNCTIONS: dict[int, str] = {
UP | DOWN: "",
LEFT | RIGHT: "",
DOWN | RIGHT: "",
DOWN | LEFT: "",
UP | RIGHT: "",
UP | LEFT: "",
UP | DOWN | RIGHT: "",
UP | DOWN | LEFT: "",
LEFT | RIGHT | DOWN: "",
LEFT | RIGHT | UP: "",
UP | DOWN | LEFT | RIGHT: "",
RIGHT: "",
LEFT: "",
UP: "",
DOWN: "",
}
_DOUBLE_JUNCTIONS: dict[int, str] = {
UP | DOWN: "",
LEFT | RIGHT: "",
DOWN | RIGHT: "",
DOWN | LEFT: "",
UP | RIGHT: "",
UP | LEFT: "",
UP | DOWN | RIGHT: "",
UP | DOWN | LEFT: "",
LEFT | RIGHT | DOWN: "",
LEFT | RIGHT | UP: "",
UP | DOWN | LEFT | RIGHT: "",
RIGHT: "",
LEFT: "",
UP: "",
DOWN: "",
}
_JUNCTION_TABLES = {
BorderWeight.LIGHT: _LIGHT_JUNCTIONS,
BorderWeight.HEAVY: _HEAVY_JUNCTIONS,
BorderWeight.DOUBLE: _DOUBLE_JUNCTIONS,
BorderWeight.ROUNDED: _LIGHT_JUNCTIONS, # rounded uses light junctions
}
# Weight priority for mixed-weight junctions
_WEIGHT_PRIORITY = {
BorderWeight.DOUBLE: 3,
BorderWeight.HEAVY: 2,
BorderWeight.LIGHT: 1,
BorderWeight.ROUNDED: 0,
}
def merge_borders(grid: CharGrid) -> None:
"""Scan the grid for adjacent border cells and fix junction characters.
This pass resolves cases where two boxes share an edge or corner,
replacing the overlapping border characters with proper junctions.
"""
for row in range(grid.height):
for col in range(grid.width):
cell = grid.cells[row][col]
if not cell.is_border:
continue
# Detect connections in 4 directions
connections = 0
max_weight = cell.border_weight or BorderWeight.LIGHT
# Check each neighbor
if row > 0 and grid.cells[row - 1][col].is_border:
connections |= UP
nw = grid.cells[row - 1][col].border_weight or BorderWeight.LIGHT
if _WEIGHT_PRIORITY.get(nw, 0) > _WEIGHT_PRIORITY.get(max_weight, 0):
max_weight = nw
if row < grid.height - 1 and grid.cells[row + 1][col].is_border:
connections |= DOWN
nw = grid.cells[row + 1][col].border_weight or BorderWeight.LIGHT
if _WEIGHT_PRIORITY.get(nw, 0) > _WEIGHT_PRIORITY.get(max_weight, 0):
max_weight = nw
if col > 0 and grid.cells[row][col - 1].is_border:
connections |= LEFT
nw = grid.cells[row][col - 1].border_weight or BorderWeight.LIGHT
if _WEIGHT_PRIORITY.get(nw, 0) > _WEIGHT_PRIORITY.get(max_weight, 0):
max_weight = nw
if col < grid.width - 1 and grid.cells[row][col + 1].is_border:
connections |= RIGHT
nw = grid.cells[row][col + 1].border_weight or BorderWeight.LIGHT
if _WEIGHT_PRIORITY.get(nw, 0) > _WEIGHT_PRIORITY.get(max_weight, 0):
max_weight = nw
# Skip rounded corners — they should preserve ╭╮╰╯
if cell.border_weight == BorderWeight.ROUNDED and connections in (
DOWN | RIGHT, DOWN | LEFT, UP | RIGHT, UP | LEFT
):
continue
# Look up the junction character
if connections:
table = _JUNCTION_TABLES.get(max_weight, _LIGHT_JUNCTIONS)
junction = table.get(connections)
if junction:
cell.char = junction

158
backend/uframe/chars.py Normal file
View File

@@ -0,0 +1,158 @@
"""Unicode character lookup tables for box-drawing, block elements, braille, and indicators."""
from __future__ import annotations
from uframe.ir import BorderWeight
# ---------------------------------------------------------------------------
# Box-drawing characters by weight
# ---------------------------------------------------------------------------
# Keys: (weight) → dict of part names → char
BOX_CHARS: dict[BorderWeight, dict[str, str]] = {
BorderWeight.LIGHT: {
"tl": "", "tr": "", "bl": "", "br": "",
"h": "", "v": "",
"t_down": "", "t_up": "", "t_right": "", "t_left": "",
"cross": "",
},
BorderWeight.HEAVY: {
"tl": "", "tr": "", "bl": "", "br": "",
"h": "", "v": "",
"t_down": "", "t_up": "", "t_right": "", "t_left": "",
"cross": "",
},
BorderWeight.DOUBLE: {
"tl": "", "tr": "", "bl": "", "br": "",
"h": "", "v": "",
"t_down": "", "t_up": "", "t_right": "", "t_left": "",
"cross": "",
},
BorderWeight.ROUNDED: {
"tl": "", "tr": "", "bl": "", "br": "",
"h": "", "v": "",
"t_down": "", "t_up": "", "t_right": "", "t_left": "",
"cross": "",
},
}
# ---------------------------------------------------------------------------
# Block elements for gauges and bars
# ---------------------------------------------------------------------------
# Horizontal fill blocks: full → 1/8
HFILL = "█▉▊▋▌▍▎▏"
# Vertical fill blocks: 1/8 → full (bottom-up)
VFILL = "▁▂▃▄▅▆▇█"
# Shade blocks: 25% → 100%
SHADE = "░▒▓█"
# Gauge characters
GAUGE_FILLED = ""
GAUGE_EMPTY = ""
# ---------------------------------------------------------------------------
# Braille patterns for sparklines
# ---------------------------------------------------------------------------
# Braille base: U+2800. Each character is a 2×4 dot matrix.
# Dot positions (bit index):
# 0 3
# 1 4
# 2 5
# 6 7
BRAILLE_BASE = 0x2800
# Row dot bits for left column (bits 0,1,2,6) and right column (bits 3,4,5,7)
BRAILLE_LEFT = [0x01, 0x02, 0x04, 0x40] # rows 0-3
BRAILLE_RIGHT = [0x08, 0x10, 0x20, 0x80] # rows 0-3
def braille_char(dots: list[tuple[int, int]]) -> str:
"""Build a braille character from a list of (col, row) positions.
col: 0 (left) or 1 (right)
row: 0 (top) to 3 (bottom)
"""
code = BRAILLE_BASE
for col, row in dots:
if 0 <= row <= 3:
if col == 0:
code |= BRAILLE_LEFT[row]
else:
code |= BRAILLE_RIGHT[row]
return chr(code)
def sparkline_chars(values: list[float], width: int) -> list[str]:
"""Convert a list of values into braille sparkline characters.
Each output character represents two consecutive values (left + right columns).
Values are normalized to 07 (mapping to 4 braille rows × 2 resolution).
"""
if not values:
return []
lo = min(values)
hi = max(values)
span = hi - lo if hi != lo else 1.0
# Normalize to 07 range (8 vertical positions: 4 rows × 2 resolution)
norm = [int((v - lo) / span * 7) for v in values]
# Pad to even length
if len(norm) % 2:
norm.append(norm[-1])
chars = []
for i in range(0, min(len(norm), width * 2), 2):
left_val = norm[i]
right_val = norm[i + 1] if i + 1 < len(norm) else norm[i]
dots = []
# Fill dots from bottom up for each column
for row in range(3, -1, -1):
threshold = (3 - row) * 2 # row 3=0, row 2=2, row 1=4, row 0=6
if left_val >= threshold:
dots.append((0, row))
if right_val >= threshold:
dots.append((1, row))
chars.append(braille_char(dots))
return chars[:width]
# ---------------------------------------------------------------------------
# Status indicators
# ---------------------------------------------------------------------------
STATUS_CHARS: dict[str, str] = {
"online": "",
"offline": "",
"degraded": "",
"unknown": "",
"alert": "",
}
STATUS_COLORS: dict[str, str] = {
"online": "0f0",
"offline": "f00",
"degraded": "ff0",
"unknown": "888",
"alert": "f00",
}
# ---------------------------------------------------------------------------
# Divider characters
# ---------------------------------------------------------------------------
DIVIDER_CHARS: dict[str, str] = {
"light": "",
"heavy": "",
"double": "",
"dash": "",
"dot": "",
}

View File

@@ -0,0 +1,22 @@
"""ASCII emitter — read CharGrid and output plain text.
Reads only cell.char from each cell. No color, no style tags.
"""
from __future__ import annotations
from uframe.grid import CharGrid
def emit_ascii(grid: CharGrid) -> str:
"""Emit the CharGrid as plain ASCII text."""
lines: list[str] = []
for row in range(grid.height):
line = "".join(cell.char for cell in grid.cells[row]).rstrip()
lines.append(line)
# Strip trailing blank lines
while lines and not lines[-1]:
lines.pop()
return "\n".join(lines)

View File

@@ -0,0 +1,121 @@
"""Micron emitter — read CharGrid and output Micron markup with style tags.
Scans each line left-to-right, tracks style state, and opens/closes
Micron format codes at style transitions. Box-drawing characters pass
through as literal text.
Micron format reference:
`!bold`! `*italic`* `_underline`_
`Fhex text`f `Bhex text`b
`c center`a `r right`a
[label`dest] [label`dest.mu]
>H1 >>H2 >>>H3
"""
from __future__ import annotations
from uframe.grid import CharGrid, CellStyle
from uframe.ir import Page, Heading, HeadingLevel
def _emit_style_open(style: CellStyle) -> str:
"""Emit Micron opening tags for a style."""
tags: list[str] = []
if style.bold:
tags.append("`!")
if style.italic:
tags.append("`*")
if style.underline:
tags.append("`_")
if style.fg:
tags.append(f"`F{style.fg}")
if style.bg:
tags.append(f"`B{style.bg}")
return "".join(tags)
def _emit_style_close(style: CellStyle) -> str:
"""Emit Micron closing tags for a style (in reverse order)."""
tags: list[str] = []
if style.bg:
tags.append("`b")
if style.fg:
tags.append("`f")
if style.underline:
tags.append("`_")
if style.italic:
tags.append("`*")
if style.bold:
tags.append("`!")
return "".join(tags)
_EMPTY_STYLE = CellStyle()
def emit_micron(grid: CharGrid, page_title: str = "") -> str:
"""Emit the CharGrid as Micron markup.
Args:
grid: the rendered character grid
page_title: optional page title for a leading >Title line
Returns:
Micron source string
"""
lines: list[str] = []
for row in range(grid.height):
line_parts: list[str] = []
cur_style = _EMPTY_STYLE
in_link: str | None = None
for col in range(grid.width):
cell = grid.cells[row][col]
ch = cell.char
style = cell.style
link = cell.link
# Handle link transitions
if link != in_link:
if in_link is not None:
# Close previous link: [label`dest]
line_parts.append(f"`{in_link}]")
if link is not None:
# Close any open style before link
if cur_style != _EMPTY_STYLE:
line_parts.append(_emit_style_close(cur_style))
cur_style = _EMPTY_STYLE
# Open new link
line_parts.append("[")
in_link = link
# Handle style transitions (not inside links — links handle their own style)
if link is None and style != cur_style:
# Close previous style
if cur_style != _EMPTY_STYLE:
line_parts.append(_emit_style_close(cur_style))
# Open new style
if style != _EMPTY_STYLE:
line_parts.append(_emit_style_open(style))
cur_style = style
line_parts.append(ch)
# Close any trailing link: [label`dest]
if in_link is not None:
line_parts.append(f"`{in_link}]")
in_link = None
# Close any trailing style
if cur_style != _EMPTY_STYLE:
line_parts.append(_emit_style_close(cur_style))
line = "".join(line_parts).rstrip()
lines.append(line)
# Strip trailing blank lines
while lines and not lines[-1]:
lines.pop()
return "\n".join(lines)

45
backend/uframe/errors.py Normal file
View File

@@ -0,0 +1,45 @@
"""µFrame error types with source location tracking."""
from __future__ import annotations
class UFrameError(Exception):
"""Base error for all µFrame operations."""
def __init__(self, message: str, line: int | None = None, col: int | None = None):
self.line = line
self.col = col
loc = ""
if line is not None:
loc = f" (line {line}"
if col is not None:
loc += f", col {col}"
loc += ")"
super().__init__(f"{message}{loc}")
class ParseError(UFrameError):
"""Raised when .uf source cannot be parsed."""
pass
class LayoutError(UFrameError):
"""Raised when layout constraints cannot be satisfied."""
pass
class CompileWarning:
"""Non-fatal issue discovered during compilation."""
__slots__ = ("message", "line", "col")
def __init__(self, message: str, line: int | None = None, col: int | None = None):
self.message = message
self.line = line
self.col = col
def __repr__(self) -> str:
loc = ""
if self.line is not None:
loc = f" line={self.line}"
return f"CompileWarning({self.message!r}{loc})"

170
backend/uframe/grid.py Normal file
View File

@@ -0,0 +1,170 @@
"""CharGrid — 2D character buffer with per-cell style annotations.
The CharGrid is the intermediate representation between layout and emission.
Both the ASCII and Micron emitters read from the same grid.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from uframe.chars import BOX_CHARS
from uframe.ir import BorderWeight
@dataclass
class CellStyle:
"""Per-cell visual style for Micron emission."""
fg: str | None = None # 3-digit hex color
bg: str | None = None
bold: bool = False
italic: bool = False
underline: bool = False
def __eq__(self, other: object) -> bool:
if not isinstance(other, CellStyle):
return NotImplemented
return (self.fg == other.fg and self.bg == other.bg
and self.bold == other.bold and self.italic == other.italic
and self.underline == other.underline)
def __hash__(self) -> int:
return hash((self.fg, self.bg, self.bold, self.italic, self.underline))
@dataclass
class Cell:
"""A single cell in the CharGrid."""
char: str = " "
style: CellStyle = field(default_factory=CellStyle)
is_border: bool = False # True for box-drawing characters (for merge pass)
border_weight: BorderWeight | None = None
link: str | None = None # Micron link destination
class CharGrid:
"""2D buffer of cells. Origin (0,0) is top-left."""
__slots__ = ("width", "height", "cells")
def __init__(self, width: int, height: int):
self.width = width
self.height = height
self.cells: list[list[Cell]] = [
[Cell() for _ in range(width)]
for _ in range(height)
]
def in_bounds(self, x: int, y: int) -> bool:
return 0 <= x < self.width and 0 <= y < self.height
def put(self, x: int, y: int, char: str,
style: CellStyle | None = None,
is_border: bool = False,
border_weight: BorderWeight | None = None,
link: str | None = None) -> None:
"""Write a single character to the grid."""
if not self.in_bounds(x, y):
return
cell = self.cells[y][x]
cell.char = char
if style is not None:
cell.style = style
cell.is_border = is_border
cell.border_weight = border_weight
if link is not None:
cell.link = link
def put_text(self, x: int, y: int, text: str,
style: CellStyle | None = None,
link: str | None = None) -> int:
"""Write a string horizontally starting at (x, y).
Returns the number of characters actually written.
"""
written = 0
for i, ch in enumerate(text):
px = x + i
if not self.in_bounds(px, y):
break
self.put(px, y, ch, style=style, link=link)
written += 1
return written
def fill_rect(self, x: int, y: int, w: int, h: int, char: str = " ",
style: CellStyle | None = None) -> None:
"""Fill a rectangular region with a character."""
for row in range(y, y + h):
for col in range(x, x + w):
self.put(col, row, char, style=style)
def draw_border(self, x: int, y: int, w: int, h: int,
weight: BorderWeight = BorderWeight.LIGHT,
title: str = "",
title_style: CellStyle | None = None) -> None:
"""Draw a box border. Interior is not cleared.
Args:
x, y: top-left corner
w, h: outer dimensions (including border)
weight: border style
title: optional title inset in top border
title_style: style for the title text
"""
if w < 2 or h < 2:
return
ch = BOX_CHARS[weight]
border_style = CellStyle()
# Corners
self.put(x, y, ch["tl"], border_style, is_border=True, border_weight=weight)
self.put(x + w - 1, y, ch["tr"], border_style, is_border=True, border_weight=weight)
self.put(x, y + h - 1, ch["bl"], border_style, is_border=True, border_weight=weight)
self.put(x + w - 1, y + h - 1, ch["br"], border_style, is_border=True, border_weight=weight)
# Top and bottom edges
for col in range(x + 1, x + w - 1):
self.put(col, y, ch["h"], border_style, is_border=True, border_weight=weight)
self.put(col, y + h - 1, ch["h"], border_style, is_border=True, border_weight=weight)
# Left and right edges
for row in range(y + 1, y + h - 1):
self.put(x, row, ch["v"], border_style, is_border=True, border_weight=weight)
self.put(x + w - 1, row, ch["v"], border_style, is_border=True, border_weight=weight)
# Title in top border
if title and w > 4:
title_text = f" {title} "
max_title = w - 4 # leave room for corners + padding
if len(title_text) > max_title:
title_text = title_text[:max_title]
start_x = x + 2
ts = title_style or CellStyle(bold=True)
self.put_text(start_x, y, title_text, style=ts)
def grow_height(self, new_height: int) -> None:
"""Expand the grid vertically if needed."""
if new_height <= self.height:
return
for _ in range(new_height - self.height):
self.cells.append([Cell() for _ in range(self.width)])
self.height = new_height
def get_line(self, row: int) -> str:
"""Get a single row as a plain string (chars only)."""
if 0 <= row < self.height:
return "".join(cell.char for cell in self.cells[row])
return ""
def to_text(self) -> str:
"""Emit the entire grid as plain text (ASCII mode)."""
lines = []
for row in range(self.height):
line = self.get_line(row).rstrip()
lines.append(line)
# Strip trailing blank lines
while lines and not lines[-1]:
lines.pop()
return "\n".join(lines)

243
backend/uframe/ir.py Normal file
View File

@@ -0,0 +1,243 @@
"""µFrame Intermediate Representation — node types for the IR tree.
Every .uf source parses into a tree of IRNode subclasses. The layout
engine measures, positions, and paints these nodes into a CharGrid.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Any
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
class BorderWeight(Enum):
LIGHT = auto()
HEAVY = auto()
DOUBLE = auto()
ROUNDED = auto()
class HeadingLevel(Enum):
H1 = 1
H2 = 2
H3 = 3
class DividerStyle(Enum):
LIGHT = auto()
HEAVY = auto()
DOUBLE = auto()
DASH = auto()
DOT = auto()
class ListStyle(Enum):
BULLET = auto()
DASH = auto()
NUMBER = auto()
ARROW = auto()
class Align(Enum):
LEFT = auto()
CENTER = auto()
RIGHT = auto()
# ---------------------------------------------------------------------------
# Style
# ---------------------------------------------------------------------------
@dataclass
class Style:
"""Visual style attached to any node."""
fg: str | None = None # 3-digit hex
bg: str | None = None # 3-digit hex
bold: bool = False
italic: bool = False
underline: bool = False
align: Align = Align.LEFT
# ---------------------------------------------------------------------------
# Inline text spans (parsed from @modifier{} syntax in text content)
# ---------------------------------------------------------------------------
@dataclass
class TextSpan:
"""A run of text with optional inline styling."""
text: str
bold: bool = False
italic: bool = False
underline: bool = False
fg: str | None = None
bg: str | None = None
# ---------------------------------------------------------------------------
# Layout rect — assigned by the layout engine
# ---------------------------------------------------------------------------
@dataclass
class Rect:
x: int = 0
y: int = 0
w: int = 0
h: int = 0
# ---------------------------------------------------------------------------
# Base node
# ---------------------------------------------------------------------------
@dataclass
class IRNode:
"""Base class for all IR nodes."""
children: list[IRNode] = field(default_factory=list)
style: Style = field(default_factory=Style)
rect: Rect = field(default_factory=Rect)
source_line: int | None = None
# Set by measure pass
min_width: int = 0
min_height: int = 0
pref_width: int = 0
pref_height: int = 0
# ---------------------------------------------------------------------------
# Layout nodes
# ---------------------------------------------------------------------------
@dataclass
class Page(IRNode):
"""Root container. One per .uf file."""
title: str = ""
width: int = 64
@dataclass
class Box(IRNode):
"""Bordered panel with optional title."""
title: str = ""
weight: BorderWeight = BorderWeight.LIGHT
@dataclass
class Row(IRNode):
"""Horizontal layout — children split available width."""
gap: int = 1
@dataclass
class Col(IRNode):
"""Explicit column in a row. Width in chars or None (auto)."""
col_width: int | None = None
@dataclass
class Spacer(IRNode):
"""Vertical whitespace."""
lines: int = 1
@dataclass
class Pad(IRNode):
"""Inner margin for a container."""
top: int = 0
right: int = 0
bottom: int = 0
left: int = 0
# ---------------------------------------------------------------------------
# Content nodes
# ---------------------------------------------------------------------------
@dataclass
class Heading(IRNode):
"""Styled heading (levels 13)."""
level: HeadingLevel = HeadingLevel.H1
text: str = ""
@dataclass
class Text(IRNode):
"""Text content with optional @modifier{} inline styles."""
content: str = ""
spans: list[TextSpan] = field(default_factory=list)
@dataclass
class Label(IRNode):
"""Aligned key-value pair."""
key: str = ""
value: str = ""
@dataclass
class Divider(IRNode):
"""Full-width horizontal rule."""
divider_style: DividerStyle = DividerStyle.LIGHT
@dataclass
class Link(IRNode):
"""Clickable link — visual in ASCII, interactive in Micron."""
display: str = ""
dest: str = ""
@dataclass
class ListNode(IRNode):
"""Bulleted or numbered list."""
list_style: ListStyle = ListStyle.BULLET
@dataclass
class ListItem(IRNode):
"""Single entry in a ListNode."""
content: str = ""
# ---------------------------------------------------------------------------
# Placeholder nodes for future phases
# ---------------------------------------------------------------------------
@dataclass
class Gauge(IRNode):
"""Horizontal bar chart (Phase 4)."""
label: str = ""
value: float = 0
max_val: float = 100
bar_width: int = 28
warn: float | None = None
crit: float | None = None
@dataclass
class Sparkline(IRNode):
"""Braille sparkline (Phase 4)."""
label: str = ""
values: list[float] = field(default_factory=list)
spark_width: int = 20
@dataclass
class Status(IRNode):
"""Status indicator (Phase 4)."""
label: str = ""
state: str = "unknown"
@dataclass
class Table(IRNode):
"""Box-drawn table (Phase 4)."""
title: str = ""
columns: list[tuple[str, int]] = field(default_factory=list) # (name, width)
rows: list[list[str]] = field(default_factory=list)

134
backend/uframe/layout.py Normal file
View File

@@ -0,0 +1,134 @@
"""Top-down layout pass — assign (x, y, w, h) positions to every node."""
from __future__ import annotations
from uframe.ir import (
IRNode, Page, Box, Row, Col, Spacer, Pad, Rect,
Heading, Text, Label, Divider, Link, ListNode, ListItem,
Gauge, Sparkline, Status,
)
def layout(node: IRNode, x: int, y: int, w: int, h: int) -> int:
"""Assign positions to a node and its children.
Args:
node: the IR node to lay out
x, y: top-left position in the grid
w: available width
h: available height (advisory, may grow)
Returns:
The actual height consumed by this node.
"""
node.rect = Rect(x=x, y=y, w=w, h=0)
if isinstance(node, Page):
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, Box):
# Border takes 1 char on each side
inner_x = x + 1
inner_y = y + 1
inner_w = w - 2
cursor_y = inner_y
for child in node.children:
child_h = layout(child, inner_x, cursor_y, inner_w,
h - 2 - (cursor_y - inner_y))
cursor_y += child_h
inner_h = cursor_y - inner_y
node.rect.h = inner_h + 2 # +2 for top/bottom border
return node.rect.h
elif isinstance(node, Row):
n = len(node.children)
if n == 0:
return 0
gap_total = node.gap * (n - 1)
usable = w - gap_total
# Distribute width
widths: list[int] = []
fixed_total = 0
flex_count = 0
for child in node.children:
if isinstance(child, Col) and child.col_width is not None:
widths.append(child.col_width)
fixed_total += child.col_width
else:
widths.append(0)
flex_count += 1
flex_each = max(1, (usable - fixed_total) // flex_count) if flex_count > 0 else 0
remainder = (usable - fixed_total) - (flex_each * flex_count) if flex_count > 0 else 0
for i, child in enumerate(node.children):
if widths[i] == 0:
widths[i] = flex_each
if remainder > 0:
widths[i] += 1
remainder -= 1
# Lay out each child at its column position
max_h = 0
col_x = x
for i, child in enumerate(node.children):
child_h = layout(child, col_x, y, widths[i], h)
max_h = max(max_h, child_h)
col_x += widths[i] + node.gap
node.rect.h = max_h
return max_h
elif isinstance(node, Col):
cursor_y = y
col_w = node.col_width if node.col_width is not None else w
col_w = min(col_w, w)
for child in node.children:
child_h = layout(child, x, cursor_y, col_w, h - (cursor_y - y))
cursor_y += child_h
node.rect.w = col_w
node.rect.h = cursor_y - y
return node.rect.h
elif isinstance(node, Spacer):
node.rect.h = node.lines
return node.lines
elif isinstance(node, Pad):
cursor_y = y + node.top
inner_w = w - node.left - node.right
for child in node.children:
child_h = layout(child, x + node.left, cursor_y, inner_w,
h - node.top - node.bottom - (cursor_y - y - node.top))
cursor_y += child_h
node.rect.h = (cursor_y - y) + node.bottom
return node.rect.h
elif isinstance(node, (Heading, Text, Label, Divider, Link, ListItem,
Gauge, Sparkline, Status)):
node.rect.h = node.pref_height
return node.pref_height
elif isinstance(node, ListNode):
cursor_y = y
for child in node.children:
child_h = layout(child, x + 2, cursor_y, w - 2, h - (cursor_y - y))
cursor_y += child_h
node.rect.h = cursor_y - y
return node.rect.h
else:
# Generic vertical stacking
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

206
backend/uframe/measure.py Normal file
View File

@@ -0,0 +1,206 @@
"""Bottom-up measure pass — compute min/preferred width and height for each node."""
from __future__ import annotations
from uframe.ir import (
IRNode, Page, Box, Row, Col, Spacer, Pad,
Heading, Text, Label, Divider, Link, ListNode, ListItem,
Gauge, Sparkline, Status,
)
def _text_height(text: str, width: int) -> int:
"""Compute how many lines a text string needs at a given width."""
if not text or width <= 0:
return 1
words = text.split()
lines = 1
col = 0
for word in words:
wlen = len(word)
if col > 0 and col + 1 + wlen > width:
lines += 1
col = wlen
elif col == 0:
col = wlen
else:
col += 1 + wlen
return max(lines, 1)
def measure(node: IRNode, available_width: int) -> None:
"""Recursively compute min/preferred sizes for an IR subtree.
This is a bottom-up pass: children are measured before their parent.
"""
# Dispatch to node type
if isinstance(node, Page):
w = node.width
node.pref_width = w
node.min_width = w
# Measure children with full page width
total_h = 0
for child in node.children:
measure(child, w)
total_h += child.pref_height
node.pref_height = total_h
node.min_height = total_h
elif isinstance(node, Box):
# Box adds 2 chars for borders on each axis (left+right, top+bottom)
inner_w = available_width - 2
total_h = 0
for child in node.children:
measure(child, inner_w)
total_h += child.pref_height
node.pref_width = available_width
node.min_width = 4 # minimum: border + 2 chars + border
node.pref_height = total_h + 2 # +2 for top/bottom border
node.min_height = 3 # top border + 1 line + bottom border
elif isinstance(node, Row):
# Children laid out horizontally, split available width
n = len(node.children)
if n == 0:
node.pref_width = available_width
node.pref_height = 0
node.min_width = 0
node.min_height = 0
return
gap_total = node.gap * (n - 1)
usable = available_width - gap_total
# First pass: measure children to get their preferred sizes
# Distribute width proportionally or equally
fixed_width_children = []
flex_children = []
fixed_total = 0
for child in node.children:
if isinstance(child, Col) and child.col_width is not None:
fixed_width_children.append(child)
fixed_total += child.col_width
else:
flex_children.append(child)
flex_each = 0
if flex_children:
flex_each = max(1, (usable - fixed_total) // len(flex_children))
max_h = 0
for child in node.children:
if isinstance(child, Col) and child.col_width is not None:
child_w = child.col_width
else:
child_w = flex_each
measure(child, child_w)
max_h = max(max_h, child.pref_height)
node.pref_width = available_width
node.min_width = n # at minimum 1 char per child
node.pref_height = max_h
node.min_height = max_h
elif isinstance(node, Col):
w = node.col_width if node.col_width is not None else available_width
total_h = 0
for child in node.children:
measure(child, w)
total_h += child.pref_height
node.pref_width = w
node.min_width = min(w, 1)
node.pref_height = total_h
node.min_height = total_h
elif isinstance(node, Spacer):
node.pref_width = available_width
node.min_width = 0
node.pref_height = node.lines
node.min_height = node.lines
elif isinstance(node, Pad):
inner_w = available_width - node.left - node.right
total_h = 0
for child in node.children:
measure(child, inner_w)
total_h += child.pref_height
node.pref_width = available_width
node.min_width = node.left + node.right + 1
node.pref_height = total_h + node.top + node.bottom
node.min_height = node.top + node.bottom
elif isinstance(node, Heading):
node.pref_width = available_width
node.min_width = len(node.text) + 1
node.pref_height = 1
node.min_height = 1
elif isinstance(node, Text):
node.pref_width = available_width
node.min_width = 1
node.pref_height = _text_height(node.content, available_width)
node.min_height = 1
elif isinstance(node, Label):
node.pref_width = available_width
node.min_width = len(node.key) + 2 + len(node.value)
node.pref_height = 1
node.min_height = 1
elif isinstance(node, Divider):
node.pref_width = available_width
node.min_width = 1
node.pref_height = 1
node.min_height = 1
elif isinstance(node, Link):
node.pref_width = available_width
node.min_width = len(node.display) + 2
node.pref_height = 1
node.min_height = 1
elif isinstance(node, ListNode):
total_h = 0
for child in node.children:
measure(child, available_width - 2) # indent for bullet
total_h += child.pref_height
node.pref_width = available_width
node.min_width = 4
node.pref_height = total_h
node.min_height = total_h
elif isinstance(node, ListItem):
node.pref_width = available_width
node.min_width = len(node.content) + 1
node.pref_height = _text_height(node.content, available_width)
node.min_height = 1
elif isinstance(node, Gauge):
node.pref_width = available_width
node.min_width = node.bar_width + len(node.label) + 6
node.pref_height = 1
node.min_height = 1
elif isinstance(node, Sparkline):
node.pref_width = available_width
node.min_width = node.spark_width + len(node.label) + 4
node.pref_height = 1
node.min_height = 1
elif isinstance(node, Status):
node.pref_width = available_width
node.min_width = len(node.label) + 4
node.pref_height = 1
node.min_height = 1
else:
# Generic: just measure 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 = max(total_h, 1)
node.min_height = 1

216
backend/uframe/paint.py Normal file
View File

@@ -0,0 +1,216 @@
"""Paint pass — write IR nodes into the CharGrid as characters.
Depth-first traversal: each node writes its content at its assigned
rect position. Containers recurse into children after drawing their
own structure (borders, etc.).
"""
from __future__ import annotations
import textwrap
from uframe.chars import (
DIVIDER_CHARS, GAUGE_FILLED, GAUGE_EMPTY,
STATUS_CHARS, STATUS_COLORS, sparkline_chars,
)
from uframe.grid import CharGrid, CellStyle
from uframe.ir import (
IRNode, Page, Box, Row, Col, Spacer, Pad,
Heading, Text, Label, Divider, Link, ListNode, ListItem,
Gauge, Sparkline, Status,
HeadingLevel, DividerStyle, ListStyle, Align,
)
def _align_text(text: str, width: int, align: Align) -> str:
"""Align text within a field of the given width."""
if len(text) >= width:
return text[:width]
if align == Align.CENTER:
return text.center(width)
elif align == Align.RIGHT:
return text.rjust(width)
return text.ljust(width)
def _style_from_node(node: IRNode) -> CellStyle:
"""Create a CellStyle from a node's style attributes."""
return CellStyle(
fg=node.style.fg,
bg=node.style.bg,
bold=node.style.bold,
italic=node.style.italic,
underline=node.style.underline,
)
def paint(node: IRNode, grid: CharGrid) -> None:
"""Recursively paint an IR node and its children into the grid."""
x, y, w = node.rect.x, node.rect.y, node.rect.w
# Ensure grid is tall enough
grid.grow_height(y + node.rect.h)
if isinstance(node, Page):
for child in node.children:
paint(child, grid)
elif isinstance(node, Box):
# Draw the border
title_style = CellStyle(bold=True, fg=node.style.fg)
grid.draw_border(x, y, w, node.rect.h,
weight=node.weight,
title=node.title,
title_style=title_style)
# Paint children inside the border
for child in node.children:
paint(child, grid)
elif isinstance(node, Row):
for child in node.children:
paint(child, grid)
elif isinstance(node, Col):
for child in node.children:
paint(child, grid)
elif isinstance(node, Spacer):
pass # Just empty space
elif isinstance(node, Pad):
for child in node.children:
paint(child, grid)
elif isinstance(node, Heading):
style = CellStyle(bold=True)
if node.level == HeadingLevel.H1:
style.fg = "0f0" # green
elif node.level == HeadingLevel.H2:
style.fg = "0cf" # cyan
elif node.level == HeadingLevel.H3:
style.fg = "88f" # light blue
# Underline-style heading
grid.put_text(x, y, node.text[:w], style=style)
elif isinstance(node, Text):
style = _style_from_node(node)
if node.spans and any(
s.bold or s.italic or s.underline or s.fg or s.bg
for s in node.spans
):
# Render with inline spans
col = x
row = y
for span in node.spans:
span_style = CellStyle(
fg=span.fg or style.fg,
bg=span.bg or style.bg,
bold=span.bold or style.bold,
italic=span.italic or style.italic,
underline=span.underline or style.underline,
)
for ch in span.text:
if col >= x + w:
col = x
row += 1
if grid.in_bounds(col, row):
grid.put(col, row, ch, style=span_style)
col += 1
else:
# Simple text with word wrapping
wrapped = textwrap.wrap(node.content, width=w) if node.content else [""]
for i, line in enumerate(wrapped):
if y + i < grid.height:
text = _align_text(line, w, node.style.align)
grid.put_text(x, y + i, text, style=style)
elif isinstance(node, Label):
style = _style_from_node(node)
key_style = CellStyle(bold=True, fg=style.fg, bg=style.bg)
# Key: value layout with padding
key_text = f"{node.key}:"
pad = max(1, 16 - len(key_text))
grid.put_text(x, y, key_text, style=key_style)
grid.put_text(x + len(key_text) + pad, y, node.value, style=style)
elif isinstance(node, Divider):
ds = node.divider_style
char = DIVIDER_CHARS.get(ds.name.lower(), "")
style = CellStyle(fg="555")
for col in range(x, x + w):
grid.put(col, y, char, style=style)
elif isinstance(node, Link):
style = CellStyle(fg="0cf", underline=True)
# In ASCII mode, display as [text]. In Micron, the emitter wraps with link syntax.
# Write just the display text — the link metadata goes on cells for Micron emission.
grid.put_text(x, y, node.display[:w], style=style, link=node.dest)
elif isinstance(node, ListNode):
for child in node.children:
paint(child, grid)
elif isinstance(node, ListItem):
style = _style_from_node(node)
# Parent determines bullet style — use a simple bullet for now
bullet = ""
grid.put_text(x - 2, y, bullet, style=CellStyle(fg="888"))
# Wrap content
wrapped = textwrap.wrap(node.content, width=w) if node.content else [""]
for i, line in enumerate(wrapped):
if y + i < grid.height:
grid.put_text(x, y + i, line, style=style)
elif isinstance(node, Gauge):
style = _style_from_node(node)
# Label
label_text = f"{node.label} "
grid.put_text(x, y, label_text, style=CellStyle(bold=True))
bar_x = x + len(label_text)
bar_w = min(node.bar_width, w - len(label_text) - 6)
if bar_w > 0:
pct = min(node.value / node.max_val, 1.0) if node.max_val > 0 else 0
filled = int(bar_w * pct)
# Determine color based on thresholds
fg = "0f0" # green
if node.crit is not None and node.value >= node.crit:
fg = "f00" # red
elif node.warn is not None and node.value >= node.warn:
fg = "ff0" # yellow
for i in range(bar_w):
if i < filled:
grid.put(bar_x + i, y, GAUGE_FILLED, style=CellStyle(fg=fg))
else:
grid.put(bar_x + i, y, GAUGE_EMPTY, style=CellStyle(fg="555"))
# Percentage
pct_text = f" {int(pct * 100)}%"
grid.put_text(bar_x + bar_w, y, pct_text, style=CellStyle(fg=fg))
elif isinstance(node, Sparkline):
style = _style_from_node(node)
label_text = f"{node.label} "
grid.put_text(x, y, label_text, style=CellStyle(bold=True))
spark_x = x + len(label_text)
chars = sparkline_chars(node.values, node.spark_width)
spark_style = CellStyle(fg="0cf")
for i, ch in enumerate(chars):
grid.put(spark_x + i, y, ch, style=spark_style)
elif isinstance(node, Status):
char = STATUS_CHARS.get(node.state, "")
color = STATUS_COLORS.get(node.state, "888")
grid.put(x, y, char, style=CellStyle(fg=color))
grid.put_text(x + 2, y, node.label)
else:
# Generic: paint children
for child in node.children:
paint(child, grid)

350
backend/uframe/parser.py Normal file
View File

@@ -0,0 +1,350 @@
"""µFrame parser — .uf source text → IR tree.
Line-oriented, indentation-based (2-space). Each line is parsed as:
(indent_level, keyword, arguments)
Nesting is determined by indentation: children are indented deeper
than their parent.
"""
from __future__ import annotations
import re
import shlex
from typing import Sequence
from uframe.errors import ParseError
from uframe.ir import (
IRNode, Page, Box, Row, Col, Spacer, Pad,
Heading, Text, Label, Divider, Link, ListNode, ListItem,
Gauge, Sparkline, Status, Table, TextSpan,
BorderWeight, HeadingLevel, DividerStyle, ListStyle, Align, Style,
)
# ---------------------------------------------------------------------------
# Tokenisation helpers
# ---------------------------------------------------------------------------
_INDENT_RE = re.compile(r"^( *)")
_MODIFIER_RE = re.compile(r"@(\w+)\{([^}]*)\}")
def _indent_level(line: str) -> int:
"""Count leading spaces and return indent level (2 spaces = 1 level)."""
spaces = len(_INDENT_RE.match(line).group(1)) # type: ignore[union-attr]
return spaces // 2
def _split_args(text: str) -> list[str]:
"""Split argument string respecting quoted tokens."""
try:
return shlex.split(text)
except ValueError:
return text.split()
def parse_inline(content: str) -> list[TextSpan]:
"""Parse @modifier{text} syntax into a list of TextSpan objects.
Supported modifiers: @bold{}, @italic{}, @under{}, @color{hex}{},
@bg{hex}{}.
"""
spans: list[TextSpan] = []
pos = 0
# Match @modifier{content} — including nested @color{hex}{text}
pattern = re.compile(
r"@(bold|italic|under|color|bg)"
r"(?:\{([0-9a-fA-F]{3})\})?" # optional hex arg for color/bg
r"\{([^}]*)\}"
)
for m in pattern.finditer(content):
# Add plain text before this modifier
if m.start() > pos:
spans.append(TextSpan(text=content[pos:m.start()]))
mod = m.group(1)
hex_arg = m.group(2)
inner = m.group(3)
span = TextSpan(text=inner)
if mod == "bold":
span.bold = True
elif mod == "italic":
span.italic = True
elif mod == "under":
span.underline = True
elif mod == "color" and hex_arg:
span.fg = hex_arg
elif mod == "bg" and hex_arg:
span.bg = hex_arg
spans.append(span)
pos = m.end()
# Trailing plain text
if pos < len(content):
spans.append(TextSpan(text=content[pos:]))
# If no modifiers found, return single plain span
if not spans:
spans.append(TextSpan(text=content))
return spans
# ---------------------------------------------------------------------------
# Line-level parsing — keyword dispatch
# ---------------------------------------------------------------------------
def _parse_border_weight(s: str) -> BorderWeight:
return {
"light": BorderWeight.LIGHT,
"heavy": BorderWeight.HEAVY,
"double": BorderWeight.DOUBLE,
"rounded": BorderWeight.ROUNDED,
}.get(s.lower(), BorderWeight.LIGHT)
def _parse_divider_style(s: str) -> DividerStyle:
return {
"light": DividerStyle.LIGHT,
"heavy": DividerStyle.HEAVY,
"double": DividerStyle.DOUBLE,
"dash": DividerStyle.DASH,
"dot": DividerStyle.DOT,
}.get(s.lower(), DividerStyle.LIGHT)
def _parse_heading_level(s: str) -> HeadingLevel:
return {
"1": HeadingLevel.H1,
"2": HeadingLevel.H2,
"3": HeadingLevel.H3,
}.get(s, HeadingLevel.H1)
def _parse_list_style(s: str) -> ListStyle:
return {
"bullet": ListStyle.BULLET,
"dash": ListStyle.DASH,
"number": ListStyle.NUMBER,
"arrow": ListStyle.ARROW,
}.get(s.lower(), ListStyle.BULLET)
def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
"""Parse a single line into an IR node based on the keyword."""
if keyword == "page":
title = args[0] if args else "Untitled"
width = int(args[1]) if len(args) > 1 else 64
return Page(title=title, width=width, source_line=line_num)
elif keyword == "box":
# box [weight] "title"
if len(args) >= 2:
weight = _parse_border_weight(args[0])
title = args[1]
elif len(args) == 1:
# Could be weight or title
if args[0].lower() in ("light", "heavy", "double", "rounded"):
weight = _parse_border_weight(args[0])
title = ""
else:
weight = BorderWeight.LIGHT
title = args[0]
else:
weight = BorderWeight.LIGHT
title = ""
return Box(title=title, weight=weight, source_line=line_num)
elif keyword == "row":
gap = int(args[0]) if args else 1
return Row(gap=gap, source_line=line_num)
elif keyword == "col":
w = int(args[0]) if args else None
return Col(col_width=w, source_line=line_num)
elif keyword == "spacer":
lines = int(args[0]) if args else 1
return Spacer(lines=lines, source_line=line_num)
elif keyword == "pad":
vals = [int(a) for a in args[:4]]
while len(vals) < 4:
vals.append(0)
return Pad(top=vals[0], right=vals[1], bottom=vals[2], left=vals[3],
source_line=line_num)
elif keyword == "heading":
level_str = args[0] if args else "1"
text = args[1] if len(args) > 1 else ""
return Heading(level=_parse_heading_level(level_str), text=text,
source_line=line_num)
elif keyword == "text":
content = args[0] if args else ""
spans = parse_inline(content)
return Text(content=content, spans=spans, source_line=line_num)
elif keyword == "label":
key = args[0] if args else ""
value = args[1] if len(args) > 1 else ""
return Label(key=key, value=value, source_line=line_num)
elif keyword == "divider":
style = _parse_divider_style(args[0]) if args else DividerStyle.LIGHT
return Divider(divider_style=style, source_line=line_num)
elif keyword == "link":
display = args[0] if args else ""
dest = args[1] if len(args) > 1 else ""
return Link(display=display, dest=dest, source_line=line_num)
elif keyword == "list":
style = _parse_list_style(args[0]) if args else ListStyle.BULLET
return ListNode(list_style=style, source_line=line_num)
elif keyword == "item":
content = args[0] if args else ""
return ListItem(content=content, source_line=line_num)
# Style modifiers (applied to parent)
elif keyword == "align":
val = args[0].lower() if args else "left"
align = {"left": Align.LEFT, "center": Align.CENTER, "right": Align.RIGHT}.get(val, Align.LEFT)
return _StyleDirective("align", align, line_num)
elif keyword == "color":
return _StyleDirective("fg", args[0] if args else None, line_num)
elif keyword == "bg":
return _StyleDirective("bg", args[0] if args else None, line_num)
elif keyword == "bold":
return _StyleDirective("bold", True, line_num)
elif keyword == "italic":
return _StyleDirective("italic", True, line_num)
elif keyword == "underline":
return _StyleDirective("underline", True, line_num)
# 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
# Parse warn=N crit=N from remaining args
warn = crit = None
for a in args[4:]:
if a.startswith("warn="):
warn = float(a[5:])
elif a.startswith("crit="):
crit = float(a[5:])
return Gauge(label=label, value=value, max_val=max_val,
bar_width=bar_width, warn=warn, crit=crit,
source_line=line_num)
elif keyword == "sparkline":
label = args[0] if args else ""
vals_str = args[1] if len(args) > 1 else ""
values = [float(v) for v in vals_str.split(",") if v.strip()] if vals_str else []
width = int(args[2]) if len(args) > 2 else 20
return Sparkline(label=label, values=values, spark_width=width,
source_line=line_num)
elif keyword == "status":
label = args[0] if args else ""
state = args[1] if len(args) > 1 else "unknown"
return Status(label=label, state=state, source_line=line_num)
else:
raise ParseError(f"Unknown keyword: {keyword!r}", line=line_num)
# ---------------------------------------------------------------------------
# Style directives — pseudo-nodes that modify their parent's style
# ---------------------------------------------------------------------------
class _StyleDirective(IRNode):
"""Temporary node representing a style modifier (align, color, bold, etc.).
These are absorbed by the parent during tree building and never appear
in the final IR tree.
"""
def __init__(self, attr: str, value: object, line_num: int):
super().__init__(source_line=line_num)
self.attr = attr
self.value = value
# ---------------------------------------------------------------------------
# Tree builder
# ---------------------------------------------------------------------------
def parse(source: str) -> Page:
"""Parse a .uf source string into an IR tree rooted at a Page node.
Returns the Page node with all children attached.
"""
lines = source.split("\n")
# Stack: list of (indent_level, node)
stack: list[tuple[int, IRNode]] = []
root: Page | None = None
for line_num, raw_line in enumerate(lines, start=1):
# Skip blank lines and comments
stripped = raw_line.strip()
if not stripped or stripped.startswith("#"):
continue
indent = _indent_level(raw_line)
# Split into keyword + arguments
parts = stripped.split(None, 1)
keyword = parts[0].lower()
arg_str = parts[1] if len(parts) > 1 else ""
args = _split_args(arg_str)
# Parse this line into a node
node = _parse_line(keyword, args, line_num)
# Pop stack back to find the parent (parent indent < this indent)
while stack and stack[-1][0] >= indent:
stack.pop()
if isinstance(node, _StyleDirective):
# Apply style directive to the current top of stack (parent)
if stack:
parent = stack[-1][1]
setattr(parent.style, node.attr, node.value)
continue
# Attach to parent
if stack:
parent = stack[-1][1]
parent.children.append(node)
elif isinstance(node, Page):
root = node
else:
# Auto-wrap in a default Page if source doesn't start with `page`
root = Page(title="Untitled", width=64, source_line=0)
root.children.append(node)
stack.append((-1, root))
# Push onto stack
if isinstance(node, Page) and root is node:
stack.append((-1, node))
else:
stack.append((indent, node))
if root is None:
root = Page(title="Untitled", width=64, source_line=0)
return root

View File

View File

@@ -0,0 +1,212 @@
"""End-to-end tests for the µFrame compile pipeline."""
import uframe
def test_empty_source():
result = uframe.compile("")
assert result.ascii == ""
assert result.micron == ""
def test_simple_heading():
result = uframe.compile('page "Test" 40\n heading 1 "Hello World"')
assert "Hello World" in result.ascii
assert "Hello World" in result.micron
def test_box_with_title():
source = '''page "Demo" 40
box light "Status"
text "All systems go"'''
result = uframe.compile(source)
# ASCII should have box-drawing characters
assert "" in result.ascii
assert "" in result.ascii
assert "Status" in result.ascii
assert "All systems go" in result.ascii
def test_box_heavy():
source = '''page "Demo" 40
box heavy "Alert"
text "Warning"'''
result = uframe.compile(source)
assert "" in result.ascii
assert "Alert" in result.ascii
def test_box_double():
source = '''page "Demo" 40
box double "Title"
text "Content"'''
result = uframe.compile(source)
assert "" in result.ascii
assert "Title" in result.ascii
def test_box_rounded():
source = '''page "Demo" 40
box rounded "Panel"
text "Inside"'''
result = uframe.compile(source)
assert "" in result.ascii
assert "Panel" in result.ascii
def test_row_with_columns():
source = '''page "Demo" 40
row 2
col 18
text "Left"
col 18
text "Right"'''
result = uframe.compile(source)
assert "Left" in result.ascii
assert "Right" in result.ascii
def test_label():
source = '''page "Demo" 40
label "Name" "Alice"'''
result = uframe.compile(source)
assert "Name:" in result.ascii
assert "Alice" in result.ascii
def test_divider():
source = '''page "Demo" 40
divider heavy'''
result = uframe.compile(source)
assert "" in result.ascii
def test_link():
source = '''page "Demo" 40
link "Home" "/page/index.mu"'''
result = uframe.compile(source)
assert "Home" in result.ascii
# Micron should have link syntax
assert "index.mu" in result.micron
def test_list():
source = '''page "Demo" 40
list bullet
item "First"
item "Second"'''
result = uframe.compile(source)
assert "First" in result.ascii
assert "Second" in result.ascii
def test_spacer():
source = '''page "Demo" 40
text "Before"
spacer 2
text "After"'''
result = uframe.compile(source)
lines = result.ascii.split("\n")
# Should have blank lines between Before and After
before_idx = next(i for i, l in enumerate(lines) if "Before" in l)
after_idx = next(i for i, l in enumerate(lines) if "After" in l)
assert after_idx - before_idx >= 3 # at least 2 blank lines between
def test_nested_boxes():
source = '''page "Demo" 40
box light "Outer"
box heavy "Inner"
text "Deep"'''
result = uframe.compile(source)
assert "Outer" in result.ascii
assert "Inner" in result.ascii
assert "Deep" in result.ascii
def test_micron_has_style_tags():
source = '''page "Demo" 40
heading 1 "Title"'''
result = uframe.compile(source)
# Micron should contain color tags for the heading
assert "`F" in result.micron or "Title" in result.micron
def test_gauge():
source = '''page "Demo" 40
gauge "CPU" 62 100 20 warn=75 crit=90'''
result = uframe.compile(source)
assert "CPU" in result.ascii
assert "" in result.ascii
assert "62%" in result.ascii
def test_comment_ignored():
source = '''page "Demo" 40
# this is a comment
text "Visible"'''
result = uframe.compile(source)
assert "Visible" in result.ascii
assert "comment" not in result.ascii
def test_inline_modifiers():
source = '''page "Demo" 40
text "Hello @bold{world} today"'''
result = uframe.compile(source)
assert "Hello" in result.ascii
assert "world" in result.ascii
# Micron should have bold tags around "world"
assert "`!" in result.micron
def test_status():
source = '''page "Demo" 40
status "Server" online'''
result = uframe.compile(source)
assert "" in result.ascii
assert "Server" in result.ascii
def test_full_dashboard():
"""Integration test: a realistic dashboard layout."""
source = '''page "Node Status" 60
box double "Relay Alpha-7"
align center
text "Reticulum Network Node"
spacer
heading 1 "Resources"
gauge "CPU" 62 100 28 warn=75 crit=90
gauge "MEM" 84 100 28 warn=80 crit=95
spacer
heading 2 "Peers"
label "Active" "7 / 12"
status "East Relay" online
status "South Bridge" online
status "Node Gamma" degraded
divider heavy
link "Home" "/page/index.mu"'''
result = uframe.compile(source)
# Verify key elements are present
assert "Relay Alpha-7" in result.ascii
assert "" in result.ascii # double box
assert "CPU" in result.ascii
assert "MEM" in result.ascii
assert "" in result.ascii # gauge bars
assert "" in result.ascii # status indicators
assert "" in result.ascii # heavy divider
assert "Home" in result.ascii
# Micron should have color tags
assert "`F" in result.micron
assert result.micron # non-empty