From df11705875d7f1cd27940964c3e78c756cd559c5 Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 1 Apr 2026 07:50:43 +0200 Subject: [PATCH] feat: templates --- backend/graph.py | 83 ++++++- backend/uframe/layout.py | 4 +- backend/uframe/measure.py | 10 +- backend/uframe/paint.py | 132 +++++++++- backend/uframe/parser.py | 51 ++++ backend/uframe/tests/test_compile.py | 31 +++ frontend/src/components/editor/ToolBar.tsx | 56 ++++- frontend/src/components/editor/examples.ts | 230 ++++++++++++++++++ .../src/components/editor/uframeCommands.ts | 10 + frontend/src/routes/EditorView.tsx | 1 + 10 files changed, 589 insertions(+), 19 deletions(-) create mode 100644 frontend/src/components/editor/examples.ts diff --git a/backend/graph.py b/backend/graph.py index 91829d3..2c8eec5 100644 --- a/backend/graph.py +++ b/backend/graph.py @@ -1,5 +1,6 @@ import os import re +import shlex from pathlib import Path from fastapi import APIRouter @@ -10,8 +11,10 @@ router = APIRouter() SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources")) PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages")) -# Matches Micron links: [label`slug] or [label`slug.mu] -_INTERNAL_LINK = re.compile(r'\[([^`\]]+)`([a-zA-Z0-9_-]+)(?:\.mu)?\]') +# Match µFrame link nodes: link "display" "/page/slug.mu" or link "display" "slug" +_UF_LINK = re.compile(r'^\s*link\s+', re.IGNORECASE) +# Fallback: Micron links [label`slug] or [label`slug.mu] +_MICRON_LINK = re.compile(r'\[([^`\]]+)`([a-zA-Z0-9_-]+)(?:\.mu)?\]') class GraphNode(BaseModel): @@ -38,19 +41,78 @@ def _all_page_names() -> set[str]: names.add(f.stem) if SOURCES_DIR.is_dir(): for f in SOURCES_DIR.iterdir(): - if f.suffix == ".mu" and f.is_file(): + if f.suffix in (".uf", ".mu") and f.is_file(): names.add(f.stem) return names -def _extract_title(micron: str) -> str | None: - for line in micron.splitlines(): +def _extract_title(source: str) -> str | None: + """Extract title from µFrame page or heading, or legacy Micron >Title.""" + for line in source.splitlines(): stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if stripped.lower().startswith("page "): + try: + parts = shlex.split(stripped) + if len(parts) >= 2: + return parts[1] + except ValueError: + pass + break + if stripped.lower().startswith("heading "): + try: + parts = shlex.split(stripped) + if len(parts) >= 3: + return parts[2] + except ValueError: + pass + break if stripped.startswith(">") and not stripped.startswith(">>"): return stripped[1:].strip() + break return None +def _extract_links(source: str, all_names: set[str]) -> list[str]: + """Extract internal link targets from µFrame or Micron source.""" + targets: list[str] = [] + + for line in source.splitlines(): + stripped = line.strip() + + # µFrame: link "display" "/page/slug.mu" or link "display" "slug" + if _UF_LINK.match(stripped): + try: + parts = shlex.split(stripped) + if len(parts) >= 3: + dest = parts[2] + # Normalize: /page/slug.mu → slug + slug = dest.rsplit("/", 1)[-1].removesuffix(".mu") + if slug in all_names: + targets.append(slug) + except ValueError: + pass + continue + + # Fallback: Micron link syntax [label`slug] + for m in _MICRON_LINK.finditer(stripped): + slug = m.group(2) + if slug in all_names: + targets.append(slug) + + return targets + + +def _source_path(name: str) -> Path | None: + """Get source file path, preferring .uf over .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 None + + @router.get("/graph", response_model=GraphData) async def get_graph(): all_names = _all_page_names() @@ -58,18 +120,15 @@ async def get_graph(): edges: list[GraphEdge] = [] for name in sorted(all_names): - src_path = SOURCES_DIR / f"{name}.mu" + src_path = _source_path(name) mu_path = PAGES_DIR / f"{name}.mu" title = None - if src_path.is_file(): + if src_path: content = src_path.read_text(encoding="utf-8") title = _extract_title(content) - - for match in _INTERNAL_LINK.finditer(content): - target = match.group(2) - if target in all_names: - edges.append(GraphEdge(source=name, target=target)) + for target in _extract_links(content, all_names): + edges.append(GraphEdge(source=name, target=target)) nodes.append(GraphNode( id=name, diff --git a/backend/uframe/layout.py b/backend/uframe/layout.py index ee908f7..ed27738 100644 --- a/backend/uframe/layout.py +++ b/backend/uframe/layout.py @@ -5,7 +5,7 @@ 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, + Gauge, Sparkline, Status, Table, ) @@ -112,7 +112,7 @@ def layout(node: IRNode, x: int, y: int, w: int, h: int) -> int: return node.rect.h elif isinstance(node, (Heading, Text, Label, Divider, Link, ListItem, - Gauge, Sparkline, Status)): + Gauge, Sparkline, Status, Table)): node.rect.h = node.pref_height return node.pref_height diff --git a/backend/uframe/measure.py b/backend/uframe/measure.py index 354be71..26eefb1 100644 --- a/backend/uframe/measure.py +++ b/backend/uframe/measure.py @@ -5,7 +5,7 @@ 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, + Gauge, Sparkline, Status, Table, ) @@ -194,6 +194,14 @@ def measure(node: IRNode, available_width: int) -> None: node.pref_height = 1 node.min_height = 1 + elif isinstance(node, Table): + # Height = header border + header row + separator + data rows + bottom border + num_rows = len(node.rows) + node.pref_width = available_width + node.min_width = len(node.columns) * 3 + 1 # minimum 3 chars per col + borders + node.pref_height = num_rows + 4 # top border + header + separator + rows + bottom border + node.min_height = 4 + else: # Generic: just measure children total_h = 0 diff --git a/backend/uframe/paint.py b/backend/uframe/paint.py index 0a35f78..407ab45 100644 --- a/backend/uframe/paint.py +++ b/backend/uframe/paint.py @@ -10,15 +10,15 @@ from __future__ import annotations import textwrap from uframe.chars import ( - DIVIDER_CHARS, GAUGE_FILLED, GAUGE_EMPTY, + BOX_CHARS, 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, + Gauge, Sparkline, Status, Table, + HeadingLevel, DividerStyle, ListStyle, Align, BorderWeight, ) @@ -210,7 +210,133 @@ def paint(node: IRNode, grid: CharGrid) -> None: grid.put(x, y, char, style=CellStyle(fg=color)) grid.put_text(x + 2, y, node.label) + elif isinstance(node, Table): + _paint_table(node, grid, x, y, w) + else: # Generic: paint children for child in node.children: paint(child, grid) + + +def _paint_table(node: Table, grid: CharGrid, x: int, y: int, w: int) -> None: + """Paint a box-drawn table with header and data rows.""" + if not node.columns: + return + + ch = BOX_CHARS[BorderWeight.LIGHT] + border_style = CellStyle() + header_style = CellStyle(bold=True) + + num_cols = len(node.columns) + + # Calculate column widths + # If columns have explicit widths, use them. Otherwise distribute evenly. + col_widths: list[int] = [] + total_explicit = 0 + auto_count = 0 + for _, cw in node.columns: + if cw > 0: + col_widths.append(cw) + total_explicit += cw + else: + col_widths.append(0) + auto_count += 1 + + # Available inner width = total - borders (num_cols + 1 border chars) + inner_w = w - (num_cols + 1) + if auto_count > 0: + auto_each = max(1, (inner_w - total_explicit) // auto_count) + for i in range(len(col_widths)): + if col_widths[i] == 0: + col_widths[i] = auto_each + + # Compute column x positions (after each left border) + col_x: list[int] = [] + cx = x + 1 # after left border + for cw in col_widths: + col_x.append(cx) + cx += cw + 1 # +1 for separator + + table_w = cx - x # total table width including right border + + row_y = y + + # ── Top border ── + grid.put(x, row_y, ch["tl"], border_style, is_border=True, border_weight=BorderWeight.LIGHT) + grid.put(x + table_w - 1, row_y, ch["tr"], border_style, is_border=True, border_weight=BorderWeight.LIGHT) + for ci, cw in enumerate(col_widths): + for j in range(cw): + grid.put(col_x[ci] + j, row_y, ch["h"], border_style, is_border=True, border_weight=BorderWeight.LIGHT) + # Column separator on top border + if ci < num_cols - 1: + grid.put(col_x[ci] + cw, row_y, ch["t_down"], border_style, is_border=True, border_weight=BorderWeight.LIGHT) + row_y += 1 + + # ── Header row ── + grid.put(x, row_y, ch["v"], border_style, is_border=True, border_weight=BorderWeight.LIGHT) + for ci, (col_name, _) in enumerate(node.columns): + text = col_name[:col_widths[ci]].ljust(col_widths[ci]) + grid.put_text(col_x[ci], row_y, text, style=header_style) + sep_x = col_x[ci] + col_widths[ci] + grid.put(sep_x, row_y, ch["v"], border_style, is_border=True, border_weight=BorderWeight.LIGHT) + row_y += 1 + + # ── Header separator ── + grid.put(x, row_y, ch["t_right"], border_style, is_border=True, border_weight=BorderWeight.LIGHT) + grid.put(x + table_w - 1, row_y, ch["t_left"], border_style, is_border=True, border_weight=BorderWeight.LIGHT) + for ci, cw in enumerate(col_widths): + for j in range(cw): + grid.put(col_x[ci] + j, row_y, ch["h"], border_style, is_border=True, border_weight=BorderWeight.LIGHT) + if ci < num_cols - 1: + grid.put(col_x[ci] + cw, row_y, ch["cross"], border_style, is_border=True, border_weight=BorderWeight.LIGHT) + row_y += 1 + + # ── Data rows ── + cell_style = CellStyle() + for row_data in node.rows: + grid.put(x, row_y, ch["v"], border_style, is_border=True, border_weight=BorderWeight.LIGHT) + for ci in range(num_cols): + cell_text = row_data[ci] if ci < len(row_data) else "" + text = cell_text[:col_widths[ci]].ljust(col_widths[ci]) + + # Check for @color{hex}{text} modifiers in cell content + if "@" in cell_text: + spans = [] + import re as _re + pattern = _re.compile(r"@color\{([0-9a-fA-F]{3})\}\{([^}]*)\}") + pos = 0 + styled_parts: list[tuple[str, CellStyle]] = [] + for m in pattern.finditer(cell_text): + if m.start() > pos: + styled_parts.append((cell_text[pos:m.start()], cell_style)) + styled_parts.append((m.group(2), CellStyle(fg=m.group(1)))) + pos = m.end() + if pos < len(cell_text): + styled_parts.append((cell_text[pos:], cell_style)) + + col_pos = col_x[ci] + for part_text, part_style in styled_parts: + for pch in part_text: + if col_pos < col_x[ci] + col_widths[ci]: + grid.put(col_pos, row_y, pch, style=part_style) + col_pos += 1 + # Pad remaining + while col_pos < col_x[ci] + col_widths[ci]: + grid.put(col_pos, row_y, " ") + col_pos += 1 + else: + grid.put_text(col_x[ci], row_y, text, style=cell_style) + + sep_x = col_x[ci] + col_widths[ci] + grid.put(sep_x, row_y, ch["v"], border_style, is_border=True, border_weight=BorderWeight.LIGHT) + row_y += 1 + + # ── Bottom border ── + grid.put(x, row_y, ch["bl"], border_style, is_border=True, border_weight=BorderWeight.LIGHT) + grid.put(x + table_w - 1, row_y, ch["br"], border_style, is_border=True, border_weight=BorderWeight.LIGHT) + for ci, cw in enumerate(col_widths): + for j in range(cw): + grid.put(col_x[ci] + j, row_y, ch["h"], border_style, is_border=True, border_weight=BorderWeight.LIGHT) + if ci < num_cols - 1: + grid.put(col_x[ci] + cw, row_y, ch["t_up"], border_style, is_border=True, border_weight=BorderWeight.LIGHT) diff --git a/backend/uframe/parser.py b/backend/uframe/parser.py index 7d841b1..d8652b7 100644 --- a/backend/uframe/parser.py +++ b/backend/uframe/parser.py @@ -161,6 +161,15 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode: return Box(title=title, weight=weight, source_line=line_num) elif keyword == "row": + # Table row (has | separators) vs layout Row (has gap number or nothing) + raw = " ".join(args) + if "|" in raw: + # Split by | and strip quotes from each cell, preserving @modifiers + cells: list[str] = [] + for part in raw.split("|"): + cell = part.strip().strip('"') + cells.append(cell) + return _TableRow(cells, line_num) gap = int(args[0]) if args else 1 return Row(gap=gap, source_line=line_num) @@ -263,10 +272,41 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode: state = args[1] if len(args) > 1 else "unknown" return Status(label=label, state=state, source_line=line_num) + elif keyword == "table": + title = args[0] if args else "" + return Table(title=title, source_line=line_num) + + elif keyword == "columns": + # columns "Name" 24 | "Hops" 6 | "Status" 10 + # Re-join args and split by | + raw = " ".join(args) + cols: list[tuple[str, int]] = [] + for part in raw.split("|"): + tokens = _split_args(part.strip()) + if tokens: + col_name = tokens[0] + col_w = int(tokens[1]) if len(tokens) > 1 else 0 + cols.append((col_name, col_w)) + return _TableColumns(cols, line_num) + else: raise ParseError(f"Unknown keyword: {keyword!r}", line=line_num) +class _TableColumns(IRNode): + """Temporary node — absorbed by parent Table during tree building.""" + def __init__(self, columns: list[tuple[str, int]], line_num: int): + super().__init__(source_line=line_num) + self.columns = columns + + +class _TableRow(IRNode): + """Temporary node — absorbed by parent Table during tree building.""" + def __init__(self, cells: list[str], line_num: int): + super().__init__(source_line=line_num) + self.cells = cells + + # --------------------------------------------------------------------------- # Style directives — pseudo-nodes that modify their parent's style # --------------------------------------------------------------------------- @@ -326,6 +366,17 @@ def parse(source: str) -> Page: setattr(parent.style, node.attr, node.value) continue + # Table children: columns and rows are absorbed by the Table node + if isinstance(node, _TableColumns): + if stack and isinstance(stack[-1][1], Table): + stack[-1][1].columns = node.columns + continue + + if isinstance(node, _TableRow): + if stack and isinstance(stack[-1][1], Table): + stack[-1][1].rows.append(node.cells) + continue + # Attach to parent if stack: parent = stack[-1][1] diff --git a/backend/uframe/tests/test_compile.py b/backend/uframe/tests/test_compile.py index 7875259..159c924 100644 --- a/backend/uframe/tests/test_compile.py +++ b/backend/uframe/tests/test_compile.py @@ -168,6 +168,37 @@ def test_status(): assert "Server" in result.ascii +def test_table(): + source = '''page "Demo" 50 + table "Routes" + columns "Destination" 20 | "Hops" 6 | "Status" 10 + row "relay-east" | "2" | "alive" + row "bridge-south" | "4" | "alive" + row "node-gamma" | "7" | "stale"''' + result = uframe.compile(source) + assert "┌" in result.ascii + assert "┼" in result.ascii # column separators at header line + assert "Destination" in result.ascii + assert "relay-east" in result.ascii + assert "bridge-south" in result.ascii + assert "stale" in result.ascii + # Should have proper structure + lines = result.ascii.split("\n") + assert len(lines) >= 6 # top border + header + sep + 3 rows + bottom border + + +def test_table_with_color(): + source = '''page "Demo" 50 + table "Peers" + columns "Name" 16 | "State" 12 + row "east-relay" | "@color{0f0}{● alive}" + row "node-gamma" | "@color{f00}{○ stale}"''' + result = uframe.compile(source) + assert "●" in result.ascii + assert "○" in result.ascii + assert "`F0f0" in result.micron # green color tag + + def test_full_dashboard(): """Integration test: a realistic dashboard layout.""" source = '''page "Node Status" 60 diff --git a/frontend/src/components/editor/ToolBar.tsx b/frontend/src/components/editor/ToolBar.tsx index c79332c..1f933fe 100644 --- a/frontend/src/components/editor/ToolBar.tsx +++ b/frontend/src/components/editor/ToolBar.tsx @@ -1,14 +1,25 @@ +import { useState } from "react"; +import { BookOpen } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverTrigger, + PopoverContent, + PopoverHeader, + PopoverTitle, +} from "@/components/ui/popover"; import { useEditorStore } from "@/stores/editorStore"; import { useBacklinks } from "@/hooks/useBacklinks"; import BacklinkIndicator from "@/components/editor/BacklinkIndicator"; +import { EXAMPLES } from "@/components/editor/examples"; interface Props { pageName: string; onNameChange?: (name: string) => void; onSaveDraft: () => void; onPublish: () => void; + onInsertExample: (source: string) => void; saving: boolean; isDirty: boolean; } @@ -18,11 +29,13 @@ export default function ToolBar({ onNameChange, onSaveDraft, onPublish, + onInsertExample, saving, isDirty, }: Props) { const currentPage = useEditorStore((s) => s.currentPage); const backlinks = useBacklinks(currentPage?.name); + const [examplesOpen, setExamplesOpen] = useState(false); return (
@@ -37,6 +50,42 @@ export default function ToolBar({ {pageName} )} + + + + Examples + + } + /> + + + Insert Example + +
+ {EXAMPLES.map((ex) => ( + + ))} +
+
+
+
@@ -45,7 +94,12 @@ export default function ToolBar({ Unsaved )} -