feat: added a twist
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
112
backend/pages.py
112
backend/pages.py
@@ -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()
|
||||
|
||||
|
||||
74
backend/uframe/__init__.py
Normal file
74
backend/uframe/__init__.py
Normal 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
148
backend/uframe/borders.py
Normal 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
158
backend/uframe/chars.py
Normal 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 0–7 (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 0–7 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": "┄",
|
||||
}
|
||||
22
backend/uframe/emit_ascii.py
Normal file
22
backend/uframe/emit_ascii.py
Normal 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)
|
||||
121
backend/uframe/emit_micron.py
Normal file
121
backend/uframe/emit_micron.py
Normal 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
45
backend/uframe/errors.py
Normal 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
170
backend/uframe/grid.py
Normal 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
243
backend/uframe/ir.py
Normal 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 1–3)."""
|
||||
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
134
backend/uframe/layout.py
Normal 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
206
backend/uframe/measure.py
Normal 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
216
backend/uframe/paint.py
Normal 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
350
backend/uframe/parser.py
Normal 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
|
||||
0
backend/uframe/tests/__init__.py
Normal file
0
backend/uframe/tests/__init__.py
Normal file
212
backend/uframe/tests/test_compile.py
Normal file
212
backend/uframe/tests/test_compile.py
Normal 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
|
||||
Reference in New Issue
Block a user