Compare commits
43 Commits
0b7deee59e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 46185705d7 | |||
| d8abe311cd | |||
| 2994735f3b | |||
| d7e9788f99 | |||
| 6e8a4af40d | |||
| 20e41f7680 | |||
| c9eead1965 | |||
| a1ad332e01 | |||
| 3c5856aecb | |||
| ad89f409bf | |||
| e1db06104e | |||
| 7838760ca4 | |||
| 914945279f | |||
| 3eec7f316e | |||
| 3132d40391 | |||
| 72ff02dbdf | |||
| e5c9c00d2f | |||
| 285825fff7 | |||
| b878068ab3 | |||
| 234081db09 | |||
| 911f3fb57d | |||
| 0e7e435edd | |||
| e1c7ea7635 | |||
| c820f06d1c | |||
| 0d469f70bf | |||
| f082a30b7f | |||
| 576af715bd | |||
| 82cb4a9786 | |||
| f0b0a5dd24 | |||
| fcf3dc74a8 | |||
| 462d6bf289 | |||
| a95594f446 | |||
| a0a5f18128 | |||
| 0642d0f894 | |||
| 528d2d8519 | |||
| 175de893aa | |||
| 8d3245b7b1 | |||
| 01a3e0095c | |||
| 0316e50233 | |||
| 8776459ffb | |||
| 619d3ec538 | |||
| df11705875 | |||
| b40c6436cd |
17
.claude/launch.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Backend (uvicorn)",
|
||||
"runtimeExecutable": "/bin/sh",
|
||||
"runtimeArgs": ["-c", "PAGES_DIR=$HOME/.nomadnetwork/storage/pages SOURCES_DIR=$HOME/.micron-editor/sources /Users/dtoro/Projects/micronomicon/backend/.venv/bin/uvicorn main:app --reload --port 8080 --app-dir /Users/dtoro/Projects/micronomicon/backend"],
|
||||
"port": 8080
|
||||
},
|
||||
{
|
||||
"name": "Frontend (vite)",
|
||||
"runtimeExecutable": "/opt/homebrew/bin/node",
|
||||
"runtimeArgs": ["/Users/dtoro/Projects/micronomicon/frontend/node_modules/.bin/vite", "/Users/dtoro/Projects/micronomicon/frontend"],
|
||||
"port": 5173
|
||||
}
|
||||
]
|
||||
}
|
||||
353
.claude/plans/nifty-beaming-hanrahan-agent-a35bf8f885e907d39.md
Normal file
@@ -0,0 +1,353 @@
|
||||
# uFrame Integration Plan for Micronomicon
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Replace the existing raw-Micron editor with a uFrame IDE. uFrame is a declarative DSL that compiles `.uf` files into ASCII art and Micron `.mu` files. The user edits `.uf` source; the backend compiles it; the frontend shows dual ASCII + Micron previews. On publish, the backend compiles `.uf` to `.mu` and writes the result to PAGES_DIR.
|
||||
|
||||
---
|
||||
|
||||
## Current Architecture (as-is)
|
||||
|
||||
**Backend** (`backend/`): 5 Python files, no package structure.
|
||||
- `main.py` — FastAPI app, mounts routers at `/api`, serves static frontend
|
||||
- `pages.py` — CRUD for `.mu` files across SOURCES_DIR and PAGES_DIR
|
||||
- `converter.py` — POST `/api/convert` wrapping md2txt (markdown -> micron)
|
||||
- `graph.py` — GET `/api/graph` parsing Micron `[label` + `slug]` links
|
||||
- `docker_utils.py` — POST `/api/restart` for NomadNet container
|
||||
|
||||
**Frontend** (`frontend/src/`):
|
||||
- `EditorView.tsx` — main route, split pane: `EditorPane` (CodeMirror) + `PreviewPane` (Micron HTML)
|
||||
- `editorStore.ts` — state: `micronSource`, `isDirty`, `currentPage`, `previewMode`
|
||||
- `pagesStore.ts` — page list, fetch/delete/unpublish
|
||||
- `micronHighlight.ts` — CodeMirror StreamLanguage for Micron syntax
|
||||
- `micronRenderer.ts` — Micron markup to HTML for preview
|
||||
- `slashCommands.ts` — `/H1`, `/Bold`, etc. autocomplete for Micron codes
|
||||
- `wikiLinkCompletion.ts` — `[[` autocomplete inserting Micron links
|
||||
|
||||
**Storage model**: Sources stored as `.mu` in SOURCES_DIR, published copies in PAGES_DIR. Both are Micron markup. No intermediate format.
|
||||
|
||||
**Key invariant**: NomadNet reads `.mu` files from PAGES_DIR. This must be preserved — uFrame compiles to `.mu` for publishing.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — uFrame Core Engine (Backend Python Package)
|
||||
|
||||
### 1.1 Package Structure
|
||||
|
||||
Create `backend/uframe/` as a Python package:
|
||||
|
||||
```
|
||||
backend/uframe/
|
||||
__init__.py # Public API: compile(source, width) -> CompileResult
|
||||
parser.py # Line-by-line indentation parser -> IR tree
|
||||
ir.py # IR node dataclasses (Page, Box, Row, Col, Text, etc.)
|
||||
grid.py # CharGrid class — 2D character + style buffer
|
||||
measure.py # Bottom-up size computation pass
|
||||
layout.py # Top-down position assignment pass
|
||||
paint.py # Write chars into CharGrid
|
||||
borders.py # Border merge pass — fix junction characters
|
||||
emit_ascii.py # CharGrid -> plain text string
|
||||
emit_micron.py # CharGrid -> Micron markup with style tags
|
||||
errors.py # ParseError, LayoutError exception types
|
||||
```
|
||||
|
||||
### 1.2 New Files to Create
|
||||
|
||||
**`backend/uframe/__init__.py`**
|
||||
Public API surface. Exports `compile(source: str, width: int = 72) -> CompileResult`.
|
||||
CompileResult is a dataclass with fields: `ascii: str`, `micron: str`, `warnings: list[str]`.
|
||||
The function orchestrates: parse -> measure -> layout -> paint -> merge_borders -> emit.
|
||||
|
||||
**`backend/uframe/ir.py`**
|
||||
IR node types as Python dataclasses. Every node has:
|
||||
- `children: list[Node]`
|
||||
- `width`, `height` (computed by measure)
|
||||
- `x`, `y` (computed by layout)
|
||||
- `style: Style` (fg, bg, bold, italic, underline)
|
||||
|
||||
Node types for Phase 1:
|
||||
- `Page` — root, has `width` property, optional `title`
|
||||
- `Box` — border weight (none/light/heavy/double/rounded), padding
|
||||
- `Row` — horizontal container, children laid out left-to-right
|
||||
- `Col` — vertical container, children laid out top-to-bottom
|
||||
- `Spacer` — flexible space filler
|
||||
- `Pad` — explicit padding wrapper
|
||||
- `Heading` — level 1-3, renders with Micron `>`, `>>`, `>>>`
|
||||
- `Text` — body text with inline @modifiers (@bold, @italic, @color)
|
||||
- `Label` — fixed-width key:value pair
|
||||
- `Divider` — horizontal rule with character choice
|
||||
- `Link` — generates `[label` + `target]` in Micron output
|
||||
- `List` — bulleted or numbered list items
|
||||
|
||||
**`backend/uframe/parser.py`**
|
||||
Line-by-line, indentation-based parser. Design:
|
||||
- Each line is `(indent_level, keyword, props_string)`
|
||||
- Indent = 2 spaces per level (configurable)
|
||||
- Keywords: page, box, row, col, spacer, pad, heading, text, label, divider, link, list
|
||||
- Properties parsed from the rest of the line after the keyword
|
||||
- Multiline text: subsequent indented lines without a keyword are text content
|
||||
- Returns ParseTree with root Page node and warnings list
|
||||
- Raises ParseError with line number + message for unrecoverable errors
|
||||
|
||||
**`backend/uframe/grid.py`**
|
||||
CharGrid — the core rendering buffer:
|
||||
- 2D array of Cell objects: (char, fg, bg, bold, italic, underline, border_flag)
|
||||
- Methods: put(x, y, char, style), put_str(x, y, text, style), hline(x, y, length, char), vline(x, y, length, char), rect(x, y, w, h, weight)
|
||||
- Border characters stored with border=True for the merge pass
|
||||
- width and height properties
|
||||
|
||||
**`backend/uframe/measure.py`**
|
||||
Bottom-up pass computing sizes:
|
||||
- Leaf nodes (Text, Heading, Label, Divider) compute natural size
|
||||
- Text wraps to available width, computes resulting height
|
||||
- Row: width = sum(child widths), height = max(child heights)
|
||||
- Col: width = max(child widths), height = sum(child heights)
|
||||
- Box: adds 2 for borders (if weight != none), plus padding
|
||||
- Percentage-based widths resolved relative to parent
|
||||
|
||||
**`backend/uframe/layout.py`**
|
||||
Top-down pass assigning positions:
|
||||
- Page starts at (0, 0)
|
||||
- Row distributes x positions left-to-right
|
||||
- Col distributes y positions top-to-bottom
|
||||
- Spacer expands to fill remaining space
|
||||
|
||||
**`backend/uframe/paint.py`**
|
||||
Renders each node into the CharGrid:
|
||||
- Box: draws border rectangle, then paints children inside
|
||||
- Text: writes word-wrapped text with style
|
||||
- Heading: writes text, marks heading level for Micron emitter
|
||||
- Divider: draws horizontal line
|
||||
- Label: writes "key: value" with key highlighted
|
||||
- Link: writes label text, stores target in metadata for Micron emitter
|
||||
|
||||
**`backend/uframe/borders.py`**
|
||||
Post-paint pass over CharGrid:
|
||||
- Scans for adjacent border cells
|
||||
- Replaces with correct junction characters (T, cross, corner)
|
||||
- Unicode box-drawing character lookup tables
|
||||
- Handles mixed weights (light meets heavy, etc.)
|
||||
|
||||
**`backend/uframe/emit_ascii.py`**
|
||||
Reads CharGrid row by row, outputs plain characters only. Strips trailing whitespace per line.
|
||||
|
||||
**`backend/uframe/emit_micron.py`**
|
||||
Reads CharGrid row by row, inserts Micron formatting codes:
|
||||
- Track current style state, emit format codes on change
|
||||
- Headings get `>`, `>>`, `>>>` prefixes
|
||||
- Links emit Micron link syntax
|
||||
- Reset formatting at end of styled runs
|
||||
|
||||
**`backend/uframe/errors.py`**
|
||||
Exception types: UFrameError (base), ParseError, LayoutError. Each carries line/col/message.
|
||||
|
||||
### 1.3 Existing Files to Modify
|
||||
|
||||
**`backend/converter.py`** — REPLACE contents.
|
||||
Remove md2txt wrapper. Add uFrame compile endpoint:
|
||||
- POST `/api/compile` accepts `{source: str, width: int}`, returns `{ascii: str, micron: str, warnings: list[str]}`
|
||||
- Old POST `/api/convert` can remain as deprecated during transition
|
||||
|
||||
**`backend/pages.py`** — Modify storage model.
|
||||
- SOURCES_DIR stores `.uf` files instead of `.mu` files
|
||||
- `_list_all_page_names()`: scan for `.uf` in SOURCES_DIR, `.mu` in PAGES_DIR
|
||||
- `get_page()`: returns `.uf` source from SOURCES_DIR
|
||||
- `save_page()`: accepts `.uf` source, saves as `.uf`
|
||||
- On publish: compile .uf -> .mu via uframe.compile(), write .mu to PAGES_DIR
|
||||
- `_extract_title()`: parse .uf source for heading or page title= property
|
||||
- PageDetail model: add `source` field (the .uf content)
|
||||
- SaveRequest model: `source` field replaces `micron`
|
||||
|
||||
**`backend/graph.py`** — Modify link extraction.
|
||||
- Parse .uf source files for link nodes instead of Micron link pattern
|
||||
- Use uframe.parser.parse() to get IR tree, walk for Link nodes
|
||||
- Or: simpler regex for link keyword in .uf syntax
|
||||
- `_extract_title()`: parse .uf for page title= or first heading
|
||||
|
||||
**`backend/requirements.txt`** — Remove md2txt dependency (eventually).
|
||||
|
||||
### 1.4 API Changes
|
||||
|
||||
| Method | Path | Before | After |
|
||||
|--------|------|--------|-------|
|
||||
| POST | `/api/convert` | markdown -> micron | DEPRECATED |
|
||||
| POST | `/api/compile` | NEW | .uf source -> {ascii, micron, warnings} |
|
||||
| GET | `/api/pages/{name}` | returns {name, micron} | returns {name, source, micron} |
|
||||
| POST | `/api/pages/{name}` | body: {micron, publish} | body: {source, publish} |
|
||||
|
||||
### 1.5 Migration Strategy for Existing Pages
|
||||
|
||||
Existing .mu source files in SOURCES_DIR need a migration path:
|
||||
- Option A (recommended): One-time migration script `backend/migrate_mu_to_uf.py` wrapping existing Micron in minimal .uf template
|
||||
- Option B: Keep .mu fallback — if .uf not found but .mu exists in SOURCES_DIR, return raw Micron as read-only legacy
|
||||
- pages.py get_page should check for .uf first, fall back to .mu
|
||||
|
||||
### 1.6 Verification Steps
|
||||
|
||||
1. **Unit tests** — `backend/tests/test_uframe/`:
|
||||
- test_parser.py, test_grid.py, test_measure.py, test_layout.py
|
||||
- test_borders.py, test_emit_ascii.py, test_emit_micron.py, test_compile.py
|
||||
|
||||
2. **Golden file tests** — `backend/tests/test_uframe/golden/`:
|
||||
- .uf input files paired with expected .ascii and .mu outputs
|
||||
- Cases: single box, nested rows/cols, heading+text page, mixed content
|
||||
|
||||
3. **API test** — verify POST /api/compile returns valid response
|
||||
4. **Edge cases**: empty page, deeply nested boxes, text overflow, zero-width
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Data Visualization Nodes
|
||||
|
||||
### 2.1 New Files
|
||||
|
||||
**`backend/uframe/viz.py`** — Viz node implementations:
|
||||
- Gauge, Meter, BarH, BarV, Sparkline (braille), Heatmap, Status, Table
|
||||
|
||||
### 2.2 Modified Files
|
||||
|
||||
- `backend/uframe/ir.py` — add viz node dataclasses
|
||||
- `backend/uframe/parser.py` — add viz keyword handlers
|
||||
- `backend/uframe/measure.py` — add viz size computation
|
||||
- `backend/uframe/paint.py` — add viz rendering
|
||||
|
||||
### 2.3 Verification
|
||||
|
||||
- Golden file tests for each viz type
|
||||
- Edge cases: empty data, single value, overflow
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Web IDE (Frontend Replacement)
|
||||
|
||||
### 3.1 New Files
|
||||
|
||||
**`frontend/src/components/editor/uframeHighlight.ts`**
|
||||
CodeMirror language support for .uf syntax:
|
||||
- Keywords: page, box, row, col, spacer, pad, heading, text, label, divider, link, list, gauge, sparkline, etc.
|
||||
- Properties: width=, height=, weight=, title=, char=, @modifiers
|
||||
- String literals, comments (#), indentation awareness
|
||||
|
||||
**`frontend/src/components/editor/uframeCommands.ts`**
|
||||
Slash commands for .uf syntax:
|
||||
- /box, /row, /col, /heading, /text, /divider, etc.
|
||||
- Each inserts a snippet with correct indentation
|
||||
- Property suggestions after =
|
||||
|
||||
**`frontend/src/components/editor/AsciiPreviewPane.tsx`**
|
||||
ASCII output preview:
|
||||
- Monospace rendering of plain-text ASCII art
|
||||
- Terminal aesthetic (green-on-black optional)
|
||||
|
||||
**`frontend/src/hooks/useCompile.ts`**
|
||||
Hook calling POST /api/compile:
|
||||
- Debounced (300ms) on source change
|
||||
- Returns { ascii, micron, warnings, isCompiling, error }
|
||||
|
||||
### 3.2 Modified Files
|
||||
|
||||
**`frontend/src/stores/editorStore.ts`** — Major rewrite:
|
||||
- `ufSource` replaces `micronSource`
|
||||
- Add `compiledAscii`, `compiledMicron`, `warnings`, `isCompiling`
|
||||
- `previewMode`: "ascii" | "micron" | "raw" (was "preview" | "raw")
|
||||
|
||||
**`frontend/src/routes/EditorView.tsx`** — Significant changes:
|
||||
- Use ufSource instead of micronSource
|
||||
- Add compile-on-change hook (debounced /api/compile)
|
||||
- Save sends source field not micron
|
||||
- Replace micronHighlight with uframeHighlight
|
||||
- Replace slashCommands with uframeCommands
|
||||
- Replace single PreviewPane with tabbed ASCII + Micron preview
|
||||
|
||||
**`frontend/src/components/editor/PreviewPane.tsx`** — Major rewrite:
|
||||
- Tab bar: "ASCII" | "Micron" | "Raw Micron"
|
||||
- ASCII tab: compiledAscii in monospace pre block
|
||||
- Micron tab: compiledMicron through existing renderMicron()
|
||||
- Raw tab: raw Micron source
|
||||
|
||||
**`frontend/src/components/editor/ToolBar.tsx`** — Minor:
|
||||
- Compile warnings count in status area
|
||||
|
||||
**`frontend/src/stores/pagesStore.ts`** — Minor:
|
||||
- unpublishPage sends source not micron
|
||||
|
||||
**`frontend/src/components/editor/wikiLinkCompletion.ts`** — Adapt to .uf link syntax
|
||||
|
||||
### 3.3 Removed Files
|
||||
|
||||
- `frontend/src/components/editor/slashCommands.ts` (replaced by uframeCommands.ts)
|
||||
- `frontend/src/components/editor/micronHighlight.ts` (replaced by uframeHighlight.ts)
|
||||
|
||||
### 3.4 Files Kept As-Is
|
||||
|
||||
- `micronRenderer.ts` — still needed for Micron preview tab
|
||||
- `oneDarkTheme.ts` — theme remains
|
||||
- `DashboardView.tsx`, `GraphView.tsx` — no changes
|
||||
- All shadcn/ui components — unchanged
|
||||
|
||||
### 3.5 Verification
|
||||
|
||||
- Create page with boxes, headings, text — verify ASCII and Micron previews
|
||||
- Publish writes correct .mu to PAGES_DIR
|
||||
- Page load round-trip: save .uf, reload, content preserved
|
||||
- Dashboard still shows pages correctly
|
||||
- Graph still shows links
|
||||
- Keyboard shortcuts (Ctrl+S, Ctrl+P) still work
|
||||
- Unsaved changes guard still fires
|
||||
|
||||
---
|
||||
|
||||
## Phase 4+ — Forms, Dynamic, Components (Future)
|
||||
|
||||
### Forms (Phase 4)
|
||||
- IR nodes: Field, Password, Radio, Checkbox, Button
|
||||
- Parser + measure + paint for each
|
||||
- Micron emit generates form codes
|
||||
|
||||
### Dynamic (Phase 5)
|
||||
- source blocks, on_submit, if/else, for loops, state
|
||||
- Requires runtime component beyond static compilation
|
||||
|
||||
### Components (Phase 6)
|
||||
- Reusable definitions, import, parameterized props
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order (Phase 1 detail)
|
||||
|
||||
Build sequence within Phase 1, each step testable in isolation:
|
||||
|
||||
1. `errors.py` — Exception types (no dependencies)
|
||||
2. `ir.py` — Node dataclasses (depends on nothing)
|
||||
3. `parser.py` — Parse .uf -> IR tree (depends on ir, errors)
|
||||
4. `grid.py` — CharGrid buffer (depends on nothing)
|
||||
5. `measure.py` — Size computation (depends on ir)
|
||||
6. `layout.py` — Position assignment (depends on ir)
|
||||
7. `paint.py` — Render to grid (depends on ir, grid)
|
||||
8. `borders.py` — Junction resolution (depends on grid)
|
||||
9. `emit_ascii.py` — ASCII output (depends on grid)
|
||||
10. `emit_micron.py` — Micron output (depends on grid)
|
||||
11. `__init__.py` — Public API tying it together
|
||||
12. `converter.py` update — Wire /api/compile endpoint
|
||||
13. `pages.py` update — .uf storage model
|
||||
14. `graph.py` update — .uf link extraction
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **Separate measure/layout/paint passes** — enables caching (re-layout without re-parse) and independent testability.
|
||||
|
||||
2. **CharGrid as intermediate** — decouples layout from emission. Same grid produces ASCII and Micron. Adding new emitters (HTML, ANSI) later is trivial.
|
||||
|
||||
3. **Border flag on cells** — merge pass identifies borders without confusing them with box-drawing text content.
|
||||
|
||||
4. **Style state in cells** — each cell carries its own style so Micron emitter can diff adjacent cells and emit minimal formatting codes.
|
||||
|
||||
5. **Line-based parser** — matches indentation-sensitive DSL. Simpler than full tokenizer. Upgradeable to PEG grammar later.
|
||||
|
||||
6. **No new Python dependencies** — pure stdlib. Box-drawing and braille characters are Unicode literals.
|
||||
|
||||
7. **SOURCES_DIR uses .uf extension** — clean break from .mu sources. Published files remain .mu (NomadNet requirement).
|
||||
|
||||
8. **Compile on backend** — single source of truth. Frontend is thin editor + preview client. Enables future CLI compilation and batch processing.
|
||||
448
CLAUDE.md
Normal file
@@ -0,0 +1,448 @@
|
||||
# µFrame (Micronomicon)
|
||||
|
||||
A self-hosted web IDE and CLI for building rich terminal UIs using a declarative DSL. Compiles `.uf` source files into both plain ASCII art and styled Micron `.mu` pages for NomadNet — a decentralized communication platform running on the Reticulum mesh network.
|
||||
|
||||
## What It Does
|
||||
|
||||
Write this:
|
||||
```
|
||||
page "Node Status" 60
|
||||
box double "Relay Alpha-7"
|
||||
align center
|
||||
text "Reticulum Network Node"
|
||||
gauge "CPU" 62 100 28 warn=75 crit=90
|
||||
status "East Relay" online
|
||||
table "Routes"
|
||||
columns "Dest" 20 | "Hops" 6 | "Status" 10
|
||||
row "relay-east" | "2" | "@color{0f0}{alive}"
|
||||
```
|
||||
|
||||
Get this (ASCII):
|
||||
```
|
||||
╔═ Relay Alpha-7 ════════════════════════════════════════════╗
|
||||
║ Reticulum Network Node ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
CPU ████████████████████░░░░░░░░ 62%
|
||||
● East Relay
|
||||
┌────────────────────┬──────┬──────────┐
|
||||
│Dest │Hops │Status │
|
||||
├────────────────────┼──────┼──────────┤
|
||||
│relay-east │2 │● alive │
|
||||
└────────────────────┴──────┴──────────┘
|
||||
```
|
||||
|
||||
And the same content as Micron `.mu` with color tags, bold, links, and interactive form fields — ready to serve on NomadNet.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Web IDE
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend
|
||||
source .venv/bin/activate
|
||||
PAGES_DIR=~/.nomadnetwork/storage/pages \
|
||||
SOURCES_DIR=~/.micron-editor/sources \
|
||||
uvicorn main:app --reload --port 8080
|
||||
|
||||
# Frontend (separate terminal)
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open http://localhost:5173 → click **New Page** → click **Examples** → pick a template.
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
source .venv/bin/activate
|
||||
|
||||
# Render to terminal
|
||||
python -m uframe render page.uf
|
||||
|
||||
# Render Micron only
|
||||
python -m uframe render page.uf --micron
|
||||
|
||||
# Compile to .mu file
|
||||
python -m uframe compile page.uf --out page.mu
|
||||
|
||||
# Validate without output
|
||||
python -m uframe check page.uf
|
||||
|
||||
# Compile and deploy to NomadNet
|
||||
python -m uframe deploy page.uf
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|------------|-------------------------------------------------|
|
||||
| Backend | Python 3.13 + FastAPI + uvicorn |
|
||||
| µFrame | Pure Python (zero deps): parser → IR → CharGrid → emitters |
|
||||
| Frontend | React 19 + Vite + TypeScript |
|
||||
| UI | shadcn/ui + Tailwind CSS v4 |
|
||||
| Editor | CodeMirror 6 (custom µFrame syntax mode) |
|
||||
| Graph | React Flow (@xyflow/react) + dagre |
|
||||
| State | Zustand |
|
||||
| Container | Docker + Compose |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
micronomicon/
|
||||
├── backend/
|
||||
│ ├── main.py # FastAPI app + static file serving
|
||||
│ ├── converter.py # POST /api/compile endpoint
|
||||
│ ├── pages.py # CRUD /api/pages (.uf sources + .mu publish)
|
||||
│ ├── graph.py # GET /api/graph (link parser)
|
||||
│ ├── docker_utils.py # POST /api/restart (NomadNet container)
|
||||
│ └── uframe/ # µFrame engine (16 modules, ~3800 LOC)
|
||||
│ ├── __init__.py # compile(source, width) → CompileResult
|
||||
│ ├── parser.py # .uf DSL → IR tree (indentation-based)
|
||||
│ ├── ir.py # 30+ IR node dataclasses
|
||||
│ ├── grid.py # CharGrid — 2D char + style buffer
|
||||
│ ├── chars.py # Unicode tables (box-drawing, braille)
|
||||
│ ├── measure.py # Bottom-up size computation
|
||||
│ ├── layout.py # Top-down position assignment
|
||||
│ ├── paint.py # IR nodes → CharGrid rendering
|
||||
│ ├── borders.py # Junction merging post-pass
|
||||
│ ├── emit_ascii.py # CharGrid → plain text
|
||||
│ ├── emit_micron.py # CharGrid → Micron with style tags
|
||||
│ ├── codegen.py # Dynamic page → executable Python script
|
||||
│ ├── cli.py # CLI: render / compile / check / deploy
|
||||
│ ├── errors.py # ParseError, LayoutError, CompileWarning
|
||||
│ └── tests/ # 42 tests (compile, dynamic, components)
|
||||
├── frontend/src/
|
||||
│ ├── routes/ # DashboardView, EditorView, GraphView
|
||||
│ ├── components/editor/
|
||||
│ │ ├── EditorPane.tsx # CodeMirror 6 host
|
||||
│ │ ├── PreviewPane.tsx # ASCII / Micron / Raw / Script tabs
|
||||
│ │ ├── ToolBar.tsx # Save, Publish, Examples, Backlinks
|
||||
│ │ ├── uframeHighlight.ts # µFrame syntax highlighting
|
||||
│ │ ├── uframeCommands.ts # "/" slash command palette
|
||||
│ │ ├── micronRenderer.ts # Micron → HTML preview renderer
|
||||
│ │ └── examples.ts # 9 built-in example templates
|
||||
│ ├── hooks/
|
||||
│ │ ├── useCompile.ts # Debounced POST /api/compile
|
||||
│ │ └── useUnsavedGuard.ts # Prevent accidental navigation
|
||||
│ └── stores/
|
||||
│ ├── editorStore.ts # Zustand: source, compiled output, preview mode
|
||||
│ └── pagesStore.ts # Zustand: page list, delete, unpublish
|
||||
└── docs/
|
||||
├── framework-design-v3.md # Full DSL spec + rendering model
|
||||
└── dynamic-templates.md # Dynamic page addendum
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|---------------------|------------------------------------------------------|
|
||||
| GET | /api/health | Health check |
|
||||
| POST | /api/compile | Compile `.uf` → `{ascii, micron, script, is_dynamic}` |
|
||||
| GET | /api/pages | List all pages with metadata |
|
||||
| GET | /api/pages/{name} | Read page source |
|
||||
| POST | /api/pages/{name} | Save page — `{ source, publish }` |
|
||||
| DELETE | /api/pages/{name} | Delete source and/or .mu file |
|
||||
| GET | /api/graph | Page link graph (nodes + edges) |
|
||||
| POST | /api/restart | Restart NomadNet Docker container |
|
||||
|
||||
## Storage
|
||||
|
||||
```
|
||||
~/.micron-editor/sources/ ← .uf source files (drafts + published)
|
||||
~/.nomadnetwork/storage/pages/ ← Compiled .mu files served by NomadNet
|
||||
```
|
||||
|
||||
- **Save Draft**: writes `.uf` to sources dir only
|
||||
- **Publish (static)**: compiles `.uf` → `.mu`, writes to pages dir (chmod 644)
|
||||
- **Publish (dynamic)**: compiles `.uf` → executable Python script, writes to pages dir (chmod 755)
|
||||
|
||||
NomadNet auto-detects the execute bit: static pages are served as-is, dynamic pages are executed and their stdout is served.
|
||||
|
||||
## µFrame DSL Reference
|
||||
|
||||
### Layout
|
||||
```
|
||||
page "Title" [width] # root (default width 64)
|
||||
box [light|heavy|double|rounded] "Title" # bordered panel
|
||||
row [gap] # horizontal layout
|
||||
col [width] # column in a row
|
||||
spacer [lines] # vertical whitespace
|
||||
pad [t] [r] [b] [l] # inner margin
|
||||
```
|
||||
|
||||
### Content
|
||||
```
|
||||
heading [1|2|3] "Text" # styled heading
|
||||
text "Content with @bold{inline} @color{hex}{modifiers}"
|
||||
label "Key" "Value" # aligned key-value pair
|
||||
list [bullet|dash]
|
||||
item "Entry"
|
||||
link "Display text" "/dest.mu" # clickable in Micron
|
||||
divider [light|heavy|double|dash|dot] # horizontal rule
|
||||
# comment # ignored in output
|
||||
```
|
||||
|
||||
### Data Visualization
|
||||
```
|
||||
gauge "Label" value max width [warn=N crit=N] # ████░░░░ bar with thresholds
|
||||
sparkline "Label" "1,3,5,8,7,5" width # ⣀⣤⣶⣿⣷⣤ braille chart
|
||||
status "Label" [online|offline|degraded] # ●○◐ colored indicators
|
||||
table "Title"
|
||||
columns "Name" 20 | "Hops" 6 | "Status" 10
|
||||
row "relay" | "2" | "@color{0f0}{● alive}"
|
||||
```
|
||||
|
||||
### Forms
|
||||
```
|
||||
form "name"
|
||||
field "name" [width] "placeholder" # text input
|
||||
password "name" [width] "placeholder" # masked input
|
||||
radio "group" "Opt A" | "Opt B" | "Opt C" # radio buttons
|
||||
checkbox "name" "Label" # checkbox
|
||||
button "Label" "/action/path" # submit link
|
||||
```
|
||||
|
||||
### Dynamic Features
|
||||
|
||||
Any page using `source`, `if`, `for`, `on_submit`, or `state` becomes **dynamic**: it compiles to an executable Python script instead of static Micron. NomadNet runs the script on each request and serves its stdout.
|
||||
|
||||
#### Variables
|
||||
```
|
||||
let name = "Relay Alpha" # string assignment
|
||||
let threshold = 75 # numeric
|
||||
let tags = "alpha","beta","gamma" # comma-separated → list
|
||||
```
|
||||
Variables are substituted with `$name` in text, labels, and other content. They work in both static and dynamic pages.
|
||||
|
||||
#### Data Sources
|
||||
```
|
||||
source var_name : type "command" [timeout=N]
|
||||
```
|
||||
Sources fetch data **at render time** and bind results to variables:
|
||||
|
||||
| Type | Description | Example |
|
||||
|----------|--------------------------------------|----------------------------------------------------------|
|
||||
| `shell` | Run shell command, capture stdout | `source cpu : shell "cat /proc/loadavg"` |
|
||||
| `file` | Read file contents as string | `source motd : file "/etc/motd"` |
|
||||
| `json` | Read + parse JSON file → dict/list | `source config : json "/etc/config.json"` |
|
||||
| `python` | Evaluate Python expression | `source ts : python "datetime.now().strftime('%H:%M')"` |
|
||||
| `http` | HTTP request, auto-parses JSON | `source data : http "https://api.example.com/data"` |
|
||||
| `sqlite` | SQLite query → list of dicts | `source users : sqlite "/path/db" "SELECT * FROM users"` |
|
||||
| `env` | Read environment variable | `source key : env "API_KEY"` |
|
||||
| `param` | Read URL parameter from link | `source hash : param "hash"` |
|
||||
| `rns` | Query Reticulum via `rnstatus` | `source peers : rns "peers"` |
|
||||
|
||||
**Shell** commands have a default 5-second timeout (override with `timeout=N`).
|
||||
|
||||
**Python** expressions have access to: `datetime` (the class, so `datetime.now()` works), `timedelta`, `secrets`, `os`, `json`. Expressions are evaluated via `eval()` — single expressions only, not statements.
|
||||
|
||||
```
|
||||
# Python source examples
|
||||
source timestamp : python "datetime.now().strftime('%H:%M:%S')"
|
||||
source rand_id : python "secrets.token_hex(4)"
|
||||
source cpu_sim : python "secrets.randbelow(60) + 20"
|
||||
source uptime : python "str(timedelta(seconds=12345))"
|
||||
source hostname : python "os.uname().nodename"
|
||||
```
|
||||
|
||||
**HTTP** requests return parsed JSON (dict/list) or raw string. Default timeout 10s.
|
||||
|
||||
```
|
||||
# GET request — JSON auto-parsed into dict
|
||||
source todo : http "https://api.example.com/todos/1"
|
||||
text "Title: $todo.title"
|
||||
|
||||
# POST with JSON body
|
||||
source result : http "https://api.example.com/search" method=POST body='{"q":"relay"}'
|
||||
|
||||
# Custom headers (semicolon-separated)
|
||||
source data : http "https://api.example.com/data" headers='Authorization: Bearer tok123'
|
||||
|
||||
# Use $var references in URL, headers, and body — resolved at runtime
|
||||
source token : env "API_TOKEN"
|
||||
source data : http "https://api.example.com/data" headers='Authorization: Bearer $token'
|
||||
```
|
||||
|
||||
**Env** reads server-side environment variables. Use this for secrets — tokens never appear in `.uf` source or compiled scripts.
|
||||
|
||||
```
|
||||
source api_key : env "API_KEY"
|
||||
source db_pass : env "DB_PASSWORD"
|
||||
```
|
||||
|
||||
**SQLite** queries return a list of dicts (or a single dict for one row). Uses Python stdlib `sqlite3`.
|
||||
|
||||
```
|
||||
# Query returns list of dicts with column names as keys
|
||||
source nodes : sqlite "/data/network.db" "SELECT name, status, hops FROM nodes"
|
||||
|
||||
# Iterate results
|
||||
for node in $nodes
|
||||
label "$node.name" "$node.status ($node.hops hops)"
|
||||
|
||||
# Single row queries return a dict directly
|
||||
source config : sqlite "/data/app.db" "SELECT value FROM config WHERE key='theme'"
|
||||
text "Theme: $config.value"
|
||||
```
|
||||
|
||||
#### Conditionals
|
||||
```
|
||||
if $cpu > 90
|
||||
text "ALERT: CPU critical"
|
||||
elif $cpu > 75
|
||||
text "Warning: elevated"
|
||||
else
|
||||
text "All clear"
|
||||
```
|
||||
Conditions are Python expressions. `$var` references resolve to the variable's value. Supports `>`, `<`, `>=`, `<=`, `==`, `!=`, `&&` (and), `||` (or).
|
||||
|
||||
#### Loops
|
||||
```
|
||||
for peer in $peers
|
||||
status "$peer.name" $peer.state
|
||||
```
|
||||
Iterates over lists (from JSON sources), dicts (wrapped as single-item list), or newline-delimited strings (from shell output). Access nested fields with `$item.field`.
|
||||
|
||||
#### Cache Control
|
||||
```
|
||||
cache 0 # never cache (re-execute every request)
|
||||
cache 300 # cache for 5 minutes
|
||||
```
|
||||
Emits the `#!c=N` header that NomadNet uses to control page caching.
|
||||
|
||||
#### Form Submission Handling
|
||||
```
|
||||
on_submit "form_name"
|
||||
# Runs when the named form is submitted
|
||||
# Form field values are available as $field_name
|
||||
source results : shell "search.py '$query'"
|
||||
text "Found: $results"
|
||||
```
|
||||
Field values are read from `FIELD_*` environment variables set by NomadNet.
|
||||
|
||||
#### Persistent State
|
||||
```
|
||||
state "counter" "/tmp/counter.json" # load JSON into $counter
|
||||
```
|
||||
Loads a JSON file into a variable. Use `_save_state(path, data)` in the generated script to persist changes.
|
||||
|
||||
#### Using Variables in Content
|
||||
```
|
||||
text "Hello, $name" # inline substitution
|
||||
label "CPU" "$cpu_pct%" # in labels
|
||||
gauge "CPU" $cpu_pct 100 28 warn=75 crit=90 # as gauge values
|
||||
status "$peer" $state # in status indicators
|
||||
link "View $name" "/page/detail.mu" # in links
|
||||
```
|
||||
|
||||
#### Generated Script Runtime
|
||||
|
||||
The compiled script includes these helpers, available in `on_submit` and source blocks:
|
||||
|
||||
| Helper | Description |
|
||||
|-------------------------------------|-----------------------------------------------|
|
||||
| `_shell(cmd, timeout=5)` | Execute shell command, return stdout |
|
||||
| `_read_file(path)` | Read file contents |
|
||||
| `_read_json(path)` | Read + parse JSON file |
|
||||
| `_http(url, method, body, headers)` | HTTP request, auto-parse JSON response |
|
||||
| `_sqlite(db_path, query)` | SQLite query → list of dicts (or single dict) |
|
||||
| `_get_field(name, default)` | Read submitted form field |
|
||||
| `_get_param(name, default)` | Read URL parameter |
|
||||
| `_load_state(path)` | Load state from JSON file |
|
||||
| `_save_state(path, data)` | Save state to JSON file |
|
||||
| `_iter(val)` | Make a value iterable (list/dict/string) |
|
||||
|
||||
### Components
|
||||
```
|
||||
# Define a reusable component
|
||||
component stat(label, value, max)
|
||||
gauge "$label" $value $max 20
|
||||
|
||||
# Use it
|
||||
stat "CPU" 62 100
|
||||
stat "MEM" 84 100
|
||||
|
||||
# Import standard library
|
||||
use std/dashboard
|
||||
banner "My Node" "Mesh Network"
|
||||
resources 62 84
|
||||
```
|
||||
|
||||
### Standard Libraries
|
||||
| Library | Components |
|
||||
|---------|-----------|
|
||||
| `std/dashboard` | `banner(title, subtitle)`, `resources(cpu, mem)`, `peer_status(name, state)` |
|
||||
| `std/status-bar` | `status_bar(label, value, max)`, `status_item(name, state)` |
|
||||
| `std/nav` | `nav_link(label, dest)`, `nav_divider()` |
|
||||
|
||||
## Rendering Pipeline
|
||||
|
||||
```
|
||||
.uf source
|
||||
│
|
||||
▼
|
||||
Parse ──→ IR Tree (30+ node types)
|
||||
│
|
||||
▼
|
||||
Measure (bottom-up: compute sizes)
|
||||
│
|
||||
▼
|
||||
Layout (top-down: assign positions)
|
||||
│
|
||||
▼
|
||||
Paint (depth-first: write chars into CharGrid)
|
||||
│
|
||||
▼
|
||||
Merge Borders (fix junction characters)
|
||||
│
|
||||
├──→ ASCII emitter → plain text
|
||||
├──→ Micron emitter → styled .mu (with colors, links, form tags)
|
||||
└──→ Codegen (if dynamic) → executable Python script
|
||||
```
|
||||
|
||||
## Web IDE Features
|
||||
|
||||
- **Split-pane editor**: µFrame DSL source (left) / live preview (right)
|
||||
- **Syntax highlighting**: keywords, strings, variables, comments in distinct colors
|
||||
- **`/` command palette**: type `/` to insert layout, content, data viz, form, and style primitives
|
||||
- **4 preview tabs**: ASCII | Micron (rendered) | Raw (Micron source) | Script (dynamic pages only)
|
||||
- **`⚡ dynamic` badge**: auto-detected when source contains `source`, `if`, `for`, etc.
|
||||
- **Examples dropdown**: 9 built-in templates (Hello World → Full Node Page → Dynamic Dashboard)
|
||||
- **Pages dashboard**: table view with Published/Draft/Orphan status badges
|
||||
- **Page graph**: React Flow visualization of inter-page links
|
||||
- **Keyboard shortcuts**: `Ctrl+S` save draft, `Ctrl+P` publish
|
||||
- **Unsaved changes guard**: warns before navigating away
|
||||
- **Backlink indicator**: shows which pages link to the current page
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
source .venv/bin/activate
|
||||
python -m pytest uframe/tests/ -v
|
||||
```
|
||||
|
||||
42 tests covering:
|
||||
- Static compilation (boxes, headings, text, gauges, tables, links, lists, spacers, dividers)
|
||||
- Form elements (field, radio, checkbox, button)
|
||||
- Dynamic pages (source, if/for, let, state, on_submit, codegen)
|
||||
- Components (inline definitions, standard library, parameter substitution)
|
||||
|
||||
## Conventions
|
||||
|
||||
- µFrame engine is **pure Python stdlib** — zero external dependencies
|
||||
- Backend is FastAPI; no ORM, flat file storage
|
||||
- Frontend uses shadcn/ui components in `frontend/src/components/ui/`
|
||||
- Feature components grouped by domain: `dashboard/`, `editor/`, `shared/`
|
||||
- State management via Zustand stores
|
||||
- All `.uf` sources stored in `SOURCES_DIR`, compiled `.mu` in `PAGES_DIR`
|
||||
|
||||
## References
|
||||
|
||||
- µFrame design spec: `docs/framework-design-v3.md`
|
||||
- Dynamic templates spec: `docs/dynamic-templates.md`
|
||||
- NomadNet: https://github.com/markqvist/NomadNet
|
||||
- Micron syntax: https://github.com/fr33n0w/micron-composer
|
||||
- Reticulum: https://github.com/markqvist/Reticulum
|
||||
@@ -10,7 +10,6 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY backend/ ./
|
||||
COPY frontend/dist/ ./static/
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
|
||||
289
README.md
@@ -1,21 +1,64 @@
|
||||
# Micronomicon
|
||||
# µFrame (Micronomicon)
|
||||
|
||||
A self-hosted web editor for writing Markdown and publishing `.mu` pages to a NomadNet node.
|
||||
A declarative DSL and web IDE for building rich terminal UIs that publish as `.mu` pages to [NomadNet](https://github.com/markqvist/NomadNet) — a decentralized communication platform on the [Reticulum](https://github.com/markqvist/Reticulum) mesh network.
|
||||
|
||||
Write in Markdown → preview as Micron → publish directly to `~/.nomadnetwork/storage/pages/`.
|
||||
Write structured layouts with box-drawing, gauges, tables, and forms in a simple DSL. Get both plain ASCII art (viewable in any terminal) and styled Micron markup (with colors, links, and interactive form fields) from the same source.
|
||||
|
||||
```
|
||||
page "Dashboard" 60 ╔═ Relay Alpha ══════════════════╗
|
||||
box double "Relay Alpha" ║ Reticulum Network Node ║
|
||||
align center ╚════════════════════════════════╝
|
||||
text "Reticulum Network Node"
|
||||
CPU ████████████████░░░░ 62%
|
||||
gauge "CPU" 62 100 28 warn=75 crit=90 MEM ██████████████████░░ 84% ⚠
|
||||
gauge "MEM" 84 100 28 warn=80 crit=95
|
||||
┌──────────┬──────┬──────────┐
|
||||
table "Routes" │Dest │Hops │Status │
|
||||
columns "Dest" 10 | "Hops" 6 | ... ├──────────┼──────┼──────────┤
|
||||
row "east" | "2" | "alive" │east │2 │● alive │
|
||||
└──────────┴──────┴──────────┘
|
||||
status "East Relay" online ● East Relay
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
## Quick Start
|
||||
|
||||
- Docker + Docker Compose
|
||||
- Python 3.13+ (for local backend development only)
|
||||
- Node 20+ (for local frontend development only)
|
||||
- A running NomadNet container named `nomadnet` (for the restart button)
|
||||
### Web IDE
|
||||
|
||||
---
|
||||
```bash
|
||||
# 1. Create directories
|
||||
mkdir -p ~/.nomadnetwork/storage/pages ~/.micron-editor/sources
|
||||
|
||||
## Quick Start (Docker)
|
||||
# 2. Backend
|
||||
cd backend
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
PAGES_DIR=~/.nomadnetwork/storage/pages \
|
||||
SOURCES_DIR=~/.micron-editor/sources \
|
||||
uvicorn main:app --reload --port 8080
|
||||
|
||||
# 3. Frontend (separate terminal)
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open http://localhost:5173 → **New Page** → **Examples** → pick a template.
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
cd backend && source .venv/bin/activate
|
||||
|
||||
python -m uframe render page.uf # ASCII to stdout
|
||||
python -m uframe render page.uf --micron # Micron to stdout
|
||||
python -m uframe compile page.uf # → page.mu
|
||||
python -m uframe check page.uf # validate
|
||||
python -m uframe deploy page.uf # compile + copy to NomadNet pages
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
# 1. Build the frontend
|
||||
@@ -24,48 +67,135 @@ npm install
|
||||
npm run build
|
||||
cd ..
|
||||
|
||||
# 2. Create source directories
|
||||
mkdir -p ~/.nomadnetwork/storage/pages ~/.micron-editor/sources
|
||||
|
||||
# 3. Start the stack
|
||||
docker compose up --build
|
||||
# 2. Build and start all services
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
App is available at `http://localhost:8080`.
|
||||
This starts two containers:
|
||||
|
||||
### Tailscale HTTPS
|
||||
- **micronomicon** — the web IDE + API on <http://localhost:8080>
|
||||
- **nomadnet** — NomadNet node serving your published `.mu` pages
|
||||
|
||||
Pages published through the IDE are written to a shared volume that NomadNet reads from. The Docker socket is mounted read-only so the IDE can restart NomadNet when needed.
|
||||
|
||||
To follow logs: `docker compose logs -f`
|
||||
|
||||
To stop: `docker compose down`
|
||||
|
||||
To expose over Tailscale: `tailscale serve --bg https+insecure://localhost:8080`
|
||||
|
||||
#### Deploy a page via CLI (into the running stack)
|
||||
|
||||
```bash
|
||||
tailscale serve --bg https+insecure://localhost:8080
|
||||
cd backend && source .venv/bin/activate
|
||||
python -m uframe deploy page.uf --dest "$(docker volume inspect micronomicon_pages -f '{{.Mountpoint}}')"
|
||||
```
|
||||
|
||||
Or use the web IDE at <http://localhost:8080> and click **Publish**.
|
||||
|
||||
---
|
||||
|
||||
## DSL Overview
|
||||
|
||||
### Layout
|
||||
```
|
||||
page "Title" [width] # root container
|
||||
box [light|heavy|double|rounded] "Title" # bordered panel
|
||||
row [gap] # horizontal split
|
||||
col [width] # column
|
||||
spacer [lines] # vertical space
|
||||
```
|
||||
|
||||
### Content
|
||||
```
|
||||
heading [1|2|3] "Text" # heading
|
||||
text "Hello @bold{world} @color{0f0}{green}" # text with inline modifiers
|
||||
label "Key" "Value" # key-value pair
|
||||
link "Click me" "/page/dest.mu" # clickable link
|
||||
divider [light|heavy|double] # horizontal rule
|
||||
```
|
||||
|
||||
### Data Visualization
|
||||
```
|
||||
gauge "CPU" 62 100 28 warn=75 crit=90 # ████████░░░░ 62%
|
||||
sparkline "Net" "1,3,5,8,7,5" 20 # ⣀⣤⣶⣿⣷⣤ braille chart
|
||||
status "Server" [online|offline|degraded] # ●○◐ indicator
|
||||
table "Routes"
|
||||
columns "Dest" 20 | "Hops" 6
|
||||
row "east" | "2"
|
||||
```
|
||||
|
||||
### Forms
|
||||
```
|
||||
form "search"
|
||||
field "query" 30 "Search..." # text input
|
||||
radio "scope" "Local" | "Network" # radio buttons
|
||||
checkbox "cache" "Include cached" # checkbox
|
||||
button "Go" "/page/search.mu" # submit
|
||||
```
|
||||
|
||||
### Dynamic Pages
|
||||
```
|
||||
cache 0 # re-execute on every request
|
||||
source cpu : shell "cat /proc/loadavg" # live data
|
||||
if $cpu > 90
|
||||
text "ALERT"
|
||||
for peer in $peers
|
||||
status "$peer.name" $peer.state
|
||||
state "visits" "/tmp/visits.json" # persistent store
|
||||
```
|
||||
|
||||
### Components
|
||||
```
|
||||
component stat(label, value, max)
|
||||
gauge "$label" $value $max 20
|
||||
|
||||
stat "CPU" 62 100 # reuse
|
||||
use std/dashboard # import standard library
|
||||
banner "My Node" "Mesh Network" # use library component
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Local Development
|
||||
## Architecture
|
||||
|
||||
Run backend and frontend separately with hot reload.
|
||||
|
||||
**Backend**
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
PAGES_DIR=~/.nomadnetwork/storage/pages \
|
||||
SOURCES_DIR=~/.micron-editor/sources \
|
||||
uvicorn main:app --reload --port 8080
|
||||
```
|
||||
.uf source → Parse → IR Tree → Measure → Layout → Paint → CharGrid
|
||||
├→ ASCII (plain text)
|
||||
├→ Micron (.mu with styles)
|
||||
└→ Script (dynamic: executable Python)
|
||||
```
|
||||
|
||||
**Frontend**
|
||||
**Static pages**: `.uf` compiles to `.mu` (Micron markup). NomadNet serves the file directly.
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # proxies /api → localhost:8080
|
||||
```
|
||||
**Dynamic pages**: `.uf` with `source`/`if`/`for` compiles to an executable Python script. NomadNet detects the `+x` bit, runs the script on each request, and serves the stdout as Micron. Live system data, form handling, and state persistence all work through this model.
|
||||
|
||||
Open `http://localhost:5173`.
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|---------------------|-------------------------------------------------------|
|
||||
| POST | /api/compile | Compile `.uf` → `{ascii, micron, script, is_dynamic}` |
|
||||
| GET | /api/pages | List all pages with metadata |
|
||||
| GET | /api/pages/{name} | Read page source |
|
||||
| POST | /api/pages/{name} | Save `{source, publish}` — draft or publish |
|
||||
| DELETE | /api/pages/{name} | Delete page |
|
||||
| GET | /api/graph | Page link graph |
|
||||
| POST | /api/restart | Restart NomadNet container |
|
||||
|
||||
---
|
||||
|
||||
## Web IDE Features
|
||||
|
||||
- **Split-pane editor** with µFrame syntax highlighting and live preview
|
||||
- **`/` command palette** — type `/` to insert any DSL primitive
|
||||
- **4 preview tabs** — ASCII | Micron (rendered) | Raw | Script (dynamic only)
|
||||
- **Examples dropdown** — 9 templates from Hello World to Dynamic Dashboard
|
||||
- **Pages dashboard** with Published / Draft / Orphan status badges
|
||||
- **Page graph** — React Flow visualization of inter-page links
|
||||
- **Keyboard shortcuts** — `Ctrl+S` save, `Ctrl+P` publish
|
||||
- **Backlink indicator** — shows which pages link to the current page
|
||||
|
||||
---
|
||||
|
||||
@@ -74,85 +204,24 @@ Open `http://localhost:5173`.
|
||||
| Variable | Default | Description |
|
||||
|----------------------|------------------|--------------------------------------|
|
||||
| `PAGES_DIR` | `/data/pages` | NomadNet pages directory |
|
||||
| `SOURCES_DIR` | `/data/sources` | Markdown source files directory |
|
||||
| `SOURCES_DIR` | `/data/sources` | µFrame source files directory |
|
||||
| `NOMADNET_CONTAINER` | `nomadnet` | Docker container name to restart |
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
## Tests
|
||||
|
||||
| Method | Path | Description |
|
||||
|----------|---------------------|-----------------------------------------------|
|
||||
| `GET` | `/api/health` | Health check |
|
||||
| `POST` | `/api/convert` | Convert `{ markdown }` → `{ micron }` |
|
||||
| `GET` | `/api/pages` | List all pages with metadata |
|
||||
| `GET` | `/api/pages/{name}` | Read page (markdown source + micron output) |
|
||||
| `POST` | `/api/pages/{name}` | Save `{ markdown, publish }` — draft or live |
|
||||
| `DELETE` | `/api/pages/{name}` | Delete source and/or `.mu` file |
|
||||
| `GET` | `/api/graph` | Graph nodes + edges from parsed link sources |
|
||||
| `POST` | `/api/restart` | Restart NomadNet Docker container |
|
||||
|
||||
---
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```
|
||||
micronomicon/
|
||||
Dockerfile
|
||||
compose.yml
|
||||
backend/
|
||||
main.py ← FastAPI app + static file serving
|
||||
converter.py ← md2txt wrapper (POST /api/convert)
|
||||
pages.py ← file management (CRUD /api/pages)
|
||||
graph.py ← link parser (GET /api/graph)
|
||||
docker_utils.py ← container restart (POST /api/restart)
|
||||
requirements.txt
|
||||
frontend/
|
||||
src/
|
||||
App.tsx
|
||||
routes/ ← DashboardView, EditorView, GraphView
|
||||
components/ ← dashboard/, editor/, shared/, ui/ (shadcn)
|
||||
stores/ ← editorStore, pagesStore (Zustand)
|
||||
hooks/ ← useConversion, useGraph, useUnsavedGuard
|
||||
lib/ ← utils (cn)
|
||||
|
||||
~/.nomadnetwork/storage/pages/ ← published .mu files (NomadNet serves these)
|
||||
~/.micron-editor/sources/ ← markdown sources (managed by this app)
|
||||
```bash
|
||||
cd backend && source .venv/bin/activate
|
||||
python -m pytest uframe/tests/ -v # 91 tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Page Lifecycle
|
||||
## References
|
||||
|
||||
```
|
||||
New Page → /editor/new → Save Draft → .md saved to sources/
|
||||
→ Publish → .md saved + .mu written to pages/
|
||||
```
|
||||
|
||||
- **Draft** — `.md` exists, no `.mu`. Not visible on NomadNet.
|
||||
- **Published** — both `.md` and `.mu` exist.
|
||||
- **Orphan** — `.mu` exists but no `.md` source (e.g. pages created outside this tool).
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-----------|-----------------------------------------|
|
||||
| Backend | Python 3.13 + FastAPI + uvicorn |
|
||||
| Converter | md2txt (micron renderer) |
|
||||
| Frontend | React 19 + Vite + TypeScript |
|
||||
| UI | shadcn/ui + Tailwind CSS v4 |
|
||||
| Editor | CodeMirror 6 |
|
||||
| Graph | React Flow + dagre |
|
||||
| State | Zustand |
|
||||
| Container | Docker + Compose |
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations (Phase 1)
|
||||
|
||||
- Micron preview is plain text — full terminal rendering comes in a later phase (micron-parser-js iframe)
|
||||
- `[[` link autocomplete not yet implemented (Phase 2)
|
||||
- Graph view is read-only; click a node to open it in the editor
|
||||
- No metrics (Phase 4)
|
||||
- [NomadNet](https://github.com/markqvist/NomadNet) — decentralized communication
|
||||
- [Reticulum](https://github.com/markqvist/Reticulum) — mesh networking stack
|
||||
- [Micron syntax](https://github.com/fr33n0w/micron-composer) — markup reference
|
||||
- [Design spec](docs/framework-design-v3.md) — full DSL design document
|
||||
- [Dynamic templates](docs/dynamic-templates.md) — dynamic page system spec
|
||||
|
||||
463
backend/browse.py
Normal file
@@ -0,0 +1,463 @@
|
||||
"""Network browser — discovers NomadNet nodes and fetches pages via Reticulum.
|
||||
|
||||
Starts an RNS transport on FastAPI startup, listens for NomadNet node
|
||||
announces, and exposes discovered nodes + remote page fetching via API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
router = APIRouter()
|
||||
log = logging.getLogger("browse")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_nodes: dict[str, dict] = {} # hash_hex -> node info
|
||||
_own_hash: str | None = None
|
||||
def _own_name() -> str:
|
||||
"""Read node name from NomadNet config, falling back to env/default."""
|
||||
try:
|
||||
path = _CONFIG_PATHS["nomadnet"]()
|
||||
if path.exists():
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("node_name"):
|
||||
_, _, val = stripped.partition("=")
|
||||
val = val.strip()
|
||||
if val:
|
||||
return val
|
||||
except Exception:
|
||||
pass
|
||||
return os.environ.get("NOMADNET_NODE_NAME", "Micronomicon")
|
||||
_lock = threading.Lock()
|
||||
_started = False
|
||||
_subscribers: list[asyncio.Queue] = []
|
||||
_sub_lock = threading.Lock()
|
||||
_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RNS announce handler (must be an object with aspect_filter + method)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _push_node(node_data: dict) -> None:
|
||||
"""Push a node update to all SSE subscribers (thread-safe)."""
|
||||
with _sub_lock:
|
||||
for q in list(_subscribers):
|
||||
if _loop and _loop.is_running():
|
||||
_loop.call_soon_threadsafe(q.put_nowait, node_data)
|
||||
else:
|
||||
try:
|
||||
q.put_nowait(node_data)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
|
||||
class _AnnounceHandler:
|
||||
"""RNS-compatible announce handler.
|
||||
|
||||
RNS.Transport.register_announce_handler() requires an object with:
|
||||
- aspect_filter: str attribute
|
||||
- received_announce(dest_hash, identity, app_data, ...): callable
|
||||
"""
|
||||
|
||||
aspect_filter = "nomadnetwork.node"
|
||||
|
||||
def received_announce(
|
||||
self,
|
||||
destination_hash: bytes,
|
||||
announced_identity,
|
||||
app_data: bytes | None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
import RNS
|
||||
|
||||
hash_hex = RNS.hexrep(destination_hash, delimit=False)
|
||||
|
||||
name = hash_hex[:12]
|
||||
if app_data:
|
||||
try:
|
||||
name = app_data.decode("utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
is_self = name == _own_name()
|
||||
|
||||
# Determine which interface this announce arrived on
|
||||
iface_name = None
|
||||
try:
|
||||
path_entry = RNS.Transport.path_table.get(destination_hash)
|
||||
if path_entry and path_entry[5]: # IDX_PT_RVCD_IF = 5
|
||||
iface_name = getattr(path_entry[5], "name", None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with _lock:
|
||||
_nodes[hash_hex] = {
|
||||
"hash": hash_hex,
|
||||
"name": name,
|
||||
"last_seen": time.time(),
|
||||
"is_self": is_self,
|
||||
"type": "node",
|
||||
"interface": iface_name,
|
||||
}
|
||||
if is_self:
|
||||
global _own_hash
|
||||
_own_hash = hash_hex
|
||||
|
||||
log.info(
|
||||
"Node announce: %s (%s) via %s%s",
|
||||
name, hash_hex[:8], iface_name or "?",
|
||||
" [self]" if is_self else "",
|
||||
)
|
||||
_push_node(_nodes[hash_hex])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RNS lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_reticulum = None
|
||||
|
||||
|
||||
def _collect_interfaces() -> list[dict]:
|
||||
"""Read active RNS interfaces and return them as node-like dicts."""
|
||||
try:
|
||||
import RNS
|
||||
except ImportError:
|
||||
return []
|
||||
|
||||
ifaces = []
|
||||
for iface in RNS.Transport.interfaces:
|
||||
name = getattr(iface, "name", str(iface))
|
||||
iface_id = f"iface_{name}"
|
||||
target = getattr(iface, "target_ip", None) or getattr(iface, "target_host", None)
|
||||
port = getattr(iface, "target_port", None) or getattr(iface, "bind_port", None)
|
||||
ifaces.append({
|
||||
"hash": iface_id,
|
||||
"name": name,
|
||||
"last_seen": time.time(),
|
||||
"is_self": False,
|
||||
"type": "interface",
|
||||
"online": getattr(iface, "online", False),
|
||||
"target": f"{target}:{port}" if target and port else None,
|
||||
"txb": getattr(iface, "txb", 0),
|
||||
"rxb": getattr(iface, "rxb", 0),
|
||||
"bitrate": getattr(iface, "bitrate", 0),
|
||||
"clients": len(getattr(iface, "clients", None) or []) if hasattr(iface, "clients") else None,
|
||||
})
|
||||
return ifaces
|
||||
|
||||
|
||||
def start_browser() -> None:
|
||||
"""Initialize RNS and begin listening for NomadNet node announces."""
|
||||
global _started, _loop, _reticulum
|
||||
|
||||
if _started:
|
||||
return
|
||||
|
||||
try:
|
||||
_loop = asyncio.get_event_loop()
|
||||
except RuntimeError:
|
||||
_loop = None
|
||||
|
||||
try:
|
||||
import RNS
|
||||
|
||||
configdir = os.environ.get("RNS_CONFIG_DIR", None)
|
||||
if configdir:
|
||||
Path(configdir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_reticulum = RNS.Reticulum(configdir=configdir)
|
||||
|
||||
RNS.Transport.register_announce_handler(_AnnounceHandler())
|
||||
|
||||
_started = True
|
||||
log.info("RNS browser started (v%s)", RNS.__version__)
|
||||
|
||||
except Exception as exc:
|
||||
log.warning("Failed to start RNS browser: %s", exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CONFIG_PATHS = {
|
||||
"reticulum": lambda: Path(os.environ.get("RNS_SERVER_CONFIG_DIR", os.environ.get("RNS_CONFIG_DIR", str(Path.home() / ".reticulum")))) / "config",
|
||||
"reticulum-client": lambda: Path(os.environ.get("RNS_CONFIG_DIR", str(Path.home() / ".reticulum"))) / "config",
|
||||
"nomadnet": lambda: Path(os.environ.get("NOMADNET_CONFIG_DIR", str(Path.home() / ".nomadnetwork"))) / "config",
|
||||
}
|
||||
|
||||
|
||||
def _config_path(kind: str) -> Path:
|
||||
resolver = _CONFIG_PATHS.get(kind)
|
||||
if not resolver:
|
||||
raise ValueError(f"Unknown config kind: {kind}")
|
||||
return resolver()
|
||||
|
||||
|
||||
def _restart_nomadnet() -> bool:
|
||||
"""Restart the NomadNet container. Returns True on success."""
|
||||
try:
|
||||
from docker_utils import NOMADNET_CONTAINER
|
||||
import docker
|
||||
client = docker.from_env()
|
||||
container = client.containers.get(NOMADNET_CONTAINER)
|
||||
container.restart()
|
||||
log.info("NomadNet container restarted")
|
||||
return True
|
||||
except Exception as exc:
|
||||
log.info("NomadNet container not available: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _build_snapshot() -> list[dict]:
|
||||
"""Build a full snapshot: self node + interfaces + discovered nodes."""
|
||||
with _lock:
|
||||
nodes = list(_nodes.values())
|
||||
|
||||
# Ensure type field on all nodes
|
||||
for n in nodes:
|
||||
n.setdefault("type", "node")
|
||||
|
||||
if not any(n["is_self"] for n in nodes):
|
||||
nodes.insert(0, {
|
||||
"hash": _own_hash or "self",
|
||||
"name": _own_name(),
|
||||
"last_seen": time.time(),
|
||||
"is_self": True,
|
||||
"type": "node",
|
||||
})
|
||||
|
||||
# Add interfaces
|
||||
nodes.extend(_collect_interfaces())
|
||||
return nodes
|
||||
|
||||
|
||||
@router.get("/browse/nodes")
|
||||
async def list_nodes():
|
||||
"""Return all discovered NomadNet nodes and interfaces."""
|
||||
return _build_snapshot()
|
||||
|
||||
|
||||
@router.get("/browse/nodes/stream")
|
||||
async def stream_nodes():
|
||||
"""SSE stream — pushes full snapshot then live node announces."""
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=64)
|
||||
|
||||
with _sub_lock:
|
||||
_subscribers.append(queue)
|
||||
|
||||
async def event_generator():
|
||||
try:
|
||||
# Send full snapshot (self + interfaces + known nodes)
|
||||
for entry in _build_snapshot():
|
||||
yield f"data: {json.dumps(entry)}\n\n"
|
||||
|
||||
# Then stream new announces as they arrive
|
||||
while True:
|
||||
node = await queue.get()
|
||||
yield f"data: {json.dumps(node)}\n\n"
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
with _sub_lock:
|
||||
_subscribers.remove(queue)
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/browse/page/{hash_hex}")
|
||||
async def get_remote_page(hash_hex: str, path: str = Query("index.mu")):
|
||||
"""Fetch a Micron page from a node.
|
||||
|
||||
For the user's own node reads from the local pages directory.
|
||||
For remote nodes establishes an RNS link and requests the page.
|
||||
"""
|
||||
with _lock:
|
||||
node = _nodes.get(hash_hex)
|
||||
if (node and node.get("is_self")) or hash_hex == "self":
|
||||
return _read_local_page(path)
|
||||
|
||||
content = await _request_remote_page(hash_hex, path)
|
||||
if content is None:
|
||||
return {"content": None, "error": "Could not reach node"}
|
||||
return {"content": content}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Local page reader
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _read_local_page(path: str) -> dict:
|
||||
from converter import execute_dynamic_script
|
||||
|
||||
pages_dir = os.environ.get("PAGES_DIR", str(Path.home() / ".nomadnetwork/storage/pages"))
|
||||
filepath = Path(pages_dir) / path
|
||||
if not filepath.exists():
|
||||
return {"content": None, "error": "Page not found"}
|
||||
try:
|
||||
if os.access(filepath, os.X_OK):
|
||||
script = filepath.read_text(encoding="utf-8")
|
||||
return {"content": execute_dynamic_script(script)}
|
||||
return {"content": filepath.read_text()}
|
||||
except Exception as exc:
|
||||
return {"content": None, "error": str(exc)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Remote page fetcher (RNS link + request/response)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _request_remote_page(hash_hex: str, path: str) -> str | None:
|
||||
"""Establish an RNS link to a remote NomadNet node and request a page."""
|
||||
try:
|
||||
import RNS
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
future: asyncio.Future[str | None] = loop.create_future()
|
||||
|
||||
def _do_request():
|
||||
try:
|
||||
dest_hash = bytes.fromhex(hash_hex)
|
||||
|
||||
if not RNS.Transport.has_path(dest_hash):
|
||||
RNS.Transport.request_path(dest_hash)
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline:
|
||||
if RNS.Transport.has_path(dest_hash):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
loop.call_soon_threadsafe(future.set_result, None)
|
||||
return
|
||||
|
||||
identity = RNS.Identity.recall(dest_hash)
|
||||
if not identity:
|
||||
loop.call_soon_threadsafe(future.set_result, None)
|
||||
return
|
||||
|
||||
dest = RNS.Destination(
|
||||
identity,
|
||||
RNS.Destination.OUT,
|
||||
RNS.Destination.SINGLE,
|
||||
"nomadnetwork",
|
||||
"node",
|
||||
)
|
||||
|
||||
link = RNS.Link(dest)
|
||||
|
||||
deadline = time.time() + 15
|
||||
while time.time() < deadline:
|
||||
if link.status == RNS.Link.ACTIVE:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
link.teardown()
|
||||
loop.call_soon_threadsafe(future.set_result, None)
|
||||
return
|
||||
|
||||
def on_response(request_receipt):
|
||||
try:
|
||||
resp = request_receipt.response
|
||||
if resp is not None:
|
||||
content = resp.decode("utf-8") if isinstance(resp, bytes) else str(resp)
|
||||
loop.call_soon_threadsafe(future.set_result, content)
|
||||
else:
|
||||
loop.call_soon_threadsafe(future.set_result, None)
|
||||
except Exception:
|
||||
loop.call_soon_threadsafe(future.set_result, None)
|
||||
finally:
|
||||
link.teardown()
|
||||
|
||||
def on_failed(request_receipt):
|
||||
if not future.done():
|
||||
loop.call_soon_threadsafe(future.set_result, None)
|
||||
link.teardown()
|
||||
|
||||
link.request(
|
||||
"/page/" + path,
|
||||
response_callback=on_response,
|
||||
failed_callback=on_failed,
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
log.warning("Remote page request failed: %s", exc)
|
||||
if not future.done():
|
||||
loop.call_soon_threadsafe(future.set_result, None)
|
||||
|
||||
threading.Thread(target=_do_request, daemon=True).start()
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(future, timeout=30.0)
|
||||
except asyncio.TimeoutError:
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reticulum config endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/browse/identity")
|
||||
async def get_identity():
|
||||
"""Return the node's RNS identity hash and configured name."""
|
||||
identity_hash = None
|
||||
try:
|
||||
import RNS
|
||||
if _reticulum and RNS.Transport.identity:
|
||||
identity_hash = RNS.hexrep(RNS.Transport.identity.hash, delimit=False)
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"name": _own_name(),
|
||||
"hash": _own_hash or identity_hash,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/browse/restart")
|
||||
async def restart_services():
|
||||
"""Restart NomadNet to apply config changes."""
|
||||
restarted = _restart_nomadnet()
|
||||
return {"ok": True, "nomadnet_restarted": restarted}
|
||||
|
||||
|
||||
@router.get("/browse/config/{kind}")
|
||||
async def get_config(kind: str):
|
||||
"""Return config file contents for reticulum or nomadnet."""
|
||||
path = _config_path(kind)
|
||||
if not path.exists():
|
||||
return {"content": ""}
|
||||
return {"content": path.read_text(encoding="utf-8")}
|
||||
|
||||
|
||||
@router.post("/browse/config/{kind}")
|
||||
async def save_config(kind: str, body: dict):
|
||||
"""Write config file for reticulum or nomadnet."""
|
||||
content = body.get("content", "")
|
||||
path = _config_path(kind)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return {"ok": True}
|
||||
@@ -1,28 +1,126 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
"""µFrame compile, DSL metadata, and image upload endpoints."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File
|
||||
from pydantic import BaseModel
|
||||
|
||||
import uframe
|
||||
import uframe.keywords # noqa: F401 — triggers keyword registration
|
||||
from uframe.errors import UFrameError
|
||||
from uframe.registry import get_dsl_meta
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ConvertRequest(BaseModel):
|
||||
markdown: str
|
||||
width: int = 80
|
||||
UPLOAD_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources")) / "images"
|
||||
BACKEND_DIR = str(Path(__file__).resolve().parent)
|
||||
|
||||
|
||||
class ConvertResponse(BaseModel):
|
||||
def execute_dynamic_script(script: str, timeout: int = 10) -> str:
|
||||
"""Execute a dynamic page script and return its stdout (micron output).
|
||||
|
||||
Used by both the compile preview and the browse page reader.
|
||||
"""
|
||||
env = {**os.environ, "PYTHONPATH": BACKEND_DIR}
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
|
||||
f.write(script)
|
||||
f.flush()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, f.name],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
cwd=BACKEND_DIR, env=env,
|
||||
)
|
||||
if result.returncode != 0 and result.stderr:
|
||||
return result.stderr
|
||||
return result.stdout
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Error: script timed out"
|
||||
finally:
|
||||
os.unlink(f.name)
|
||||
|
||||
|
||||
class CompileRequest(BaseModel):
|
||||
source: str
|
||||
width: int = 64
|
||||
|
||||
|
||||
class CompileResponse(BaseModel):
|
||||
ascii: str
|
||||
micron: str
|
||||
script: str
|
||||
is_dynamic: bool
|
||||
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.
|
||||
|
||||
For dynamic pages, the generated script is executed and the
|
||||
resolved micron output replaces the static micron in the response.
|
||||
"""
|
||||
try:
|
||||
from md2txt import convert_markdown
|
||||
result = uframe.compile(req.source, width=req.width)
|
||||
|
||||
result = convert_markdown(
|
||||
req.markdown,
|
||||
width=req.width,
|
||||
renderer_name="micron",
|
||||
micron = result.micron
|
||||
if result.is_dynamic and result.script:
|
||||
executed = execute_dynamic_script(result.script)
|
||||
# Strip cache header line if present
|
||||
lines = executed.split("\n")
|
||||
if lines and lines[0].startswith("#!c="):
|
||||
lines = lines[1:]
|
||||
micron = "\n".join(lines)
|
||||
|
||||
return CompileResponse(
|
||||
ascii=result.ascii,
|
||||
micron=micron,
|
||||
script=result.script,
|
||||
is_dynamic=result.is_dynamic,
|
||||
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))
|
||||
|
||||
|
||||
@router.get("/dsl-meta")
|
||||
async def dsl_meta():
|
||||
"""Return DSL metadata for frontend syntax highlighting and autocomplete."""
|
||||
return get_dsl_meta()
|
||||
|
||||
|
||||
@router.post("/upload-image")
|
||||
async def upload_image(file: UploadFile = File(...)):
|
||||
"""Upload an image for use in .uf pages."""
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
# Sanitize filename
|
||||
name = file.filename or "upload.png"
|
||||
safe_name = "".join(c for c in name if c.isalnum() or c in "._-").rstrip(".")
|
||||
if not safe_name:
|
||||
safe_name = "upload.png"
|
||||
dest = UPLOAD_DIR / safe_name
|
||||
content = await file.read()
|
||||
dest.write_bytes(content)
|
||||
# Return the path relative to backend working directory
|
||||
rel_path = str(dest)
|
||||
return {"path": rel_path, "filename": safe_name, "size": len(content)}
|
||||
|
||||
|
||||
@router.get("/images")
|
||||
async def list_images():
|
||||
"""List uploaded images available for embedding."""
|
||||
if not UPLOAD_DIR.is_dir():
|
||||
return []
|
||||
images = []
|
||||
for f in sorted(UPLOAD_DIR.iterdir()):
|
||||
if f.suffix.lower() in (".png", ".jpg", ".jpeg", ".bmp", ".webp", ".gif"):
|
||||
images.append({
|
||||
"filename": f.name,
|
||||
"path": str(f),
|
||||
"size": f.stat().st_size,
|
||||
})
|
||||
return images
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
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_-]+)\)")
|
||||
|
||||
|
||||
class GraphNode(BaseModel):
|
||||
id: str
|
||||
published: bool
|
||||
title: str | None = None
|
||||
|
||||
|
||||
class GraphEdge(BaseModel):
|
||||
source: str
|
||||
target: str
|
||||
|
||||
|
||||
class GraphData(BaseModel):
|
||||
nodes: list[GraphNode]
|
||||
edges: list[GraphEdge]
|
||||
|
||||
|
||||
def _all_page_names() -> set[str]:
|
||||
names: set[str] = set()
|
||||
if PAGES_DIR.is_dir():
|
||||
for f in PAGES_DIR.iterdir():
|
||||
if f.suffix == ".mu" and f.is_file():
|
||||
names.add(f.stem)
|
||||
if SOURCES_DIR.is_dir():
|
||||
for f in SOURCES_DIR.iterdir():
|
||||
if f.suffix == ".md" and f.is_file():
|
||||
names.add(f.stem)
|
||||
return names
|
||||
|
||||
|
||||
def _extract_title(markdown: str) -> str | None:
|
||||
for line in markdown.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("# "):
|
||||
return stripped[2:].strip()
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/graph", response_model=GraphData)
|
||||
async def get_graph():
|
||||
all_names = _all_page_names()
|
||||
nodes: list[GraphNode] = []
|
||||
edges: list[GraphEdge] = []
|
||||
|
||||
for name in sorted(all_names):
|
||||
md_path = SOURCES_DIR / f"{name}.md"
|
||||
mu_path = PAGES_DIR / f"{name}.mu"
|
||||
|
||||
title = None
|
||||
if md_path.is_file():
|
||||
content = md_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:
|
||||
edges.append(GraphEdge(source=name, target=target))
|
||||
|
||||
nodes.append(GraphNode(
|
||||
id=name,
|
||||
published=mu_path.is_file(),
|
||||
title=title,
|
||||
))
|
||||
|
||||
return GraphData(nodes=nodes, edges=edges)
|
||||
@@ -4,17 +4,24 @@ 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 pages import router as pages_router, files_router, ensure_default_pages
|
||||
from docker_utils import router as docker_router
|
||||
from converter import router as converter_router
|
||||
from browse import router as browse_router, start_browser
|
||||
|
||||
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")
|
||||
app.include_router(graph_router, prefix="/api")
|
||||
app.include_router(files_router, prefix="/api")
|
||||
app.include_router(docker_router, prefix="/api")
|
||||
app.include_router(browse_router, prefix="/api")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
ensure_default_pages()
|
||||
start_browser()
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
@@ -22,7 +29,16 @@ async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# Serve built frontend as static files (SPA fallback)
|
||||
# Serve built frontend as static files with SPA fallback
|
||||
static_dir = Path(__file__).parent / "static"
|
||||
if static_dir.is_dir():
|
||||
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
app.mount("/assets", StaticFiles(directory=str(static_dir / "assets")), name="assets")
|
||||
|
||||
@app.get("/{path:path}")
|
||||
async def spa_fallback(path: str):
|
||||
file = static_dir / path
|
||||
if file.is_file():
|
||||
return FileResponse(file)
|
||||
return FileResponse(static_dir / "index.html")
|
||||
|
||||
6
backend/package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
351
backend/pages.py
@@ -1,14 +1,72 @@
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
import uframe
|
||||
|
||||
router = APIRouter()
|
||||
files_router = APIRouter()
|
||||
|
||||
PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages"))
|
||||
SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources"))
|
||||
|
||||
DEFAULT_INDEX_SOURCE = '''\
|
||||
page "Welcome" 60
|
||||
bigtitle "uFrame" thin
|
||||
|
||||
box rounded "Micronomicon"
|
||||
align center
|
||||
text "Decentralized Page Server"
|
||||
text "Powered by @bold{Reticulum} and @bold{NomadNet}"
|
||||
|
||||
spacer
|
||||
|
||||
heading 2 "Pages"
|
||||
text "This node is serving pages built with the uFrame DSL."
|
||||
text "Use the web IDE to create and publish new pages."
|
||||
|
||||
spacer
|
||||
|
||||
divider light
|
||||
|
||||
label "Node" "Micronomicon"
|
||||
label "Engine" "uFrame v1"
|
||||
status "Node" online
|
||||
'''
|
||||
|
||||
|
||||
def ensure_default_pages():
|
||||
"""Create default index page and .env file if they don't exist."""
|
||||
PAGES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
SOURCES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
index_mu = PAGES_DIR / "index.mu"
|
||||
index_src = SOURCES_DIR / "index.uf"
|
||||
|
||||
if not index_mu.is_file():
|
||||
# Compile and publish the default index
|
||||
result = uframe.compile(DEFAULT_INDEX_SOURCE)
|
||||
index_mu.write_text(result.micron, encoding="utf-8")
|
||||
index_mu.chmod(0o644)
|
||||
|
||||
if not index_src.is_file():
|
||||
index_src.write_text(DEFAULT_INDEX_SOURCE, encoding="utf-8")
|
||||
|
||||
env_path = SOURCES_DIR / ".env"
|
||||
if not env_path.is_file():
|
||||
env_path.write_text(
|
||||
"# Environment variables for dynamic pages\n"
|
||||
"# Access with: source name : env \"KEY\"\n"
|
||||
"#\n"
|
||||
"# Example:\n"
|
||||
"# API_KEY=your-key-here\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
class PageMeta(BaseModel):
|
||||
name: str
|
||||
@@ -21,20 +79,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 +126,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 +158,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 +171,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,40 +191,223 @@ 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
|
||||
result = uframe.compile(req.source)
|
||||
mu_path = PAGES_DIR / f"{name}.mu"
|
||||
|
||||
micron = convert_markdown(
|
||||
req.markdown,
|
||||
width=80,
|
||||
renderer_name="micron",
|
||||
)
|
||||
if result.is_dynamic and result.script:
|
||||
# Dynamic page: write executable Python script
|
||||
mu_path.write_text(result.script, encoding="utf-8")
|
||||
mu_path.chmod(0o755) # Set execute bit for NomadNet
|
||||
else:
|
||||
# Static page: write compiled Micron
|
||||
mu_path.write_text(result.micron, encoding="utf-8")
|
||||
# Remove execute bit if it was previously dynamic
|
||||
mu_path.chmod(0o644)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=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()
|
||||
|
||||
return {"deleted": name}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File browser endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class FileEntry(BaseModel):
|
||||
name: str
|
||||
path: str
|
||||
type: str # "file" | "folder" | "env"
|
||||
size: int | None = None
|
||||
last_modified: float | None = None
|
||||
title: str | None = None
|
||||
published: bool = False
|
||||
|
||||
|
||||
class MkdirRequest(BaseModel):
|
||||
path: str
|
||||
|
||||
|
||||
class MoveRequest(BaseModel):
|
||||
model_config = {"populate_by_name": True}
|
||||
from_path: str = Field(alias="from")
|
||||
to: str
|
||||
|
||||
|
||||
class EnvRequest(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
def _validate_relative_path(rel: str) -> Path:
|
||||
"""Validate that a relative path has no traversal components and resolves
|
||||
inside the expected base directories. Returns the cleaned relative Path."""
|
||||
p = Path(rel)
|
||||
# Reject absolute paths and any ".." components
|
||||
if p.is_absolute():
|
||||
raise HTTPException(status_code=400, detail="Absolute paths not allowed")
|
||||
for part in p.parts:
|
||||
if part == "..":
|
||||
raise HTTPException(status_code=400, detail="Directory traversal not allowed")
|
||||
# Extra safety: resolve against SOURCES_DIR and verify containment
|
||||
resolved = (SOURCES_DIR / p).resolve()
|
||||
if not str(resolved).startswith(str(SOURCES_DIR.resolve())):
|
||||
raise HTTPException(status_code=400, detail="Path escapes base directory")
|
||||
return p
|
||||
|
||||
|
||||
def _file_entry(base: Path, rel_path: Path) -> FileEntry:
|
||||
"""Build a FileEntry for a file or directory at base/rel_path."""
|
||||
full = base / rel_path
|
||||
name = rel_path.name
|
||||
|
||||
if full.is_dir():
|
||||
return FileEntry(
|
||||
name=name,
|
||||
path=str(rel_path),
|
||||
type="folder",
|
||||
)
|
||||
|
||||
# .env file
|
||||
if name == ".env":
|
||||
stat = full.stat()
|
||||
return FileEntry(
|
||||
name=name,
|
||||
path=str(rel_path),
|
||||
type="env",
|
||||
size=stat.st_size,
|
||||
last_modified=stat.st_mtime,
|
||||
)
|
||||
|
||||
# Regular file
|
||||
stat = full.stat()
|
||||
title = None
|
||||
published = False
|
||||
|
||||
if full.suffix == ".uf":
|
||||
try:
|
||||
title = _extract_title(full.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
# Check published status: corresponding .mu in PAGES_DIR at same relative path
|
||||
mu_rel = rel_path.with_suffix(".mu")
|
||||
published = (PAGES_DIR / mu_rel).is_file()
|
||||
|
||||
return FileEntry(
|
||||
name=name,
|
||||
path=str(rel_path),
|
||||
type="file",
|
||||
size=stat.st_size,
|
||||
last_modified=stat.st_mtime,
|
||||
title=title,
|
||||
published=published,
|
||||
)
|
||||
|
||||
|
||||
@files_router.get("/files", response_model=list[FileEntry])
|
||||
async def list_files(path: str = Query(default="")):
|
||||
"""List files and folders in SOURCES_DIR, optionally scoped to a subfolder."""
|
||||
if path:
|
||||
rel = _validate_relative_path(path)
|
||||
else:
|
||||
rel = Path(".")
|
||||
|
||||
target = (SOURCES_DIR / rel).resolve()
|
||||
if not str(target).startswith(str(SOURCES_DIR.resolve())):
|
||||
raise HTTPException(status_code=400, detail="Path escapes base directory")
|
||||
if not target.is_dir():
|
||||
raise HTTPException(status_code=404, detail="Directory not found")
|
||||
|
||||
entries: list[FileEntry] = []
|
||||
for item in sorted(target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
|
||||
item_rel = item.relative_to(SOURCES_DIR)
|
||||
entries.append(_file_entry(SOURCES_DIR, item_rel))
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
@files_router.post("/files/mkdir")
|
||||
async def mkdir(req: MkdirRequest):
|
||||
"""Create a folder in both SOURCES_DIR and PAGES_DIR."""
|
||||
rel = _validate_relative_path(req.path)
|
||||
|
||||
(SOURCES_DIR / rel).mkdir(parents=True, exist_ok=True)
|
||||
(PAGES_DIR / rel).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return {"created": str(rel)}
|
||||
|
||||
|
||||
@files_router.post("/files/move")
|
||||
async def move_file(req: MoveRequest):
|
||||
"""Move/rename a file or folder in both SOURCES_DIR and PAGES_DIR."""
|
||||
from_rel = _validate_relative_path(req.from_path)
|
||||
to_rel = _validate_relative_path(req.to)
|
||||
|
||||
# Move in SOURCES_DIR
|
||||
src_from = SOURCES_DIR / from_rel
|
||||
src_to = SOURCES_DIR / to_rel
|
||||
if src_from.exists():
|
||||
src_to.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(src_from), str(src_to))
|
||||
|
||||
# Move in PAGES_DIR (for .uf files, look for .mu counterpart)
|
||||
if src_from.suffix == ".uf" or (not src_from.exists() and from_rel.suffix == ".uf"):
|
||||
pages_from = PAGES_DIR / from_rel.with_suffix(".mu")
|
||||
pages_to = PAGES_DIR / to_rel.with_suffix(".mu")
|
||||
else:
|
||||
pages_from = PAGES_DIR / from_rel
|
||||
pages_to = PAGES_DIR / to_rel
|
||||
|
||||
if pages_from.exists():
|
||||
pages_to.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(pages_from), str(pages_to))
|
||||
|
||||
return {"moved": {"from": str(from_rel), "to": str(to_rel)}}
|
||||
|
||||
|
||||
@files_router.get("/files/env")
|
||||
async def read_env():
|
||||
"""Read the .env file from SOURCES_DIR root."""
|
||||
env_path = SOURCES_DIR / ".env"
|
||||
if env_path.is_file():
|
||||
return {"content": env_path.read_text(encoding="utf-8")}
|
||||
return {"content": ""}
|
||||
|
||||
|
||||
@files_router.post("/files/env")
|
||||
async def save_env(req: EnvRequest):
|
||||
"""Save the .env file to SOURCES_DIR root."""
|
||||
SOURCES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
env_path = SOURCES_DIR / ".env"
|
||||
env_path.write_text(req.content, encoding="utf-8")
|
||||
return {"saved": True}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.34
|
||||
docker>=7.0
|
||||
Pillow>=10.0
|
||||
python-multipart>=0.0.6
|
||||
md2txt @ git+https://codeberg.org/randogoth/md2txt
|
||||
rns>=0.9.3
|
||||
|
||||
BIN
backend/test_logo.png
Normal file
|
After Width: | Height: | Size: 898 B |
149
backend/uframe/__init__.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""µ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
|
||||
from uframe.ir import (
|
||||
IRNode, Field, Password, Radio, Checkbox, FormButton,
|
||||
Source, IfBlock, ForLoop, OnSubmit, StateDecl, CacheControl,
|
||||
)
|
||||
from uframe.themes import get_theme, ThemeDef
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompileResult:
|
||||
"""Result of compiling a .uf source."""
|
||||
ascii: str = ""
|
||||
micron: str = ""
|
||||
script: str = "" # generated Python script (dynamic mode only)
|
||||
is_dynamic: bool = False
|
||||
warnings: list[CompileWarning] = field(default_factory=list)
|
||||
|
||||
|
||||
def _has_dynamic_nodes(node: IRNode) -> bool:
|
||||
"""Check if the IR tree contains any dynamic nodes."""
|
||||
if isinstance(node, (Source, IfBlock, ForLoop, OnSubmit, StateDecl, CacheControl)):
|
||||
return True
|
||||
return any(_has_dynamic_nodes(child) for child in node.children)
|
||||
|
||||
|
||||
def _collect_form_nodes(node: IRNode) -> dict[int, IRNode]:
|
||||
"""Walk the IR tree and collect form nodes keyed by their y position."""
|
||||
result: dict[int, IRNode] = {}
|
||||
if isinstance(node, (Field, Password, Radio, Checkbox, FormButton)):
|
||||
result[node.rect.y] = node
|
||||
for child in node.children:
|
||||
result.update(_collect_form_nodes(child))
|
||||
return result
|
||||
|
||||
|
||||
def _micron_form_line(node: IRNode) -> str:
|
||||
"""Generate the Micron form tag for a form node."""
|
||||
if isinstance(node, Field):
|
||||
w = node.field_width
|
||||
name = node.field_name
|
||||
ph = node.placeholder or name
|
||||
return f"{name}: `<{w}|{name}`{ph}>"
|
||||
elif isinstance(node, Password):
|
||||
w = node.field_width
|
||||
name = node.field_name
|
||||
ph = node.placeholder or name
|
||||
return f"{name}: `<!{w}|{name}`{ph}>"
|
||||
elif isinstance(node, Radio):
|
||||
parts = []
|
||||
for i, opt in enumerate(node.options):
|
||||
val = opt.lower().replace(" ", "_")
|
||||
checked = "|*" if i == 0 else ""
|
||||
parts.append(f"`<^|{node.group}|{val}{checked}`{opt}>")
|
||||
return f"{node.group}: {' '.join(parts)}"
|
||||
elif isinstance(node, Checkbox):
|
||||
name = node.field_name
|
||||
label = node.checkbox_label
|
||||
checked = "|*" if node.checked else ""
|
||||
return f"`<?|{name}|yes{checked}`{label}>"
|
||||
elif isinstance(node, FormButton):
|
||||
return f"`[`!{node.button_label}`!`:{node.dest}]"
|
||||
return ""
|
||||
|
||||
|
||||
def compile(source: str, width: int = 64, theme: str = "") -> 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
|
||||
|
||||
# 1b. Resolve theme (CLI flag overrides source directive)
|
||||
theme_name = theme or page.theme_name or "default"
|
||||
theme_def = get_theme(theme_name)
|
||||
|
||||
# 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, theme_def)
|
||||
|
||||
# 5. Merge borders
|
||||
merge_borders(grid)
|
||||
|
||||
# 6. Emit
|
||||
ascii_out = emit_ascii(grid)
|
||||
micron_out = emit_micron(grid, page_title=page.title)
|
||||
|
||||
# 7. Post-pass: replace form element lines in Micron output
|
||||
form_nodes = _collect_form_nodes(page)
|
||||
if form_nodes:
|
||||
micron_lines = micron_out.split("\n")
|
||||
for row_y, form_node in form_nodes.items():
|
||||
if 0 <= row_y < len(micron_lines):
|
||||
micron_lines[row_y] = _micron_form_line(form_node)
|
||||
micron_out = "\n".join(micron_lines)
|
||||
|
||||
# 8. Check if this page has dynamic features
|
||||
is_dynamic = _has_dynamic_nodes(page)
|
||||
script = ""
|
||||
if is_dynamic:
|
||||
from uframe.codegen import compile_dynamic
|
||||
script = compile_dynamic(page)
|
||||
|
||||
return CompileResult(
|
||||
ascii=ascii_out,
|
||||
micron=micron_out,
|
||||
script=script,
|
||||
is_dynamic=is_dynamic,
|
||||
warnings=warnings,
|
||||
)
|
||||
4
backend/uframe/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Allow running µFrame as: python -m uframe render file.uf"""
|
||||
from uframe.cli import main
|
||||
import sys
|
||||
sys.exit(main())
|
||||
19
backend/uframe/borders.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Border merging post-pass (currently no-op).
|
||||
|
||||
The draw_border and _paint_table functions produce correct border characters
|
||||
directly. The original merge pass caused garbled junctions when borders from
|
||||
different boxes were adjacent, so it was disabled.
|
||||
|
||||
If future features need cross-box junction merging (e.g. tables sharing
|
||||
edges with parent boxes), add targeted logic here using border_id from
|
||||
the grid cells to only merge within the same border group.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uframe.grid import CharGrid
|
||||
|
||||
|
||||
def merge_borders(grid: CharGrid) -> None:
|
||||
"""No-op — borders are correctly painted by draw_border and _paint_table."""
|
||||
pass
|
||||
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": "┄",
|
||||
}
|
||||
193
backend/uframe/cli.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""µFrame CLI — render, compile, check, and deploy .uf files.
|
||||
|
||||
Usage:
|
||||
python -m uframe.cli render <file.uf> [--ascii | --micron] [--width N]
|
||||
python -m uframe.cli compile <file.uf> [--out <file.mu>] [--embed]
|
||||
python -m uframe.cli check <file.uf>
|
||||
python -m uframe.cli deploy <file.uf> [--dest <dir>]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import uframe
|
||||
from uframe.errors import UFrameError
|
||||
|
||||
|
||||
def cmd_render(args: argparse.Namespace) -> int:
|
||||
"""Render a .uf file to ASCII and/or Micron."""
|
||||
source = Path(args.file).read_text(encoding="utf-8")
|
||||
|
||||
try:
|
||||
result = uframe.compile(source, width=args.width, theme=getattr(args, 'theme', ''))
|
||||
except UFrameError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.ascii:
|
||||
print(result.ascii)
|
||||
elif args.micron:
|
||||
print(result.micron)
|
||||
else:
|
||||
# Default: show ASCII
|
||||
print(result.ascii)
|
||||
|
||||
if result.warnings:
|
||||
for w in result.warnings:
|
||||
print(f"Warning: {w.message}", file=sys.stderr)
|
||||
|
||||
if result.is_dynamic:
|
||||
print(f"\n[Dynamic page — {len(result.script)} bytes of generated script]", file=sys.stderr)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_compile(args: argparse.Namespace) -> int:
|
||||
"""Compile a .uf file to an executable .mu script (dynamic) or static .mu."""
|
||||
source_path = Path(args.file)
|
||||
source = source_path.read_text(encoding="utf-8")
|
||||
|
||||
try:
|
||||
result = uframe.compile(source, width=args.width, theme=getattr(args, 'theme', ''))
|
||||
except UFrameError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
out_path = Path(args.out) if args.out else source_path.with_suffix(".mu")
|
||||
|
||||
if result.is_dynamic and result.script:
|
||||
out_path.write_text(result.script, encoding="utf-8")
|
||||
out_path.chmod(0o755)
|
||||
print(f"Compiled dynamic: {out_path} ({len(result.script)} bytes, +x)")
|
||||
else:
|
||||
out_path.write_text(result.micron, encoding="utf-8")
|
||||
print(f"Compiled static: {out_path} ({len(result.micron)} bytes)")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_check(args: argparse.Namespace) -> int:
|
||||
"""Validate a .uf file without generating output."""
|
||||
source = Path(args.file).read_text(encoding="utf-8")
|
||||
|
||||
try:
|
||||
result = uframe.compile(source, width=args.width, theme=getattr(args, 'theme', ''))
|
||||
except UFrameError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
status = "dynamic" if result.is_dynamic else "static"
|
||||
print(f"OK: {args.file} ({status}, {len(result.ascii)} chars ASCII, {len(result.micron)} chars Micron)")
|
||||
|
||||
if result.warnings:
|
||||
for w in result.warnings:
|
||||
print(f" Warning: {w.message}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_deploy(args: argparse.Namespace) -> int:
|
||||
"""Compile and deploy a .uf file to the NomadNet pages directory."""
|
||||
source_path = Path(args.file)
|
||||
source = source_path.read_text(encoding="utf-8")
|
||||
dest_dir = Path(args.dest or os.path.expanduser("~/.nomadnetwork/storage/pages"))
|
||||
|
||||
try:
|
||||
result = uframe.compile(source, width=args.width, theme=getattr(args, 'theme', ''))
|
||||
except UFrameError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = dest_dir / f"{source_path.stem}.mu"
|
||||
|
||||
if result.is_dynamic and result.script:
|
||||
out_path.write_text(result.script, encoding="utf-8")
|
||||
out_path.chmod(0o755)
|
||||
print(f"Deployed dynamic: {out_path}")
|
||||
else:
|
||||
out_path.write_text(result.micron, encoding="utf-8")
|
||||
out_path.chmod(0o644)
|
||||
print(f"Deployed static: {out_path}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_image(args: argparse.Namespace) -> int:
|
||||
"""Convert an image file to character art."""
|
||||
from uframe.imaging import convert_image
|
||||
try:
|
||||
lines = convert_image(args.file, mode=args.mode, width=args.width,
|
||||
dither=args.dither, invert=args.invert)
|
||||
for line in lines:
|
||||
print(line)
|
||||
except ImportError:
|
||||
print("Error: Pillow is required: pip install Pillow", file=sys.stderr)
|
||||
return 1
|
||||
except FileNotFoundError:
|
||||
print(f"Error: Image not found: {args.file}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="uframe",
|
||||
description="µFrame — A DSL for rich terminal UIs rendered as ASCII and Micron",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# render
|
||||
p_render = sub.add_parser("render", help="Render a .uf file")
|
||||
p_render.add_argument("file", help="Path to .uf source file")
|
||||
p_render.add_argument("--ascii", action="store_true", help="Output ASCII only")
|
||||
p_render.add_argument("--micron", action="store_true", help="Output Micron only")
|
||||
p_render.add_argument("--width", type=int, default=64, help="Page width (default: 64)")
|
||||
p_render.add_argument("--theme", default="", help="Theme name (default, nouveau, gothic, bamboo, circuit, brutalist)")
|
||||
|
||||
# compile
|
||||
p_compile = sub.add_parser("compile", help="Compile to .mu file")
|
||||
p_compile.add_argument("file", help="Path to .uf source file")
|
||||
p_compile.add_argument("--out", help="Output file path (default: <name>.mu)")
|
||||
p_compile.add_argument("--width", type=int, default=64, help="Page width")
|
||||
p_compile.add_argument("--theme", default="", help="Theme name")
|
||||
|
||||
# check
|
||||
p_check = sub.add_parser("check", help="Validate a .uf file")
|
||||
p_check.add_argument("file", help="Path to .uf source file")
|
||||
p_check.add_argument("--width", type=int, default=64, help="Page width")
|
||||
|
||||
# deploy
|
||||
p_deploy = sub.add_parser("deploy", help="Compile and deploy to NomadNet")
|
||||
p_deploy.add_argument("file", help="Path to .uf source file")
|
||||
p_deploy.add_argument("--dest", help="Destination directory (default: ~/.nomadnetwork/storage/pages)")
|
||||
p_deploy.add_argument("--width", type=int, default=64, help="Page width")
|
||||
p_deploy.add_argument("--theme", default="", help="Theme name")
|
||||
|
||||
# image (standalone conversion)
|
||||
p_image = sub.add_parser("image", help="Convert image to character art")
|
||||
p_image.add_argument("file", help="Path to image file")
|
||||
p_image.add_argument("--mode", default="braille", help="braille, block, ascii, halfblock")
|
||||
p_image.add_argument("--width", type=int, default=40, help="Output width")
|
||||
p_image.add_argument("--dither", default="floyd", help="floyd, threshold, none")
|
||||
p_image.add_argument("--invert", action="store_true", help="Invert light/dark")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
commands = {
|
||||
"render": cmd_render,
|
||||
"compile": cmd_compile,
|
||||
"check": cmd_check,
|
||||
"deploy": cmd_deploy,
|
||||
"image": cmd_image,
|
||||
}
|
||||
|
||||
return commands[args.command](args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
417
backend/uframe/codegen.py
Normal file
@@ -0,0 +1,417 @@
|
||||
"""Dynamic page compiler — generate executable Python scripts from µFrame IR.
|
||||
|
||||
Takes a parsed IR tree containing dynamic nodes (source, if, for, on_submit,
|
||||
state) and generates a self-contained Python script that:
|
||||
1. Sets shebang + cache header
|
||||
2. Reads form data from environment variables
|
||||
3. Executes source commands
|
||||
4. Evaluates conditionals and loops
|
||||
5. Renders the layout into a CharGrid
|
||||
6. Emits Micron to stdout
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uframe.ir import (
|
||||
IRNode, Page, Box, Spacer,
|
||||
Heading, Text, Label, Divider, Link,
|
||||
Gauge, Status,
|
||||
Form, Field, FormButton,
|
||||
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
|
||||
SourceType,
|
||||
)
|
||||
|
||||
|
||||
def _indent(code: str, level: int = 1) -> str:
|
||||
"""Indent a block of code."""
|
||||
prefix = " " * level
|
||||
return "\n".join(prefix + line if line.strip() else "" for line in code.split("\n"))
|
||||
|
||||
|
||||
def _resolve_vars(text: str) -> str:
|
||||
"""Convert $var references to Python f-string expressions.
|
||||
|
||||
First escapes literal braces (e.g. @color{ff0}{text} → @color{{ff0}}{{text}})
|
||||
so they survive f-string evaluation, then replaces $var → {var}.
|
||||
"""
|
||||
import re
|
||||
# Use a unique placeholder for $var refs, escape all braces, then restore
|
||||
_PH = "\x00VAR"
|
||||
counter = [0]
|
||||
placeholders: dict[str, str] = {}
|
||||
|
||||
def stash_var(m: re.Match) -> str:
|
||||
var = m.group(1)
|
||||
if "." in var:
|
||||
parts = var.split(".")
|
||||
base = parts[0]
|
||||
chain = "".join(f"['{p}']" for p in parts[1:])
|
||||
expr = "{" + base + chain + "}"
|
||||
else:
|
||||
expr = "{" + var + "}"
|
||||
key = f"{_PH}{counter[0]}\x00"
|
||||
counter[0] += 1
|
||||
placeholders[key] = expr
|
||||
return key
|
||||
|
||||
# 1. Stash $var references with placeholders
|
||||
result = re.sub(r'\$([a-zA-Z_][\w.]*)', stash_var, text)
|
||||
# 2. Escape all remaining braces for f-string safety
|
||||
result = result.replace("{", "{{").replace("}", "}}")
|
||||
# 3. Restore $var placeholders (unescaped)
|
||||
for key, expr in placeholders.items():
|
||||
result = result.replace(key, expr)
|
||||
return result
|
||||
|
||||
|
||||
def _resolve_vars_code(text: str) -> str:
|
||||
"""Convert $var references to bare Python identifiers.
|
||||
|
||||
Simple vars: $name → name
|
||||
Dotted paths: $item.name → item['name'] (dict access)
|
||||
"""
|
||||
import re
|
||||
def replace_var(m: re.Match) -> str:
|
||||
var = m.group(1)
|
||||
if "." in var:
|
||||
parts = var.split(".")
|
||||
base = parts[0]
|
||||
chain = "".join(f"['{p}']" for p in parts[1:])
|
||||
return base + chain
|
||||
return var
|
||||
return re.sub(r'\$([a-zA-Z_][\w.]*)', replace_var, text)
|
||||
|
||||
|
||||
def _emit_node(node: IRNode, indent_level: int = 0) -> list[str]:
|
||||
"""Generate Python code lines for a single IR node."""
|
||||
lines: list[str] = []
|
||||
ind = " " * indent_level
|
||||
|
||||
if isinstance(node, Let):
|
||||
val = node.var_value
|
||||
# Try to detect numeric values
|
||||
try:
|
||||
float(val)
|
||||
lines.append(f"{ind}{node.var_name} = {val}")
|
||||
except ValueError:
|
||||
if "," in val:
|
||||
# Comma-separated list
|
||||
lines.append(f"{ind}{node.var_name} = [{val}]")
|
||||
else:
|
||||
lines.append(f"{ind}{node.var_name} = {val!r}")
|
||||
|
||||
elif isinstance(node, Source):
|
||||
var = node.var_name
|
||||
if node.source_type == SourceType.SHELL:
|
||||
lines.append(f"{ind}{var} = _shell({node.command!r}, timeout={node.timeout})")
|
||||
elif node.source_type == SourceType.FILE:
|
||||
lines.append(f"{ind}{var} = _read_file({node.command!r})")
|
||||
elif node.source_type == SourceType.JSON:
|
||||
lines.append(f"{ind}{var} = _read_json({node.command!r})")
|
||||
elif node.source_type == SourceType.PYTHON:
|
||||
lines.append(f"{ind}{var} = eval({node.command!r}, {{'datetime': _dt_cls, 'timedelta': timedelta, 'secrets': secrets, 'os': os, 'json': json}})")
|
||||
elif node.source_type == SourceType.PARAM:
|
||||
lines.append(f"{ind}{var} = _get_param({node.command!r})")
|
||||
elif node.source_type == SourceType.RNS:
|
||||
import shlex as _shlex
|
||||
safe_cmd = _shlex.quote(node.command)
|
||||
lines.append(f"{ind}{var} = _shell('rnstatus ' + shlex.quote({safe_cmd!r}), timeout={node.timeout})")
|
||||
elif node.source_type == SourceType.HTTP:
|
||||
method = node.http_method or "GET"
|
||||
url = _resolve_vars(node.command)
|
||||
hdrs = _resolve_vars(node.http_headers) if node.http_headers else ""
|
||||
body = _resolve_vars(node.http_body) if node.http_body else ""
|
||||
if body:
|
||||
lines.append(f"""{ind}{var} = _http(f'''{url}''', method={method!r}, body=f'''{body}''', headers=f'''{hdrs}''', timeout={node.timeout})""")
|
||||
elif hdrs:
|
||||
lines.append(f"""{ind}{var} = _http(f'''{url}''', method={method!r}, headers=f'''{hdrs}''', timeout={node.timeout})""")
|
||||
else:
|
||||
lines.append(f"{ind}{var} = _http({node.command!r}, method={method!r}, timeout={node.timeout})")
|
||||
elif node.source_type == SourceType.SQLITE:
|
||||
lines.append(f"{ind}{var} = _sqlite({node.command!r}, {node.query!r})")
|
||||
elif node.source_type == SourceType.ENV:
|
||||
lines.append(f"{ind}{var} = os.environ.get({node.command!r}, '')")
|
||||
|
||||
elif isinstance(node, CacheControl):
|
||||
lines.append(f"{ind}_cache_seconds = {node.seconds}")
|
||||
|
||||
elif isinstance(node, StateDecl):
|
||||
lines.append(f"{ind}{node.state_name} = _load_state({node.path!r})")
|
||||
|
||||
elif isinstance(node, IfBlock):
|
||||
cond = _resolve_vars_code(node.condition)
|
||||
cond = cond.replace("&&", " and ").replace("||", " or ")
|
||||
lines.append(f"{ind}if {cond}:")
|
||||
if node.children:
|
||||
for child in node.children:
|
||||
lines.extend(_emit_node(child, indent_level + 1))
|
||||
else:
|
||||
lines.append(f"{ind} pass")
|
||||
|
||||
for elif_cond, elif_children in node.elif_branches:
|
||||
ec = _resolve_vars_code(elif_cond).replace("&&", " and ").replace("||", " or ")
|
||||
lines.append(f"{ind}elif {ec}:")
|
||||
if elif_children:
|
||||
for child in elif_children:
|
||||
lines.extend(_emit_node(child, indent_level + 1))
|
||||
else:
|
||||
lines.append(f"{ind} pass")
|
||||
|
||||
if node.else_children:
|
||||
lines.append(f"{ind}else:")
|
||||
for child in node.else_children:
|
||||
lines.extend(_emit_node(child, indent_level + 1))
|
||||
|
||||
elif isinstance(node, ForLoop):
|
||||
iterable = _resolve_vars_code(node.iterable)
|
||||
lines.append(f"{ind}for {node.var_name} in _iter({iterable}):")
|
||||
if node.children:
|
||||
for child in node.children:
|
||||
lines.extend(_emit_node(child, indent_level + 1))
|
||||
else:
|
||||
lines.append(f"{ind} pass")
|
||||
|
||||
elif isinstance(node, OnSubmit):
|
||||
lines.append(f"{ind}if _get_field({node.form_name!r}, ''):")
|
||||
lines.append(f"{ind} # Form '{node.form_name}' was submitted")
|
||||
for child in node.children:
|
||||
lines.extend(_emit_node(child, indent_level + 1))
|
||||
|
||||
elif isinstance(node, Page):
|
||||
lines.append(f"{ind}_page_title = {node.title!r}")
|
||||
lines.append(f"{ind}_page_width = {node.width}")
|
||||
lines.append(f"{ind}_uf_source_parts = []")
|
||||
for child in node.children:
|
||||
lines.extend(_emit_node(child, indent_level))
|
||||
|
||||
# Content nodes — emit as µFrame source that gets compiled
|
||||
elif isinstance(node, Heading):
|
||||
level = node.level.value
|
||||
text = _resolve_vars(node.text)
|
||||
lines.append(f"{ind}_uf_source_parts.append(f'heading {level} \"{text}\"')")
|
||||
|
||||
elif isinstance(node, Text):
|
||||
text = _resolve_vars(node.content)
|
||||
lines.append(f"{ind}_uf_source_parts.append(f'text \"{text}\"')")
|
||||
|
||||
elif isinstance(node, Label):
|
||||
key = _resolve_vars(node.key)
|
||||
val = _resolve_vars(node.value)
|
||||
lines.append(f"{ind}_uf_source_parts.append(f'label \"{key}\" \"{val}\"')")
|
||||
|
||||
elif isinstance(node, Gauge):
|
||||
label = _resolve_vars(node.label)
|
||||
raw_val = getattr(node, "_raw_value", None)
|
||||
value = _resolve_vars(raw_val) if raw_val and "$" in raw_val else str(node.value)
|
||||
raw_max = getattr(node, "_raw_max_val", None)
|
||||
max_val = _resolve_vars(raw_max) if raw_max and "$" in raw_max else str(node.max_val)
|
||||
extra = ""
|
||||
if node.warn is not None:
|
||||
extra += f" warn={node.warn}"
|
||||
if node.crit is not None:
|
||||
extra += f" crit={node.crit}"
|
||||
lines.append(f"{ind}_uf_source_parts.append(f'gauge \"{label}\" {value} {max_val} {node.bar_width}{extra}')")
|
||||
|
||||
elif isinstance(node, Status):
|
||||
label = _resolve_vars(node.label)
|
||||
state = _resolve_vars(node.state)
|
||||
lines.append(f"{ind}_uf_source_parts.append(f'status \"{label}\" {state}')")
|
||||
|
||||
elif isinstance(node, Box):
|
||||
weight = node.weight.name.lower()
|
||||
title = _resolve_vars(node.title)
|
||||
lines.append(f"{ind}_uf_source_parts.append(f'box {weight} \"{title}\"')")
|
||||
for child in node.children:
|
||||
# Indent children for the box
|
||||
child_lines = _emit_node(child, indent_level)
|
||||
for cl in child_lines:
|
||||
if "_uf_source_parts.append" in cl:
|
||||
# Add 2-space indent to the µFrame source
|
||||
cl = cl.replace(".append(f'", ".append(f' ")
|
||||
cl = cl.replace(".append('", ".append(' ")
|
||||
lines.append(cl)
|
||||
|
||||
elif isinstance(node, Divider):
|
||||
style = node.divider_style.name.lower()
|
||||
lines.append(f"{ind}_uf_source_parts.append('divider {style}')")
|
||||
|
||||
elif isinstance(node, Spacer):
|
||||
lines.append(f"{ind}_uf_source_parts.append('spacer {node.lines}')")
|
||||
|
||||
elif isinstance(node, Link):
|
||||
display = _resolve_vars(node.display)
|
||||
dest = _resolve_vars(node.dest)
|
||||
lines.append(f"{ind}_uf_source_parts.append(f'link \"{display}\" \"{dest}\"')")
|
||||
|
||||
elif isinstance(node, Field):
|
||||
lines.append(f"{ind}_uf_source_parts.append('field \"{node.field_name}\" {node.field_width} \"{node.placeholder}\"')")
|
||||
|
||||
elif isinstance(node, FormButton):
|
||||
lines.append(f"{ind}_uf_source_parts.append('button \"{node.button_label}\" \"{node.dest}\"')")
|
||||
|
||||
elif isinstance(node, Form):
|
||||
lines.append(f"{ind}_uf_source_parts.append('form \"{node.form_name}\"')")
|
||||
for child in node.children:
|
||||
child_lines = _emit_node(child, indent_level)
|
||||
for cl in child_lines:
|
||||
if "_uf_source_parts.append" in cl:
|
||||
cl = cl.replace(".append(f'", ".append(f' ")
|
||||
cl = cl.replace(".append('", ".append(' ")
|
||||
lines.append(cl)
|
||||
|
||||
else:
|
||||
# Generic: emit children
|
||||
for child in node.children:
|
||||
lines.extend(_emit_node(child, indent_level))
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime template embedded in generated scripts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _build_script(uframe_import: str, page_logic: str, page_title: str, page_width: int) -> str:
|
||||
"""Build the dynamic script from parts (avoids str.format brace issues)."""
|
||||
lines = [
|
||||
"#!/usr/bin/env python3",
|
||||
"# Auto-generated by uFrame",
|
||||
"# Do not edit — regenerate with: uframe compile <source>.uf",
|
||||
"",
|
||||
"import os, sys, json, subprocess, datetime, secrets, shlex",
|
||||
"from datetime import datetime as _dt_cls, timedelta",
|
||||
"",
|
||||
"# ─── Runtime Helpers ─────────────────────────────────────────",
|
||||
"",
|
||||
'def _shell(cmd, timeout=5):',
|
||||
' """Execute shell command, return stdout."""',
|
||||
' try:',
|
||||
' return subprocess.check_output(cmd, shell=True, timeout=timeout).decode().strip()',
|
||||
' except Exception:',
|
||||
' return ""',
|
||||
"",
|
||||
'def _read_file(path):',
|
||||
' """Read file contents."""',
|
||||
' try:',
|
||||
' return open(path).read().strip()',
|
||||
' except Exception:',
|
||||
' return ""',
|
||||
"",
|
||||
'def _read_json(path):',
|
||||
' """Read and parse JSON file."""',
|
||||
' try:',
|
||||
' with open(path) as f:',
|
||||
' return json.load(f)',
|
||||
' except Exception:',
|
||||
' return {}',
|
||||
"",
|
||||
'def _get_field(name, default=""):',
|
||||
' """Read submitted form field from environment."""',
|
||||
' return os.environ.get(f"FIELD_{name}", default)',
|
||||
"",
|
||||
'def _get_param(name, default=""):',
|
||||
' """Read URL parameter."""',
|
||||
' return os.environ.get(f"PARAM_{name}",',
|
||||
' os.environ.get(f"var_{name}", default))',
|
||||
"",
|
||||
'def _load_state(path):',
|
||||
' """Load state from JSON file."""',
|
||||
' try:',
|
||||
' with open(path) as f:',
|
||||
' return json.load(f)',
|
||||
' except Exception:',
|
||||
' return {}',
|
||||
"",
|
||||
'def _save_state(path, data):',
|
||||
' """Save state to JSON file."""',
|
||||
' os.makedirs(os.path.dirname(path), exist_ok=True)',
|
||||
' with open(path, "w") as f:',
|
||||
' json.dump(data, f, indent=2)',
|
||||
"",
|
||||
'def _iter(val):',
|
||||
' """Make a value iterable for for-loops."""',
|
||||
' if isinstance(val, (list, tuple)):',
|
||||
' return val',
|
||||
' if isinstance(val, dict):',
|
||||
' return [val]',
|
||||
' if isinstance(val, str):',
|
||||
' return val.strip().splitlines()',
|
||||
' return []',
|
||||
"",
|
||||
'def _http(url, method="GET", body="", headers="", timeout=10):',
|
||||
' """HTTP request, return response body (JSON parsed if possible)."""',
|
||||
' import urllib.request, urllib.error',
|
||||
' try:',
|
||||
' data = body.encode("utf-8") if body else None',
|
||||
' req = urllib.request.Request(url, data=data, method=method)',
|
||||
' req.add_header("User-Agent", "uframe/1.0")',
|
||||
' if body and not headers:',
|
||||
' req.add_header("Content-Type", "application/json")',
|
||||
' if headers:',
|
||||
' for pair in headers.split(";"):',
|
||||
' if ":" in pair:',
|
||||
' k, v = pair.split(":", 1)',
|
||||
' req.add_header(k.strip(), v.strip())',
|
||||
' with urllib.request.urlopen(req, timeout=timeout) as resp:',
|
||||
' raw = resp.read().decode("utf-8")',
|
||||
' try:',
|
||||
' return json.loads(raw)',
|
||||
' except (json.JSONDecodeError, ValueError):',
|
||||
' return raw.strip()',
|
||||
' except Exception as e:',
|
||||
' return {"error": str(e)}',
|
||||
"",
|
||||
'def _sqlite(db_path, query):',
|
||||
' """Run a SQLite query, return list of dicts."""',
|
||||
' import sqlite3',
|
||||
' try:',
|
||||
' conn = sqlite3.connect(db_path)',
|
||||
' conn.row_factory = sqlite3.Row',
|
||||
' cur = conn.execute(query)',
|
||||
' rows = [dict(r) for r in cur.fetchall()]',
|
||||
' conn.close()',
|
||||
' return rows if len(rows) != 1 else rows[0]',
|
||||
' except Exception as e:',
|
||||
' return {"error": str(e)}',
|
||||
"",
|
||||
f"# ─── µFrame Compile ──────────────────────────────────────────",
|
||||
"",
|
||||
uframe_import,
|
||||
"",
|
||||
"# ─── Page Logic ──────────────────────────────────────────────",
|
||||
"",
|
||||
"_cache_seconds = 0",
|
||||
"",
|
||||
page_logic,
|
||||
"",
|
||||
"# ─── Render & Output ─────────────────────────────────────────",
|
||||
"",
|
||||
f'_uf_source = f\'\'\'page "{page_title}" {page_width}',
|
||||
"''' + \"\\n\".join(_uf_source_parts)",
|
||||
"",
|
||||
f"result = uframe.compile(_uf_source, width={page_width})",
|
||||
"",
|
||||
"if _cache_seconds >= 0:",
|
||||
' print(f"#!c={_cache_seconds}")',
|
||||
"print(result.micron)",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def compile_dynamic(page: Page) -> str:
|
||||
"""Generate a self-contained executable Python script from an IR tree.
|
||||
|
||||
The generated script imports uframe at runtime and compiles the
|
||||
dynamically-built .uf source into Micron output.
|
||||
"""
|
||||
logic_lines = _emit_node(page, indent_level=0)
|
||||
page_logic = "\n".join(logic_lines)
|
||||
|
||||
uframe_import = "import uframe"
|
||||
|
||||
return _build_script(
|
||||
uframe_import=uframe_import,
|
||||
page_logic=page_logic,
|
||||
page_title=page.title,
|
||||
page_width=page.width,
|
||||
)
|
||||
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)
|
||||
114
backend/uframe/emit_micron.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""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."""
|
||||
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 — backtick enters formatting mode
|
||||
# where the parser recognizes `[` as link start
|
||||
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
@@ -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})"
|
||||
209
backend/uframe/fonts.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""µFrame Big Text Fonts — multi-line ASCII art letter definitions.
|
||||
|
||||
Each font is a dict mapping characters to a list of strings (one per line).
|
||||
All characters in a font have the same height.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Block font — solid █ with box-drawing. Height: 6 lines.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BLOCK: dict[str, list[str]] = {
|
||||
"A": [" █████╗ ", "██╔══██╗", "███████║", "██╔══██║", "██║ ██║", "╚═╝ ╚═╝"],
|
||||
"B": ["██████╗ ", "██╔══██╗", "██████╔╝", "██╔══██╗", "██████╔╝", "╚═════╝ "],
|
||||
"C": [" ██████╗", "██╔════╝", "██║ ", "██║ ", "╚██████╗", " ╚═════╝"],
|
||||
"D": ["██████╗ ", "██╔══██╗", "██║ ██║", "██║ ██║", "██████╔╝", "╚═════╝ "],
|
||||
"E": ["███████╗", "██╔════╝", "█████╗ ", "██╔══╝ ", "███████╗", "╚══════╝"],
|
||||
"F": ["███████╗", "██╔════╝", "█████╗ ", "██╔══╝ ", "██║ ", "╚═╝ "],
|
||||
"G": [" ██████╗ ", "██╔════╝ ", "██║ ███╗", "██║ ██║", "╚██████╔╝", " ╚═════╝ "],
|
||||
"H": ["██╗ ██╗", "██║ ██║", "███████║", "██╔══██║", "██║ ██║", "╚═╝ ╚═╝"],
|
||||
"I": ["██╗", "██║", "██║", "██║", "██║", "╚═╝"],
|
||||
"J": [" ██╗", " ██║", " ██║", "██ ██║", "╚█████╔╝", " ╚════╝ "],
|
||||
"K": ["██╗ ██╗", "██║ ██╔╝", "█████╔╝ ", "██╔═██╗ ", "██║ ██╗", "╚═╝ ╚═╝"],
|
||||
"L": ["██╗ ", "██║ ", "██║ ", "██║ ", "███████╗", "╚══════╝"],
|
||||
"M": ["███╗ ███╗", "████╗ ████║", "██╔████╔██║", "██║╚██╔╝██║", "██║ ╚═╝ ██║", "╚═╝ ╚═╝"],
|
||||
"N": ["███╗ ██╗", "████╗ ██║", "██╔██╗ ██║", "██║╚██╗██║", "██║ ╚████║", "╚═╝ ╚═══╝"],
|
||||
"O": [" ██████╗ ", "██╔═══██╗", "██║ ██║", "██║ ██║", "╚██████╔╝", " ╚═════╝ "],
|
||||
"P": ["██████╗ ", "██╔══██╗", "██████╔╝", "██╔═══╝ ", "██║ ", "╚═╝ "],
|
||||
"Q": [" ██████╗ ", "██╔═══██╗", "██║ ██║", "██║▄▄ ██║", "╚██████╔╝", " ╚══▀▀═╝ "],
|
||||
"R": ["██████╗ ", "██╔══██╗", "██████╔╝", "██╔══██╗", "██║ ██║", "╚═╝ ╚═╝"],
|
||||
"S": ["███████╗", "██╔════╝", "███████╗", "╚════██║", "███████║", "╚══════╝"],
|
||||
"T": ["████████╗", "╚══██╔══╝", " ██║ ", " ██║ ", " ██║ ", " ╚═╝ "],
|
||||
"U": ["██╗ ██╗", "██║ ██║", "██║ ██║", "██║ ██║", "╚██████╔╝", " ╚═════╝ "],
|
||||
"V": ["██╗ ██╗", "██║ ██║", "██║ ██║", "╚██╗ ██╔╝", " ╚████╔╝ ", " ╚═══╝ "],
|
||||
"W": ["██╗ ██╗", "██║ ██║", "██║ █╗ ██║", "██║███╗██║", "╚███╔███╔╝", " ╚══╝╚══╝ "],
|
||||
"X": ["██╗ ██╗", "╚██╗██╔╝", " ╚███╔╝ ", " ██╔██╗ ", "██╔╝ ██╗", "╚═╝ ╚═╝"],
|
||||
"Y": ["██╗ ██╗", "╚██╗ ██╔╝", " ╚████╔╝ ", " ╚██╔╝ ", " ██║ ", " ╚═╝ "],
|
||||
"Z": ["███████╗", "╚══███╔╝", " ███╔╝ ", " ███╔╝ ", "███████╗", "╚══════╝"],
|
||||
"0": [" ██████╗ ", "██╔═══██╗", "██║ ██║", "██║ ██║", "╚██████╔╝", " ╚═════╝ "],
|
||||
"1": [" ██╗", "███║", "╚██║", " ██║", " ██║", " ╚═╝"],
|
||||
"2": ["██████╗ ", "╚════██╗", " █████╔╝", "██╔═══╝ ", "███████╗", "╚══════╝"],
|
||||
"3": ["██████╗ ", "╚════██╗", " █████╔╝", " ╚═══██╗", "██████╔╝", "╚═════╝ "],
|
||||
"4": ["██╗ ██╗", "██║ ██║", "███████║", "╚════██║", " ██║", " ╚═╝"],
|
||||
"5": ["███████╗", "██╔════╝", "███████╗", "╚════██║", "███████║", "╚══════╝"],
|
||||
"6": [" ██████╗", "██╔════╝", "██████╗ ", "██╔══██╗", "╚█████╔╝", " ╚════╝ "],
|
||||
"7": ["███████╗", "╚════██║", " ██╔╝", " ██╔╝ ", " ██║ ", " ╚═╝ "],
|
||||
"8": [" █████╗ ", "██╔══██╗", "╚█████╔╝", "██╔══██╗", "╚█████╔╝", " ╚════╝ "],
|
||||
"9": [" █████╗ ", "██╔══██╗", "╚██████║", " ╚═══██║", " █████╔╝", " ╚════╝ "],
|
||||
"-": [" ", " ", "██████╗ ", "╚═════╝ ", " ", " "],
|
||||
" ": [" ", " ", " ", " ", " ", " "],
|
||||
".": [" ", " ", " ", " ", "██╗", "╚═╝"],
|
||||
"!": ["██╗", "██║", "██║", "╚═╝", "██╗", "╚═╝"],
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thin font — light single-stroke. Height: 3 lines.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_THIN: dict[str, list[str]] = {
|
||||
"A": ["┌─┐", "├─┤", "┘ └"],
|
||||
"B": ["┬─┐", "├─┤", "┴─┘"],
|
||||
"C": ["┌─ ", "│ ", "└─ "],
|
||||
"D": ["┬─┐", "│ │", "┴─┘"],
|
||||
"E": ["┬──", "├─ ", "┴──"],
|
||||
"F": ["┬──", "├─ ", "┘ "],
|
||||
"G": ["┌─ ", "│ ┐", "└─┘"],
|
||||
"H": ["┐ ┌", "├─┤", "┘ └"],
|
||||
"I": ["┬", "│", "┴"],
|
||||
"J": [" ┬", " │", "└─┘"],
|
||||
"K": ["┐ ┌", "├┬┘", "┘└ "],
|
||||
"L": ["│ ", "│ ", "└──"],
|
||||
"M": ["┌┬┐", "│││", "┘ └"],
|
||||
"N": ["┌┐ ", "│└┐", "┘ └"],
|
||||
"O": ["┌─┐", "│ │", "└─┘"],
|
||||
"P": ["┌─┐", "├─┘", "┘ "],
|
||||
"Q": ["┌─┐", "│ │", "└─┤"],
|
||||
"R": ["┌─┐", "├─┤", "┘ └"],
|
||||
"S": ["┌─ ", "└─┐", " ─┘"],
|
||||
"T": ["┬─┬", " │ ", " ┴ "],
|
||||
"U": ["┐ ┌", "│ │", "└─┘"],
|
||||
"V": ["┐ ┌", "│ │", "└┬┘"],
|
||||
"W": ["┐ ┌", "│││", "└┴┘"],
|
||||
"X": ["╲ ╱", " ╳ ", "╱ ╲"],
|
||||
"Y": ["┐ ┌", "└┬┘", " ┴ "],
|
||||
"Z": ["──┐", " ╱ ", "└──"],
|
||||
"0": ["┌─┐", "│ │", "└─┘"],
|
||||
"1": [" ┐", " │", " ┴"],
|
||||
"2": ["─┐", "┌┘", "└─"],
|
||||
"3": ["─┐", " ┤", "─┘"],
|
||||
"4": ["┐ ┐", "└─┤", " ┘"],
|
||||
"5": ["┌─", "└┐", "─┘"],
|
||||
"6": ["┌─", "├┐", "└┘"],
|
||||
"7": ["──┐", " │", " ┘"],
|
||||
"8": ["┌┐", "├┤", "└┘"],
|
||||
"9": ["┌┐", "└┤", "─┘"],
|
||||
"-": [" ", "── ", " "],
|
||||
" ": [" ", " ", " "],
|
||||
".": [" ", " ", "·"],
|
||||
"!": ["│", " ", "·"],
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pixel font — retro bitmap. Height: 3 lines.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PIXEL: dict[str, list[str]] = {
|
||||
"A": ["▀▀▀▄", "█▀▀█", "▀ ▀"],
|
||||
"B": ["▀▀▀▄", "█▀▀▄", "▀▀▀ "],
|
||||
"C": ["▄▀▀▀", "█ ", "▀▀▀ "],
|
||||
"D": ["▀▀▀▄", "█ █", "▀▀▀ "],
|
||||
"E": ["▀▀▀ ", "█▀ ", "▀▀▀ "],
|
||||
"F": ["▀▀▀ ", "█▀ ", "▀ "],
|
||||
"G": ["▄▀▀ ", "█ ▀█", "▀▀▀ "],
|
||||
"H": ["▀ ▀ ", "█▀▀█", "▀ ▀"],
|
||||
"I": ["▀", "█", "▀"],
|
||||
"J": [" ▀ ", " █ ", "▀▀ "],
|
||||
"K": ["▀ ▄ ", "█▀▄ ", "▀ ▀"],
|
||||
"L": ["▀ ", "█ ", "▀▀▀ "],
|
||||
"M": ["▀▄▄▀", "█▀▀█", "▀ ▀"],
|
||||
"N": ["▀▄ ▀", "█ ▀█", "▀ ▀"],
|
||||
"O": ["▄▀▀▄", "█ █", "▀▀▀ "],
|
||||
"P": ["▀▀▀▄", "█▀▀ ", "▀ "],
|
||||
"Q": ["▄▀▀▄", "█ █", "▀▀▀▄"],
|
||||
"R": ["▀▀▀▄", "█▀▀▄", "▀ ▀"],
|
||||
"S": ["▄▀▀ ", "▀▀▀▄", " ▀▀ "],
|
||||
"T": ["▀▀▀▀", " █ ", " ▀ "],
|
||||
"U": ["▀ ▀", "█ █", "▀▀▀ "],
|
||||
"V": ["▀ ▀", "█ █", " ▀▀ "],
|
||||
"W": ["▀ ▀", "█▄▄█", "▀▀▀▀"],
|
||||
"X": ["▀ ▀", " ▀▀ ", "▀ ▀"],
|
||||
"Y": ["▀ ▀", " ▀▀ ", " ▀ "],
|
||||
"Z": ["▀▀▀▀", " ▄▀ ", "▀▀▀▀"],
|
||||
"0": ["▄▀▀▄", "█ █", "▀▀▀ "],
|
||||
"1": [" ▄", " █", " ▀"],
|
||||
"2": ["▀▀▄", " ▄▀", "▀▀▀"],
|
||||
"3": ["▀▀▄", " ▀▄", "▀▀ "],
|
||||
"4": ["▀ ▀", "▀▀█", " ▀"],
|
||||
"5": ["▀▀▀", "▀▀▄", "▀▀ "],
|
||||
"6": ["▄▀▀", "█▀▄", "▀▀ "],
|
||||
"7": ["▀▀▀", " █", " ▀"],
|
||||
"8": ["▄▀▄", "█▀█", "▀▀ "],
|
||||
"9": ["▄▀▄", "▀▀█", "▀▀ "],
|
||||
"-": [" ", " ▀▀ ", " "],
|
||||
" ": [" ", " ", " "],
|
||||
".": [" ", " ", "▄"],
|
||||
"!": ["█", " ", "▄"],
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Font registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FONTS: dict[str, dict[str, list[str]]] = {
|
||||
"block": _BLOCK,
|
||||
"thin": _THIN,
|
||||
"pixel": _PIXEL,
|
||||
}
|
||||
|
||||
FONT_HEIGHTS: dict[str, int] = {
|
||||
"block": 6,
|
||||
"thin": 3,
|
||||
"pixel": 3,
|
||||
}
|
||||
|
||||
|
||||
def render_big_text(text: str, font_name: str = "block", kerning: int = 1) -> list[str]:
|
||||
"""Render text as multi-line ASCII art using the specified font.
|
||||
|
||||
Args:
|
||||
text: the string to render (uppercase recommended for block/pixel)
|
||||
font_name: "block", "thin", or "pixel"
|
||||
kerning: space between characters (0=tight, 1=normal, 2=wide)
|
||||
|
||||
Returns:
|
||||
List of strings, one per line of the rendered text.
|
||||
"""
|
||||
font = FONTS.get(font_name, _BLOCK)
|
||||
height = FONT_HEIGHTS.get(font_name, 6)
|
||||
text_upper = text.upper()
|
||||
|
||||
# Build each line by concatenating character columns
|
||||
lines: list[str] = ["" for _ in range(height)]
|
||||
spacer = " " * kerning
|
||||
|
||||
for i, ch in enumerate(text_upper):
|
||||
glyph = font.get(ch, font.get("?", [" " * 3] * height))
|
||||
for row in range(height):
|
||||
if row < len(glyph):
|
||||
lines[row] += glyph[row]
|
||||
else:
|
||||
lines[row] += " " * (len(glyph[0]) if glyph else 3)
|
||||
if i < len(text_upper) - 1:
|
||||
lines[row] += spacer
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def get_text_width(text: str, font_name: str = "block", kerning: int = 1) -> int:
|
||||
"""Calculate the rendered width of big text without rendering it."""
|
||||
font = FONTS.get(font_name, _BLOCK)
|
||||
text_upper = text.upper()
|
||||
width = 0
|
||||
for i, ch in enumerate(text_upper):
|
||||
glyph = font.get(ch, font.get("?", [" " * 3]))
|
||||
width += len(glyph[0]) if glyph else 3
|
||||
if i < len(text_upper) - 1:
|
||||
width += kerning
|
||||
return width
|
||||
182
backend/uframe/grid.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""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
|
||||
border_id: int = 0 # Identifies which box this border belongs to
|
||||
link: str | None = None # Micron link destination
|
||||
|
||||
|
||||
class CharGrid:
|
||||
"""2D buffer of cells. Origin (0,0) is top-left."""
|
||||
|
||||
__slots__ = ("width", "height", "cells", "_border_counter")
|
||||
|
||||
def __init__(self, width: int, height: int):
|
||||
self.width = width
|
||||
self.height = height
|
||||
self._border_counter = 0
|
||||
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,
|
||||
border_id: int = 0,
|
||||
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 border_id:
|
||||
cell.border_id = border_id
|
||||
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,
|
||||
border_chars: dict[str, str] | None = None,
|
||||
title_caps: tuple[str, str] | 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
|
||||
|
||||
self._border_counter += 1
|
||||
bid = self._border_counter
|
||||
|
||||
ch = border_chars or BOX_CHARS[weight]
|
||||
border_style = CellStyle()
|
||||
|
||||
# Corners
|
||||
self.put(x, y, ch["tl"], border_style, is_border=True, border_weight=weight, border_id=bid)
|
||||
self.put(x + w - 1, y, ch["tr"], border_style, is_border=True, border_weight=weight, border_id=bid)
|
||||
self.put(x, y + h - 1, ch["bl"], border_style, is_border=True, border_weight=weight, border_id=bid)
|
||||
self.put(x + w - 1, y + h - 1, ch["br"], border_style, is_border=True, border_weight=weight, border_id=bid)
|
||||
|
||||
# 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, border_id=bid)
|
||||
self.put(col, y + h - 1, ch["h"], border_style, is_border=True, border_weight=weight, border_id=bid)
|
||||
|
||||
# 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, border_id=bid)
|
||||
self.put(x + w - 1, row, ch["v"], border_style, is_border=True, border_weight=weight, border_id=bid)
|
||||
|
||||
# Title in top border
|
||||
if title and w > 4:
|
||||
lc = title_caps[0] if title_caps else " "
|
||||
rc = title_caps[1] if title_caps else " "
|
||||
title_text = f"{lc}{title}{rc}"
|
||||
max_title = w - 4
|
||||
if len(title_text) > max_title:
|
||||
title_text = title_text[:max_title]
|
||||
|
||||
start_x = x + 1
|
||||
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)
|
||||
267
backend/uframe/imaging.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""µFrame Image Converter — convert images to character art.
|
||||
|
||||
Supports braille, block, ascii, and halfblock rendering modes
|
||||
with optional dithering and color output.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from uframe.chars import BRAILLE_BASE, BRAILLE_LEFT, BRAILLE_RIGHT
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
HAS_PIL = True
|
||||
except ImportError:
|
||||
HAS_PIL = False
|
||||
|
||||
|
||||
# ASCII brightness ramp (light → dark)
|
||||
ASCII_RAMP = " .:-=+*#%@"
|
||||
# Block shade ramp (light → dark)
|
||||
BLOCK_RAMP = " ░▒▓█"
|
||||
|
||||
|
||||
def _load_image(path: str) -> "Image.Image":
|
||||
"""Load an image from file path."""
|
||||
if not HAS_PIL:
|
||||
raise ImportError("Pillow is required for image conversion: pip install Pillow")
|
||||
resolved = Path(path).expanduser()
|
||||
if not resolved.is_file():
|
||||
raise FileNotFoundError(f"Image not found: {path}")
|
||||
return Image.open(str(resolved))
|
||||
|
||||
|
||||
def _floyd_steinberg(pixels: list[list[float]], w: int, h: int, levels: int = 2) -> list[list[int]]:
|
||||
"""Apply Floyd-Steinberg dithering to a grayscale pixel array.
|
||||
|
||||
Returns quantized values in range [0, levels-1].
|
||||
"""
|
||||
result = [[0] * w for _ in range(h)]
|
||||
err = [row[:] for row in pixels] # copy
|
||||
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
old = err[y][x]
|
||||
new = round(old * (levels - 1)) / (levels - 1) if levels > 1 else (1.0 if old > 0.5 else 0.0)
|
||||
result[y][x] = int(round(new * (levels - 1)))
|
||||
quant_err = old - new
|
||||
|
||||
if x + 1 < w:
|
||||
err[y][x + 1] += quant_err * 7 / 16
|
||||
if y + 1 < h:
|
||||
if x - 1 >= 0:
|
||||
err[y + 1][x - 1] += quant_err * 3 / 16
|
||||
err[y + 1][x] += quant_err * 5 / 16
|
||||
if x + 1 < w:
|
||||
err[y + 1][x + 1] += quant_err * 1 / 16
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def convert_braille(img: "Image.Image", width: int, dither: str = "floyd",
|
||||
invert: bool = False) -> list[str]:
|
||||
"""Convert image to braille character art.
|
||||
|
||||
Each character encodes a 2×4 pixel block. Resolution: 2x horizontal, 4x vertical.
|
||||
"""
|
||||
# Resize: each output char = 2 pixels wide × 4 pixels tall
|
||||
pixel_w = width * 2
|
||||
aspect = img.height / img.width
|
||||
pixel_h = int(pixel_w * aspect / 2) # /2 for terminal cell aspect
|
||||
pixel_h = max(pixel_h, 4)
|
||||
# Round up to multiple of 4
|
||||
pixel_h = ((pixel_h + 3) // 4) * 4
|
||||
|
||||
img_resized = img.resize((pixel_w, pixel_h)).convert("L")
|
||||
|
||||
# Get pixel data as 0.0–1.0 floats
|
||||
pixels = []
|
||||
for y in range(pixel_h):
|
||||
row = []
|
||||
for x in range(pixel_w):
|
||||
v = img_resized.getpixel((x, y)) / 255.0
|
||||
if invert:
|
||||
v = 1.0 - v
|
||||
row.append(v)
|
||||
pixels.append(row)
|
||||
|
||||
# Dither to binary
|
||||
if dither == "floyd":
|
||||
binary = _floyd_steinberg(pixels, pixel_w, pixel_h, levels=2)
|
||||
else:
|
||||
binary = [[1 if p > 0.5 else 0 for p in row] for row in pixels]
|
||||
|
||||
# Map 2×4 blocks to braille characters
|
||||
lines: list[str] = []
|
||||
for by in range(0, pixel_h, 4):
|
||||
line = ""
|
||||
for bx in range(0, pixel_w, 2):
|
||||
code = BRAILLE_BASE
|
||||
for row in range(4):
|
||||
py = by + row
|
||||
if py < pixel_h:
|
||||
# Left column
|
||||
px_l = bx
|
||||
if px_l < pixel_w and binary[py][px_l]:
|
||||
code |= BRAILLE_LEFT[row]
|
||||
# Right column
|
||||
px_r = bx + 1
|
||||
if px_r < pixel_w and binary[py][px_r]:
|
||||
code |= BRAILLE_RIGHT[row]
|
||||
line += chr(code)
|
||||
lines.append(line)
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def convert_block(img: "Image.Image", width: int, dither: str = "none",
|
||||
invert: bool = False) -> list[str]:
|
||||
"""Convert image to block shade characters (░▒▓█)."""
|
||||
aspect = img.height / img.width
|
||||
height = max(1, int(width * aspect / 2)) # /2 for terminal cell aspect
|
||||
|
||||
img_resized = img.resize((width, height)).convert("L")
|
||||
|
||||
pixels = []
|
||||
for y in range(height):
|
||||
row = []
|
||||
for x in range(width):
|
||||
v = img_resized.getpixel((x, y)) / 255.0
|
||||
if invert:
|
||||
v = 1.0 - v
|
||||
row.append(v)
|
||||
pixels.append(row)
|
||||
|
||||
if dither == "floyd":
|
||||
quantized = _floyd_steinberg(pixels, width, height, levels=len(BLOCK_RAMP))
|
||||
else:
|
||||
quantized = [[int(p * (len(BLOCK_RAMP) - 1)) for p in row] for row in pixels]
|
||||
|
||||
lines: list[str] = []
|
||||
for row in quantized:
|
||||
line = "".join(BLOCK_RAMP[min(v, len(BLOCK_RAMP) - 1)] for v in row)
|
||||
lines.append(line)
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def convert_ascii(img: "Image.Image", width: int, dither: str = "none",
|
||||
invert: bool = False) -> list[str]:
|
||||
"""Convert image to classic ASCII art using brightness ramp."""
|
||||
aspect = img.height / img.width
|
||||
height = max(1, int(width * aspect / 2))
|
||||
|
||||
img_resized = img.resize((width, height)).convert("L")
|
||||
|
||||
lines: list[str] = []
|
||||
for y in range(height):
|
||||
line = ""
|
||||
for x in range(width):
|
||||
v = img_resized.getpixel((x, y)) / 255.0
|
||||
if invert:
|
||||
v = 1.0 - v
|
||||
idx = int(v * (len(ASCII_RAMP) - 1))
|
||||
line += ASCII_RAMP[min(idx, len(ASCII_RAMP) - 1)]
|
||||
lines.append(line)
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def convert_halfblock(img: "Image.Image", width: int,
|
||||
invert: bool = False, use_color: bool = False) -> list[tuple[str, str | None, str | None]]:
|
||||
"""Convert image to half-block characters with optional color.
|
||||
|
||||
Uses ▄ with foreground (bottom pixel) and background (top pixel) colors.
|
||||
Returns list of (line_text, fg_colors, bg_colors) tuples.
|
||||
Each fg/bg color string has one 3-digit hex per character, or None for mono.
|
||||
"""
|
||||
aspect = img.height / img.width
|
||||
height = max(2, int(width * aspect / 2))
|
||||
# Round up to even
|
||||
height = height + (height % 2)
|
||||
|
||||
img_resized = img.resize((width, height))
|
||||
|
||||
if use_color:
|
||||
img_rgb = img_resized.convert("RGB")
|
||||
img_gray = img_resized.convert("L")
|
||||
|
||||
lines: list[tuple[str, str | None, str | None]] = []
|
||||
for y in range(0, height, 2):
|
||||
chars = ""
|
||||
fgs = "" if use_color else None
|
||||
bgs = "" if use_color else None
|
||||
|
||||
for x in range(width):
|
||||
top_v = img_gray.getpixel((x, y)) / 255.0
|
||||
bot_v = img_gray.getpixel((x, y + 1)) / 255.0 if y + 1 < height else 0
|
||||
|
||||
if invert:
|
||||
top_v = 1.0 - top_v
|
||||
bot_v = 1.0 - bot_v
|
||||
|
||||
if use_color:
|
||||
top_rgb = img_rgb.getpixel((x, y))
|
||||
bot_rgb = img_rgb.getpixel((x, y + 1)) if y + 1 < height else (0, 0, 0)
|
||||
# Quantize to 3-digit hex
|
||||
fg_hex = f"{round(bot_rgb[0]*15/255):x}{round(bot_rgb[1]*15/255):x}{round(bot_rgb[2]*15/255):x}"
|
||||
bg_hex = f"{round(top_rgb[0]*15/255):x}{round(top_rgb[1]*15/255):x}{round(top_rgb[2]*15/255):x}"
|
||||
fgs += fg_hex
|
||||
bgs += bg_hex
|
||||
|
||||
chars += "▄"
|
||||
|
||||
lines.append((chars, fgs, bgs))
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def convert_image(path: str, mode: str = "braille", width: int = 30,
|
||||
dither: str = "floyd", invert: bool = False,
|
||||
use_color: bool = False) -> list[str]:
|
||||
"""High-level image conversion — returns list of character art lines.
|
||||
|
||||
Args:
|
||||
path: image file path
|
||||
mode: "braille", "block", "ascii", "halfblock"
|
||||
width: output width in characters
|
||||
dither: "floyd", "threshold", "none"
|
||||
invert: flip light/dark
|
||||
use_color: preserve colors (halfblock only for now)
|
||||
|
||||
Returns:
|
||||
List of strings, one per output line.
|
||||
"""
|
||||
img = _load_image(path)
|
||||
|
||||
if mode == "braille":
|
||||
return convert_braille(img, width, dither, invert)
|
||||
elif mode == "block":
|
||||
return convert_block(img, width, dither, invert)
|
||||
elif mode == "ascii":
|
||||
return convert_ascii(img, width, dither, invert)
|
||||
elif mode == "halfblock":
|
||||
hb_lines = convert_halfblock(img, width, invert, use_color)
|
||||
# For non-color mode, just return the character strings
|
||||
return [line[0] for line in hb_lines]
|
||||
else:
|
||||
return convert_braille(img, width, dither, invert)
|
||||
|
||||
|
||||
def get_image_height(path: str, mode: str = "braille", width: int = 30) -> int:
|
||||
"""Calculate the output height for an image without full conversion."""
|
||||
try:
|
||||
img = _load_image(path)
|
||||
except (ImportError, FileNotFoundError):
|
||||
return 1
|
||||
|
||||
aspect = img.height / img.width
|
||||
|
||||
if mode == "braille":
|
||||
pixel_h = int(width * 2 * aspect / 2)
|
||||
return max(1, ((pixel_h + 3) // 4))
|
||||
else:
|
||||
return max(1, int(width * aspect / 2))
|
||||
434
backend/uframe/ir.py
Normal file
@@ -0,0 +1,434 @@
|
||||
"""µ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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
theme_name: str = ""
|
||||
|
||||
|
||||
@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."""
|
||||
title: str = ""
|
||||
columns: list[tuple[str, int]] = field(default_factory=list) # (name, width)
|
||||
rows: list[list[str]] = field(default_factory=list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Form nodes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class Form(IRNode):
|
||||
"""Form container grouping interactive fields."""
|
||||
form_name: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Field(IRNode):
|
||||
"""Text input field."""
|
||||
field_name: str = ""
|
||||
field_width: int = 24
|
||||
placeholder: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Password(IRNode):
|
||||
"""Masked password field."""
|
||||
field_name: str = ""
|
||||
field_width: int = 24
|
||||
placeholder: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Radio(IRNode):
|
||||
"""Radio button group — options separated by |."""
|
||||
group: str = ""
|
||||
options: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Checkbox(IRNode):
|
||||
"""Checkbox field."""
|
||||
field_name: str = ""
|
||||
checkbox_label: str = ""
|
||||
checked: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class FormButton(IRNode):
|
||||
"""Submit button — clickable link in Micron."""
|
||||
button_label: str = ""
|
||||
dest: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dynamic nodes (Phase 7)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SourceType(Enum):
|
||||
SHELL = auto()
|
||||
FILE = auto()
|
||||
JSON = auto()
|
||||
PYTHON = auto()
|
||||
RNS = auto()
|
||||
PARAM = auto()
|
||||
HTTP = auto()
|
||||
SQLITE = auto()
|
||||
ENV = auto()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Let(IRNode):
|
||||
"""Variable assignment: let name = "value" or let name = 1,2,3."""
|
||||
var_name: str = ""
|
||||
var_value: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Source(IRNode):
|
||||
"""Data source resolved at render time (dynamic pages only)."""
|
||||
var_name: str = ""
|
||||
source_type: SourceType = SourceType.SHELL
|
||||
command: str = ""
|
||||
# HTTP-specific
|
||||
http_method: str = "GET"
|
||||
http_body: str = ""
|
||||
http_headers: str = ""
|
||||
# SQLite-specific: command = db path, query = SQL
|
||||
query: str = ""
|
||||
timeout: int = 5
|
||||
|
||||
|
||||
@dataclass
|
||||
class IfBlock(IRNode):
|
||||
"""Conditional block: if $var > threshold."""
|
||||
condition: str = ""
|
||||
# children = the "then" branch
|
||||
elif_branches: list[tuple[str, list[IRNode]]] = field(default_factory=list)
|
||||
else_children: list[IRNode] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ForLoop(IRNode):
|
||||
"""Iteration: for item in $collection."""
|
||||
var_name: str = ""
|
||||
iterable: str = ""
|
||||
# children = loop body
|
||||
|
||||
|
||||
@dataclass
|
||||
class CacheControl(IRNode):
|
||||
"""Cache header: cache 0 (never cache) or cache 300 (5 min)."""
|
||||
seconds: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class OnSubmit(IRNode):
|
||||
"""Form submission handler: on_submit "form_name"."""
|
||||
form_name: str = ""
|
||||
# children = handler body
|
||||
|
||||
|
||||
@dataclass
|
||||
class StateDecl(IRNode):
|
||||
"""State persistence: state "name" "/path.json"."""
|
||||
state_name: str = ""
|
||||
path: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Components (Phase 8)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Navigation nodes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class NavItem:
|
||||
"""A single item in a navigation bar."""
|
||||
kind: str = "item" # "item", "separator", "heading"
|
||||
label: str = ""
|
||||
dest: str = ""
|
||||
active: bool = False
|
||||
children: list["NavItem"] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HNav(IRNode):
|
||||
"""Horizontal navigation bar."""
|
||||
nav_style: str = "bar" # bar, tabs, pills, breadcrumb, underline
|
||||
items: list[NavItem] = field(default_factory=list)
|
||||
compact: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class VNav(IRNode):
|
||||
"""Vertical navigation panel."""
|
||||
nav_style: str = "list" # list, boxed, tree, sidebar, minimal
|
||||
nav_width: int = 0 # 0 = auto-fit
|
||||
items: list[NavItem] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageNode(IRNode):
|
||||
"""Image converted to character art."""
|
||||
path: str = ""
|
||||
mode: str = "braille" # braille, block, ascii, halfblock
|
||||
img_width: int = 30
|
||||
dither: str = "floyd" # floyd, threshold, none
|
||||
invert: bool = False
|
||||
use_color: bool = False
|
||||
caption: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class BigTitle(IRNode):
|
||||
"""Large multi-line ASCII art text."""
|
||||
text: str = ""
|
||||
font: str = "block" # block, thin, pixel
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComponentDef(IRNode):
|
||||
"""Component definition: component name(arg1, arg2)."""
|
||||
comp_name: str = ""
|
||||
params: list[str] = field(default_factory=list)
|
||||
# children = the component body template
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComponentUse(IRNode):
|
||||
"""Component instantiation: name "arg1" "arg2"."""
|
||||
comp_name: str = ""
|
||||
args: list[str] = field(default_factory=list)
|
||||
289
backend/uframe/keywords.py
Normal file
@@ -0,0 +1,289 @@
|
||||
"""µFrame Keyword Registrations — single source of truth for all DSL keywords.
|
||||
|
||||
Each keyword is registered here with its metadata (section, detail, snippet,
|
||||
highlight values). The actual parse/measure/layout/paint/codegen functions
|
||||
remain in their respective modules for now — this file serves as the
|
||||
registry that the frontend reads via GET /api/dsl-meta.
|
||||
|
||||
To add a new keyword:
|
||||
1. Define its IR node in ir.py
|
||||
2. Add a register_keyword() call here
|
||||
3. Add parse logic in parser.py
|
||||
4. Add measure/layout/paint logic in their respective files
|
||||
5. That's it — syntax highlighting and slash commands auto-update from the registry
|
||||
"""
|
||||
|
||||
from uframe.registry import register_keyword, ALL_THEME_NAMES
|
||||
from uframe.ir import (
|
||||
Page, Box, Row, Col, Spacer, Pad,
|
||||
Heading, Text, Label, Divider, Link, ListNode, ListItem,
|
||||
Gauge, Sparkline, Status, Table,
|
||||
Form, Field, Password, Radio, Checkbox, FormButton,
|
||||
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
|
||||
BigTitle, ImageNode, HNav, VNav,
|
||||
ComponentDef, ComponentUse,
|
||||
)
|
||||
from uframe.themes import BUILTIN_THEMES
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layout
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
register_keyword("page", node_class=Page, section="Layout",
|
||||
detail='page "Title" 64', snippet='page "${1:Title}" ${2:64}',
|
||||
is_container=True)
|
||||
|
||||
register_keyword("box", node_class=Box, section="Layout",
|
||||
detail='box light "Title"', snippet='box ${1:light} "${2:Title}"',
|
||||
highlight_values=["light", "heavy", "double", "rounded"],
|
||||
is_container=True)
|
||||
|
||||
register_keyword("row", node_class=Row, section="Layout",
|
||||
detail="row [gap]", snippet="row ${1:2}",
|
||||
is_container=True)
|
||||
|
||||
register_keyword("col", node_class=Col, section="Layout",
|
||||
detail="col [width]", snippet="col ${1}",
|
||||
is_container=True)
|
||||
|
||||
register_keyword("spacer", node_class=Spacer, section="Layout",
|
||||
detail="spacer [lines]", snippet="spacer",
|
||||
is_leaf=True)
|
||||
|
||||
register_keyword("pad", node_class=Pad, section="Layout",
|
||||
detail="pad t r b l", snippet="pad ${1:1} ${2:1} ${3:1} ${4:1}",
|
||||
is_container=True)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Content
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
register_keyword("heading", node_class=Heading, section="Content",
|
||||
detail='heading 1 "Text"', snippet='heading ${1:1} "${2:Text}"',
|
||||
is_leaf=True)
|
||||
|
||||
register_keyword("text", node_class=Text, section="Content",
|
||||
detail='text "Content"', snippet='text "${1:Content}"',
|
||||
is_leaf=True)
|
||||
|
||||
register_keyword("label", node_class=Label, section="Content",
|
||||
detail='label "Key" "Value"', snippet='label "${1:Key}" "${2:Value}"',
|
||||
is_leaf=True)
|
||||
|
||||
register_keyword("divider", node_class=Divider, section="Content",
|
||||
detail="divider heavy", snippet="divider ${1:light}",
|
||||
highlight_values=["light", "heavy", "double", "dash", "dot"],
|
||||
is_leaf=True)
|
||||
|
||||
register_keyword("link", node_class=Link, section="Content",
|
||||
detail='link "Text" "/path.mu"', snippet='link "${1:Text}" "${2:/page/dest.mu}"',
|
||||
is_leaf=True)
|
||||
|
||||
register_keyword("list", node_class=ListNode, section="Content",
|
||||
detail="list bullet", snippet='list ${1:bullet}\n item "${2:Entry}"',
|
||||
highlight_values=["bullet", "dash", "number", "arrow"],
|
||||
is_container=True)
|
||||
|
||||
register_keyword("item", node_class=ListItem, section="",
|
||||
detail="", snippet="", # not shown in slash commands (child of list)
|
||||
is_leaf=True)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data Visualization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
register_keyword("gauge", node_class=Gauge, section="Data",
|
||||
detail="gauge label val max width",
|
||||
snippet='gauge "${1:Label}" ${2:0} ${3:100} ${4:28} warn=${5:75} crit=${6:90}',
|
||||
is_leaf=True)
|
||||
|
||||
register_keyword("sparkline", node_class=Sparkline, section="Data",
|
||||
detail="sparkline label values width",
|
||||
snippet='sparkline "${1:Label}" "${2:1,2,3,4}" ${3:20}',
|
||||
is_leaf=True)
|
||||
|
||||
register_keyword("status", node_class=Status, section="Data",
|
||||
detail="status label state",
|
||||
snippet='status "${1:Label}" ${2:online}',
|
||||
highlight_values=["online", "offline", "degraded", "unknown", "alert"],
|
||||
is_leaf=True)
|
||||
|
||||
register_keyword("table", node_class=Table, section="Data",
|
||||
detail="table + columns + rows",
|
||||
snippet='table "Title"\n columns "Name" 20 | "Value" 10\n row "entry" | "data"',
|
||||
is_leaf=True) # table handles its own children (columns/row pseudo-nodes)
|
||||
|
||||
register_keyword("columns", section="",
|
||||
detail="", snippet="") # child of table, not shown in slash commands
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Big Text
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
register_keyword("hnav", node_class=HNav, section="Layout",
|
||||
detail='hnav bar', snippet='hnav ${1:bar}\n item "${2:Label}" "${3:/page/dest.mu}" active',
|
||||
highlight_values=["bar", "tabs", "pills", "breadcrumb", "underline"],
|
||||
is_container=True)
|
||||
|
||||
register_keyword("vnav", node_class=VNav, section="Layout",
|
||||
detail='vnav list', snippet='vnav ${1:list}\n item "${2:Label}" "${3:/page/dest.mu}"',
|
||||
highlight_values=["list", "boxed", "tree", "sidebar", "minimal"],
|
||||
is_container=True)
|
||||
|
||||
register_keyword("image", node_class=ImageNode, section="Content",
|
||||
detail='image "path.png" braille 30',
|
||||
snippet='image "${1:path.png}" ${2:braille} ${3:30}',
|
||||
highlight_values=["braille", "block", "ascii", "halfblock"],
|
||||
is_leaf=True)
|
||||
|
||||
register_keyword("dither", section="",
|
||||
detail="", snippet="",
|
||||
highlight_values=["floyd", "threshold", "none"],
|
||||
is_style_directive=True)
|
||||
|
||||
register_keyword("invert", section="",
|
||||
detail="", snippet="",
|
||||
is_style_directive=True)
|
||||
|
||||
register_keyword("caption", section="",
|
||||
detail="", snippet="",
|
||||
is_style_directive=True)
|
||||
|
||||
register_keyword("bigtitle", node_class=BigTitle, section="Content",
|
||||
detail='bigtitle "TEXT" block',
|
||||
snippet='bigtitle "${1:TEXT}" ${2:block}',
|
||||
highlight_values=["block", "thin", "pixel"],
|
||||
is_leaf=True)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Style
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
register_keyword("align", section="Style",
|
||||
detail="align center", snippet="align ${1:center}",
|
||||
highlight_values=["left", "center", "right"],
|
||||
is_style_directive=True)
|
||||
|
||||
register_keyword("color", section="Style",
|
||||
detail="color hex", snippet="color ${1:0cf}",
|
||||
is_style_directive=True)
|
||||
|
||||
register_keyword("bg", section="Style",
|
||||
detail="", snippet="",
|
||||
is_style_directive=True)
|
||||
|
||||
register_keyword("bold", section="Style",
|
||||
detail="bold", snippet="bold",
|
||||
is_style_directive=True)
|
||||
|
||||
register_keyword("italic", section="Style",
|
||||
detail="", snippet="",
|
||||
is_style_directive=True)
|
||||
|
||||
register_keyword("underline", section="Style",
|
||||
detail="", snippet="",
|
||||
is_style_directive=True)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Forms
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
register_keyword("form", node_class=Form, section="Form",
|
||||
detail='form "name"', snippet='form "${1:name}"',
|
||||
is_container=True)
|
||||
|
||||
register_keyword("field", node_class=Field, section="Form",
|
||||
detail='field "name" 24 "placeholder"',
|
||||
snippet='field "${1:name}" ${2:24} "${3:placeholder}"',
|
||||
is_leaf=True)
|
||||
|
||||
register_keyword("password", node_class=Password, section="Form",
|
||||
detail='password "name" 24 "placeholder"',
|
||||
snippet='password "${1:name}" ${2:24} "${3:placeholder}"',
|
||||
is_leaf=True)
|
||||
|
||||
register_keyword("radio", node_class=Radio, section="Form",
|
||||
detail='radio "group" "A" | "B" | "C"',
|
||||
snippet='radio "${1:group}" "${2:Option A}" | "${3:Option B}"',
|
||||
is_leaf=True)
|
||||
|
||||
register_keyword("checkbox", node_class=Checkbox, section="Form",
|
||||
detail='checkbox "name" "Label"',
|
||||
snippet='checkbox "${1:name}" "${2:Label}"',
|
||||
is_leaf=True)
|
||||
|
||||
register_keyword("button", node_class=FormButton, section="Form",
|
||||
detail='button "Label" "/action"',
|
||||
snippet='button "${1:Label}" "${2:/page/action.mu}"',
|
||||
is_leaf=True)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dynamic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
register_keyword("let", node_class=Let, section="Dynamic",
|
||||
detail='let name = "value"', snippet='let ${1:name} = "${2:value}"',
|
||||
is_metadata=True)
|
||||
|
||||
register_keyword("source", node_class=Source, section="Dynamic",
|
||||
detail='source name : shell "cmd"',
|
||||
snippet='source ${1:name} : shell "${2:command}"',
|
||||
highlight_values=["shell", "file", "json", "python", "rns", "param"],
|
||||
is_metadata=True)
|
||||
|
||||
register_keyword("if", node_class=IfBlock, section="Dynamic",
|
||||
detail="if $var > threshold", snippet="if ${1:condition}",
|
||||
is_container=True)
|
||||
|
||||
register_keyword("elif", section="Dynamic",
|
||||
detail="", snippet="")
|
||||
|
||||
register_keyword("else", section="Dynamic",
|
||||
detail="", snippet="")
|
||||
|
||||
register_keyword("for", node_class=ForLoop, section="Dynamic",
|
||||
detail="for item in $list", snippet='for ${1:item} in ${2:\\$list}',
|
||||
is_container=True)
|
||||
|
||||
register_keyword("cache", node_class=CacheControl, section="Dynamic",
|
||||
detail="cache 0", snippet="cache ${1:0}",
|
||||
is_metadata=True)
|
||||
|
||||
register_keyword("on_submit", node_class=OnSubmit, section="Dynamic",
|
||||
detail='on_submit "form"', snippet='on_submit "${1:form_name}"',
|
||||
is_container=True)
|
||||
|
||||
register_keyword("state", node_class=StateDecl, section="Dynamic",
|
||||
detail='state "name" "/path.json"',
|
||||
snippet='state "${1:name}" "${2:/tmp/state.json}"',
|
||||
is_metadata=True)
|
||||
|
||||
register_keyword("set", section="Dynamic", detail="", snippet="")
|
||||
register_keyword("append", section="Dynamic", detail="", snippet="")
|
||||
register_keyword("prepend", section="Dynamic", detail="", snippet="")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Themes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
register_keyword("theme", section="Theme",
|
||||
detail="", snippet="",
|
||||
is_style_directive=True)
|
||||
|
||||
# Register each built-in theme as a named entry
|
||||
for _theme_name, _theme_def in BUILTIN_THEMES.items():
|
||||
register_keyword(f"theme_{_theme_name}", section="Theme",
|
||||
detail=f"{_theme_def.description}",
|
||||
snippet=f"theme {_theme_name}")
|
||||
ALL_THEME_NAMES.append(_theme_name)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Components
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
register_keyword("component", node_class=ComponentDef, section="",
|
||||
detail="", snippet="",
|
||||
is_container=True)
|
||||
|
||||
register_keyword("use", section="",
|
||||
detail="", snippet="")
|
||||
159
backend/uframe/layout.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""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, Table,
|
||||
Form, Field, Password, Radio, Checkbox, FormButton,
|
||||
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
|
||||
BigTitle, ImageNode, HNav, VNav,
|
||||
)
|
||||
|
||||
|
||||
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, Form):
|
||||
cursor_y = y
|
||||
for child in node.children:
|
||||
child_h = layout(child, x, cursor_y, w, h - (cursor_y - y))
|
||||
cursor_y += child_h
|
||||
node.rect.h = cursor_y - y
|
||||
return node.rect.h
|
||||
|
||||
elif isinstance(node, (Let, Source, CacheControl, StateDecl)):
|
||||
node.rect.h = 0
|
||||
return 0
|
||||
|
||||
elif isinstance(node, (IfBlock, ForLoop, OnSubmit)):
|
||||
cursor_y = y
|
||||
for child in node.children:
|
||||
child_h = layout(child, x, cursor_y, w, h - (cursor_y - y))
|
||||
cursor_y += child_h
|
||||
node.rect.h = cursor_y - y
|
||||
return node.rect.h
|
||||
|
||||
elif isinstance(node, (Heading, Text, Label, Divider, Link, ListItem,
|
||||
Gauge, Sparkline, Status, Table,
|
||||
Field, Password, Radio, Checkbox, FormButton,
|
||||
BigTitle, ImageNode, HNav, VNav)):
|
||||
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
|
||||
328
backend/uframe/measure.py
Normal file
@@ -0,0 +1,328 @@
|
||||
"""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, Table,
|
||||
Form, Field, Password, Radio, Checkbox, FormButton,
|
||||
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
|
||||
BigTitle, ImageNode, HNav, VNav,
|
||||
)
|
||||
from uframe.fonts import FONT_HEIGHTS, get_text_width
|
||||
from uframe.imaging import get_image_height
|
||||
|
||||
|
||||
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
|
||||
|
||||
elif isinstance(node, Table):
|
||||
num_rows = len(node.rows)
|
||||
node.pref_width = available_width
|
||||
node.min_width = len(node.columns) * 3 + 1
|
||||
node.pref_height = num_rows + 4
|
||||
node.min_height = 4
|
||||
|
||||
elif isinstance(node, Form):
|
||||
total_h = 0
|
||||
for child in node.children:
|
||||
measure(child, available_width)
|
||||
total_h += child.pref_height
|
||||
node.pref_width = available_width
|
||||
node.min_width = 10
|
||||
node.pref_height = total_h
|
||||
node.min_height = total_h
|
||||
|
||||
elif isinstance(node, Field):
|
||||
node.pref_width = available_width
|
||||
node.min_width = len(node.field_name) + node.field_width + 6
|
||||
node.pref_height = 1
|
||||
node.min_height = 1
|
||||
|
||||
elif isinstance(node, Password):
|
||||
node.pref_width = available_width
|
||||
node.min_width = len(node.field_name) + node.field_width + 6
|
||||
node.pref_height = 1
|
||||
node.min_height = 1
|
||||
|
||||
elif isinstance(node, Radio):
|
||||
node.pref_width = available_width
|
||||
node.min_width = len(node.group) + sum(len(o) + 6 for o in node.options)
|
||||
node.pref_height = 1
|
||||
node.min_height = 1
|
||||
|
||||
elif isinstance(node, Checkbox):
|
||||
node.pref_width = available_width
|
||||
node.min_width = len(node.checkbox_label) + 6
|
||||
node.pref_height = 1
|
||||
node.min_height = 1
|
||||
|
||||
elif isinstance(node, FormButton):
|
||||
node.pref_width = available_width
|
||||
node.min_width = len(node.button_label) + 6
|
||||
node.pref_height = 1
|
||||
node.min_height = 1
|
||||
|
||||
elif isinstance(node, HNav):
|
||||
# Height: 3 for bar/tabs/pills (top border + items + bottom border), 2 for underline/breadcrumb
|
||||
if node.nav_style in ("underline",):
|
||||
node.pref_height = 2
|
||||
elif node.nav_style in ("breadcrumb",):
|
||||
node.pref_height = 1
|
||||
else:
|
||||
node.pref_height = 3
|
||||
node.pref_width = available_width
|
||||
node.min_width = 10
|
||||
node.min_height = 1
|
||||
|
||||
elif isinstance(node, VNav):
|
||||
count = sum(1 for it in node.items if it.kind in ("item", "heading"))
|
||||
sep_count = sum(1 for it in node.items if it.kind == "separator")
|
||||
h = count + sep_count
|
||||
if node.nav_style == "boxed":
|
||||
h += 2 # top + bottom border
|
||||
node.pref_height = max(h, 1)
|
||||
if node.nav_width > 0:
|
||||
node.pref_width = node.nav_width
|
||||
else:
|
||||
max_label = max((len(it.label) for it in node.items if it.label), default=8)
|
||||
node.pref_width = max_label + 6 # marker + padding
|
||||
node.min_width = 8
|
||||
node.min_height = 1
|
||||
|
||||
elif isinstance(node, ImageNode):
|
||||
h = get_image_height(node.path, node.mode, node.img_width)
|
||||
if node.caption:
|
||||
h += 1 # extra line for caption
|
||||
node.pref_width = available_width
|
||||
node.min_width = node.img_width
|
||||
node.pref_height = h
|
||||
node.min_height = 1
|
||||
|
||||
elif isinstance(node, BigTitle):
|
||||
# Try the requested font, fall back to smaller if too wide
|
||||
tw = get_text_width(node.text, node.font)
|
||||
if tw <= available_width:
|
||||
h = FONT_HEIGHTS.get(node.font, 6)
|
||||
elif get_text_width(node.text, "pixel") <= available_width:
|
||||
h = FONT_HEIGHTS.get("pixel", 3)
|
||||
elif get_text_width(node.text, "thin") <= available_width:
|
||||
h = FONT_HEIGHTS.get("thin", 3)
|
||||
else:
|
||||
h = 1 # fallback to single styled line
|
||||
node.pref_width = available_width
|
||||
node.min_width = 1
|
||||
node.pref_height = h
|
||||
node.min_height = 1
|
||||
|
||||
elif isinstance(node, (Let, Source, CacheControl, StateDecl)):
|
||||
# Zero-height metadata nodes — no visual output
|
||||
node.pref_width = 0
|
||||
node.min_width = 0
|
||||
node.pref_height = 0
|
||||
node.min_height = 0
|
||||
|
||||
elif isinstance(node, (IfBlock, ForLoop, OnSubmit)):
|
||||
# Container nodes — height = sum of children
|
||||
total_h = 0
|
||||
for child in node.children:
|
||||
measure(child, available_width)
|
||||
total_h += child.pref_height
|
||||
node.pref_width = available_width
|
||||
node.min_width = 1
|
||||
node.pref_height = total_h
|
||||
node.min_height = 0
|
||||
|
||||
else:
|
||||
# Generic: just measure children
|
||||
total_h = 0
|
||||
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
|
||||
620
backend/uframe/paint.py
Normal file
@@ -0,0 +1,620 @@
|
||||
"""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 re
|
||||
import textwrap
|
||||
|
||||
from uframe.chars import (
|
||||
BOX_CHARS, DIVIDER_CHARS, 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, Table,
|
||||
Form, Field, Password, Radio, Checkbox, FormButton,
|
||||
BigTitle, ImageNode, HNav, VNav,
|
||||
HeadingLevel, DividerStyle, ListStyle, Align, BorderWeight,
|
||||
)
|
||||
from uframe.themes import ThemeDef, THEME_DEFAULT
|
||||
from uframe.fonts import render_big_text, get_text_width, FONT_HEIGHTS
|
||||
from uframe.imaging import convert_image
|
||||
|
||||
|
||||
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, theme: ThemeDef | None = None) -> None:
|
||||
"""Recursively paint an IR node and its children into the grid."""
|
||||
th = theme or THEME_DEFAULT
|
||||
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, th)
|
||||
|
||||
elif isinstance(node, Box):
|
||||
# Draw the border with themed characters
|
||||
title_style = CellStyle(bold=True, fg=node.style.fg or th.palette.accent)
|
||||
grid.draw_border(x, y, w, node.rect.h,
|
||||
weight=node.weight,
|
||||
title=node.title,
|
||||
title_style=title_style,
|
||||
border_chars=th.border_dict(node.weight.name.lower()),
|
||||
title_caps=(th.title_caps.left, th.title_caps.right))
|
||||
# Propagate box alignment/color to children that don't have their own
|
||||
for child in node.children:
|
||||
if node.style.align != Align.LEFT and child.style.align == Align.LEFT:
|
||||
child.style.align = node.style.align
|
||||
if node.style.fg and not child.style.fg:
|
||||
child.style.fg = node.style.fg
|
||||
paint(child, grid, th)
|
||||
|
||||
elif isinstance(node, Row):
|
||||
for child in node.children:
|
||||
paint(child, grid, th)
|
||||
|
||||
elif isinstance(node, Col):
|
||||
for child in node.children:
|
||||
paint(child, grid, th)
|
||||
|
||||
elif isinstance(node, Spacer):
|
||||
pass # Just empty space
|
||||
|
||||
elif isinstance(node, Pad):
|
||||
for child in node.children:
|
||||
paint(child, grid, th)
|
||||
|
||||
elif isinstance(node, Heading):
|
||||
style = CellStyle(bold=True)
|
||||
if node.level == HeadingLevel.H1:
|
||||
style.fg = th.palette.accent
|
||||
elif node.level == HeadingLevel.H2:
|
||||
style.fg = th.palette.accent2
|
||||
elif node.level == HeadingLevel.H3:
|
||||
style.fg = th.palette.accent3
|
||||
|
||||
# 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 = getattr(th.dividers, ds.name.lower(), th.dividers.light)
|
||||
style = CellStyle(fg=th.palette.muted)
|
||||
for col in range(x, x + w):
|
||||
grid.put(col, y, char, style=style)
|
||||
|
||||
elif isinstance(node, Link):
|
||||
style = CellStyle(fg=th.palette.info, underline=True)
|
||||
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, th)
|
||||
|
||||
elif isinstance(node, ListItem):
|
||||
style = _style_from_node(node)
|
||||
# Bullet to the left of the content (safe: put_text clips to bounds)
|
||||
bullet_x = max(0, x - 2)
|
||||
grid.put_text(bullet_x, y, f"{th.ornaments.bullet} ", style=CellStyle(fg=th.palette.label))
|
||||
# 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 = th.palette.success
|
||||
if node.crit is not None and node.value >= node.crit:
|
||||
fg = th.palette.danger
|
||||
elif node.warn is not None and node.value >= node.warn:
|
||||
fg = th.palette.warning
|
||||
|
||||
for i in range(bar_w):
|
||||
if i < filled:
|
||||
grid.put(bar_x + i, y, th.gauge.filled, style=CellStyle(fg=fg))
|
||||
else:
|
||||
grid.put(bar_x + i, y, th.gauge.empty, style=CellStyle(fg=th.palette.muted))
|
||||
|
||||
# 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=th.palette.info)
|
||||
for i, ch in enumerate(chars):
|
||||
grid.put(spark_x + i, y, ch, style=spark_style)
|
||||
|
||||
elif isinstance(node, Status):
|
||||
char = getattr(th.indicators, node.state, th.indicators.unknown)
|
||||
color_map = {"online": th.palette.success, "offline": th.palette.danger,
|
||||
"degraded": th.palette.warning, "unknown": th.palette.label,
|
||||
"alert": th.palette.danger}
|
||||
color = color_map.get(node.state, th.palette.label)
|
||||
grid.put(x, y, char, style=CellStyle(fg=color))
|
||||
grid.put_text(x + 2, y, node.label)
|
||||
|
||||
elif isinstance(node, HNav):
|
||||
_paint_hnav(node, grid, x, y, w, th)
|
||||
|
||||
elif isinstance(node, VNav):
|
||||
_paint_vnav(node, grid, x, y, w, th)
|
||||
|
||||
elif isinstance(node, ImageNode):
|
||||
style = CellStyle(fg=node.style.fg or th.palette.accent)
|
||||
try:
|
||||
lines = convert_image(node.path, node.mode, node.img_width,
|
||||
node.dither, node.invert, node.use_color)
|
||||
# Center if aligned
|
||||
offset = 0
|
||||
actual_w = len(lines[0]) if lines else 0
|
||||
if node.style.align == Align.CENTER:
|
||||
offset = max(0, (w - actual_w) // 2)
|
||||
elif node.style.align == Align.RIGHT:
|
||||
offset = max(0, w - actual_w)
|
||||
|
||||
for row_i, line in enumerate(lines):
|
||||
if y + row_i < grid.height:
|
||||
grid.put_text(x + offset, y + row_i, line, style=style)
|
||||
|
||||
# Caption
|
||||
if node.caption and y + len(lines) < grid.height:
|
||||
cap_style = CellStyle(fg=th.palette.label, italic=True)
|
||||
grid.put_text(x + offset, y + len(lines), node.caption, style=cap_style)
|
||||
except (ImportError, FileNotFoundError) as e:
|
||||
# Render placeholder if image can't be loaded
|
||||
grid.put_text(x, y, f"[image: {node.path}]", style=CellStyle(fg=th.palette.muted))
|
||||
|
||||
elif isinstance(node, BigTitle):
|
||||
style = CellStyle(fg=node.style.fg or th.palette.accent, bold=True)
|
||||
|
||||
# Determine which font fits
|
||||
font = node.font
|
||||
tw = get_text_width(node.text, font)
|
||||
if tw > w:
|
||||
# Try fallback cascade
|
||||
for fallback in ["pixel", "thin"]:
|
||||
if get_text_width(node.text, fallback) <= w:
|
||||
font = fallback
|
||||
tw = get_text_width(node.text, fallback)
|
||||
break
|
||||
else:
|
||||
# Final fallback: styled single line
|
||||
styled = f"═══ {node.text.upper()} ═══"
|
||||
grid.put_text(x, y, styled[:w], style=style)
|
||||
return
|
||||
|
||||
lines = render_big_text(node.text, font)
|
||||
|
||||
# Center if align is set
|
||||
offset = 0
|
||||
if node.style.align == Align.CENTER:
|
||||
offset = max(0, (w - tw) // 2)
|
||||
elif node.style.align == Align.RIGHT:
|
||||
offset = max(0, w - tw)
|
||||
|
||||
for row_i, line in enumerate(lines):
|
||||
if y + row_i < grid.height:
|
||||
grid.put_text(x + offset, y + row_i, line, style=style)
|
||||
|
||||
elif isinstance(node, Table):
|
||||
_paint_table(node, grid, x, y, w)
|
||||
|
||||
elif isinstance(node, Form):
|
||||
for child in node.children:
|
||||
paint(child, grid, th)
|
||||
|
||||
elif isinstance(node, Field):
|
||||
label_style = CellStyle(fg=th.palette.label)
|
||||
field_style = CellStyle(fg=th.palette.form)
|
||||
label_text = f"{node.field_name}: "
|
||||
grid.put_text(x, y, label_text, style=label_style)
|
||||
fl = th.form.field_l
|
||||
fr = th.form.field_r
|
||||
fx = x + len(label_text)
|
||||
fw = min(node.field_width, w - len(label_text) - len(fl) - len(fr))
|
||||
grid.put_text(fx, y, fl, style=field_style)
|
||||
placeholder = node.placeholder or node.field_name
|
||||
inner = placeholder.ljust(fw)[:fw]
|
||||
grid.put_text(fx + len(fl), y, inner, style=CellStyle(fg=th.palette.muted))
|
||||
grid.put_text(fx + len(fl) + fw, y, fr, style=field_style)
|
||||
|
||||
elif isinstance(node, Password):
|
||||
label_style = CellStyle(fg=th.palette.label)
|
||||
field_style = CellStyle(fg=th.palette.form)
|
||||
label_text = f"{node.field_name}: "
|
||||
grid.put_text(x, y, label_text, style=label_style)
|
||||
fl = th.form.field_l
|
||||
fr = th.form.field_r
|
||||
fx = x + len(label_text)
|
||||
fw = min(node.field_width, w - len(label_text) - len(fl) - len(fr))
|
||||
grid.put_text(fx, y, fl, style=field_style)
|
||||
inner = "•" * fw
|
||||
grid.put_text(fx + len(fl), y, inner[:fw], style=CellStyle(fg=th.palette.muted))
|
||||
grid.put_text(fx + len(fl) + fw, y, fr, style=field_style)
|
||||
|
||||
elif isinstance(node, Radio):
|
||||
label_style = CellStyle(fg=th.palette.label)
|
||||
label_text = f"{node.group}: "
|
||||
grid.put_text(x, y, label_text, style=label_style)
|
||||
rx = x + len(label_text)
|
||||
for i, opt in enumerate(node.options):
|
||||
dot = th.form.radio_on if i == 0 else th.form.radio_off
|
||||
opt_style = CellStyle(fg=th.palette.form if i == 0 else th.palette.label)
|
||||
grid.put_text(rx, y, dot, style=opt_style)
|
||||
rx += len(dot) + 1
|
||||
grid.put_text(rx, y, opt, style=CellStyle())
|
||||
rx += len(opt) + 2
|
||||
|
||||
elif isinstance(node, Checkbox):
|
||||
check_style = CellStyle(fg=th.palette.form)
|
||||
box_char = th.form.check_on if node.checked else th.form.check_off
|
||||
grid.put_text(x, y, box_char, style=check_style)
|
||||
grid.put_text(x + len(box_char) + 1, y, node.checkbox_label)
|
||||
|
||||
elif isinstance(node, FormButton):
|
||||
btn_style = CellStyle(bold=True, fg=th.palette.button)
|
||||
btn_text = f"[ {node.button_label} ]"
|
||||
grid.put_text(x, y, btn_text, style=btn_style, link=node.dest)
|
||||
|
||||
else:
|
||||
# Generic: paint children
|
||||
for child in node.children:
|
||||
paint(child, grid, th)
|
||||
|
||||
|
||||
def _paint_hnav(node: HNav, grid: CharGrid, x: int, y: int, w: int, th: ThemeDef) -> None:
|
||||
"""Paint a horizontal navigation bar."""
|
||||
active_style = CellStyle(bold=True, fg=th.palette.accent)
|
||||
link_style = CellStyle(fg=th.palette.info)
|
||||
sep_style = CellStyle(fg=th.palette.muted)
|
||||
border_style = CellStyle()
|
||||
|
||||
items = [it for it in node.items if it.kind in ("item", "separator")]
|
||||
marker = "▸ "
|
||||
|
||||
if node.nav_style in ("bar", "tabs", "pills"):
|
||||
# Bordered bar
|
||||
bc = th.border_dict("light")
|
||||
grid.draw_border(x, y, w, 3, border_chars=bc,
|
||||
title_caps=(th.title_caps.left, th.title_caps.right))
|
||||
col = x + 2
|
||||
for it in items:
|
||||
if it.kind == "separator":
|
||||
grid.put(col, y + 1, "│", style=sep_style)
|
||||
col += 2
|
||||
continue
|
||||
if it.active:
|
||||
grid.put_text(col, y + 1, marker, style=active_style)
|
||||
col += len(marker)
|
||||
grid.put_text(col, y + 1, it.label, style=active_style)
|
||||
else:
|
||||
grid.put_text(col, y + 1, it.label, style=link_style, link=it.dest)
|
||||
col += len(it.label) + 2
|
||||
if col < x + w - 2:
|
||||
grid.put(col, y + 1, "│", style=sep_style)
|
||||
col += 2
|
||||
|
||||
elif node.nav_style == "breadcrumb":
|
||||
col = x + 2
|
||||
sep = " ▸ "
|
||||
for i, it in enumerate(items):
|
||||
if it.kind == "separator":
|
||||
continue
|
||||
if i > 0:
|
||||
grid.put_text(col, y, sep, style=sep_style)
|
||||
col += len(sep)
|
||||
if it.active:
|
||||
grid.put_text(col, y, it.label, style=active_style)
|
||||
else:
|
||||
grid.put_text(col, y, it.label, style=link_style, link=it.dest)
|
||||
col += len(it.label)
|
||||
|
||||
elif node.nav_style == "underline":
|
||||
col = x + 2
|
||||
active_start = 0
|
||||
active_len = 0
|
||||
for i, it in enumerate(items):
|
||||
if it.kind == "separator":
|
||||
continue
|
||||
if it.active:
|
||||
active_start = col
|
||||
active_len = len(it.label)
|
||||
grid.put_text(col, y, it.label, style=active_style)
|
||||
else:
|
||||
grid.put_text(col, y, it.label, style=link_style, link=it.dest)
|
||||
col += len(it.label) + 5
|
||||
# Underline beneath active
|
||||
if active_len > 0:
|
||||
for c in range(active_start, active_start + active_len):
|
||||
grid.put(c, y + 1, "━", style=CellStyle(fg=th.palette.accent))
|
||||
|
||||
|
||||
def _paint_vnav(node: VNav, grid: CharGrid, x: int, y: int, w: int, th: ThemeDef) -> None:
|
||||
"""Paint a vertical navigation panel."""
|
||||
active_style = CellStyle(bold=True, fg=th.palette.accent)
|
||||
link_style = CellStyle(fg=th.palette.info)
|
||||
heading_style = CellStyle(bold=True, fg=th.palette.muted)
|
||||
sep_style = CellStyle(fg=th.palette.muted)
|
||||
marker = "▸ "
|
||||
|
||||
items = node.items
|
||||
row_y = y
|
||||
|
||||
if node.nav_style == "boxed":
|
||||
# Draw a box and render items inside
|
||||
bc = th.border_dict("light")
|
||||
h = node.rect.h
|
||||
grid.draw_border(x, y, w, h, border_chars=bc,
|
||||
title_caps=(th.title_caps.left, th.title_caps.right))
|
||||
row_y = y + 1
|
||||
for it in items:
|
||||
if it.kind == "separator":
|
||||
# Draw internal separator
|
||||
for c in range(x + 1, x + w - 1):
|
||||
grid.put(c, row_y, bc.get("h", "─"), style=sep_style,
|
||||
is_border=True)
|
||||
grid.put(x, row_y, "├", style=sep_style, is_border=True)
|
||||
grid.put(x + w - 1, row_y, "┤", style=sep_style, is_border=True)
|
||||
row_y += 1
|
||||
elif it.kind == "heading":
|
||||
grid.put_text(x + 2, row_y, it.label.upper(), style=heading_style)
|
||||
row_y += 1
|
||||
elif it.kind == "item":
|
||||
if it.active:
|
||||
grid.put_text(x + 2, row_y, marker, style=active_style)
|
||||
grid.put_text(x + 2 + len(marker), row_y, it.label, style=active_style)
|
||||
else:
|
||||
grid.put_text(x + 4, row_y, it.label, style=link_style, link=it.dest)
|
||||
row_y += 1
|
||||
|
||||
elif node.nav_style == "tree":
|
||||
for idx, it in enumerate(items):
|
||||
if it.kind == "separator":
|
||||
for c in range(x, x + w):
|
||||
grid.put(c, row_y, "─", style=sep_style)
|
||||
row_y += 1
|
||||
elif it.kind == "heading":
|
||||
grid.put_text(x, row_y, it.label, style=heading_style)
|
||||
row_y += 1
|
||||
elif it.kind == "item":
|
||||
# Determine connector
|
||||
remaining = [i for i in items[idx+1:] if i.kind == "item"]
|
||||
connector = "└── " if not remaining else "├── "
|
||||
grid.put_text(x, row_y, connector, style=sep_style)
|
||||
if it.active:
|
||||
grid.put_text(x + len(connector), row_y, it.label, style=active_style)
|
||||
grid.put_text(x + len(connector) + len(it.label) + 2, row_y, "◀",
|
||||
style=CellStyle(fg=th.palette.accent))
|
||||
else:
|
||||
grid.put_text(x + len(connector), row_y, it.label,
|
||||
style=link_style, link=it.dest)
|
||||
row_y += 1
|
||||
|
||||
else:
|
||||
# list, sidebar, minimal
|
||||
for it in items:
|
||||
if it.kind == "separator":
|
||||
for c in range(x, min(x + w, x + 16)):
|
||||
grid.put(c, row_y, "─", style=sep_style)
|
||||
row_y += 1
|
||||
elif it.kind == "heading":
|
||||
grid.put_text(x, row_y, it.label.upper(), style=heading_style)
|
||||
row_y += 1
|
||||
elif it.kind == "item":
|
||||
if it.active:
|
||||
grid.put_text(x, row_y, marker, style=active_style)
|
||||
grid.put_text(x + len(marker), row_y, it.label, style=active_style)
|
||||
else:
|
||||
grid.put_text(x + 2, row_y, it.label, style=link_style, link=it.dest)
|
||||
row_y += 1
|
||||
|
||||
|
||||
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:
|
||||
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)
|
||||
967
backend/uframe/parser.py
Normal file
@@ -0,0 +1,967 @@
|
||||
"""µ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 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,
|
||||
Form, Field, Password, Radio, Checkbox, FormButton,
|
||||
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
|
||||
BigTitle, ImageNode, HNav, VNav, NavItem,
|
||||
ComponentDef, ComponentUse,
|
||||
SourceType,
|
||||
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, raw_args: str = "") -> 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":
|
||||
# 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)
|
||||
|
||||
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":
|
||||
# Could be a list item or a nav item — disambiguated by parent in tree builder
|
||||
if len(args) >= 2 and ("/" in args[1] or ":" in args[1]):
|
||||
# Nav item: item "Label" "/dest.mu" [active]
|
||||
label = args[0]
|
||||
dest = args[1]
|
||||
active = "active" in args[2:] if len(args) > 2 else False
|
||||
return _NavItemNode("item", label, dest, active, line_num)
|
||||
content = args[0] if args else ""
|
||||
return ListItem(content=content, source_line=line_num)
|
||||
|
||||
elif keyword == "separator":
|
||||
return _NavItemNode("separator", "", "", False, 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 ""
|
||||
val_str = args[1] if len(args) > 1 else "0"
|
||||
max_str = args[2] if len(args) > 2 else "100"
|
||||
bw_str = args[3] if len(args) > 3 else "28"
|
||||
try:
|
||||
value = float(val_str)
|
||||
except ValueError:
|
||||
value = 0 # $variable — resolved at runtime
|
||||
try:
|
||||
max_val = float(max_str)
|
||||
except ValueError:
|
||||
max_val = 100
|
||||
try:
|
||||
bar_width = int(bw_str)
|
||||
except ValueError:
|
||||
bar_width = 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:])
|
||||
node = Gauge(label=label, value=value, max_val=max_val,
|
||||
bar_width=bar_width, warn=warn, crit=crit,
|
||||
source_line=line_num)
|
||||
# Store raw strings for deferred component expansion
|
||||
if "$" in val_str:
|
||||
node._raw_value = val_str # type: ignore[attr-defined]
|
||||
if "$" in max_str:
|
||||
node._raw_max_val = max_str # type: ignore[attr-defined]
|
||||
return node
|
||||
|
||||
elif keyword == "sparkline":
|
||||
label = args[0] if args else ""
|
||||
vals_str = args[1] if len(args) > 1 else ""
|
||||
values: list[float] = []
|
||||
if vals_str and "$" not in vals_str:
|
||||
values = [float(v) for v in vals_str.split(",") if v.strip()]
|
||||
try:
|
||||
width = int(args[2]) if len(args) > 2 else 20
|
||||
except ValueError:
|
||||
width = 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)
|
||||
|
||||
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)
|
||||
|
||||
# Forms
|
||||
elif keyword == "form":
|
||||
form_name = args[0] if args else ""
|
||||
return Form(form_name=form_name, source_line=line_num)
|
||||
|
||||
elif keyword == "field":
|
||||
name = args[0] if args else ""
|
||||
width = int(args[1]) if len(args) > 1 else 24
|
||||
placeholder = args[2] if len(args) > 2 else ""
|
||||
return Field(field_name=name, field_width=width, placeholder=placeholder,
|
||||
source_line=line_num)
|
||||
|
||||
elif keyword == "password":
|
||||
name = args[0] if args else ""
|
||||
width = int(args[1]) if len(args) > 1 else 24
|
||||
placeholder = args[2] if len(args) > 2 else ""
|
||||
return Password(field_name=name, field_width=width, placeholder=placeholder,
|
||||
source_line=line_num)
|
||||
|
||||
elif keyword == "radio":
|
||||
group = args[0] if args else ""
|
||||
raw = " ".join(args[1:]) if len(args) > 1 else ""
|
||||
options = [o.strip().strip('"') for o in raw.split("|")] if raw else []
|
||||
return Radio(group=group, options=options, source_line=line_num)
|
||||
|
||||
elif keyword == "checkbox":
|
||||
name = args[0] if args else ""
|
||||
label_text = args[1] if len(args) > 1 else ""
|
||||
return Checkbox(field_name=name, checkbox_label=label_text, source_line=line_num)
|
||||
|
||||
elif keyword == "button":
|
||||
label_text = args[0] if args else ""
|
||||
dest = args[1] if len(args) > 1 else ""
|
||||
return FormButton(button_label=label_text, dest=dest, source_line=line_num)
|
||||
|
||||
# Dynamic features
|
||||
elif keyword == "let":
|
||||
# let name = "value" or let name = 1,2,3
|
||||
raw = raw_args
|
||||
eq = raw.find("=")
|
||||
if eq != -1:
|
||||
var_name = raw[:eq].strip()
|
||||
var_value = raw[eq + 1:].strip().strip('"')
|
||||
else:
|
||||
var_name = args[0] if args else ""
|
||||
var_value = args[1] if len(args) > 1 else ""
|
||||
return Let(var_name=var_name, var_value=var_value, source_line=line_num)
|
||||
|
||||
elif keyword == "source":
|
||||
# source cpu : shell "grep 'cpu' /proc/stat"
|
||||
# source name : type "command"
|
||||
colon = raw_args.find(":")
|
||||
if colon != -1:
|
||||
var_name = raw_args[:colon].strip()
|
||||
rest = raw_args[colon + 1:].strip()
|
||||
parts = _split_args(rest)
|
||||
src_type_str = parts[0] if parts else "shell"
|
||||
command = parts[1] if len(parts) > 1 else ""
|
||||
src_type = {
|
||||
"shell": SourceType.SHELL,
|
||||
"file": SourceType.FILE,
|
||||
"json": SourceType.JSON,
|
||||
"python": SourceType.PYTHON,
|
||||
"rns": SourceType.RNS,
|
||||
"param": SourceType.PARAM,
|
||||
"http": SourceType.HTTP,
|
||||
"sqlite": SourceType.SQLITE,
|
||||
"env": SourceType.ENV,
|
||||
}.get(src_type_str.lower(), SourceType.SHELL)
|
||||
# Parse optional params from remaining parts
|
||||
timeout = 5
|
||||
http_method = "GET"
|
||||
http_body = ""
|
||||
http_headers = ""
|
||||
query = ""
|
||||
extra = parts[2:]
|
||||
if src_type == SourceType.SQLITE and len(parts) > 2:
|
||||
# sqlite "/path/db" "SELECT ..."
|
||||
query = parts[2]
|
||||
extra = parts[3:]
|
||||
for p in extra:
|
||||
if p.startswith("timeout="):
|
||||
try:
|
||||
timeout = int(p.split("=", 1)[1])
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
elif p.startswith("method="):
|
||||
http_method = p.split("=", 1)[1].upper()
|
||||
elif p.startswith("body="):
|
||||
http_body = p.split("=", 1)[1]
|
||||
elif p.startswith("headers="):
|
||||
http_headers = p.split("=", 1)[1]
|
||||
return Source(var_name=var_name, source_type=src_type,
|
||||
command=command, timeout=timeout,
|
||||
http_method=http_method, http_body=http_body,
|
||||
http_headers=http_headers, query=query,
|
||||
source_line=line_num)
|
||||
else:
|
||||
return Source(var_name=args[0] if args else "", source_line=line_num)
|
||||
|
||||
elif keyword == "if":
|
||||
condition = " ".join(args)
|
||||
return IfBlock(condition=condition, source_line=line_num)
|
||||
|
||||
elif keyword == "elif":
|
||||
condition = " ".join(args)
|
||||
return _ElifBranch(condition, line_num)
|
||||
|
||||
elif keyword == "else":
|
||||
return _ElseBranch(line_num)
|
||||
|
||||
elif keyword == "for":
|
||||
# for item in $collection
|
||||
var_name = args[0] if args else "item"
|
||||
# Skip "in" keyword
|
||||
iterable = args[2] if len(args) > 2 else (args[1] if len(args) > 1 else "")
|
||||
return ForLoop(var_name=var_name, iterable=iterable, source_line=line_num)
|
||||
|
||||
elif keyword == "cache":
|
||||
seconds = int(args[0]) if args else 0
|
||||
return CacheControl(seconds=seconds, source_line=line_num)
|
||||
|
||||
elif keyword == "on_submit":
|
||||
form_name = args[0] if args else ""
|
||||
return OnSubmit(form_name=form_name, source_line=line_num)
|
||||
|
||||
elif keyword == "state":
|
||||
state_name = args[0] if args else ""
|
||||
path = args[1] if len(args) > 1 else ""
|
||||
return StateDecl(state_name=state_name, path=path, source_line=line_num)
|
||||
|
||||
elif keyword in ("set", "append", "prepend"):
|
||||
# State operations — store as text nodes with metadata for the compiler
|
||||
content = " ".join([keyword] + args)
|
||||
return Text(content=content, source_line=line_num)
|
||||
|
||||
elif keyword == "hnav":
|
||||
nav_style = args[0] if args else "bar"
|
||||
return HNav(nav_style=nav_style, source_line=line_num)
|
||||
|
||||
elif keyword == "vnav":
|
||||
nav_style = args[0] if args else "list"
|
||||
nav_width = int(args[1]) if len(args) > 1 and args[1].isdigit() else 0
|
||||
return VNav(nav_style=nav_style, nav_width=nav_width, source_line=line_num)
|
||||
|
||||
elif keyword == "image":
|
||||
path = args[0] if args else ""
|
||||
mode = args[1] if len(args) > 1 else "braille"
|
||||
img_width = int(args[2]) if len(args) > 2 else 30
|
||||
return ImageNode(path=path, mode=mode, img_width=img_width, source_line=line_num)
|
||||
|
||||
elif keyword == "dither":
|
||||
# Style directive for image node
|
||||
return _StyleDirective("dither_val", args[0] if args else "floyd", line_num)
|
||||
|
||||
elif keyword == "invert":
|
||||
return _StyleDirective("invert_val", True, line_num)
|
||||
|
||||
elif keyword == "caption":
|
||||
return _StyleDirective("caption_val", args[0] if args else "", line_num)
|
||||
|
||||
elif keyword == "bigtitle":
|
||||
text = args[0] if args else ""
|
||||
font = args[1] if len(args) > 1 else "block"
|
||||
return BigTitle(text=text, font=font, source_line=line_num)
|
||||
|
||||
elif keyword == "theme":
|
||||
# theme "name" — sets the page theme (handled as _StyleDirective on Page)
|
||||
theme_name = args[0] if args else "default"
|
||||
return _ThemeDirective(theme_name, line_num)
|
||||
|
||||
elif keyword == "component":
|
||||
# component name(arg1, arg2)
|
||||
raw = " ".join(args)
|
||||
paren = raw.find("(")
|
||||
if paren != -1:
|
||||
comp_name = raw[:paren].strip()
|
||||
params_str = raw[paren + 1:].rstrip(")")
|
||||
params = [p.strip() for p in params_str.split(",") if p.strip()]
|
||||
else:
|
||||
comp_name = args[0] if args else ""
|
||||
params = []
|
||||
return ComponentDef(comp_name=comp_name, params=params, source_line=line_num)
|
||||
|
||||
elif keyword == "use":
|
||||
# use std/dashboard — load a library (handled at parse level)
|
||||
lib_path = args[0] if args else ""
|
||||
return _UseDirective(lib_path, line_num)
|
||||
|
||||
else:
|
||||
# Try as component invocation: name "arg1" "arg2"
|
||||
# Only if keyword isn't a known keyword — handled by the tree builder
|
||||
return ComponentUse(comp_name=keyword, args=args, source_line=line_num)
|
||||
|
||||
|
||||
class _NavItemNode(IRNode):
|
||||
"""Temporary node — absorbed by parent HNav/VNav during tree building."""
|
||||
def __init__(self, kind: str, label: str, dest: str, active: bool, line_num: int):
|
||||
super().__init__(source_line=line_num)
|
||||
self.kind = kind
|
||||
self.label = label
|
||||
self.dest = dest
|
||||
self.active = active
|
||||
|
||||
|
||||
class _ThemeDirective(IRNode):
|
||||
"""Temporary node — sets theme_name on the Page during tree building."""
|
||||
def __init__(self, theme_name: str, line_num: int):
|
||||
super().__init__(source_line=line_num)
|
||||
self.theme_name = theme_name
|
||||
|
||||
|
||||
class _UseDirective(IRNode):
|
||||
"""Temporary node — triggers library loading during tree building."""
|
||||
def __init__(self, lib_path: str, line_num: int):
|
||||
super().__init__(source_line=line_num)
|
||||
self.lib_path = lib_path
|
||||
|
||||
|
||||
class _ElifBranch(IRNode):
|
||||
"""Temporary node — absorbed by parent IfBlock during tree building."""
|
||||
def __init__(self, condition: str, line_num: int):
|
||||
super().__init__(source_line=line_num)
|
||||
self.condition = condition
|
||||
|
||||
|
||||
class _ElseBranch(IRNode):
|
||||
"""Temporary node — absorbed by parent IfBlock during tree building."""
|
||||
def __init__(self, line_num: int):
|
||||
super().__init__(source_line=line_num)
|
||||
|
||||
|
||||
class _TableColumns(IRNode):
|
||||
"""Temporary node — absorbed by parent Table during tree building."""
|
||||
def __init__(self, columns: list[tuple[str, int]], line_num: int):
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Component expansion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _expand_component(comp_def: ComponentDef, args: list[str]) -> list[IRNode]:
|
||||
"""Expand a component use into a list of IR nodes by substituting $params.
|
||||
|
||||
Clones the component body and replaces $param references with provided args.
|
||||
"""
|
||||
import copy
|
||||
|
||||
# Build param → arg mapping
|
||||
param_map: dict[str, str] = {}
|
||||
for i, param in enumerate(comp_def.params):
|
||||
param_map[param] = args[i] if i < len(args) else ""
|
||||
|
||||
# Deep copy the children and substitute
|
||||
expanded: list[IRNode] = []
|
||||
for child in comp_def.children:
|
||||
clone = copy.deepcopy(child)
|
||||
_substitute_vars(clone, param_map)
|
||||
expanded.append(clone)
|
||||
|
||||
return expanded
|
||||
|
||||
|
||||
def _substitute_vars(node: IRNode, var_map: dict[str, str]) -> None:
|
||||
"""Recursively substitute $param references in an IR node tree."""
|
||||
# Substitute in string fields
|
||||
for attr_name in ("text", "content", "title", "label", "key", "value",
|
||||
"display", "dest", "field_name", "placeholder",
|
||||
"button_label", "checkbox_label", "var_name", "var_value",
|
||||
"condition", "iterable", "command", "form_name",
|
||||
"state_name", "path", "group", "state", "comp_name"):
|
||||
val = getattr(node, attr_name, None)
|
||||
if isinstance(val, str) and "$" in val:
|
||||
for param, arg in var_map.items():
|
||||
val = val.replace(f"${param}", arg)
|
||||
setattr(node, attr_name, val)
|
||||
|
||||
# Substitute in list fields
|
||||
for attr_name in ("options", "args"):
|
||||
val = getattr(node, attr_name, None)
|
||||
if isinstance(val, list):
|
||||
for i, item in enumerate(val):
|
||||
if isinstance(item, str) and "$" in item:
|
||||
for param, arg in var_map.items():
|
||||
item = item.replace(f"${param}", arg)
|
||||
val[i] = item
|
||||
|
||||
# Substitute in TextSpan list
|
||||
spans = getattr(node, "spans", None)
|
||||
if isinstance(spans, list):
|
||||
for span in spans:
|
||||
if hasattr(span, "text") and "$" in span.text:
|
||||
for param, arg in var_map.items():
|
||||
span.text = span.text.replace(f"${param}", arg)
|
||||
|
||||
# After string substitution, resolve deferred numeric fields
|
||||
for num_attr in ("value", "max_val", "bar_width"):
|
||||
raw = getattr(node, f"_raw_{num_attr}", None)
|
||||
if raw is not None:
|
||||
for param, arg in var_map.items():
|
||||
raw = raw.replace(f"${param}", arg)
|
||||
try:
|
||||
setattr(node, num_attr, float(raw) if "." in raw else int(raw))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Recurse
|
||||
for child in node.children:
|
||||
_substitute_vars(child, var_map)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Standard library loader
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Built-in component libraries
|
||||
_STD_LIBRARIES: dict[str, str] = {
|
||||
"std/dashboard": '''\
|
||||
component banner(title, subtitle)
|
||||
box double "$title"
|
||||
align center
|
||||
text "$subtitle"
|
||||
|
||||
component resources(cpu, mem)
|
||||
heading 1 "Resources"
|
||||
gauge "CPU" $cpu 100 28 warn=75 crit=90
|
||||
gauge "MEM" $mem 100 28 warn=80 crit=95
|
||||
|
||||
component peer_status(name, state)
|
||||
status "$name" $state
|
||||
|
||||
component info_box(title, content)
|
||||
box light "$title"
|
||||
text "$content"
|
||||
|
||||
component alert_box(title, content)
|
||||
box heavy "$title"
|
||||
color f00
|
||||
text "$content"
|
||||
|
||||
component metric(label, value, max, width)
|
||||
gauge "$label" $value $max $width
|
||||
''',
|
||||
"std/status-bar": '''\
|
||||
component status_bar(label, value, max)
|
||||
gauge "$label" $value $max 28
|
||||
|
||||
component status_item(name, state)
|
||||
status "$name" $state
|
||||
|
||||
component status_row(name1, state1, name2, state2)
|
||||
row 2
|
||||
col 28
|
||||
status "$name1" $state1
|
||||
col 28
|
||||
status "$name2" $state2
|
||||
''',
|
||||
"std/nav": '''\
|
||||
component nav_link(label, dest)
|
||||
link "$label" "$dest"
|
||||
|
||||
component nav_divider()
|
||||
divider light
|
||||
|
||||
component nav_bar(label1, dest1, label2, dest2)
|
||||
row 2
|
||||
col 28
|
||||
link "$label1" "$dest1"
|
||||
col 28
|
||||
link "$label2" "$dest2"
|
||||
''',
|
||||
"std/network": '''\
|
||||
component route_table(title)
|
||||
table "$title"
|
||||
|
||||
component peer_list(title)
|
||||
heading 2 "$title"
|
||||
|
||||
component traffic(label_in, vals_in, label_out, vals_out)
|
||||
sparkline "$label_in" "$vals_in" 20
|
||||
sparkline "$label_out" "$vals_out" 20
|
||||
''',
|
||||
"std/form": '''\
|
||||
component search_form(name, action)
|
||||
form "$name"
|
||||
field "query" 30 "Search..."
|
||||
button "Search" "$action"
|
||||
|
||||
component login_form(action)
|
||||
form "login"
|
||||
field "username" 24 "Username"
|
||||
password "password" 24 "Password"
|
||||
button "Login" "$action"
|
||||
''',
|
||||
}
|
||||
|
||||
|
||||
def _load_library(lib_path: str) -> dict[str, ComponentDef]:
|
||||
"""Load a standard library and return its component definitions."""
|
||||
lib_source = _STD_LIBRARIES.get(lib_path, "")
|
||||
if not lib_source:
|
||||
return {}
|
||||
|
||||
# Parse the library source to extract ComponentDef nodes
|
||||
comps: dict[str, ComponentDef] = {}
|
||||
# Use a mini-parse: just extract component definitions
|
||||
lines = lib_source.split("\n")
|
||||
current_comp: ComponentDef | None = None
|
||||
comp_indent = 0
|
||||
|
||||
for raw_line in lines:
|
||||
stripped = raw_line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
|
||||
indent = _indent_level(raw_line)
|
||||
parts = stripped.split(None, 1)
|
||||
keyword = parts[0].lower()
|
||||
arg_str = parts[1] if len(parts) > 1 else ""
|
||||
args = _split_args(arg_str)
|
||||
|
||||
if keyword == "component":
|
||||
raw = " ".join(args)
|
||||
paren = raw.find("(")
|
||||
if paren != -1:
|
||||
comp_name = raw[:paren].strip()
|
||||
params_str = raw[paren + 1:].rstrip(")")
|
||||
params = [p.strip() for p in params_str.split(",") if p.strip()]
|
||||
else:
|
||||
comp_name = args[0] if args else ""
|
||||
params = []
|
||||
current_comp = ComponentDef(comp_name=comp_name, params=params)
|
||||
comp_indent = indent
|
||||
comps[comp_name] = current_comp
|
||||
elif current_comp and indent > comp_indent:
|
||||
# Parse child node and attach to current component
|
||||
try:
|
||||
child = _parse_line(keyword, args, 0)
|
||||
if not isinstance(child, (_StyleDirective, _TableColumns, _TableRow,
|
||||
_ElifBranch, _ElseBranch, _UseDirective)):
|
||||
current_comp.children.append(child)
|
||||
except ParseError:
|
||||
pass
|
||||
else:
|
||||
current_comp = None
|
||||
|
||||
return comps
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tree builder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse(source: str, components: dict[str, ComponentDef] | None = None) -> Page:
|
||||
"""Parse a .uf source string into an IR tree rooted at a Page node.
|
||||
|
||||
Args:
|
||||
source: the .uf DSL source text
|
||||
components: optional pre-loaded component registry (from `use` directives)
|
||||
|
||||
Returns the Page node with all children attached.
|
||||
"""
|
||||
lines = source.split("\n")
|
||||
|
||||
# Component registry: name → ComponentDef (with children as template)
|
||||
comp_registry: dict[str, ComponentDef] = dict(components or {})
|
||||
|
||||
# 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, raw_args=arg_str)
|
||||
|
||||
# Pop stack back to find the parent (parent indent < this indent)
|
||||
while stack and stack[-1][0] >= indent:
|
||||
stack.pop()
|
||||
|
||||
if isinstance(node, _StyleDirective):
|
||||
if stack:
|
||||
parent = stack[-1][1]
|
||||
# Image-specific directives
|
||||
if node.attr == "dither_val" and isinstance(parent, ImageNode):
|
||||
parent.dither = node.value
|
||||
elif node.attr == "invert_val" and isinstance(parent, ImageNode):
|
||||
parent.invert = node.value
|
||||
elif node.attr == "caption_val" and isinstance(parent, ImageNode):
|
||||
parent.caption = node.value
|
||||
elif node.attr in ("dither_val", "invert_val", "caption_val"):
|
||||
pass # ignore if not on ImageNode
|
||||
else:
|
||||
setattr(parent.style, node.attr, node.value)
|
||||
continue
|
||||
|
||||
if isinstance(node, _ThemeDirective):
|
||||
# Set theme on the root Page
|
||||
if root and isinstance(root, Page):
|
||||
root.theme_name = node.theme_name
|
||||
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
|
||||
|
||||
# elif/else branches are absorbed by the nearest IfBlock ancestor
|
||||
if isinstance(node, _ElifBranch):
|
||||
# Find the IfBlock in the stack
|
||||
for si in range(len(stack) - 1, -1, -1):
|
||||
if isinstance(stack[si][1], IfBlock):
|
||||
# Collect subsequent children under this elif
|
||||
stack[si][1].elif_branches.append((node.condition, []))
|
||||
break
|
||||
continue
|
||||
|
||||
if isinstance(node, _ElseBranch):
|
||||
# Find the IfBlock in the stack — mark it for else collection
|
||||
for si in range(len(stack) - 1, -1, -1):
|
||||
if isinstance(stack[si][1], IfBlock):
|
||||
stack[si][1].else_children = [] # will be filled by subsequent children
|
||||
break
|
||||
continue
|
||||
|
||||
# Nav items — absorbed by parent HNav/VNav
|
||||
if isinstance(node, _NavItemNode):
|
||||
for si in range(len(stack) - 1, -1, -1):
|
||||
parent = stack[si][1]
|
||||
if isinstance(parent, (HNav, VNav)):
|
||||
parent.items.append(NavItem(
|
||||
kind=node.kind, label=node.label,
|
||||
dest=node.dest, active=node.active))
|
||||
break
|
||||
continue
|
||||
|
||||
# Headings inside vnav become nav headings
|
||||
if isinstance(node, Heading):
|
||||
for si in range(len(stack) - 1, -1, -1):
|
||||
if isinstance(stack[si][1], VNav):
|
||||
stack[si][1].items.append(NavItem(
|
||||
kind="heading", label=node.text))
|
||||
break
|
||||
else:
|
||||
# Not inside a vnav — proceed as normal heading
|
||||
pass
|
||||
if any(isinstance(stack[si][1], VNav) for si in range(len(stack))):
|
||||
continue
|
||||
|
||||
# Use directive — load standard library components
|
||||
if isinstance(node, _UseDirective):
|
||||
lib_comps = _load_library(node.lib_path)
|
||||
comp_registry.update(lib_comps)
|
||||
continue
|
||||
|
||||
# Component definition — register in the component registry
|
||||
if isinstance(node, ComponentDef):
|
||||
comp_registry[node.comp_name] = node
|
||||
stack.append((indent, node)) # push so children attach to it
|
||||
continue
|
||||
|
||||
# Component use — expand inline by cloning the template with args substituted
|
||||
if isinstance(node, ComponentUse) and node.comp_name in comp_registry:
|
||||
comp_def = comp_registry[node.comp_name]
|
||||
expanded = _expand_component(comp_def, node.args)
|
||||
if stack:
|
||||
parent = stack[-1][1]
|
||||
parent.children.extend(expanded)
|
||||
continue
|
||||
elif isinstance(node, ComponentUse) and node.comp_name not in comp_registry:
|
||||
# Unknown component — treat as unknown keyword error
|
||||
# But be lenient: just skip it with a warning
|
||||
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
|
||||
130
backend/uframe/registry.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""µFrame Keyword Registry — single source of truth for all DSL keywords.
|
||||
|
||||
Each keyword is registered with its parse, measure, layout, paint, and
|
||||
codegen functions plus frontend metadata (section, detail, snippet).
|
||||
Adding a new keyword requires only one registration in keywords.py.
|
||||
|
||||
Usage:
|
||||
from uframe.registry import register, KEYWORD_REGISTRY, NODE_REGISTRY
|
||||
|
||||
@register("gauge", section="Data", detail="gauge label val max width",
|
||||
snippet='gauge "${label}" ${value} ${max:100} ${width:28}')
|
||||
def _def_gauge():
|
||||
return KeywordDef(
|
||||
parse=parse_gauge,
|
||||
measure=measure_gauge,
|
||||
paint=paint_gauge,
|
||||
...
|
||||
)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, Any
|
||||
|
||||
from uframe.ir import IRNode
|
||||
|
||||
|
||||
@dataclass
|
||||
class KeywordDef:
|
||||
"""Complete definition of a µFrame DSL keyword."""
|
||||
name: str = ""
|
||||
section: str = "" # "Layout", "Content", "Data", "Style", "Theme", "Form", "Dynamic"
|
||||
detail: str = "" # slash command detail text
|
||||
snippet: str = "" # slash command snippet (CodeMirror format)
|
||||
highlight_values: list[str] = field(default_factory=list) # values to highlight as atoms
|
||||
|
||||
# Pipeline functions — all optional, falling back to generic behavior
|
||||
parse: Callable[..., IRNode] | None = None # (args, line_num) → IRNode
|
||||
measure: Callable[..., None] | None = None # (node, available_width) → None
|
||||
layout: Callable[..., int] | None = None # (node, x, y, w, h) → height
|
||||
paint: Callable[..., None] | None = None # (node, grid, theme) → None
|
||||
codegen: Callable[..., list[str]] | None = None # (node, indent_level) → [str]
|
||||
|
||||
# Flags
|
||||
is_container: bool = False # has children (affects layout: vertical stack)
|
||||
is_leaf: bool = False # simple leaf node (layout: return pref_height)
|
||||
is_metadata: bool = False # zero-height metadata (let, source, cache)
|
||||
is_style_directive: bool = False # modifies parent's style (align, color, bold)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Keyword name → KeywordDef
|
||||
KEYWORD_REGISTRY: dict[str, KeywordDef] = {}
|
||||
|
||||
# IR node class → KeywordDef (for measure/layout/paint/codegen dispatch)
|
||||
NODE_REGISTRY: dict[type, KeywordDef] = {}
|
||||
|
||||
# All highlight values (populated during registration)
|
||||
ALL_HIGHLIGHT_VALUES: set[str] = set()
|
||||
|
||||
# All theme names
|
||||
ALL_THEME_NAMES: list[str] = []
|
||||
|
||||
|
||||
def register(name: str, node_class: type | None = None, **kwargs: Any) -> Callable:
|
||||
"""Decorator to register a keyword definition.
|
||||
|
||||
Usage:
|
||||
@register("gauge", node_class=Gauge, section="Data",
|
||||
detail="gauge label val max width",
|
||||
snippet='gauge "${label}" ${value} ...')
|
||||
def def_gauge():
|
||||
return KeywordDef(parse=..., measure=..., paint=..., ...)
|
||||
|
||||
Or simpler — pass all fields directly:
|
||||
register_keyword("gauge", node_class=Gauge, section="Data", ...)
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
kw_def = func()
|
||||
if isinstance(kw_def, KeywordDef):
|
||||
kw_def.name = name
|
||||
for k, v in kwargs.items():
|
||||
if hasattr(kw_def, k):
|
||||
setattr(kw_def, k, v)
|
||||
else:
|
||||
kw_def = KeywordDef(name=name, **kwargs)
|
||||
|
||||
KEYWORD_REGISTRY[name] = kw_def
|
||||
if node_class is not None:
|
||||
NODE_REGISTRY[node_class] = kw_def
|
||||
|
||||
ALL_HIGHLIGHT_VALUES.update(kw_def.highlight_values)
|
||||
return func
|
||||
return decorator
|
||||
|
||||
|
||||
def register_keyword(name: str, node_class: type | None = None, **kwargs: Any) -> KeywordDef:
|
||||
"""Direct registration (non-decorator form)."""
|
||||
kw_def = KeywordDef(name=name, **kwargs)
|
||||
KEYWORD_REGISTRY[name] = kw_def
|
||||
if node_class is not None:
|
||||
NODE_REGISTRY[node_class] = kw_def
|
||||
ALL_HIGHLIGHT_VALUES.update(kw_def.highlight_values)
|
||||
return kw_def
|
||||
|
||||
|
||||
def get_dsl_meta() -> dict:
|
||||
"""Return DSL metadata for the frontend (keywords, values, commands, themes)."""
|
||||
keywords = sorted(KEYWORD_REGISTRY.keys())
|
||||
values = sorted(ALL_HIGHLIGHT_VALUES)
|
||||
commands = []
|
||||
for kw in KEYWORD_REGISTRY.values():
|
||||
if kw.detail and kw.snippet:
|
||||
commands.append({
|
||||
"label": kw.name,
|
||||
"detail": kw.detail,
|
||||
"section": kw.section,
|
||||
"snippet": kw.snippet,
|
||||
})
|
||||
themes = ALL_THEME_NAMES or []
|
||||
return {
|
||||
"keywords": keywords,
|
||||
"values": values,
|
||||
"commands": commands,
|
||||
"themes": themes,
|
||||
}
|
||||
0
backend/uframe/tests/__init__.py
Normal file
102
backend/uframe/tests/test_bigtitle.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Tests for BigTitle and font rendering."""
|
||||
|
||||
import uframe
|
||||
from uframe.fonts import render_big_text, get_text_width, FONTS, FONT_HEIGHTS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests for font module
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_font_registry():
|
||||
assert "block" in FONTS
|
||||
assert "thin" in FONTS
|
||||
assert "pixel" in FONTS
|
||||
|
||||
|
||||
def test_font_heights():
|
||||
assert FONT_HEIGHTS["block"] == 6
|
||||
assert FONT_HEIGHTS["thin"] == 3
|
||||
assert FONT_HEIGHTS["pixel"] == 3
|
||||
|
||||
|
||||
def test_render_big_text_block():
|
||||
lines = render_big_text("HI", "block")
|
||||
assert len(lines) == 6
|
||||
# Should contain block characters
|
||||
assert any("█" in line for line in lines)
|
||||
|
||||
|
||||
def test_render_big_text_thin():
|
||||
lines = render_big_text("AB", "thin")
|
||||
assert len(lines) == 3
|
||||
assert any("┌" in line or "├" in line for line in lines)
|
||||
|
||||
|
||||
def test_render_big_text_pixel():
|
||||
lines = render_big_text("OK", "pixel")
|
||||
assert len(lines) == 3
|
||||
assert any("▀" in line or "█" in line for line in lines)
|
||||
|
||||
|
||||
def test_get_text_width():
|
||||
w = get_text_width("A", "block")
|
||||
assert w > 0
|
||||
w2 = get_text_width("AB", "block")
|
||||
assert w2 > w # two chars wider than one
|
||||
|
||||
|
||||
def test_kerning():
|
||||
w_tight = get_text_width("AB", "block", kerning=0)
|
||||
w_normal = get_text_width("AB", "block", kerning=1)
|
||||
w_wide = get_text_width("AB", "block", kerning=2)
|
||||
assert w_tight < w_normal < w_wide
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests — bigtitle through compile pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_bigtitle_block_font():
|
||||
source = '''page "Test" 80
|
||||
bigtitle "HI" block'''
|
||||
result = uframe.compile(source)
|
||||
assert "█" in result.ascii
|
||||
lines = result.ascii.strip().split("\n")
|
||||
assert len(lines) >= 6 # block font is 6 lines tall
|
||||
|
||||
|
||||
def test_bigtitle_thin_font():
|
||||
source = '''page "Test" 80
|
||||
bigtitle "OK" thin'''
|
||||
result = uframe.compile(source)
|
||||
# Thin font uses box-drawing characters
|
||||
assert any(c in result.ascii for c in "┌├┐┘└┬")
|
||||
|
||||
|
||||
def test_bigtitle_pixel_font():
|
||||
source = '''page "Test" 80
|
||||
bigtitle "GO" pixel'''
|
||||
result = uframe.compile(source)
|
||||
assert any(c in result.ascii for c in "▀▄█")
|
||||
|
||||
|
||||
def test_bigtitle_fallback_to_smaller_font():
|
||||
"""When text is too wide for block font, should fall back to smaller fonts."""
|
||||
source = '''page "Test" 40
|
||||
bigtitle "ABCDEFGHIJ" block'''
|
||||
result = uframe.compile(source)
|
||||
# Should still render with a fallback font (thin uses box-drawing)
|
||||
assert result.ascii.strip() != ""
|
||||
# Thin font characters should be present (fell back from block)
|
||||
assert any(c in result.ascii for c in "┌├┐┘└┬─")
|
||||
|
||||
|
||||
def test_bigtitle_with_alignment():
|
||||
source = '''page "Test" 80
|
||||
bigtitle "HI" block
|
||||
align center'''
|
||||
result = uframe.compile(source)
|
||||
assert "█" in result.ascii
|
||||
298
backend/uframe/tests/test_compile.py
Normal file
@@ -0,0 +1,298 @@
|
||||
"""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_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_form_field():
|
||||
source = '''page "Demo" 50
|
||||
form "test"
|
||||
field "name" 20 "Enter name..."'''
|
||||
result = uframe.compile(source)
|
||||
assert "name:" in result.ascii
|
||||
assert "[" in result.ascii
|
||||
assert "]" in result.ascii
|
||||
# Micron should have form tag
|
||||
assert "`<" in result.micron or "<" in result.micron
|
||||
|
||||
|
||||
def test_form_radio():
|
||||
source = '''page "Demo" 50
|
||||
form "test"
|
||||
radio "mode" "Ping" | "Trace" | "Page"'''
|
||||
result = uframe.compile(source)
|
||||
assert "(•)" in result.ascii # first option selected
|
||||
assert "( )" in result.ascii # other options unselected
|
||||
assert "Ping" in result.ascii
|
||||
assert "Trace" in result.ascii
|
||||
|
||||
|
||||
def test_form_checkbox():
|
||||
source = '''page "Demo" 50
|
||||
form "test"
|
||||
checkbox "agree" "I agree to terms"'''
|
||||
result = uframe.compile(source)
|
||||
assert "[ ]" in result.ascii
|
||||
assert "I agree to terms" in result.ascii
|
||||
|
||||
|
||||
def test_form_button():
|
||||
source = '''page "Demo" 50
|
||||
form "test"
|
||||
button "Submit" "/page/submit.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "Submit" in result.ascii
|
||||
assert "submit.mu" in result.micron
|
||||
|
||||
|
||||
def test_form_complete():
|
||||
source = '''page "Search" 50
|
||||
form "search"
|
||||
field "query" 24 "Search term..."
|
||||
radio "scope" "Local" | "Network"
|
||||
checkbox "cache" "Include cached"
|
||||
button "Go" "/page/search.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "query:" in result.ascii
|
||||
assert "(•)" in result.ascii
|
||||
assert "[ ]" in result.ascii
|
||||
assert "Go" in result.ascii
|
||||
|
||||
|
||||
def test_full_dashboard():
|
||||
"""Integration test: a realistic dashboard layout."""
|
||||
source = '''page "Node Status" 60
|
||||
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
|
||||
123
backend/uframe/tests/test_components.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""Tests for components and standard library."""
|
||||
|
||||
import uframe
|
||||
|
||||
|
||||
def test_inline_component():
|
||||
source = '''page "Test" 50
|
||||
component greeting(name)
|
||||
heading 1 "Hello $name"
|
||||
text "Welcome, $name!"
|
||||
|
||||
greeting "Alice"
|
||||
greeting "Bob"'''
|
||||
result = uframe.compile(source)
|
||||
assert "Hello Alice" in result.ascii
|
||||
assert "Welcome, Alice!" in result.ascii
|
||||
assert "Hello Bob" in result.ascii
|
||||
assert "Welcome, Bob!" in result.ascii
|
||||
|
||||
|
||||
def test_component_with_gauge():
|
||||
source = '''page "Test" 50
|
||||
component stat(label, value, max)
|
||||
gauge "$label" $value $max 20
|
||||
|
||||
stat "CPU" 62 100
|
||||
stat "MEM" 84 100'''
|
||||
result = uframe.compile(source)
|
||||
assert "CPU" in result.ascii
|
||||
assert "MEM" in result.ascii
|
||||
assert "█" in result.ascii
|
||||
|
||||
|
||||
def test_use_std_dashboard():
|
||||
source = '''page "Test" 60
|
||||
use std/dashboard
|
||||
banner "My Node" "Mesh Network"'''
|
||||
result = uframe.compile(source)
|
||||
assert "My Node" in result.ascii
|
||||
assert "Mesh Network" in result.ascii
|
||||
assert "╔" in result.ascii # double box from banner
|
||||
|
||||
|
||||
def test_use_std_dashboard_resources():
|
||||
source = '''page "Test" 60
|
||||
use std/dashboard
|
||||
resources 42 67'''
|
||||
result = uframe.compile(source)
|
||||
assert "CPU" in result.ascii
|
||||
assert "MEM" in result.ascii
|
||||
assert "Resources" in result.ascii
|
||||
|
||||
|
||||
def test_use_std_nav():
|
||||
source = '''page "Test" 50
|
||||
use std/nav
|
||||
nav_link "Home" "/page/index.mu"
|
||||
nav_divider
|
||||
nav_link "About" "/page/about.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "Home" in result.ascii
|
||||
assert "About" in result.ascii
|
||||
|
||||
|
||||
def test_use_std_status_bar():
|
||||
source = '''page "Test" 60
|
||||
use std/status-bar
|
||||
status_bar "Uptime" 95 100'''
|
||||
result = uframe.compile(source)
|
||||
assert "Uptime" in result.ascii
|
||||
assert "█" in result.ascii
|
||||
|
||||
|
||||
def test_use_std_status_bar_item():
|
||||
source = '''page "Test" 60
|
||||
use std/status-bar
|
||||
status_item "Gateway" online'''
|
||||
result = uframe.compile(source)
|
||||
assert "Gateway" in result.ascii
|
||||
assert "●" in result.ascii
|
||||
|
||||
|
||||
def test_use_std_status_bar_row():
|
||||
source = '''page "Test" 60
|
||||
use std/status-bar
|
||||
status_row "East" online "West" offline'''
|
||||
result = uframe.compile(source)
|
||||
assert "East" in result.ascii
|
||||
assert "West" in result.ascii
|
||||
|
||||
|
||||
def test_use_std_network_peer_list():
|
||||
source = '''page "Test" 60
|
||||
use std/network
|
||||
peer_list "Active Peers"'''
|
||||
result = uframe.compile(source)
|
||||
assert "Active Peers" in result.ascii
|
||||
|
||||
|
||||
def test_use_std_form_search():
|
||||
source = '''page "Test" 60
|
||||
use std/form
|
||||
search_form "search" "/page/results.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "Search" in result.ascii
|
||||
assert "results.mu" in result.micron
|
||||
|
||||
|
||||
def test_use_std_form_login():
|
||||
source = '''page "Test" 60
|
||||
use std/form
|
||||
login_form "/page/auth.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "Login" in result.ascii
|
||||
assert "auth.mu" in result.micron
|
||||
|
||||
|
||||
def test_unknown_component_ignored():
|
||||
source = '''page "Test" 50
|
||||
heading 1 "Hello"
|
||||
nonexistent_thing "arg"'''
|
||||
result = uframe.compile(source)
|
||||
assert "Hello" in result.ascii
|
||||
124
backend/uframe/tests/test_dynamic.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Tests for dynamic page features — source, if/for, codegen."""
|
||||
|
||||
import uframe
|
||||
|
||||
|
||||
def test_static_page_not_dynamic():
|
||||
result = uframe.compile('page "Test" 40\n heading 1 "Hello"')
|
||||
assert not result.is_dynamic
|
||||
assert result.script == ""
|
||||
|
||||
|
||||
def test_source_makes_dynamic():
|
||||
source = '''page "Test" 40
|
||||
source cpu : shell "echo 42"
|
||||
heading 1 "CPU: $cpu"'''
|
||||
result = uframe.compile(source)
|
||||
assert result.is_dynamic
|
||||
assert result.script != ""
|
||||
assert "#!/usr/bin/env python3" in result.script
|
||||
assert "_shell" in result.script
|
||||
|
||||
|
||||
def test_cache_control():
|
||||
source = '''page "Test" 40
|
||||
cache 0
|
||||
heading 1 "Live"'''
|
||||
result = uframe.compile(source)
|
||||
assert result.is_dynamic
|
||||
assert "_cache_seconds = 0" in result.script
|
||||
|
||||
|
||||
def test_if_block():
|
||||
source = '''page "Test" 40
|
||||
source val : shell "echo 50"
|
||||
if $val > 90
|
||||
text "Critical"'''
|
||||
result = uframe.compile(source)
|
||||
assert result.is_dynamic
|
||||
assert "if val > 90:" in result.script
|
||||
|
||||
|
||||
def test_for_loop():
|
||||
source = '''page "Test" 40
|
||||
source items : shell "echo hello"
|
||||
for item in $items
|
||||
text "$item"'''
|
||||
result = uframe.compile(source)
|
||||
assert result.is_dynamic
|
||||
assert "for item in _iter(items):" in result.script
|
||||
|
||||
|
||||
def test_let_variable():
|
||||
"""let + source makes it dynamic; let alone is static."""
|
||||
source = '''page "Test" 40
|
||||
let name = "Relay Alpha"
|
||||
source ts : python "datetime.now().isoformat()"
|
||||
heading 1 "$name"'''
|
||||
result = uframe.compile(source)
|
||||
assert result.is_dynamic
|
||||
assert "name = 'Relay Alpha'" in result.script
|
||||
|
||||
|
||||
def test_state_declaration():
|
||||
source = '''page "Test" 40
|
||||
state "counter" "/tmp/counter.json"
|
||||
heading 1 "Visits"'''
|
||||
result = uframe.compile(source)
|
||||
assert result.is_dynamic
|
||||
assert "_load_state" in result.script
|
||||
assert "/tmp/counter.json" in result.script
|
||||
|
||||
|
||||
def test_on_submit():
|
||||
source = '''page "Test" 40
|
||||
form "search"
|
||||
field "query" 20 "Search..."
|
||||
button "Go" "/page/test.mu"
|
||||
on_submit "search"
|
||||
text "Results for $query"'''
|
||||
result = uframe.compile(source)
|
||||
assert result.is_dynamic
|
||||
assert "_get_field" in result.script
|
||||
|
||||
|
||||
def test_codegen_has_runtime():
|
||||
source = '''page "Test" 40
|
||||
source data : shell "echo ok"
|
||||
text "$data"'''
|
||||
result = uframe.compile(source)
|
||||
script = result.script
|
||||
# Verify the runtime helpers are included
|
||||
assert "def _shell" in script
|
||||
assert "def _get_field" in script
|
||||
assert "def _load_state" in script
|
||||
assert "def _iter" in script
|
||||
assert "import uframe" in script
|
||||
assert "result.micron" in script
|
||||
|
||||
|
||||
def test_codegen_complete_dashboard():
|
||||
source = '''page "Dashboard" 60
|
||||
cache 0
|
||||
source cpu : shell "echo 42"
|
||||
source mem : shell "echo 67"
|
||||
|
||||
box double "Node Status"
|
||||
text "System Monitor"
|
||||
|
||||
gauge "CPU" $cpu 100 28 warn=75 crit=90
|
||||
gauge "MEM" $mem 100 28 warn=80 crit=95
|
||||
|
||||
if $cpu > 90
|
||||
text "ALERT: CPU critical"
|
||||
|
||||
divider heavy
|
||||
link "Home" "/page/index.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert result.is_dynamic
|
||||
script = result.script
|
||||
assert "#!/usr/bin/env python3" in script
|
||||
assert "_cache_seconds = 0" in script
|
||||
assert "_shell" in script
|
||||
assert "if cpu > 90:" in script
|
||||
assert "uframe.compile" in script
|
||||
191
backend/uframe/tests/test_navigation.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""Tests for HNav and VNav navigation components."""
|
||||
|
||||
import uframe
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HNav — Horizontal Navigation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_hnav_bar():
|
||||
source = '''page "Test" 60
|
||||
hnav bar
|
||||
item "Home" "/page/index.mu" active
|
||||
item "About" "/page/about.mu"
|
||||
item "Settings" "/page/settings.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "Home" in result.ascii
|
||||
assert "About" in result.ascii
|
||||
assert "Settings" in result.ascii
|
||||
# Bar style has borders
|
||||
assert "┌" in result.ascii or "─" in result.ascii
|
||||
|
||||
|
||||
def test_hnav_tabs():
|
||||
source = '''page "Test" 60
|
||||
hnav tabs
|
||||
item "Dashboard" "/page/dash.mu" active
|
||||
item "Logs" "/page/logs.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "Dashboard" in result.ascii
|
||||
assert "Logs" in result.ascii
|
||||
|
||||
|
||||
def test_hnav_pills():
|
||||
source = '''page "Test" 60
|
||||
hnav pills
|
||||
item "All" "/page/all.mu" active
|
||||
item "Active" "/page/active.mu"
|
||||
item "Stale" "/page/stale.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "All" in result.ascii
|
||||
assert "Active" in result.ascii
|
||||
assert "Stale" in result.ascii
|
||||
|
||||
|
||||
def test_hnav_breadcrumb():
|
||||
source = '''page "Test" 60
|
||||
hnav breadcrumb
|
||||
item "Home" "/page/index.mu"
|
||||
item "Network" "/page/network.mu"
|
||||
item "Node Alpha" "/page/alpha.mu" active'''
|
||||
result = uframe.compile(source)
|
||||
assert "Home" in result.ascii
|
||||
assert "Network" in result.ascii
|
||||
assert "Node Alpha" in result.ascii
|
||||
# Breadcrumb uses ▸ separator
|
||||
assert "▸" in result.ascii
|
||||
|
||||
|
||||
def test_hnav_underline():
|
||||
source = '''page "Test" 60
|
||||
hnav underline
|
||||
item "Overview" "/page/overview.mu" active
|
||||
item "Details" "/page/details.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "Overview" in result.ascii
|
||||
assert "Details" in result.ascii
|
||||
# Active item has underline
|
||||
assert "━" in result.ascii
|
||||
|
||||
|
||||
def test_hnav_with_separator():
|
||||
source = '''page "Test" 60
|
||||
hnav bar
|
||||
item "Home" "/page/index.mu" active
|
||||
separator
|
||||
item "Help" "/page/help.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "Home" in result.ascii
|
||||
assert "Help" in result.ascii
|
||||
|
||||
|
||||
def test_hnav_micron_links():
|
||||
source = '''page "Test" 60
|
||||
hnav bar
|
||||
item "Home" "/page/index.mu"
|
||||
item "About" "/page/about.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "index.mu" in result.micron
|
||||
assert "about.mu" in result.micron
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# VNav — Vertical Navigation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_vnav_list():
|
||||
source = '''page "Test" 60
|
||||
vnav list
|
||||
item "Dashboard" "/page/dash.mu" active
|
||||
item "Peers" "/page/peers.mu"
|
||||
item "Routes" "/page/routes.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "Dashboard" in result.ascii
|
||||
assert "Peers" in result.ascii
|
||||
assert "Routes" in result.ascii
|
||||
# Active item has marker
|
||||
assert "▸" in result.ascii
|
||||
|
||||
|
||||
def test_vnav_boxed():
|
||||
source = '''page "Test" 60
|
||||
vnav boxed
|
||||
item "Home" "/page/index.mu" active
|
||||
item "Settings" "/page/settings.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "Home" in result.ascii
|
||||
assert "Settings" in result.ascii
|
||||
# Boxed style has borders
|
||||
assert "┌" in result.ascii
|
||||
assert "└" in result.ascii
|
||||
|
||||
|
||||
def test_vnav_tree():
|
||||
source = '''page "Test" 60
|
||||
vnav tree
|
||||
item "Root" "/page/root.mu" active
|
||||
item "Child A" "/page/a.mu"
|
||||
item "Child B" "/page/b.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "Root" in result.ascii
|
||||
assert "Child A" in result.ascii
|
||||
assert "Child B" in result.ascii
|
||||
# Tree style has connectors
|
||||
assert "├" in result.ascii or "└" in result.ascii
|
||||
|
||||
|
||||
def test_vnav_sidebar():
|
||||
source = '''page "Test" 60
|
||||
vnav sidebar
|
||||
item "Overview" "/page/overview.mu" active
|
||||
item "Metrics" "/page/metrics.mu"
|
||||
item "Alerts" "/page/alerts.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "Overview" in result.ascii
|
||||
assert "Metrics" in result.ascii
|
||||
assert "Alerts" in result.ascii
|
||||
|
||||
|
||||
def test_vnav_minimal():
|
||||
source = '''page "Test" 60
|
||||
vnav minimal
|
||||
item "Page 1" "/page/1.mu"
|
||||
item "Page 2" "/page/2.mu" active
|
||||
item "Page 3" "/page/3.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "Page 1" in result.ascii
|
||||
assert "Page 2" in result.ascii
|
||||
assert "Page 3" in result.ascii
|
||||
|
||||
|
||||
def test_vnav_with_separator():
|
||||
source = '''page "Test" 60
|
||||
vnav list
|
||||
item "Home" "/page/index.mu" active
|
||||
separator
|
||||
item "Help" "/page/help.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "Home" in result.ascii
|
||||
assert "Help" in result.ascii
|
||||
assert "─" in result.ascii
|
||||
|
||||
|
||||
def test_vnav_with_width():
|
||||
source = '''page "Test" 60
|
||||
vnav boxed 25
|
||||
item "Nav Item" "/page/nav.mu" active'''
|
||||
result = uframe.compile(source)
|
||||
assert "Nav Item" in result.ascii
|
||||
|
||||
|
||||
def test_vnav_micron_links():
|
||||
source = '''page "Test" 60
|
||||
vnav list
|
||||
item "Home" "/page/index.mu"
|
||||
item "About" "/page/about.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "index.mu" in result.micron
|
||||
assert "about.mu" in result.micron
|
||||
154
backend/uframe/tests/test_themes.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""Tests for the theme system."""
|
||||
|
||||
import uframe
|
||||
from uframe.themes import (
|
||||
get_theme, ThemeDef, BUILTIN_THEMES,
|
||||
THEME_DEFAULT, THEME_NOUVEAU, THEME_GOTHIC,
|
||||
THEME_BAMBOO, THEME_CIRCUIT, THEME_BRUTALIST,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Theme registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_builtin_themes_exist():
|
||||
assert len(BUILTIN_THEMES) == 6
|
||||
for name in ("default", "nouveau", "gothic", "bamboo", "circuit", "brutalist"):
|
||||
assert name in BUILTIN_THEMES
|
||||
|
||||
|
||||
def test_get_theme_by_name():
|
||||
theme = get_theme("gothic")
|
||||
assert theme.name == "gothic"
|
||||
|
||||
|
||||
def test_get_theme_case_insensitive():
|
||||
theme = get_theme("GOTHIC")
|
||||
assert theme.name == "gothic"
|
||||
|
||||
|
||||
def test_get_theme_unknown_returns_default():
|
||||
theme = get_theme("nonexistent")
|
||||
assert theme.name == "default"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Theme structure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_theme_has_all_fields():
|
||||
for name, theme in BUILTIN_THEMES.items():
|
||||
assert theme.borders_light is not None
|
||||
assert theme.borders_heavy is not None
|
||||
assert theme.borders_double is not None
|
||||
assert theme.borders_rounded is not None
|
||||
assert theme.dividers is not None
|
||||
assert theme.indicators is not None
|
||||
assert theme.gauge is not None
|
||||
assert theme.form is not None
|
||||
assert theme.palette is not None
|
||||
|
||||
|
||||
def test_border_chars_method():
|
||||
theme = THEME_DEFAULT
|
||||
bc = theme.border_chars("light")
|
||||
assert bc.tl == "┌"
|
||||
assert bc.tr == "┐"
|
||||
|
||||
|
||||
def test_border_dict_method():
|
||||
theme = THEME_DEFAULT
|
||||
bd = theme.border_dict("double")
|
||||
assert bd["tl"] == "╔"
|
||||
assert bd["h"] == "═"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Themed rendering — each theme produces correct characters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_compile_with_default_theme():
|
||||
source = '''page "Test" 40
|
||||
box light "Panel"
|
||||
text "Content"'''
|
||||
result = uframe.compile(source)
|
||||
assert "┌" in result.ascii # default light border
|
||||
|
||||
|
||||
def test_compile_with_gothic_theme():
|
||||
source = '''page "Test" 40
|
||||
box light "Panel"
|
||||
text "Content"'''
|
||||
result = uframe.compile(source, theme="gothic")
|
||||
# Gothic uses double-style borders for light
|
||||
assert "╔" in result.ascii
|
||||
|
||||
|
||||
def test_compile_with_brutalist_theme():
|
||||
source = '''page "Test" 40
|
||||
box light "Panel"
|
||||
text "Content"'''
|
||||
result = uframe.compile(source, theme="brutalist")
|
||||
# Brutalist uses block characters for light borders
|
||||
assert "▛" in result.ascii
|
||||
|
||||
|
||||
def test_compile_with_bamboo_theme():
|
||||
source = '''page "Test" 40
|
||||
box light "Panel"
|
||||
text "Content"'''
|
||||
result = uframe.compile(source, theme="bamboo")
|
||||
# Bamboo uses dashed light borders
|
||||
assert "╌" in result.ascii or "┌" in result.ascii
|
||||
|
||||
|
||||
def test_compile_with_circuit_theme():
|
||||
source = '''page "Test" 40
|
||||
gauge "CPU" 50 100 20'''
|
||||
result = uframe.compile(source, theme="circuit")
|
||||
# Circuit uses ▰/▱ for gauge
|
||||
assert "▰" in result.ascii
|
||||
assert "▱" in result.ascii
|
||||
|
||||
|
||||
def test_compile_with_nouveau_theme():
|
||||
source = '''page "Test" 40
|
||||
status "Server" online'''
|
||||
result = uframe.compile(source, theme="nouveau")
|
||||
# Nouveau uses ❀ for online indicator
|
||||
assert "❀" in result.ascii
|
||||
|
||||
|
||||
def test_theme_divider_chars():
|
||||
source = '''page "Test" 40
|
||||
divider heavy'''
|
||||
result_default = uframe.compile(source)
|
||||
result_gothic = uframe.compile(source, theme="gothic")
|
||||
# Both should render but potentially with different chars
|
||||
assert "━" in result_default.ascii
|
||||
assert "═" in result_gothic.ascii
|
||||
|
||||
|
||||
def test_theme_gauge_chars():
|
||||
source = '''page "Test" 40
|
||||
gauge "Test" 50 100 20'''
|
||||
result_default = uframe.compile(source)
|
||||
result_brutalist = uframe.compile(source, theme="brutalist")
|
||||
# Default: █/░, Brutalist: █/(space)
|
||||
assert "█" in result_default.ascii
|
||||
assert "█" in result_brutalist.ascii
|
||||
|
||||
|
||||
def test_theme_form_chars():
|
||||
source = '''page "Test" 50
|
||||
form "test"
|
||||
checkbox "agree" "Accept"'''
|
||||
result_default = uframe.compile(source)
|
||||
result_gothic = uframe.compile(source, theme="gothic")
|
||||
# Default: [ ], Gothic: ⚐
|
||||
assert "[ ]" in result_default.ascii or "Accept" in result_default.ascii
|
||||
assert "⚐" in result_gothic.ascii or "Accept" in result_gothic.ascii
|
||||
241
backend/uframe/themes.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""µFrame Theme System — decorative styles for rich terminal UIs.
|
||||
|
||||
A theme maps abstract UI elements to concrete character sets and color
|
||||
palettes. The same .uf source renders with different visual character
|
||||
when a different theme is applied.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class BorderChars:
|
||||
tl: str = "┌"; t: str = "─"; tr: str = "┐"
|
||||
l: str = "│"; r: str = "│"
|
||||
bl: str = "└"; b: str = "─"; br: str = "┘"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Indicators:
|
||||
online: str = "●"
|
||||
offline: str = "○"
|
||||
degraded: str = "◐"
|
||||
unknown: str = "◌"
|
||||
alert: str = "⚠"
|
||||
|
||||
|
||||
@dataclass
|
||||
class GaugeChars:
|
||||
filled: str = "█"
|
||||
empty: str = "░"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FormChars:
|
||||
field_l: str = "[ "
|
||||
field_r: str = " ]"
|
||||
radio_on: str = "(•)"
|
||||
radio_off: str = "( )"
|
||||
check_on: str = "[✓]"
|
||||
check_off: str = "[ ]"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Ornaments:
|
||||
bullet: str = "•"
|
||||
header: str = ""
|
||||
separator: str = ""
|
||||
footer: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class TitleCaps:
|
||||
left: str = "─ "
|
||||
right: str = " ─"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Palette:
|
||||
accent: str = "0f0" # headings, primary highlights
|
||||
accent2: str = "0cf" # secondary (H2, links)
|
||||
accent3: str = "88f" # tertiary (H3)
|
||||
muted: str = "555" # dividers, empty gauge
|
||||
border: str = "" # border color (empty = no color)
|
||||
success: str = "0f0" # online, gauge ok
|
||||
warning: str = "ff0" # degraded, gauge warn
|
||||
danger: str = "f00" # offline, gauge crit
|
||||
info: str = "0cf" # links, sparklines
|
||||
form: str = "0cf" # form element accents
|
||||
label: str = "888" # labels, field names
|
||||
button: str = "0f0" # form buttons
|
||||
|
||||
|
||||
@dataclass
|
||||
class DividerChars:
|
||||
light: str = "─"
|
||||
heavy: str = "━"
|
||||
double: str = "═"
|
||||
dash: str = "╌"
|
||||
dot: str = "┄"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ThemeDef:
|
||||
name: str = "default"
|
||||
description: str = "Clean engineering — standard box-drawing"
|
||||
|
||||
borders_light: BorderChars = field(default_factory=BorderChars)
|
||||
borders_heavy: BorderChars = field(default_factory=lambda: BorderChars(
|
||||
tl="┏", t="━", tr="┓", l="┃", r="┃", bl="┗", b="━", br="┛"))
|
||||
borders_double: BorderChars = field(default_factory=lambda: BorderChars(
|
||||
tl="╔", t="═", tr="╗", l="║", r="║", bl="╚", b="═", br="╝"))
|
||||
borders_rounded: BorderChars = field(default_factory=lambda: BorderChars(
|
||||
tl="╭", t="─", tr="╮", l="│", r="│", bl="╰", b="─", br="╯"))
|
||||
|
||||
dividers: DividerChars = field(default_factory=DividerChars)
|
||||
title_caps: TitleCaps = field(default_factory=TitleCaps)
|
||||
indicators: Indicators = field(default_factory=Indicators)
|
||||
gauge: GaugeChars = field(default_factory=GaugeChars)
|
||||
form: FormChars = field(default_factory=FormChars)
|
||||
ornaments: Ornaments = field(default_factory=Ornaments)
|
||||
palette: Palette = field(default_factory=Palette)
|
||||
|
||||
def border_chars(self, weight_name: str) -> BorderChars:
|
||||
return {
|
||||
"light": self.borders_light,
|
||||
"heavy": self.borders_heavy,
|
||||
"double": self.borders_double,
|
||||
"rounded": self.borders_rounded,
|
||||
}.get(weight_name, self.borders_light)
|
||||
|
||||
def border_dict(self, weight_name: str) -> dict[str, str]:
|
||||
"""Return BOX_CHARS-compatible dict for a border weight."""
|
||||
bc = self.border_chars(weight_name)
|
||||
return {
|
||||
"tl": bc.tl, "tr": bc.tr, "bl": bc.bl, "br": bc.br,
|
||||
"h": bc.t, "v": bc.l,
|
||||
"t_down": bc.t, "t_up": bc.b, "t_right": bc.l, "t_left": bc.r,
|
||||
"cross": bc.t,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Built-in themes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
THEME_DEFAULT = ThemeDef()
|
||||
|
||||
THEME_NOUVEAU = ThemeDef(
|
||||
name="nouveau",
|
||||
description="Art Nouveau — organic flowing ornament",
|
||||
borders_heavy=BorderChars(tl="☙", t="━", tr="❧", l="┃", r="┃", bl="☙", b="━", br="❧"),
|
||||
borders_light=BorderChars(tl="╭", t="┈", tr="╮", l="┊", r="┊", bl="╰", b="┈", br="╯"),
|
||||
borders_double=BorderChars(tl="☙", t="━", tr="❧", l="┃", r="┃", bl="☙", b="━", br="❧"),
|
||||
borders_rounded=BorderChars(tl="╭", t="┈", tr="╮", l="┊", r="┊", bl="╰", b="┈", br="╯"),
|
||||
dividers=DividerChars(light="┈", heavy="━", double="━", dash="┈", dot="┈"),
|
||||
title_caps=TitleCaps(left="✾─── ", right=" ───✾"),
|
||||
indicators=Indicators(online="❀", offline="✿", degraded="⚘", unknown="✿", alert="❋"),
|
||||
gauge=GaugeChars(filled="▐", empty="░"),
|
||||
form=FormChars(field_l="❴ ", field_r=" ❵", radio_on="❀", radio_off="✿",
|
||||
check_on="❀", check_off="✿"),
|
||||
ornaments=Ornaments(bullet="❀", header="─✾──────✾─", separator="☙━━━━━━━━━━━━━❧"),
|
||||
palette=Palette(accent="da5", accent2="8b5", accent3="886", muted="886",
|
||||
border="a85", success="6b4", warning="da5", danger="a33",
|
||||
info="68a", form="da5", label="886", button="6b4"),
|
||||
)
|
||||
|
||||
THEME_GOTHIC = ThemeDef(
|
||||
name="gothic",
|
||||
description="Gothic — heavy blackletter, monumental",
|
||||
borders_heavy=BorderChars(tl="╬", t="═", tr="╬", l="║", r="║", bl="╬", b="═", br="╬"),
|
||||
borders_light=BorderChars(tl="╔", t="═", tr="╗", l="║", r="║", bl="╚", b="═", br="╝"),
|
||||
borders_double=BorderChars(tl="╬", t="═", tr="╬", l="║", r="║", bl="╬", b="═", br="╬"),
|
||||
borders_rounded=BorderChars(tl="╔", t="═", tr="╗", l="║", r="║", bl="╚", b="═", br="╝"),
|
||||
dividers=DividerChars(light="═", heavy="═", double="═", dash="═", dot="═"),
|
||||
title_caps=TitleCaps(left="═══╡ ", right=" ╞═══"),
|
||||
indicators=Indicators(online="⚑", offline="⚐", degraded="⚑", unknown="⚐", alert="⚔"),
|
||||
gauge=GaugeChars(filled="▓", empty="░"),
|
||||
form=FormChars(field_l="║ ", field_r=" ║", radio_on="⚑", radio_off="⚐",
|
||||
check_on="⚑", check_off="⚐"),
|
||||
ornaments=Ornaments(bullet="▪", header="═══╡══════╞═══"),
|
||||
palette=Palette(accent="cc8", accent2="a66", accent3="888", muted="666",
|
||||
border="888", success="8a8", warning="cc8", danger="a44",
|
||||
info="8ac", form="cc8", label="888", button="cc8"),
|
||||
)
|
||||
|
||||
THEME_BAMBOO = ThemeDef(
|
||||
name="bamboo",
|
||||
description="Bamboo — East Asian minimalism, light brush strokes",
|
||||
borders_heavy=BorderChars(tl="〔", t=" ", tr="〕", l=" ", r=" ", bl=" ", b=" ", br=" "),
|
||||
borders_light=BorderChars(tl="┌", t="╌", tr="┐", l="╎", r="╎", bl="└", b="╌", br="┘"),
|
||||
borders_double=BorderChars(tl="〔", t=" ", tr="〕", l=" ", r=" ", bl=" ", b=" ", br=" "),
|
||||
borders_rounded=BorderChars(tl="┌", t="╌", tr="┐", l="╎", r="╎", bl="└", b="╌", br="┘"),
|
||||
dividers=DividerChars(light="┄", heavy="┄", double="┄", dash="┄", dot="┄"),
|
||||
title_caps=TitleCaps(left="┄┄┄ ", right=" ┄┄┄"),
|
||||
indicators=Indicators(online="◉", offline="◦", degraded="◎", unknown="◦", alert="◈"),
|
||||
gauge=GaugeChars(filled="▏", empty=" "),
|
||||
form=FormChars(field_l="〈 ", field_r=" 〉", radio_on="◉", radio_off="◦",
|
||||
check_on="◉", check_off="◦"),
|
||||
ornaments=Ornaments(bullet="‣"),
|
||||
palette=Palette(accent="bca", accent2="ab9", accent3="998", muted="998",
|
||||
border="776", success="8b8", warning="cc9", danger="b77",
|
||||
info="9ab", form="bca", label="998", button="8b8"),
|
||||
)
|
||||
|
||||
THEME_CIRCUIT = ThemeDef(
|
||||
name="circuit",
|
||||
description="Circuit — digital, technical, neon",
|
||||
borders_heavy=BorderChars(tl="╒", t="═", tr="╕", l="│", r="│", bl="╘", b="═", br="╛"),
|
||||
borders_light=BorderChars(tl="┌", t="─", tr="┐", l="│", r="│", bl="└", b="─", br="┘"),
|
||||
borders_double=BorderChars(tl="╒", t="═", tr="╕", l="│", r="│", bl="╘", b="═", br="╛"),
|
||||
borders_rounded=BorderChars(tl="╒", t="═", tr="╕", l="│", r="│", bl="╘", b="═", br="╛"),
|
||||
dividers=DividerChars(light="─", heavy="═", double="═", dash="╌", dot="┄"),
|
||||
title_caps=TitleCaps(left="══[ ", right=" ]═══"),
|
||||
indicators=Indicators(online="◈", offline="◇", degraded="◈", unknown="◇", alert="⚡"),
|
||||
gauge=GaugeChars(filled="▰", empty="▱"),
|
||||
form=FormChars(field_l=">_ [ ", field_r=" ]", radio_on="[▰]", radio_off="[▱]",
|
||||
check_on="[▰]", check_off="[▱]"),
|
||||
ornaments=Ornaments(bullet="▸"),
|
||||
palette=Palette(accent="0ff", accent2="f0f", accent3="0af", muted="555",
|
||||
border="0aa", success="0f0", warning="ff0", danger="f00",
|
||||
info="0ff", form="0ff", label="0aa", button="0f0"),
|
||||
)
|
||||
|
||||
THEME_BRUTALIST = ThemeDef(
|
||||
name="brutalist",
|
||||
description="Brutalist — raw blocks, monochrome, anti-decorative",
|
||||
borders_heavy=BorderChars(tl="█", t="█", tr="█", l="█", r="█", bl="█", b="█", br="█"),
|
||||
borders_light=BorderChars(tl="▛", t="▀", tr="▜", l="▌", r="▐", bl="▙", b="▄", br="▟"),
|
||||
borders_double=BorderChars(tl="█", t="█", tr="█", l="█", r="█", bl="█", b="█", br="█"),
|
||||
borders_rounded=BorderChars(tl="▛", t="▀", tr="▜", l="▌", r="▐", bl="▙", b="▄", br="▟"),
|
||||
dividers=DividerChars(light="▔", heavy="█", double="█", dash="▔", dot="▔"),
|
||||
title_caps=TitleCaps(left="▌ ", right=" ▐"),
|
||||
indicators=Indicators(online="■", offline="□", degraded="■", unknown="□", alert="!"),
|
||||
gauge=GaugeChars(filled="█", empty=" "),
|
||||
form=FormChars(field_l="[", field_r="]", radio_on="■", radio_off="□",
|
||||
check_on="■", check_off="□"),
|
||||
ornaments=Ornaments(bullet="▪"),
|
||||
palette=Palette(accent="fff", accent2="fff", accent3="ccc", muted="888",
|
||||
border="fff", success="fff", warning="fff", danger="fff",
|
||||
info="fff", form="fff", label="aaa", button="fff"),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Theme registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BUILTIN_THEMES: dict[str, ThemeDef] = {
|
||||
"default": THEME_DEFAULT,
|
||||
"nouveau": THEME_NOUVEAU,
|
||||
"gothic": THEME_GOTHIC,
|
||||
"bamboo": THEME_BAMBOO,
|
||||
"circuit": THEME_CIRCUIT,
|
||||
"brutalist": THEME_BRUTALIST,
|
||||
}
|
||||
|
||||
|
||||
def get_theme(name: str) -> ThemeDef:
|
||||
"""Get a built-in theme by name. Returns default if not found."""
|
||||
return BUILTIN_THEMES.get(name.lower(), THEME_DEFAULT)
|
||||
41
compose.yml
@@ -1,14 +1,41 @@
|
||||
services:
|
||||
micron-editor:
|
||||
build: .
|
||||
micronomicon:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
volumes:
|
||||
- ~/.nomadnetwork/storage/pages:/data/pages
|
||||
- ~/.micron-editor/sources:/data/sources
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
- PAGES_DIR=/data/pages
|
||||
- SOURCES_DIR=/data/sources
|
||||
- NOMADNET_CONTAINER=nomadnet
|
||||
restart: always
|
||||
- RNS_CONFIG_DIR=/rns
|
||||
- RNS_SERVER_CONFIG_DIR=/rns-server
|
||||
- NOMADNET_CONFIG_DIR=/nomadnet
|
||||
- LOG_LEVEL=DEBUG
|
||||
volumes:
|
||||
- pages:/data/pages
|
||||
- sources:/data/sources
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./reticulum-client.conf:/rns/config
|
||||
- ./reticulum.conf:/rns-server/config
|
||||
- ./nomadnet.conf:/nomadnet/config
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- nomadnet
|
||||
|
||||
nomadnet:
|
||||
image: ghcr.io/markqvist/nomadnet:latest
|
||||
ports:
|
||||
- "0.0.0.0:4242:4242"
|
||||
volumes:
|
||||
- pages:/root/.nomadnetwork/storage/pages
|
||||
- nomadnet-config:/root/.nomadnetwork
|
||||
- ./nomadnet.conf:/root/.nomadnetwork/config
|
||||
- ./reticulum.conf:/root/.reticulum/config
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pages:
|
||||
sources:
|
||||
nomadnet-config:
|
||||
|
||||
14
deploy.sh
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "==> Building frontend..."
|
||||
(cd frontend && npm run build)
|
||||
|
||||
echo "==> Deploying with docker compose..."
|
||||
docker compose -f compose.yml up -d --build
|
||||
|
||||
echo "==> Done!"
|
||||
echo " Web IDE: http://localhost:8080"
|
||||
echo " Reticulum: tcp://0.0.0.0:4242"
|
||||
850
docs/dynamic-templates.md
Normal file
@@ -0,0 +1,850 @@
|
||||
# µFrame Dynamic Templates — Making Rich UIs Live on NomadNet
|
||||
|
||||
## Addendum to the µFrame Design Document (v3)
|
||||
|
||||
---
|
||||
|
||||
## 1. The NomadNet Dynamic Page Model
|
||||
|
||||
Understanding how NomadNet serves dynamic pages is essential to
|
||||
understanding how µFrame templates become live applications.
|
||||
|
||||
### How it works
|
||||
|
||||
NomadNet has a simple but powerful execution model, analogous to
|
||||
CGI on the early web:
|
||||
|
||||
```
|
||||
Client Node Server
|
||||
────── ───────────
|
||||
1. Browse to /page/dashboard.mu
|
||||
───────────▶
|
||||
2. Is dashboard.mu executable?
|
||||
YES → run it
|
||||
NO → send contents as-is
|
||||
|
||||
3. Execute: #!/usr/bin/env python3
|
||||
Script prints Micron to stdout
|
||||
|
||||
4. Capture stdout → send to client
|
||||
◀───────────
|
||||
5. Render Micron in terminal
|
||||
```
|
||||
|
||||
Key mechanics:
|
||||
|
||||
- A `.mu` file without the execute bit is **static** — NomadNet
|
||||
sends its contents directly to the browsing client
|
||||
- A `.mu` file with the execute bit set is **dynamic** — NomadNet
|
||||
runs it as a subprocess and serves whatever it prints to stdout
|
||||
- The shebang line (`#!/usr/bin/env python3`) determines the
|
||||
interpreter — Python, Bash, Lua, Rust, anything that runs
|
||||
- The script must **terminate** — it cannot wait for input or
|
||||
run indefinitely
|
||||
- Cache behavior is controlled via a header line: `#!c=0` means
|
||||
never cache (always re-execute), `#!c=300` means cache for 5 min
|
||||
|
||||
### Form data flow
|
||||
|
||||
Micron form fields (`\`<field\`placeholder>`, `\`<^|group|val\`label>`,
|
||||
etc.) collect user input. When the user clicks a link on a page
|
||||
that contains form fields, the field data is submitted along with
|
||||
the link request:
|
||||
|
||||
```
|
||||
Page A (has form fields + a submit link)
|
||||
|
||||
┌───────────────────────────────────────────────┐
|
||||
│ Name: `<24|name`Enter name...> │
|
||||
│ Role: `<^|role|admin`Admin> `<^|role|user`User> │
|
||||
│ │
|
||||
│ `[Submit`:/page/handle.mu] │
|
||||
└───────────────────────────────────────────────┘
|
||||
|
||||
User fills in "Alice", selects "Admin", clicks Submit
|
||||
│
|
||||
▼
|
||||
Page B (handle.mu) — receives field data via environment
|
||||
variables, generates a response page with Micron output
|
||||
```
|
||||
|
||||
The submitted field data is passed to the executable script
|
||||
through environment variables in the format:
|
||||
|
||||
```
|
||||
FIELD_name=Alice
|
||||
FIELD_role=admin
|
||||
```
|
||||
|
||||
Or via stdin as a URL-encoded or structured data payload
|
||||
(implementation varies by NomadNet version). The executable
|
||||
script reads these values and uses them to generate its output.
|
||||
|
||||
---
|
||||
|
||||
## 2. µFrame's Dynamic Compilation Model
|
||||
|
||||
Here is the key insight: **µFrame doesn't just emit static
|
||||
Micron text — it can compile `.uf` templates into executable
|
||||
Python scripts that generate Micron at request time.**
|
||||
|
||||
This gives us three output modes:
|
||||
|
||||
```
|
||||
.uf source ──▶ Parser ──▶ IR ──┬──▶ Plain ASCII (static preview)
|
||||
├──▶ Static .mu (static page)
|
||||
└──▶ Dynamic .mu (executable script)
|
||||
│
|
||||
▼
|
||||
#!/usr/bin/env python3
|
||||
# Auto-generated by µFrame
|
||||
# from: dashboard.uf
|
||||
import os, sys, json, subprocess
|
||||
...
|
||||
print(rendered_micron)
|
||||
```
|
||||
|
||||
The dynamic output is a self-contained Python script with:
|
||||
- The µFrame rendering engine embedded (or imported)
|
||||
- Data-fetching hooks that run at request time
|
||||
- Form field processing from environment variables
|
||||
- Conditional rendering based on submitted data
|
||||
- The full ASCII art + Micron generation pipeline
|
||||
|
||||
### The three modes compared
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ µFrame Output Modes │
|
||||
├────────────────┬───────────────┬─────────────────────────────┤
|
||||
│ Plain ASCII │ Static .mu │ Dynamic .mu │
|
||||
├────────────────┼───────────────┼─────────────────────────────┤
|
||||
│ Box-drawing │ Box-drawing │ Box-drawing │
|
||||
│ Block chars │ Block chars │ Block chars │
|
||||
│ Braille │ Braille │ Braille │
|
||||
│ │ + Color │ + Color │
|
||||
│ │ + Bold/italic │ + Bold/italic │
|
||||
│ │ + Links │ + Links │
|
||||
│ │ + Form fields │ + LIVE form fields │
|
||||
│ │ │ + Server-side data binding │
|
||||
│ │ │ + Conditional rendering │
|
||||
│ │ │ + Form submission handling │
|
||||
│ │ │ + System data at render time │
|
||||
│ │ │ + State persistence │
|
||||
├────────────────┼───────────────┼─────────────────────────────┤
|
||||
│ Local terminal │ NomadNet page │ NomadNet live application │
|
||||
│ preview │ (cached) │ (re-executed per request) │
|
||||
└────────────────┴───────────────┴─────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. DSL Extensions for Dynamic Behavior
|
||||
|
||||
### 3.1 Data Sources — `source` blocks
|
||||
|
||||
A `source` block declares where live data comes from. At render
|
||||
time, the generated script executes the source and binds the
|
||||
result to a variable.
|
||||
|
||||
```
|
||||
# Shell command — output captured as string
|
||||
source cpu_pct : shell "grep 'cpu ' /proc/stat | awk '{print int(($2+$4)*100/($2+$4+$5))}'"
|
||||
source mem_pct : shell "free | awk '/Mem/{print int($3/$2*100)}'"
|
||||
source uptime : shell "uptime -p"
|
||||
source peers : shell "rnstatus -j | python3 -c 'import sys,json; d=json.load(sys.stdin); print(len(d.get(\"peers\",[]))); '"
|
||||
|
||||
# File read — contents loaded as string or parsed as JSON
|
||||
source motd : file "/etc/motd"
|
||||
source config : json "/home/node/.nomadnetwork/config.json"
|
||||
|
||||
# Python expression — evaluated inline (available: datetime, timedelta, secrets, os, json)
|
||||
source timestamp : python "datetime.now().strftime('%Y-%m-%d %H:%M')"
|
||||
source rand_hex : python "secrets.token_hex(4)"
|
||||
source uptime : python "str(timedelta(seconds=12345))"
|
||||
source hostname : python "os.uname().nodename"
|
||||
|
||||
# RNS/Reticulum API — direct integration
|
||||
source peer_list : rns "peers"
|
||||
source node_info : rns "identity"
|
||||
```
|
||||
|
||||
Sources are resolved **at page render time** — every time a
|
||||
client requests the page, the commands run fresh.
|
||||
|
||||
Usage in templates:
|
||||
|
||||
```
|
||||
gauge "CPU" $cpu_pct 100 28 warn=75 crit=90
|
||||
gauge "MEM" $mem_pct 100 28 warn=80 crit=95
|
||||
label "Uptime" "$uptime"
|
||||
label "Peers" "$peers active"
|
||||
text "Last updated: $timestamp"
|
||||
```
|
||||
|
||||
### 3.2 Form Handling — `on_submit` blocks
|
||||
|
||||
An `on_submit` block defines what happens when a form is
|
||||
submitted. It receives field values and controls what the
|
||||
page renders in response.
|
||||
|
||||
```
|
||||
page "Search" 64
|
||||
|
||||
form "search"
|
||||
field "query" 30 "Enter search term..."
|
||||
radio "scope" "Local" | "Network" | "All"
|
||||
button "Search" "/page/search.mu"
|
||||
|
||||
on_submit "search"
|
||||
# $query and $scope are now populated from submitted form data
|
||||
source results : shell "search_index.py '$query' --scope '$scope'"
|
||||
|
||||
heading 2 "Results for: $query"
|
||||
|
||||
if $results
|
||||
text "$results"
|
||||
else
|
||||
text "No results found."
|
||||
```
|
||||
|
||||
The generated Python script handles this as:
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
#!c=0
|
||||
import os, subprocess
|
||||
|
||||
# Read submitted form data
|
||||
query = os.environ.get("FIELD_query", "")
|
||||
scope = os.environ.get("FIELD_scope", "Local")
|
||||
|
||||
if query:
|
||||
# Form was submitted — render results
|
||||
results = subprocess.check_output(
|
||||
["search_index.py", query, "--scope", scope]
|
||||
).decode().strip()
|
||||
# ... render results template with Micron ...
|
||||
else:
|
||||
# No submission — render the form
|
||||
# ... render form template with Micron ...
|
||||
```
|
||||
|
||||
### 3.3 Conditional Rendering — `if` / `else` / `elif`
|
||||
|
||||
```
|
||||
source disk_pct : shell "df / | awk 'NR==2{print int($5)}'"
|
||||
|
||||
if $disk_pct > 90
|
||||
box heavy "DISK CRITICAL"
|
||||
color f00
|
||||
gauge "Disk" $disk_pct 100 40 crit=90
|
||||
text "@bold{@color{f00}{Immediate action required!}}"
|
||||
elif $disk_pct > 75
|
||||
box light "Disk Warning"
|
||||
color ff0
|
||||
gauge "Disk" $disk_pct 100 40 warn=75
|
||||
else
|
||||
gauge "Disk" $disk_pct 100 40
|
||||
```
|
||||
|
||||
### 3.4 Iteration — `for` loops
|
||||
|
||||
```
|
||||
source peer_json : shell "rnstatus --json-peers"
|
||||
|
||||
heading 2 "Active Peers"
|
||||
|
||||
for peer in $peer_json
|
||||
row 2
|
||||
col 30
|
||||
text "$peer.name"
|
||||
col 10
|
||||
status "$peer.name" $peer.state
|
||||
col 10
|
||||
text "$peer.latency"
|
||||
```
|
||||
|
||||
The `for` construct works with JSON arrays or newline-delimited
|
||||
text from shell commands.
|
||||
|
||||
### 3.5 State Persistence — `state` blocks
|
||||
|
||||
Since each page request is a fresh script execution, state must
|
||||
be stored externally. µFrame provides a simple key-value store
|
||||
backed by a JSON file on the node:
|
||||
|
||||
```
|
||||
state "counter" "/tmp/uframe_counter.json"
|
||||
|
||||
# Read
|
||||
let visits = $counter.visits || 0
|
||||
|
||||
# Write (increments on each page load)
|
||||
set counter.visits = $visits + 1
|
||||
|
||||
text "This page has been viewed $counter.visits times."
|
||||
```
|
||||
|
||||
For form-driven state (e.g., a guestbook):
|
||||
|
||||
```
|
||||
state "guestbook" "/var/nomadnet/guestbook.json"
|
||||
|
||||
form "sign"
|
||||
field "name" 20 "Your name..."
|
||||
field "message" 40 "Your message..."
|
||||
button "Sign" "/page/guestbook.mu"
|
||||
|
||||
on_submit "sign"
|
||||
append guestbook.entries { name: $name, message: $message, time: $timestamp }
|
||||
text "@color{0f0}{Thanks, $name! Your message has been saved.}"
|
||||
|
||||
heading 2 "Guestbook ($guestbook.entries.length entries)"
|
||||
|
||||
for entry in $guestbook.entries
|
||||
box light
|
||||
text "@bold{$entry.name} — @italic{$entry.time}"
|
||||
text "$entry.message"
|
||||
spacer
|
||||
```
|
||||
|
||||
### 3.6 Page Navigation with Data — `link` with parameters
|
||||
|
||||
Links can pass data to the target page via query-style encoding:
|
||||
|
||||
```
|
||||
# Simple navigation
|
||||
link "Home" "/page/index.mu"
|
||||
|
||||
# Navigation with parameters
|
||||
link "View Peer $peer.name" "/page/peer_detail.mu?hash=$peer.hash"
|
||||
|
||||
# In the target page, access with:
|
||||
source peer_hash : param "hash"
|
||||
```
|
||||
|
||||
### 3.7 Cache Control
|
||||
|
||||
```
|
||||
page "Dashboard" 64
|
||||
cache 0 # never cache — always re-execute
|
||||
# cache 60 # cache for 60 seconds
|
||||
# cache none # alias for 0
|
||||
|
||||
source cpu : shell "..."
|
||||
...
|
||||
```
|
||||
|
||||
Translates to the Micron header `#!c=0` in the first line of output.
|
||||
|
||||
---
|
||||
|
||||
## 4. Compilation Pipeline — .uf to Executable .mu
|
||||
|
||||
### 4.1 What the compiler generates
|
||||
|
||||
A `.uf` file with dynamic features compiles into a Python script
|
||||
that:
|
||||
|
||||
1. Sets the shebang and cache header
|
||||
2. Imports required modules + the `uframe` package
|
||||
3. Defines runtime helpers (`_shell`, `_read_file`, `_read_json`, etc.)
|
||||
4. Executes source commands and evaluates conditionals/loops
|
||||
5. Dynamically builds a `.uf` source string with resolved variables
|
||||
6. Compiles that source with `uframe.compile()` at runtime
|
||||
7. Prints the resulting Micron to stdout
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
# Auto-generated by uFrame
|
||||
# Do not edit — regenerate with: uframe compile <source>.uf
|
||||
|
||||
import os, sys, json, subprocess, datetime, secrets, shlex
|
||||
from datetime import datetime as _dt_cls, timedelta
|
||||
|
||||
# ─── Runtime Helpers ─────────────────────────────────────────
|
||||
|
||||
def _shell(cmd, timeout=5):
|
||||
"""Execute shell command, return stdout."""
|
||||
try:
|
||||
return subprocess.check_output(cmd, shell=True, timeout=timeout).decode().strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _read_file(path):
|
||||
"""Read file contents."""
|
||||
# ...
|
||||
|
||||
def _read_json(path):
|
||||
"""Read and parse JSON file."""
|
||||
# ...
|
||||
|
||||
def _get_field(name, default=""):
|
||||
"""Read submitted form field from environment."""
|
||||
return os.environ.get(f"FIELD_{name}", default)
|
||||
|
||||
def _get_param(name, default=""):
|
||||
"""Read URL parameter."""
|
||||
return os.environ.get(f"PARAM_{name}",
|
||||
os.environ.get(f"var_{name}", default))
|
||||
|
||||
def _load_state(path):
|
||||
"""Load state from JSON file."""
|
||||
# ...
|
||||
|
||||
def _save_state(path, data):
|
||||
"""Save state to JSON file."""
|
||||
# ...
|
||||
|
||||
def _iter(val):
|
||||
"""Make a value iterable for for-loops."""
|
||||
# handles lists, dicts, newline-delimited strings
|
||||
|
||||
# ─── µFrame Compile ──────────────────────────────────────────
|
||||
|
||||
import uframe
|
||||
|
||||
# ─── Page Logic ──────────────────────────────────────────────
|
||||
|
||||
_cache_seconds = 0
|
||||
|
||||
_uf_source_parts = []
|
||||
cpu_pct = eval('secrets.randbelow(60) + 20', {'datetime': _dt_cls, ...})
|
||||
timestamp = eval("datetime.now().strftime('%H:%M:%S')", {'datetime': _dt_cls, ...})
|
||||
|
||||
_uf_source_parts.append(f'heading 1 "Resources"')
|
||||
_uf_source_parts.append(f'gauge "CPU" {cpu_pct} 100 28 warn=75.0 crit=90.0')
|
||||
_uf_source_parts.append(f'text "Updated: {timestamp}"')
|
||||
|
||||
if cpu_pct > 90:
|
||||
_uf_source_parts.append(f'text "ALERT: CPU critical"')
|
||||
|
||||
# ─── Render & Output ─────────────────────────────────────────
|
||||
|
||||
_uf_source = f'''page "Live Status" 60
|
||||
''' + "\n".join(_uf_source_parts)
|
||||
|
||||
result = uframe.compile(_uf_source, width=60)
|
||||
|
||||
if _cache_seconds >= 0:
|
||||
print(f"#!c={_cache_seconds}")
|
||||
print(result.micron)
|
||||
```
|
||||
|
||||
The key insight: the generated script **rebuilds `.uf` source** with
|
||||
live data substituted in, then compiles it with the full µFrame
|
||||
pipeline. This means every layout feature (boxes, gauges, tables,
|
||||
sparklines) works identically in both static and dynamic pages.
|
||||
|
||||
### 4.2 CLI usage
|
||||
|
||||
```bash
|
||||
# Compile to dynamic executable .mu
|
||||
uframe compile dashboard.uf --out dashboard.mu
|
||||
chmod +x dashboard.mu
|
||||
|
||||
# Compile and deploy in one step
|
||||
uframe deploy dashboard.uf
|
||||
# → renders, sets +x, copies to ~/.nomadnetwork/storage/pages/
|
||||
|
||||
# Compile with embedded vs. imported runtime
|
||||
uframe compile dashboard.uf --embed # single self-contained file
|
||||
uframe compile dashboard.uf --import # requires uframe_runtime.py on node
|
||||
```
|
||||
|
||||
### 4.3 Compilation modes
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ µFrame Compilation Modes │
|
||||
├────────────────┬───────────────┬───────────────────────────────┤
|
||||
│ uframe render │ uframe render │ uframe compile │
|
||||
│ --ascii │ --micron │ │
|
||||
├────────────────┼───────────────┼───────────────────────────────┤
|
||||
│ Static ASCII │ Static .mu │ Executable .mu (Python) │
|
||||
│ to stdout │ file │ file with +x │
|
||||
├────────────────┼───────────────┼───────────────────────────────┤
|
||||
│ All values │ All values │ source{} values fetched │
|
||||
│ resolved at │ resolved at │ at request time │
|
||||
│ render time │ render time │ │
|
||||
│ │ │ Form fields become live │
|
||||
│ │ │ on_submit{} blocks active │
|
||||
│ │ │ if/for evaluated per request │
|
||||
│ │ │ State persists across visits │
|
||||
└────────────────┴───────────────┴───────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Complete Dynamic Example
|
||||
|
||||
### 5.1 Source — `search_node.uf`
|
||||
|
||||
```
|
||||
page "Node Search" 64
|
||||
cache 0
|
||||
|
||||
source timestamp : python "datetime.now().strftime('%H:%M:%S')"
|
||||
source peer_count : shell "rnstatus 2>/dev/null | grep -c 'Peer'"
|
||||
state "history" "/var/nomadnet/search_history.json"
|
||||
|
||||
box double "Node Search"
|
||||
align center
|
||||
text "Find peers and pages on the Reticulum mesh"
|
||||
text "@italic{$peer_count peers reachable · updated $timestamp}"
|
||||
|
||||
spacer
|
||||
|
||||
form "search"
|
||||
field "query" 30 "Search term..."
|
||||
radio "type" "Nodes" | "Pages" | "Files"
|
||||
checkbox "cache" "Include cached results"
|
||||
button "Search" "/page/search_node.mu"
|
||||
|
||||
on_submit "search"
|
||||
# Log the search
|
||||
append history.queries { q: $query, type: $type, time: $timestamp }
|
||||
|
||||
source results : shell "mesh_search.py '$query' --type '$type'"
|
||||
source result_count : python "len('''$results'''.strip().splitlines())"
|
||||
|
||||
divider light
|
||||
|
||||
heading 2 "Results for \"$query\" ($result_count found)"
|
||||
|
||||
if $result_count > 0
|
||||
for line in $results
|
||||
source parts : python "'''$line'''.split('|')"
|
||||
row 1
|
||||
col 28
|
||||
link "$parts.0" "/page/detail.mu?hash=$parts.1"
|
||||
col 8
|
||||
text "$parts.2"
|
||||
col 10
|
||||
status "$parts.0" $parts.3
|
||||
else
|
||||
spacer
|
||||
text "@center{@color{ff0}{No results found for \"$query\"}}"
|
||||
spacer
|
||||
|
||||
divider light
|
||||
heading 3 "Recent Searches"
|
||||
|
||||
for entry in $history.queries[-5:]
|
||||
text " $entry.time $entry.q ($entry.type)"
|
||||
|
||||
divider heavy
|
||||
text "@center{@italic{Relay Alpha-7 · $timestamp}}"
|
||||
```
|
||||
|
||||
### 5.2 What the client sees
|
||||
|
||||
**Before submission** (form is empty):
|
||||
|
||||
```
|
||||
╔══ Node Search ═══════════════════════════════════════════════╗
|
||||
║ Find peers and pages on the Reticulum mesh ║
|
||||
║ 7 peers reachable · updated 14:32:07 ║
|
||||
╚══════════════════════════════════════════════════════════════╝
|
||||
|
||||
Search: [ Search term...___________________ ]
|
||||
Type: (•) Nodes ( ) Pages ( ) Files
|
||||
[ ] Include cached results
|
||||
|
||||
`[`!Search`!`:/page/search_node.mu]
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Relay Alpha-7 · 14:32:07
|
||||
```
|
||||
|
||||
**After submitting "relay"** (script re-executes with FIELD_query=relay):
|
||||
|
||||
```
|
||||
╔══ Node Search ═══════════════════════════════════════════════╗
|
||||
║ Find peers and pages on the Reticulum mesh ║
|
||||
║ 7 peers reachable · updated 14:32:15 ║
|
||||
╚══════════════════════════════════════════════════════════════╝
|
||||
|
||||
Search: [ relay__________________________ ]
|
||||
Type: (•) Nodes ( ) Pages ( ) Files
|
||||
[ ] Include cached results
|
||||
|
||||
`[`!Search`!`:/page/search_node.mu]
|
||||
|
||||
──────────────────────────────────────────────────────────────
|
||||
|
||||
>> Results for "relay" (3 found)
|
||||
|
||||
`F0cfRelay-East`f 2 hops `F0f0●`f online
|
||||
`F0cfRelay-South`f 4 hops `F0f0●`f online
|
||||
`F0cfRelay-Backup`f 6 hops `Fff0◐`f degraded
|
||||
|
||||
──────────────────────────────────────────────────────────────
|
||||
|
||||
>>> Recent Searches
|
||||
14:32:15 relay (Nodes)
|
||||
14:28:44 bridge (Pages)
|
||||
14:25:01 firmware (Files)
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Relay Alpha-7 · 14:32:15
|
||||
```
|
||||
|
||||
The form fields retain submitted values, results appear below,
|
||||
and the search history persists across visits via the state file.
|
||||
Every piece of box-drawing, every colored indicator, every braille
|
||||
sparkline renders identically in both ASCII preview and live Micron.
|
||||
|
||||
---
|
||||
|
||||
## 6. Dynamic Patterns — A Cookbook
|
||||
|
||||
### 6.1 Live Dashboard (auto-refresh via cache=0)
|
||||
|
||||
```
|
||||
page "Status" 64
|
||||
cache 0
|
||||
|
||||
source cpu : python "secrets.randbelow(60) + 20"
|
||||
source mem : python "secrets.randbelow(40) + 50"
|
||||
source uptime : python "str(timedelta(seconds=secrets.randbelow(86400)))"
|
||||
source timestamp : python "datetime.now().strftime('%H:%M:%S')"
|
||||
|
||||
box heavy "System Status"
|
||||
row 2
|
||||
gauge "CPU" $cpu 100 28 warn=75 crit=90
|
||||
gauge "MEM" $mem 100 28 warn=80 crit=95
|
||||
spacer
|
||||
label "Uptime" "$uptime"
|
||||
label "Updated" "$timestamp"
|
||||
|
||||
text "@center{@italic{Press Ctrl+R to refresh}}"
|
||||
```
|
||||
|
||||
Client hits the page → script runs → evaluates sources →
|
||||
renders gauges with live data → client sees it.
|
||||
Ctrl+R re-requests → fresh execution → updated values.
|
||||
|
||||
On a full Linux node, replace the python sources with shell commands
|
||||
to read real system data:
|
||||
|
||||
```
|
||||
source cpu : shell "grep 'cpu ' /proc/stat | awk '{print int(($2+$4)*100/($2+$4+$5))}'"
|
||||
source mem : shell "free | awk '/Mem/{print int($3/$2*100)}'"
|
||||
source uptime : shell "uptime -p"
|
||||
```
|
||||
|
||||
### 6.2 Guestbook with Persistent State
|
||||
|
||||
```
|
||||
page "Guestbook" 64
|
||||
cache 0
|
||||
|
||||
state "gb" "/var/nomadnet/guestbook.json"
|
||||
source timestamp : python "datetime.now().strftime('%Y-%m-%d %H:%M')"
|
||||
|
||||
heading 1 "Guestbook"
|
||||
|
||||
form "sign"
|
||||
field "name" 20 "Your name"
|
||||
field "msg" 40 "Leave a message..."
|
||||
button "Sign" "/page/guestbook.mu"
|
||||
|
||||
on_submit "sign"
|
||||
if $name && $msg
|
||||
prepend gb.entries { name: $name, msg: $msg, time: $timestamp }
|
||||
text "@color{0f0}{✓ Thanks, $name!}"
|
||||
|
||||
divider light
|
||||
|
||||
for entry in $gb.entries[:20]
|
||||
box rounded
|
||||
text "@bold{$entry.name} @italic{@color{888}{$entry.time}}"
|
||||
text "$entry.msg"
|
||||
spacer
|
||||
```
|
||||
|
||||
### 6.3 Multi-Page Wizard with Navigation
|
||||
|
||||
```
|
||||
# Page 1: setup.mu
|
||||
page "Setup Wizard — Step 1" 64
|
||||
cache 0
|
||||
|
||||
heading 1 "Network Configuration"
|
||||
form "net"
|
||||
field "interface" 20 "eth0"
|
||||
radio "mode" "Auto" | "Manual" | "Mesh Only"
|
||||
button "Next →" "/page/setup_2.mu"
|
||||
|
||||
# Page 2: setup_2.mu
|
||||
page "Setup Wizard — Step 2" 64
|
||||
cache 0
|
||||
|
||||
source iface : param "interface" # or field from previous page
|
||||
source mode : param "mode"
|
||||
|
||||
heading 1 "Confirm Settings"
|
||||
label "Interface" "$iface"
|
||||
label "Mode" "$mode"
|
||||
|
||||
form "confirm"
|
||||
checkbox "apply_now" "Apply immediately"
|
||||
button "← Back" "/page/setup.mu"
|
||||
button "Finish ✓" "/page/setup_done.mu?interface=$iface&mode=$mode"
|
||||
```
|
||||
|
||||
### 6.4 Chat Room (community pattern)
|
||||
|
||||
```
|
||||
page "Chat" 64
|
||||
cache 0
|
||||
|
||||
state "chat" "/var/nomadnet/chatlog.json"
|
||||
source timestamp : python "datetime.now().strftime('%H:%M')"
|
||||
|
||||
heading 1 "Node Chat"
|
||||
|
||||
# Display last 15 messages
|
||||
for msg in $chat.messages[-15:]
|
||||
text "@bold{@color{$msg.color}{$msg.nick}} @color{888}{$msg.time}"
|
||||
text " $msg.text"
|
||||
|
||||
divider light
|
||||
|
||||
form "send"
|
||||
field "nick" 12 "Nickname"
|
||||
field "text" 40 "Type message..."
|
||||
button "Send" "/page/chat.mu"
|
||||
|
||||
on_submit "send"
|
||||
if $nick && $text
|
||||
source color : python "format(hash('$nick')%4095,'03x')"
|
||||
append chat.messages { nick: $nick, text: $text, time: $timestamp, color: $color }
|
||||
|
||||
text "@center{@italic{@color{888}{Ctrl+R to refresh · $chat.messages.length messages}}}"
|
||||
```
|
||||
|
||||
### 6.5 Interactive Data Explorer
|
||||
|
||||
```
|
||||
page "Peer Explorer" 64
|
||||
cache 0
|
||||
|
||||
source peers_json : shell "rnstatus --json 2>/dev/null"
|
||||
source selected : param "hash"
|
||||
|
||||
heading 1 "Peer Explorer"
|
||||
|
||||
table "Peers"
|
||||
columns "Name" 24 | "Hops" 6 | "RTT" 8 | "Status" 10
|
||||
for peer in $peers_json.peers
|
||||
row "$peer.name" | "$peer.hops" | "$peer.rtt" | "$peer.status"
|
||||
|
||||
if $selected
|
||||
divider heavy
|
||||
source detail : shell "rnstatus --peer $selected --json 2>/dev/null"
|
||||
|
||||
box double "Peer Detail: $detail.name"
|
||||
label "Hash" "$detail.hash"
|
||||
label "Address" "$detail.address"
|
||||
label "Hops" "$detail.hops"
|
||||
label "Latency" "$detail.rtt"
|
||||
label "Last Seen" "$detail.last_seen"
|
||||
label "Transport" "$detail.transport"
|
||||
|
||||
sparkline "Latency (24h)" $detail.latency_history 40
|
||||
|
||||
row 2
|
||||
link "Ping" "/page/action.mu?cmd=ping&hash=$detail.hash"
|
||||
link "Trace" "/page/action.mu?cmd=trace&hash=$detail.hash"
|
||||
link "Browse" "$detail.hash:/page/index.mu"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Security Considerations
|
||||
|
||||
Dynamic pages execute code on the node server. µFrame enforces:
|
||||
|
||||
- **Shell command sanitization**: all `$variable` values interpolated
|
||||
into shell commands are escaped with `shlex.quote()` to prevent
|
||||
injection
|
||||
- **State file isolation**: state files are restricted to a
|
||||
configurable directory (default: `/var/nomadnet/uframe/`)
|
||||
- **Execution timeout**: all shell sources have a default 5-second
|
||||
timeout, configurable per source
|
||||
- **No network egress by default**: source commands run in the
|
||||
node's local context — they can read local system data but
|
||||
µFrame does not add network capabilities beyond what the
|
||||
scripts themselves invoke
|
||||
- **Input validation**: field values are length-limited and
|
||||
sanitized before use in sources or state operations
|
||||
|
||||
```
|
||||
# In the DSL, explicit sanitization:
|
||||
source result : shell "search.py" --arg $query --sanitize
|
||||
timeout 10
|
||||
max_length 256
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Architecture Summary
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ .uf Source │
|
||||
│ (layout + sources + forms │
|
||||
│ + conditionals + state) │
|
||||
└──────────────┬───────────────┘
|
||||
│
|
||||
┌────▼────┐
|
||||
│ Parse │
|
||||
└────┬────┘
|
||||
│
|
||||
┌────▼────┐
|
||||
│ IR │
|
||||
│ Tree │
|
||||
└──┬───┬──┘
|
||||
│ │
|
||||
┌──────────────┘ └────────────────┐
|
||||
│ │
|
||||
┌──────▼──────┐ ┌───────▼───────┐
|
||||
│ render mode │ │ compile mode │
|
||||
│ (immediate) │ │ (codegen) │
|
||||
└──┬───────┬──┘ └───────┬───────┘
|
||||
│ │ │
|
||||
┌──────▼┐ ┌───▼─────┐ ┌───────▼────────┐
|
||||
│ ASCII │ │ Static │ │ Executable .mu │
|
||||
│ stdout│ │ .mu file│ │ Python script │
|
||||
└───────┘ └─────────┘ │ with embedded │
|
||||
│ runtime + │
|
||||
│ data sources + │
|
||||
│ form handling │
|
||||
└───────┬────────┘
|
||||
│
|
||||
chmod +x
|
||||
deploy to
|
||||
│
|
||||
┌─────────▼─────────┐
|
||||
│ NomadNet Node │
|
||||
│ ~/.nomadnetwork/ │
|
||||
│ storage/pages/ │
|
||||
│ │
|
||||
│ Client request → │
|
||||
│ Execute script → │
|
||||
│ Stdout = Micron │
|
||||
│ with live data, │
|
||||
│ rich ASCII art, │
|
||||
│ color + forms │
|
||||
└───────────────────┘
|
||||
```
|
||||
|
||||
The dynamic model turns µFrame from a static template engine into a
|
||||
**full application framework for NomadNet** — where the same DSL that
|
||||
defines the visual layout also defines the data flow, user interaction,
|
||||
and server-side logic. The ASCII art isn't decoration — it's the UI
|
||||
of a live, interactive, decentralized application running over
|
||||
encrypted mesh networks.
|
||||
889
docs/framework-design-v3.md
Normal file
@@ -0,0 +1,889 @@
|
||||
# µFrame — A DSL for Rich Terminal UIs rendered as ASCII and Micron
|
||||
|
||||
## Overview
|
||||
|
||||
**µFrame** is a declarative DSL that compiles to rich terminal
|
||||
interfaces built from Unicode box-drawing, block elements, braille
|
||||
patterns, and careful spatial layout. It produces two outputs from
|
||||
the same source:
|
||||
|
||||
1. **Plain ASCII** — the raw visual layout, viewable in any terminal
|
||||
2. **Micron `.mu`** — the same visual layout enhanced with Micron's
|
||||
color, styling, links, and interactive form fields
|
||||
|
||||
Both outputs share the same rich character art. Micron doesn't
|
||||
degrade the visuals — it *elevates* them. The ASCII art passes
|
||||
through verbatim into the `.mu` file, and Micron tags wrap it
|
||||
with color, emphasis, alignment, and interactivity that plain
|
||||
text cannot express.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
┌───▶│ Plain ASCII │
|
||||
│ │ Box drawing, braille, blocks │
|
||||
│ │ No color, no links, no forms │
|
||||
.uf ──▶ Parser ──▶ IR ─┤ └──────────────────────────────────┘
|
||||
│ ┌──────────────────────────────────┐
|
||||
└───▶│ Micron .mu │
|
||||
│ Same visual base │
|
||||
│ + `Fhex color`f │
|
||||
│ + `!bold`! `*italic`* │
|
||||
│ + `[links`/dest] │
|
||||
│ + `<form fields`> │
|
||||
│ + `c alignment`a │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
The relationship is additive:
|
||||
|
||||
```
|
||||
Plain ASCII = layout + structure + data viz
|
||||
Micron = layout + structure + data viz + color + style + interaction
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Design Philosophy
|
||||
|
||||
### The terminal is the canvas — in both modes
|
||||
|
||||
A NomadNet node browser is a terminal. A local shell is a terminal.
|
||||
The character grid is the shared substrate. Unicode box-drawing,
|
||||
block elements, and braille dots render identically in both
|
||||
contexts. µFrame exploits this fully:
|
||||
|
||||
- **Box drawing** (`┌─┐│└┘`) creates bordered panels, tables,
|
||||
nested layouts — same characters in ASCII and Micron
|
||||
- **Block elements** (`█▉▊▋▌▍▎▏░▒▓`) build bar charts, gauges,
|
||||
heatmaps — passed through as literal text in Micron
|
||||
- **Braille** (`⠀`–`⣿`, 256 patterns) gives 2×4 sub-cell resolution
|
||||
for sparklines and dot plots — just text, works everywhere
|
||||
- **Micron then paints on top**: colored bars, highlighted thresholds,
|
||||
bold headers, clickable links, interactive form fields
|
||||
|
||||
### What Micron adds beyond ASCII
|
||||
|
||||
Micron's tag system maps perfectly onto the styling layer that
|
||||
plain ASCII lacks:
|
||||
|
||||
| Capability | Plain ASCII | Micron |
|
||||
|--------------------|---------------------|---------------------------------------|
|
||||
| Borders & boxes | ✓ box-drawing chars | ✓ same chars + colored with `Fhex` |
|
||||
| Bar charts | ✓ block elements | ✓ same blocks + colored thresholds |
|
||||
| Sparklines | ✓ braille dots | ✓ same braille + colored |
|
||||
| Status indicators | ✓ ● ○ ◐ chars | ✓ same chars + `F0f0` green/red |
|
||||
| Table data | ✓ monospace align | ✓ same alignment + bold headers |
|
||||
| Emphasis | ✗ (no mechanism) | ✓ `!bold`! `*italic`* `_underline`_ |
|
||||
| Color | ✗ (no mechanism)* | ✓ `Fhex text`f / `Bhex text`b |
|
||||
| Alignment | manual spacing | ✓ `c center`a / `r right`a |
|
||||
| Links | ✗ display only | ✓ `[click here`/page.mu] |
|
||||
| Text input | ✗ display only | ✓ `<name`placeholder> |
|
||||
| Radio / checkbox | ✗ visual only | ✓ `<^|group|val`label> `<?|..`label> |
|
||||
| Headings | manual styling | ✓ `>` / `>>` / `>>>` with styling |
|
||||
|
||||
*ASCII mode can optionally emit ANSI escape codes with `--ansi`,
|
||||
but the default is pure text.
|
||||
|
||||
---
|
||||
|
||||
## 2. The Rendering Model
|
||||
|
||||
Both renderers share a common **CharGrid** — a 2D matrix of
|
||||
characters that represents the visual layout. They diverge only
|
||||
in the final emission step.
|
||||
|
||||
```
|
||||
┌──────────┐
|
||||
.uf ──▶ Parse ──▶│ IR Tree │
|
||||
└────┬─────┘
|
||||
│
|
||||
┌────▼─────┐
|
||||
│ Layout │ ◀── width resolution, row splits,
|
||||
│ Engine │ border merging, chart rendering
|
||||
└────┬─────┘
|
||||
│
|
||||
┌────▼─────┐
|
||||
│ CharGrid │ ◀── 2D array of (char, style) pairs
|
||||
│ + Styles │ style = {fg, bg, bold, italic,
|
||||
└──┬────┬──┘ underline, link, field_meta}
|
||||
│ │
|
||||
┌───────▼┐ ┌▼─────────┐
|
||||
│ ASCII │ │ Micron │
|
||||
│ Emitter │ │ Emitter │
|
||||
└─────────┘ └──────────┘
|
||||
chars only chars + tags
|
||||
```
|
||||
|
||||
### The CharGrid
|
||||
|
||||
Every cell in the grid stores:
|
||||
|
||||
```
|
||||
Cell:
|
||||
char : string # the visible character (e.g. "█", "┌", "⣿")
|
||||
fg : string|null # foreground color, 3-digit hex
|
||||
bg : string|null # background color, 3-digit hex
|
||||
bold : bool
|
||||
italic : bool
|
||||
underline : bool
|
||||
link : string|null # destination path for clickable cells
|
||||
field : FieldMeta|null # form field metadata for interactive cells
|
||||
```
|
||||
|
||||
### ASCII Emitter
|
||||
|
||||
Reads only `cell.char` from each cell. Produces a plain text file.
|
||||
With `--ansi`, reads `fg`, `bg`, `bold`, `italic`, `underline`
|
||||
and emits ANSI escape codes.
|
||||
|
||||
### Micron Emitter
|
||||
|
||||
Reads every cell property. Scans each line left-to-right, tracks
|
||||
style state, and opens/closes Micron tags at style transitions:
|
||||
|
||||
```
|
||||
Line scan: ┌── gauge "CPU" ──────────┐
|
||||
Chars: C P U █ █ █ ░ ░ 6 2 %
|
||||
Styles: bold fg:0f0 fg:f00
|
||||
↓ ↓
|
||||
Micron: `!CPU`! `F0f0███`f`Ff00░░`f `Ff0062%`f
|
||||
```
|
||||
|
||||
This means the exact same box-drawing layout appears in both
|
||||
outputs. The Micron version simply has color and emphasis tags
|
||||
woven between the same characters.
|
||||
|
||||
---
|
||||
|
||||
## 3. Visual Primitives — The Shared Toolkit
|
||||
|
||||
Everything below renders identically in both ASCII and Micron.
|
||||
The Micron output adds color/style annotations on top.
|
||||
|
||||
### 3.1 Box Drawing
|
||||
|
||||
Four border weights:
|
||||
|
||||
```
|
||||
Light Heavy Double Rounded
|
||||
┌──────┐ ┏━━━━━━┓ ╔══════╗ ╭──────╮
|
||||
│ │ ┃ ┃ ║ ║ │ │
|
||||
└──────┘ ┗━━━━━━┛ ╚══════╝ ╰──────╯
|
||||
```
|
||||
|
||||
Nested boxes with automatic junction merging:
|
||||
|
||||
```
|
||||
┌─────────────────┬──────────┐
|
||||
│ Left Panel │ Right │
|
||||
│ │ │
|
||||
├─────────────────┴──────────┤
|
||||
│ Footer spans full width │
|
||||
└────────────────────────────┘
|
||||
```
|
||||
|
||||
Titled boxes (title inlined in top border):
|
||||
|
||||
```
|
||||
┌─ System Health ─────────────────────────┐
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
|
||||
┏━ ALERT ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
┃ ┃
|
||||
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
```
|
||||
|
||||
In Micron, the border characters are plain text. The title
|
||||
can be wrapped in `!bold`! and `Fhex color`f tags.
|
||||
|
||||
### 3.2 Data Visualization
|
||||
|
||||
**Horizontal bars** — block elements with optional color thresholds:
|
||||
|
||||
```
|
||||
ASCII: CPU ████████████████████░░░░░░░░░░ 62%
|
||||
|
||||
Micron: CPU `F0f0████████████████████`f`F333░░░░░░░░░░`f 62%
|
||||
↑ green fill ↑ dim empty
|
||||
```
|
||||
|
||||
**Gauges with thresholds** — color shifts at warn/crit boundaries:
|
||||
|
||||
```
|
||||
ASCII: MEM █████████████████████████████░ 93% ⚠
|
||||
|
||||
Micron: MEM `Ff00█████████████████████████████`f░ `Ff00 93% ⚠`f
|
||||
↑ red because value > crit threshold
|
||||
```
|
||||
|
||||
**Vertical bar charts**:
|
||||
|
||||
```
|
||||
ASCII: Micron adds color per bar:
|
||||
|
||||
█ `F08f█`f
|
||||
█ █ `F08f█`f `F0f0█`f
|
||||
█ █ █ █ `F08f█`f `Fff0█`f `F0f0█`f `Fff0█`f
|
||||
█ █ █ █ █ █ `F08f█`f `Fff0█`f █ `F0f0█`f █ `Fff0█`f
|
||||
█ █ █ █ █ █ █ █ ...
|
||||
─────────────────
|
||||
0 3 6 9 12 15
|
||||
```
|
||||
|
||||
**Sparklines** — braille characters for inline time series:
|
||||
|
||||
```
|
||||
ASCII: NET ⣀⣤⣶⣿⣿⣷⣶⣤⣀⣀⣤⣶⣿⣷⣤⣀ avg 31%
|
||||
|
||||
Micron: NET `F0ff⣀⣤⣶⣿⣿⣷⣶⣤⣀⣀⣤⣶⣿⣷⣤⣀`f avg 31%
|
||||
↑ cyan sparkline
|
||||
```
|
||||
|
||||
**Heatmap** — using shade blocks with per-cell color:
|
||||
|
||||
```
|
||||
Mon `F0f0░`f`F0f0░`f`F4f0▒`f`F8f0▓`f`Fff0█`f`F8f0▓`f`F4f0▒`f
|
||||
Tue `F4f0▒`f`F8f0▓`f`Fff0█`f`Fff0█`f`Fff0█`f`F8f0▓`f`F4f0▒`f
|
||||
Wed `F0f0░`f`F0f0░`f`F0f0░`f`F4f0▒`f`F4f0▒`f`F0f0░`f`F0f0░`f
|
||||
0 4 8 12 16 20 24
|
||||
```
|
||||
|
||||
In plain ASCII, same characters, no color — the shade density
|
||||
still communicates intensity.
|
||||
|
||||
**Status indicators** — colored in Micron, shape-coded in ASCII:
|
||||
|
||||
```
|
||||
ASCII: ● Online ○ Offline ◐ Degraded ◌ Unknown
|
||||
|
||||
Micron: `F0f0●`f Online `Ff00○`f Offline `Fff0◐`f Degraded
|
||||
```
|
||||
|
||||
Both modes are readable — color adds clarity but shape carries
|
||||
the information alone.
|
||||
|
||||
### 3.3 Tables
|
||||
|
||||
Tables use box-drawing for structure. Identical in both outputs.
|
||||
Micron adds bold headers and colored status cells:
|
||||
|
||||
```
|
||||
ASCII:
|
||||
┌──────────────────────┬──────┬─────────┬──────────┐
|
||||
│ Destination │ Hops │ Latency │ Status │
|
||||
├──────────────────────┼──────┼─────────┼──────────┤
|
||||
│ a7f2::relay-east │ 2 │ 34ms │ ● alive │
|
||||
│ c4e1::bridge-south │ 4 │ 112ms │ ● alive │
|
||||
│ 01ab::node-gamma │ 7 │ 580ms │ ○ stale │
|
||||
└──────────────────────┴──────┴─────────┴──────────┘
|
||||
|
||||
Micron:
|
||||
┌──────────────────────┬──────┬─────────┬──────────┐
|
||||
│ `!Destination`! │`!Hops`!│`!Latency`!│`!Status`!│
|
||||
├──────────────────────┼──────┼─────────┼──────────┤
|
||||
│ a7f2::relay-east │ 2 │ 34ms │ `F0f0●`f alive │
|
||||
│ c4e1::bridge-south │ 4 │ 112ms │ `F0f0●`f alive │
|
||||
│ 01ab::node-gamma │ 7 │ 580ms │ `Ff00○`f stale │
|
||||
└──────────────────────┴──────┴─────────┴──────────┘
|
||||
```
|
||||
|
||||
### 3.4 Form Elements
|
||||
|
||||
In ASCII, forms are visual representations. In Micron, the same
|
||||
characters appear but the input areas become live interactive
|
||||
fields.
|
||||
|
||||
```
|
||||
ASCII (visual only):
|
||||
┌─ Search ──────────────────────────────┐
|
||||
│ │
|
||||
│ Query: [ _________________________ ] │
|
||||
│ │
|
||||
│ Scope: (•) Local ( ) Network │
|
||||
│ [ ] Include offline nodes │
|
||||
│ │
|
||||
│ ┌──────────┐ │
|
||||
│ │ Search │ │
|
||||
│ └──────────┘ │
|
||||
└───────────────────────────────────────┘
|
||||
|
||||
Micron (interactive):
|
||||
┌─ `!Search`! ──────────────────────────┐
|
||||
│ │
|
||||
│ Query: `<32|query`Enter search...> │
|
||||
│ │
|
||||
│ Scope: `<^|scope|local|*`Local> `<^|scope|net`Network>
|
||||
│ `<?|offline|yes`Include offline nodes>
|
||||
│ │
|
||||
│ `[`!Search`!`:/action/search]
|
||||
│ │
|
||||
└───────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The border characters are identical. Micron replaces the
|
||||
placeholder bracket notation with live form tags and turns
|
||||
the button into a clickable link.
|
||||
|
||||
---
|
||||
|
||||
## 4. The DSL — `.uf` Files
|
||||
|
||||
### 4.1 Syntax
|
||||
|
||||
- **Indentation** defines nesting (2-space)
|
||||
- **Keywords** lead each line
|
||||
- **Strings** in double quotes
|
||||
- **Pipe `|`** separates inline list items
|
||||
- **`$`** references variables
|
||||
- **`#`** starts comments
|
||||
|
||||
### 4.2 Layout Primitives
|
||||
|
||||
```
|
||||
page "Title" [width]
|
||||
# Root container. Default width: 64.
|
||||
|
||||
box [weight] "Title"
|
||||
# Bordered region. weight: light|heavy|double|rounded
|
||||
# Title is inset in the top border.
|
||||
# In Micron: title gets `!bold`!, border chars are literal.
|
||||
|
||||
row [gap]
|
||||
# Horizontal layout. Children split available width.
|
||||
# gap: chars between children (default: 1)
|
||||
|
||||
col [width]
|
||||
# Explicit column in a row. Width in chars or percentage.
|
||||
|
||||
spacer [lines]
|
||||
# Vertical whitespace. Default: 1
|
||||
|
||||
pad [top] [right] [bottom] [left]
|
||||
# Inner margin for a container.
|
||||
```
|
||||
|
||||
### 4.3 Content Primitives
|
||||
|
||||
```
|
||||
heading [1|2|3] "Text"
|
||||
# Rendered with underline/box in ASCII.
|
||||
# In Micron: > / >> / >>> plus `!bold`!
|
||||
|
||||
text "Content with @bold{inline} @color{0f0}{modifiers}"
|
||||
# @bold{...} → Micron `!...`!
|
||||
# @italic{...} → Micron `*...*`
|
||||
# @under{...} → Micron `_..._`
|
||||
# @color{hex}{...} → Micron `Fhex...`f
|
||||
# @bg{hex}{...} → Micron `Bhex...`b
|
||||
# In ASCII: modifiers stripped (or ANSI with --ansi)
|
||||
|
||||
label "Key" "Value"
|
||||
# Aligned key-value pair.
|
||||
|
||||
list [bullet|number|dash|arrow]
|
||||
item "First"
|
||||
item "Second"
|
||||
|
||||
link "Display text" "/destination.mu"
|
||||
# ASCII: [Display text]
|
||||
# Micron: `[Display text`/destination.mu]
|
||||
|
||||
divider [light|heavy|double|dash|dot]
|
||||
# Full-width horizontal rule using appropriate chars.
|
||||
```
|
||||
|
||||
### 4.4 Data Visualization Primitives
|
||||
|
||||
```
|
||||
gauge "Label" [value] [max] [width]
|
||||
# Horizontal progress bar.
|
||||
# Thresholds: warn=[n] crit=[n]
|
||||
# ASCII: Label ████████████░░░░ 62%
|
||||
# Micron: same, with color shifts at thresholds
|
||||
|
||||
meter "Label" [value] [max]
|
||||
# Compact inline gauge (no border, just bar + %)
|
||||
|
||||
bar_h "Label" [value] [max] [width]
|
||||
# Single horizontal bar in a chart context.
|
||||
|
||||
bar_v [height]
|
||||
# Vertical bar chart container.
|
||||
bar "Label" [value]
|
||||
bar "Label" [value]
|
||||
# Uses ▁▂▃▄▅▆▇█ stacked vertically.
|
||||
|
||||
sparkline "Label" [values] [width]
|
||||
# Braille-dot inline chart.
|
||||
# values: comma-separated or $variable
|
||||
|
||||
heatmap [rows] [cols]
|
||||
# Grid of colored shade blocks.
|
||||
# Uses ░▒▓█ for intensity.
|
||||
# Micron adds per-cell `Fhex` color.
|
||||
|
||||
status "Label" [online|offline|degraded|unknown|alert]
|
||||
# Shape-coded indicator + label.
|
||||
# Micron adds color to the indicator.
|
||||
|
||||
table "Title"
|
||||
columns "Name" [width] | "Name" [width] | ...
|
||||
row "val" | "val" | ...
|
||||
# Box-drawn table with header separator.
|
||||
# Micron: bold headers, colored cells via @modifiers in values.
|
||||
```
|
||||
|
||||
### 4.5 Form Primitives
|
||||
|
||||
```
|
||||
form "name"
|
||||
field "name" [width] "placeholder"
|
||||
password "name" [width] "placeholder"
|
||||
radio "group" "Opt A" | "Opt B" | "Opt C"
|
||||
checkbox "name" "Label"
|
||||
toggle "name" "Label" [on|off]
|
||||
dropdown "name" "Opt A" | "Opt B" | "Opt C"
|
||||
button "Label" ["/action/path"]
|
||||
|
||||
# ASCII: visual placeholders (brackets, radio dots, etc.)
|
||||
# Micron: live interactive fields using native form tags
|
||||
```
|
||||
|
||||
### 4.6 Style Modifiers
|
||||
|
||||
Applied as indented children of any node:
|
||||
|
||||
```
|
||||
align [left|center|right]
|
||||
color [3-digit hex]
|
||||
bg [3-digit hex]
|
||||
border [light|heavy|double|rounded|none]
|
||||
bold
|
||||
italic
|
||||
underline
|
||||
```
|
||||
|
||||
### 4.7 Variables & Components
|
||||
|
||||
```
|
||||
# Variables
|
||||
let name = "Relay Alpha-7"
|
||||
let cpu_data = 42, 67, 55, 78, 91, 63, 48
|
||||
|
||||
# Component definition
|
||||
component stat(label, value, max, trend)
|
||||
box light "$label"
|
||||
gauge "$label" $value $max 20
|
||||
text "@italic{$trend}"
|
||||
|
||||
# Component usage
|
||||
row 2
|
||||
stat "CPU" 62 100 "▲ +5%"
|
||||
stat "MEM" 84 100 "▼ -2%"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Complete Example
|
||||
|
||||
### 5.1 Source
|
||||
|
||||
```
|
||||
let node = "Relay Alpha-7"
|
||||
let uptime = "14d 3h 22m"
|
||||
|
||||
page "$node" 66
|
||||
|
||||
box double "$node"
|
||||
align center
|
||||
text "Reticulum Network Node"
|
||||
text "Online $uptime"
|
||||
|
||||
spacer
|
||||
|
||||
heading 1 "Resources"
|
||||
|
||||
row 2
|
||||
col
|
||||
gauge "CPU" 62 100 28 warn=75 crit=90
|
||||
gauge "GPU" 21 100 28
|
||||
col
|
||||
gauge "MEM" 84 100 28 warn=80 crit=95
|
||||
gauge "SWP" 3 100 28
|
||||
|
||||
spacer
|
||||
|
||||
heading 1 "Network"
|
||||
|
||||
row 2
|
||||
col 40
|
||||
text "Traffic (60s)"
|
||||
sparkline "IN" 1,3,5,8,7,5,3,2,1,3,6,8,7,4 20
|
||||
sparkline "OUT" 2,2,3,5,8,7,5,3,2,1,1,3,5,8 20
|
||||
col
|
||||
label "Peers" "7 / 12"
|
||||
status "East Relay" online
|
||||
status "South Bridge" online
|
||||
status "Node Gamma" degraded
|
||||
|
||||
spacer
|
||||
|
||||
heading 1 "Routing"
|
||||
|
||||
table "Routes"
|
||||
columns "Destination" 22 | "Hops" 6 | "RTT" 8 | "State" 10
|
||||
row "a7f2::relay-east" | "2" | "34ms" | "@color{0f0}{● alive}"
|
||||
row "c4e1::bridge-south" | "4" | "112ms" | "@color{0f0}{● alive}"
|
||||
row "01ab::node-gamma" | "7" | "580ms" | "@color{f00}{○ stale}"
|
||||
row "f390::hub-north" | "1" | "8ms" | "@color{0f0}{● alive}"
|
||||
|
||||
spacer
|
||||
|
||||
heading 1 "Actions"
|
||||
|
||||
box rounded "Quick Command"
|
||||
form "cmd"
|
||||
field "target" 30 "Destination hash..."
|
||||
radio "mode" "Ping" | "Trace" | "Page"
|
||||
checkbox "verbose" "Verbose output"
|
||||
button "Execute" "/action/exec"
|
||||
|
||||
divider heavy
|
||||
|
||||
text "@center{© 2026 $node · Reticulum Network}"
|
||||
```
|
||||
|
||||
### 5.2 ASCII Output
|
||||
|
||||
```
|
||||
╔══ Relay Alpha-7 ═════════════════════════════════════════════════╗
|
||||
║ Reticulum Network Node ║
|
||||
║ Online 14d 3h 22m ║
|
||||
╚══════════════════════════════════════════════════════════════════╝
|
||||
|
||||
── Resources ─────────────────────────────────────────────────────
|
||||
|
||||
CPU ████████████████████░░░░░░░░ 62% MEM █████████████████████████░░ 84% ⚠
|
||||
GPU ██████░░░░░░░░░░░░░░░░░░░░░ 21% SWP █░░░░░░░░░░░░░░░░░░░░░░░░░ 3%
|
||||
|
||||
── Network ───────────────────────────────────────────────────────
|
||||
|
||||
Traffic (60s) Peers: 7 / 12
|
||||
IN ⣀⣤⣶⣿⣷⣶⣤⣀⣀⣤⣶⣿⣷⣤ 4.2 KB/s East Relay ● online
|
||||
OUT ⣀⣀⣠⣤⣶⣿⣷⣶⣤⣀⣀⣠⣤⣶⣿ 2.1 KB/s South Bridge ● online
|
||||
Node Gamma ◐ degraded
|
||||
|
||||
── Routing ───────────────────────────────────────────────────────
|
||||
|
||||
┌────────────────────────┬────────┬──────────┬────────────┐
|
||||
│ Destination │ Hops │ RTT │ State │
|
||||
├────────────────────────┼────────┼──────────┼────────────┤
|
||||
│ a7f2::relay-east │ 2 │ 34ms │ ● alive │
|
||||
│ c4e1::bridge-south │ 4 │ 112ms │ ● alive │
|
||||
│ 01ab::node-gamma │ 7 │ 580ms │ ○ stale │
|
||||
│ f390::hub-north │ 1 │ 8ms │ ● alive │
|
||||
└────────────────────────┴────────┴──────────┴────────────┘
|
||||
|
||||
── Actions ───────────────────────────────────────────────────────
|
||||
|
||||
╭─ Quick Command ──────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ target: [ Destination hash...________________ ] │
|
||||
│ mode: (•) Ping ( ) Trace ( ) Page │
|
||||
│ [ ] Verbose output │
|
||||
│ ┌───────────┐ │
|
||||
│ │ Execute │ │
|
||||
│ └───────────┘ │
|
||||
╰──────────────────────────────────────────────────────────────╯
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
© 2026 Relay Alpha-7 · Reticulum Network
|
||||
```
|
||||
|
||||
### 5.3 Micron Output
|
||||
|
||||
The **same characters** — every box corner, every bar segment, every
|
||||
braille dot — with Micron tags added for color and interactivity:
|
||||
|
||||
```
|
||||
╔══ `!Relay Alpha-7`! ═════════════════════════════════════════════╗
|
||||
║`c Reticulum Network Node`a ║
|
||||
║`c Online 14d 3h 22m`a ║
|
||||
╚══════════════════════════════════════════════════════════════════╝
|
||||
|
||||
>Resources
|
||||
|
||||
`!CPU`! `F0f0████████████████████`f`F555░░░░░░░░`f 62% `!MEM`! `Ff80█████████████████████████`f`F555░░`f `Ff8084%`f ⚠
|
||||
`!GPU`! `F0f0██████`f`F555░░░░░░░░░░░░░░░░░░░░░`f 21% `!SWP`! `F0f0█`f`F555░░░░░░░░░░░░░░░░░░░░░░░░░`f 3%
|
||||
|
||||
>Network
|
||||
|
||||
`!Traffic (60s)`! `!Peers:`! 7 / 12
|
||||
IN `F0cf⣀⣤⣶⣿⣷⣶⣤⣀⣀⣤⣶⣿⣷⣤`f 4.2 KB/s East Relay `F0f0●`f online
|
||||
OUT `F0cf⣀⣀⣠⣤⣶⣿⣷⣶⣤⣀⣀⣠⣤⣶⣿`f 2.1 KB/s South Bridge `F0f0●`f online
|
||||
Node Gamma `Fff0◐`f degraded
|
||||
|
||||
>Routing
|
||||
|
||||
┌────────────────────────┬────────┬──────────┬────────────┐
|
||||
│ `!Destination`! │ `!Hops`! │ `!RTT`! │ `!State`! │
|
||||
├────────────────────────┼────────┼──────────┼────────────┤
|
||||
│ a7f2::relay-east │ 2 │ 34ms │ `F0f0●`f alive │
|
||||
│ c4e1::bridge-south │ 4 │ 112ms │ `F0f0●`f alive │
|
||||
│ 01ab::node-gamma │ 7 │ 580ms │ `Ff00○`f stale │
|
||||
│ f390::hub-north │ 1 │ 8ms │ `F0f0●`f alive │
|
||||
└────────────────────────┴────────┴──────────┴────────────┘
|
||||
|
||||
>Actions
|
||||
|
||||
╭─ `!Quick Command`! ──────────────────────────────────────────╮
|
||||
│ │
|
||||
│ target: `<30|target`Destination hash...> │
|
||||
│ mode: `<^|mode|ping|*`Ping> `<^|mode|trace`Trace> `<^|mode|page`Page>
|
||||
│ `<?|verbose|yes`Verbose output> │
|
||||
│ `[`!Execute`!`:/action/exec]
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────╯
|
||||
|
||||
-━
|
||||
|
||||
`c© 2026 Relay Alpha-7 · Reticulum Network`a
|
||||
```
|
||||
|
||||
Note how the table borders, box corners, and gauge characters
|
||||
are **byte-for-byte identical** in both outputs. Micron simply
|
||||
interleaves its backtick tags around the characters that need
|
||||
color or emphasis.
|
||||
|
||||
---
|
||||
|
||||
## 6. Intermediate Representation
|
||||
|
||||
### IR Node
|
||||
|
||||
```
|
||||
IRNode:
|
||||
type : NodeType
|
||||
label : string | null
|
||||
children : IRNode[]
|
||||
styles : {
|
||||
fg : string | null # 3-digit hex
|
||||
bg : string | null
|
||||
bold : bool
|
||||
italic : bool
|
||||
underline: bool
|
||||
align : left | center | right
|
||||
border : light | heavy | double | rounded | none
|
||||
}
|
||||
layout : {
|
||||
width : int | pct | null
|
||||
height : int | null
|
||||
gap : int
|
||||
pad : [top, right, bottom, left]
|
||||
}
|
||||
data : { # type-specific
|
||||
value : number | null
|
||||
max : number | null
|
||||
warn : number | null
|
||||
crit : number | null
|
||||
values : number[] | null # sparkline, bar_v
|
||||
state : enum | null # status indicator
|
||||
options : string[] | null # radio, dropdown
|
||||
columns : Column[] | null # table
|
||||
rows : Row[] | null # table
|
||||
link : string | null # destination
|
||||
field : FieldMeta | null # form metadata
|
||||
}
|
||||
inline : InlineSpan[] # parsed @modifiers
|
||||
```
|
||||
|
||||
### The CharGrid
|
||||
|
||||
```
|
||||
CharGrid:
|
||||
width : int
|
||||
height : int
|
||||
cells : Cell[height][width]
|
||||
|
||||
Cell:
|
||||
char : char # visible character
|
||||
style : CellStyle # for Micron emission
|
||||
field : FieldMeta? # if this cell is part of a form field
|
||||
link : string? # if this cell is clickable
|
||||
|
||||
CellStyle:
|
||||
fg : string? # 3-digit hex
|
||||
bg : string?
|
||||
bold : bool
|
||||
italic : bool
|
||||
underline : bool
|
||||
```
|
||||
|
||||
The layout engine fills the CharGrid. Both emitters read it.
|
||||
The ASCII emitter ignores the style layer. The Micron emitter
|
||||
scans for style transitions and inserts tags.
|
||||
|
||||
---
|
||||
|
||||
## 7. Rendering Pipeline
|
||||
|
||||
```
|
||||
Phase 1: Parse
|
||||
.uf source → token stream → IR tree
|
||||
Variables resolved, components expanded.
|
||||
|
||||
Phase 2: Measure
|
||||
Bottom-up pass: compute min/preferred width and height
|
||||
for each node. Leaf nodes (text, gauge, field) report
|
||||
their intrinsic sizes. Containers aggregate children.
|
||||
|
||||
Phase 3: Layout
|
||||
Top-down pass: assign (x, y, w, h) to every node.
|
||||
Row nodes divide width among columns.
|
||||
Box nodes reserve border characters (1 char each side).
|
||||
|
||||
Phase 4: Paint
|
||||
Depth-first traversal. Each node writes characters into
|
||||
the CharGrid at its assigned position:
|
||||
- Box: draw border chars, set title style
|
||||
- Gauge: compute bar length, write █ and ░, set fg color
|
||||
based on thresholds
|
||||
- Sparkline: convert values to braille patterns
|
||||
- Table: draw grid, write cell content, set header bold
|
||||
- Form: write visual placeholders, attach FieldMeta
|
||||
- Status: write indicator char, set color by state
|
||||
|
||||
Phase 5: Merge Borders
|
||||
Post-pass: scan for adjacent border characters and replace
|
||||
with correct junction characters (┬ ┴ ├ ┤ ┼ etc.).
|
||||
Weight priority: double > heavy > light > rounded.
|
||||
|
||||
Phase 6: Emit
|
||||
ASCII: read cell.char for every cell, join into lines.
|
||||
Micron: scan each line, diff style between adjacent cells,
|
||||
open/close Micron tags at transitions.
|
||||
```
|
||||
|
||||
### Border Merging Detail
|
||||
|
||||
```
|
||||
Before: After:
|
||||
┌────┐┌────┐ ┌────┬────┐
|
||||
│ ││ │ ──▶ │ │ │
|
||||
└────┘└────┘ └────┴────┘
|
||||
|
||||
┌────────┐ ┌────────┐
|
||||
│┌──────┐│ ├──────┐ │ (nested box shares
|
||||
││ ││ ──▶ │ │ │ parent left edge)
|
||||
│└──────┘│ ├──────┘ │
|
||||
└────────┘ └────────┘
|
||||
```
|
||||
|
||||
The merging pass checks each cell against its 4 neighbors
|
||||
and selects from a lookup table of ~40 junction characters.
|
||||
|
||||
---
|
||||
|
||||
## 8. Character Reference
|
||||
|
||||
### Boxes
|
||||
|
||||
```
|
||||
Light: ┌ ─ ┐ │ └ ┘ ├ ┤ ┬ ┴ ┼
|
||||
Heavy: ┏ ━ ┓ ┃ ┗ ┛ ┣ ┫ ┳ ┻ ╋
|
||||
Double: ╔ ═ ╗ ║ ╚ ╝ ╠ ╣ ╦ ╩ ╬
|
||||
Rounded: ╭ ╮ ╰ ╯
|
||||
Mixed: ╒ ╓ ╕ ╖ ╘ ╙ ╛ ╜ (light+double junctions)
|
||||
```
|
||||
|
||||
### Blocks
|
||||
|
||||
```
|
||||
Horizontal fill: █ ▉ ▊ ▋ ▌ ▍ ▎ ▏ (full → 1/8)
|
||||
Vertical fill: ▁ ▂ ▃ ▄ ▅ ▆ ▇ █ (1/8 → full)
|
||||
Shade: ░ ▒ ▓ █ (25% → 100%)
|
||||
Quadrants: ▖ ▗ ▘ ▝ ▞ ▟ ▙ ▛ ▜ ▚
|
||||
```
|
||||
|
||||
### Braille (sparklines, dot plots)
|
||||
|
||||
```
|
||||
Range: U+2800–U+28FF (256 patterns)
|
||||
Each char = 2×4 dot matrix (2 cols × 4 rows)
|
||||
Smooth curves: ⠀⣀⣠⣤⣴⣶⣾⣿⣷⣶⣤⣀⠀
|
||||
```
|
||||
|
||||
### Indicators
|
||||
|
||||
```
|
||||
Status: ● ○ ◐ ◑ ◒ ◓ ◌ ◉
|
||||
Arrows: ▲ ▼ ◀ ▶ ← → ↑ ↓ ↗ ↘
|
||||
Marks: ✓ ✗ ◆ ◇ ★ ☆ ⚠ ⚡
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. CLI
|
||||
|
||||
```bash
|
||||
uframe render <file.uf> # emit ASCII (stdout) + .mu (file)
|
||||
--ascii # ASCII only
|
||||
--micron # .mu only
|
||||
--ansi # ANSI colors in ASCII output
|
||||
--width <int> # override page width
|
||||
--out <dir> # directory for .mu output
|
||||
|
||||
uframe preview <file.uf> # live terminal preview
|
||||
--watch # re-render on file change
|
||||
|
||||
uframe check <file.uf> # validate / lint
|
||||
|
||||
uframe deploy <file.uf> [dest] # render + copy to NomadNet pages dir
|
||||
# default dest: ~/.nomadnetwork/storage/pages/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Standard Component Library
|
||||
|
||||
```
|
||||
use std/dashboard
|
||||
use std/filebrowser
|
||||
use std/board
|
||||
|
||||
# Pre-built patterns:
|
||||
dashboard.banner node_name uptime
|
||||
dashboard.resources cpu mem gpu swap
|
||||
dashboard.peers peer_list
|
||||
dashboard.traffic in_data out_data
|
||||
|
||||
filebrowser.tree root_path
|
||||
filebrowser.listing dir_path
|
||||
|
||||
board.recent count
|
||||
board.compose action_path
|
||||
|
||||
nav.tabs items active
|
||||
nav.breadcrumb path
|
||||
|
||||
chart.timeseries label values width
|
||||
chart.compare items
|
||||
chart.histogram bins
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Future Directions
|
||||
|
||||
- **Responsive reflow** — breakpoints that stack `row` children
|
||||
vertically at narrow widths
|
||||
- **Themes** — `.uf-theme` files defining color palettes and
|
||||
border preferences shared across pages
|
||||
- **Animation** — frame-by-frame for live dashboards using
|
||||
terminal cursor repositioning
|
||||
- **Bidirectional** — parse existing `.mu` into `.uf` for editing
|
||||
- **HTML export** — for web-based Reticulum browsers (rBrowser)
|
||||
- **Shebang mode** — `#!/usr/bin/env uframe --micron` for
|
||||
executable dynamic NomadNet pages
|
||||
- **LSP** — editor support: highlighting, completion, live preview
|
||||
145
docs/multi-node-plan.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# Multi-Node Support for Micronomicon
|
||||
|
||||
## Context
|
||||
|
||||
Currently Micronomicon runs a single NomadNet node — one identity, one set of pages, one `index.mu`. The user wants to host multiple independent "sites" on the Reticulum network, each with its own node identity, name, and pages. This requires changes across Docker infrastructure, backend API, and frontend UI.
|
||||
|
||||
## Approach: Dynamic Node Containers via Docker API
|
||||
|
||||
The FastAPI backend already has Docker socket access. Instead of statically defining NomadNet services in `compose.yml`, the backend will create/manage NomadNet containers dynamically — one per node. Each node gets its own directory with pages, sources, config, and identity.
|
||||
|
||||
## Storage Layout
|
||||
|
||||
```
|
||||
/data/nodes/
|
||||
default/
|
||||
node.json # {"id", "display_name", "port", "created_at"}
|
||||
nomadnet.conf # generated — unique node_name
|
||||
pages/ # compiled .mu files (mounted into container)
|
||||
sources/ # .uf source files
|
||||
my-relay/
|
||||
node.json
|
||||
nomadnet.conf
|
||||
pages/
|
||||
sources/
|
||||
```
|
||||
|
||||
## Files to Create
|
||||
|
||||
### `backend/nodes.py` — Node lifecycle management
|
||||
- `NodeConfig` pydantic model: `id, display_name, port, created_at`
|
||||
- `NODES_DIR = Path(os.environ.get("NODES_DIR", "/data/nodes"))`
|
||||
- CRUD: `list_nodes()`, `get_node(id)`, `create_node(id, display_name)`, `delete_node(id)`
|
||||
- `_generate_nomadnet_conf(display_name)` — template from existing `nomadnet.conf` with node-specific `node_name`
|
||||
- `_next_port()` — allocate TCP ports starting from 4242
|
||||
- Docker container management via Docker SDK (already a dependency):
|
||||
- `start_node_container(node)` — create container from `ghcr.io/markqvist/nomadnet:latest`, mount node's `pages/` dir, per-node identity volume, shared `reticulum.conf`, generated `nomadnet.conf`
|
||||
- `stop_node_container(id)`, `restart_node_container(id)`, `get_node_status(id)`
|
||||
- `ensure_default_node()` — migration: if `NODES_DIR/default` doesn't exist, create it and move contents from old `PAGES_DIR`/`SOURCES_DIR`
|
||||
- Startup reconciliation: check existing containers, start any that should be running
|
||||
- API router:
|
||||
- `GET /api/nodes` — list all nodes with status
|
||||
- `POST /api/nodes` — create `{id, display_name}`
|
||||
- `GET /api/nodes/{node_id}` — node details
|
||||
- `DELETE /api/nodes/{node_id}` — stop container, remove dir
|
||||
- `POST /api/nodes/{node_id}/restart`
|
||||
|
||||
### `frontend/src/stores/nodesStore.ts` — Node state
|
||||
- `nodes: NodeMeta[]`, `activeNodeId: string` (persisted to localStorage, defaults to `"default"`)
|
||||
- `fetchNodes()`, `setActiveNode(id)`, `createNode(id, displayName)`, `deleteNode(id)`, `restartNode(id)`
|
||||
|
||||
### `frontend/src/components/shared/NodeSelector.tsx` — Header dropdown
|
||||
- Dropdown listing nodes with status indicator (green/red dot)
|
||||
- Switching active node refetches pages
|
||||
- "Manage Nodes" link to `/nodes`
|
||||
|
||||
### `frontend/src/routes/NodesView.tsx` — Node management page
|
||||
- Table: Name, Status, Port, Created, Actions (restart/delete)
|
||||
- "Create Node" button with dialog (ID + display name)
|
||||
|
||||
## Files to Modify
|
||||
|
||||
### `backend/pages.py`
|
||||
- Replace global `PAGES_DIR`/`SOURCES_DIR` with helpers: `_node_pages_dir(node_id)` -> `NODES_DIR/node_id/pages`, `_node_sources_dir(node_id)` -> `NODES_DIR/node_id/sources`
|
||||
- Refactor all functions to accept `node_id` parameter
|
||||
- Add node-scoped endpoints: `GET/POST/DELETE /api/nodes/{node_id}/pages/{name}`
|
||||
- Keep existing `/api/pages/{name}` as aliases -> `node_id="default"`
|
||||
- `ensure_default_pages(node_id)` — create `index.mu` per node
|
||||
|
||||
### `backend/graph.py`
|
||||
- Same refactor: accept `node_id`, add `/api/nodes/{node_id}/graph`
|
||||
- Keep `/api/graph` as alias for default
|
||||
|
||||
### `backend/docker_utils.py`
|
||||
- Deprecate in favor of `nodes.py` restart endpoint
|
||||
- Keep `/api/restart` as alias -> restart default node
|
||||
|
||||
### `backend/main.py`
|
||||
- Import and include `nodes` router
|
||||
- Startup: call `ensure_default_node()`, then start all node containers
|
||||
- Remove direct `ensure_default_pages()` call (handled by node creation)
|
||||
|
||||
### `compose.yml`
|
||||
- Remove static `nomadnet` service (backend manages containers dynamically)
|
||||
- Replace `pages`/`sources`/`nomadnet-config` volumes with single `nodes` volume
|
||||
- Add `NODES_DIR=/data/nodes`, `NOMADNET_IMAGE`, `RETICULUM_CONF` env vars
|
||||
- Add `reticulum` bridge network for container communication
|
||||
- Keep `reticulum.conf` bind-mount for backend to pass to spawned containers
|
||||
|
||||
### `frontend/src/stores/pagesStore.ts`
|
||||
- Read `activeNodeId` from `nodesStore`
|
||||
- All fetch calls go to `/api/nodes/{activeNodeId}/pages/...`
|
||||
|
||||
### `frontend/src/routes/DashboardView.tsx`
|
||||
- Show active node name in header
|
||||
- Restart button restarts active node
|
||||
- Page list scoped to active node via store
|
||||
|
||||
### `frontend/src/routes/EditorView.tsx`
|
||||
- Save/publish calls use `/api/nodes/{activeNodeId}/pages/{slug}`
|
||||
- Load page from `/api/nodes/{activeNodeId}/pages/{name}`
|
||||
- Show active node name in toolbar
|
||||
|
||||
### `frontend/src/routes/GraphView.tsx`
|
||||
- Fetch from `/api/nodes/{activeNodeId}/graph`
|
||||
|
||||
### `frontend/src/App.tsx`
|
||||
- Add `/nodes` route -> `NodesView`
|
||||
|
||||
### `frontend/src/components/shared/AppShell.tsx` (or equivalent layout)
|
||||
- Add `NodeSelector` to header/nav area
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
- Existing `/api/pages/...`, `/api/restart`, `/api/graph` remain as aliases for `node_id="default"`
|
||||
- `ensure_default_node()` migrates old flat `PAGES_DIR`/`SOURCES_DIR` into `NODES_DIR/default/`
|
||||
- Frontend defaults `activeNodeId` to `"default"` — single-node users see no change
|
||||
- `NodeSelector` only shows when >1 node exists (or shows as subtle indicator for single node)
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. `backend/nodes.py` — core model, storage, Docker container management, API
|
||||
2. `backend/pages.py` — refactor to accept `node_id`, add node-scoped endpoints
|
||||
3. `backend/graph.py` — refactor to accept `node_id`
|
||||
4. `backend/main.py` — wire in nodes router, startup logic
|
||||
5. `compose.yml` — restructure (remove static nomadnet, add nodes volume + network)
|
||||
6. `frontend/src/stores/nodesStore.ts` — new store
|
||||
7. `frontend/src/stores/pagesStore.ts` — parameterize by active node
|
||||
8. `frontend/src/components/shared/NodeSelector.tsx` — node switcher
|
||||
9. `frontend/src/routes/NodesView.tsx` — management UI
|
||||
10. `frontend/src/App.tsx` + layout — add routes, add selector to shell
|
||||
11. `frontend/src/routes/DashboardView.tsx` — node-aware
|
||||
12. `frontend/src/routes/EditorView.tsx` — node-aware save/publish/load
|
||||
13. `frontend/src/routes/GraphView.tsx` — node-aware
|
||||
|
||||
## Verification
|
||||
|
||||
1. `docker compose up --build -d` — starts backend only (no static nomadnet)
|
||||
2. Hit `GET /api/nodes` — should return `[{id: "default", display_name: "Micronomicon", status: "running", ...}]`
|
||||
3. Open web IDE — pages dashboard shows default node's pages, NodeSelector shows "Micronomicon"
|
||||
4. Create a page, publish — appears in default node's container at `/root/.nomadnetwork/storage/pages/`
|
||||
5. `POST /api/nodes` with `{id: "relay", display_name: "Relay Alpha"}` — new container starts, `GET /api/nodes` shows 2 nodes
|
||||
6. Switch to "Relay Alpha" in NodeSelector — empty page list, create and publish `index.mu`
|
||||
7. From remote NomadNet client, both nodes visible on network with separate identities and pages
|
||||
8. Delete "Relay Alpha" — container stops, files removed, back to single node
|
||||
9. Run backend tests: `python -m pytest uframe/tests/ -v` — all 91 pass (DSL unchanged)
|
||||
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
46
frontend/README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Micronomicon — Frontend
|
||||
|
||||
React 19 + Vite + TypeScript + shadcn/ui. See the [root README](../README.md) for full project docs.
|
||||
|
||||
## Dev
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # http://localhost:5173 — proxies /api to localhost:8080
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run build # output → dist/ (copied into Docker image)
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/
|
||||
App.tsx ← routes
|
||||
routes/
|
||||
DashboardView.tsx ← page table
|
||||
EditorView.tsx ← split-pane editor
|
||||
GraphView.tsx ← React Flow graph
|
||||
components/
|
||||
dashboard/StatusBadge.tsx
|
||||
editor/
|
||||
EditorPane.tsx ← CodeMirror 6
|
||||
PreviewPane.tsx ← micron output (ScrollArea)
|
||||
ToolBar.tsx
|
||||
shared/
|
||||
AppShell.tsx ← TooltipProvider + Toaster wrapper
|
||||
NavBar.tsx
|
||||
ui/ ← shadcn components (auto-generated)
|
||||
lib/
|
||||
utils.ts ← cn() utility
|
||||
stores/
|
||||
editorStore.ts ← Zustand: markdown, micron, dirty state
|
||||
pagesStore.ts ← Zustand: pages list, fetch/delete
|
||||
hooks/
|
||||
useConversion.ts ← debounced POST /api/convert
|
||||
useGraph.ts ← GET /api/graph
|
||||
useUnsavedGuard.ts ← beforeunload + dirty guard
|
||||
```
|
||||
25
frontend/components.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
23
frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Micronomicon</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
7697
frontend/package-lock.json
generated
Normal file
59
frontend/package.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "micronomicon",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@codemirror/autocomplete": "^6.20.1",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.40.0",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dompurify": "^3.3.3",
|
||||
"lucide-react": "^1.7.0",
|
||||
"micron-parser": "^1.0.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-force-graph-3d": "^1.29.1",
|
||||
"react-resizable-panels": "^4.8.0",
|
||||
"react-router-dom": "^7.13.2",
|
||||
"shadcn": "^4.1.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"three-spritetext": "^1.10.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"vite": "^8.0.1"
|
||||
}
|
||||
}
|
||||
1
frontend/public/favicon.svg
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
24
frontend/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
17
frontend/src/App.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import AppShell from "./components/shared/AppShell";
|
||||
import ComposeView from "./routes/ComposeView";
|
||||
import BrowseView from "./routes/BrowseView";
|
||||
import SettingsView from "./routes/SettingsView";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AppShell>
|
||||
<Routes>
|
||||
<Route path="/" element={<ComposeView />} />
|
||||
<Route path="/browse" element={<BrowseView />} />
|
||||
<Route path="/settings" element={<SettingsView />} />
|
||||
</Routes>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
274
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Centralized API client for all backend communication.
|
||||
*
|
||||
* Every fetch call in the app should go through here so that
|
||||
* endpoint URLs live in one place and are easy to update
|
||||
* (e.g. when adding multi-node support with /api/nodes/{id}/...).
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function json<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PageMeta {
|
||||
name: string;
|
||||
title: string | null;
|
||||
published: boolean;
|
||||
has_source: boolean;
|
||||
last_modified: number | null;
|
||||
size: number | null;
|
||||
}
|
||||
|
||||
export interface PageDetail {
|
||||
name: string;
|
||||
source: string | null;
|
||||
}
|
||||
|
||||
export async function fetchPages(): Promise<PageMeta[]> {
|
||||
const res = await fetch("/api/pages");
|
||||
return json(res);
|
||||
}
|
||||
|
||||
export async function fetchPage(name: string): Promise<PageDetail> {
|
||||
const res = await fetch(`/api/pages/${name}`);
|
||||
return json(res);
|
||||
}
|
||||
|
||||
export async function savePage(
|
||||
name: string,
|
||||
source: string,
|
||||
publish: boolean,
|
||||
): Promise<PageMeta> {
|
||||
const res = await fetch(`/api/pages/${name}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ source, publish }),
|
||||
});
|
||||
return json(res);
|
||||
}
|
||||
|
||||
export async function deletePage(name: string): Promise<void> {
|
||||
const res = await fetch(`/api/pages/${name}`, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CompileResult {
|
||||
ascii: string;
|
||||
micron: string;
|
||||
script: string;
|
||||
is_dynamic: boolean;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export async function compile(
|
||||
source: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CompileResult> {
|
||||
const res = await fetch("/api/compile", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ source }),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: "Compile failed" }));
|
||||
throw new Error(err.detail || "Compile failed");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DSL Metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DslCommand {
|
||||
label: string;
|
||||
detail: string;
|
||||
section: string;
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
export interface DslMeta {
|
||||
keywords: string[];
|
||||
values: string[];
|
||||
commands: DslCommand[];
|
||||
themes: string[];
|
||||
}
|
||||
|
||||
export async function fetchDslMeta(): Promise<DslMeta> {
|
||||
const res = await fetch("/api/dsl-meta");
|
||||
return json(res);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Node Management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function restartNode(): Promise<void> {
|
||||
const res = await fetch("/api/restart", { method: "POST" });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Browse — network node discovery + remote pages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface NetworkNode {
|
||||
hash: string;
|
||||
name: string;
|
||||
last_seen: number;
|
||||
is_self: boolean;
|
||||
type?: "node" | "interface";
|
||||
interface?: string | null;
|
||||
online?: boolean;
|
||||
target?: string | null;
|
||||
txb?: number;
|
||||
rxb?: number;
|
||||
bitrate?: number;
|
||||
clients?: number | null;
|
||||
}
|
||||
|
||||
export async function fetchBrowseNodes(): Promise<NetworkNode[]> {
|
||||
const res = await fetch("/api/browse/nodes");
|
||||
const data = await json<NetworkNode[] | unknown>(res);
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
export function subscribeBrowseNodes(
|
||||
onNode: (node: NetworkNode) => void,
|
||||
): () => void {
|
||||
const es = new EventSource("/api/browse/nodes/stream");
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
onNode(JSON.parse(e.data));
|
||||
} catch { /* ignore parse errors */ }
|
||||
};
|
||||
return () => es.close();
|
||||
}
|
||||
|
||||
export async function fetchRemotePage(
|
||||
hash: string,
|
||||
path: string = "index.mu",
|
||||
): Promise<{ content: string | null; error?: string }> {
|
||||
const res = await fetch(
|
||||
`/api/browse/page/${hash}?path=${encodeURIComponent(path)}`,
|
||||
);
|
||||
return json(res);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File Browser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "file" | "folder" | "env";
|
||||
size: number | null;
|
||||
last_modified: number | null;
|
||||
title: string | null;
|
||||
published: boolean;
|
||||
}
|
||||
|
||||
export async function fetchFiles(path: string = ""): Promise<FileEntry[]> {
|
||||
const params = path ? `?path=${encodeURIComponent(path)}` : "";
|
||||
const res = await fetch(`/api/files${params}`);
|
||||
return json(res);
|
||||
}
|
||||
|
||||
export async function createFolder(path: string): Promise<void> {
|
||||
const res = await fetch("/api/files/mkdir", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
}
|
||||
|
||||
export async function moveFile(from: string, to: string): Promise<void> {
|
||||
const res = await fetch("/api/files/move", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ from, to }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
}
|
||||
|
||||
export async function fetchEnv(): Promise<string> {
|
||||
const res = await fetch("/api/files/env");
|
||||
const data = await json<{ content: string }>(res);
|
||||
return data.content;
|
||||
}
|
||||
|
||||
export async function saveEnv(content: string): Promise<void> {
|
||||
const res = await fetch("/api/files/env", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config (Reticulum + NomadNet)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface NodeIdentity {
|
||||
name: string;
|
||||
hash: string | null;
|
||||
}
|
||||
|
||||
export async function fetchIdentity(): Promise<NodeIdentity> {
|
||||
const res = await fetch("/api/browse/identity");
|
||||
return json(res);
|
||||
}
|
||||
|
||||
export async function fetchConfig(kind: "reticulum" | "reticulum-client" | "nomadnet"): Promise<string> {
|
||||
const res = await fetch(`/api/browse/config/${kind}`);
|
||||
const data = await json<{ content: string }>(res);
|
||||
return data.content;
|
||||
}
|
||||
|
||||
export async function saveConfig(kind: "reticulum" | "reticulum-client" | "nomadnet", content: string): Promise<void> {
|
||||
const res = await fetch(`/api/browse/config/${kind}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
}
|
||||
|
||||
export async function restartServices(): Promise<{ nomadnet_restarted: boolean }> {
|
||||
const res = await fetch("/api/browse/restart", { method: "POST" });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Images
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UploadResult {
|
||||
filename: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export async function uploadImage(file: File): Promise<UploadResult> {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const res = await fetch("/api/upload-image", { method: "POST", body: form });
|
||||
return json(res);
|
||||
}
|
||||
1
frontend/src/assets/axis-mundi.min.svg
Normal file
|
After Width: | Height: | Size: 293 KiB |
1
frontend/src/assets/browser.min.svg
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
38
frontend/src/assets/frame.themed.svg
Normal file
|
After Width: | Height: | Size: 154 KiB |
1
frontend/src/assets/loading.min.svg
Normal file
|
After Width: | Height: | Size: 980 KiB |
1
frontend/src/assets/menu.min.svg
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
1
frontend/src/assets/pointer.min.svg
Normal file
|
After Width: | Height: | Size: 552 KiB |
77
frontend/src/components/browse/BrowseNodeWindow.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import FloatingWindow from "@/components/shared/FloatingWindow";
|
||||
import Loader from "@/components/shared/Loader";
|
||||
import type { ManagedWindow } from "@/hooks/useWindowManager";
|
||||
import type { BrowseWinData } from "./types";
|
||||
|
||||
interface BrowseNodeWindowProps {
|
||||
win: ManagedWindow<BrowseWinData>;
|
||||
focused: boolean;
|
||||
onUpdate: (id: string, patch: Partial<ManagedWindow<BrowseWinData>>) => void;
|
||||
onClose: (id: string) => void;
|
||||
onFocus: (id: string) => void;
|
||||
onNavBack: (winId: string, data: BrowseWinData) => void;
|
||||
onNavForward: (winId: string, data: BrowseWinData) => void;
|
||||
onNavReload: (winId: string, data: BrowseWinData) => void;
|
||||
onContentClick: (e: React.MouseEvent, winId: string, data: BrowseWinData) => void;
|
||||
}
|
||||
|
||||
export default function BrowseNodeWindow({
|
||||
win, focused, onUpdate, onClose, onFocus,
|
||||
onNavBack, onNavForward, onNavReload, onContentClick,
|
||||
}: BrowseNodeWindowProps) {
|
||||
const d = win.data;
|
||||
const canBack = d.historyIndex > 0;
|
||||
const canFwd = d.historyIndex < d.history.length - 1;
|
||||
|
||||
return (
|
||||
<FloatingWindow
|
||||
id={win.id}
|
||||
title={d.node.name}
|
||||
x={win.x} y={win.y} w={win.w} h={win.h}
|
||||
zIndex={win.zIndex}
|
||||
focused={focused}
|
||||
onUpdate={onUpdate}
|
||||
onClose={onClose}
|
||||
onFocus={onFocus}
|
||||
addressBar={
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 border-b border-border shrink-0 bg-muted/15">
|
||||
<button onClick={() => onNavBack(win.id, d)} disabled={!canBack || d.pageLoading}
|
||||
className="w-5 h-5 flex items-center justify-center rounded text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-default transition-colors" title="Back">
|
||||
◀
|
||||
</button>
|
||||
<button onClick={() => onNavForward(win.id, d)} disabled={!canFwd || d.pageLoading}
|
||||
className="w-5 h-5 flex items-center justify-center rounded text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-default transition-colors" title="Forward">
|
||||
▶
|
||||
</button>
|
||||
<button onClick={() => onNavReload(win.id, d)} disabled={d.pageLoading}
|
||||
className="w-5 h-5 flex items-center justify-center rounded text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-default transition-colors" title="Reload">
|
||||
↻
|
||||
</button>
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<div className="flex-1 flex items-center h-5 px-2 bg-background/60 border border-border rounded text-[10px] font-mono text-foreground/80 truncate cursor-text"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) { const sel = window.getSelection(); if (sel) { const range = document.createRange(); range.selectNodeContents(e.currentTarget); sel.removeAllRanges(); sel.addRange(range); } } }}>
|
||||
<span className="text-muted-foreground/60 truncate">{d.node.hash.slice(0, 12)}\u2026/</span>{d.currentPath}
|
||||
</div>
|
||||
<span className="flex items-center gap-1 text-[9px] uppercase tracking-wider shrink-0">
|
||||
{d.pageLoading ? <span className="text-muted-foreground animate-pulse">loading</span>
|
||||
: d.pageError ? <><span className="w-1.5 h-1.5 rounded-full bg-destructive inline-block" /><span className="text-destructive">error</span></>
|
||||
: d.pageHtml ? <><span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" /><span className="text-muted-foreground">ok</span></> : null}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<div className="flex items-center gap-3 px-3 py-1 border-t border-border shrink-0 bg-muted/15 rounded-b-lg">
|
||||
<span className="text-[9px] text-muted-foreground uppercase tracking-wider truncate">{d.node.type ?? "peer"}</span>
|
||||
{d.node.interface && <span className="text-[9px] text-muted-foreground uppercase tracking-wider truncate">via {d.node.interface}</span>}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<div className="p-3 h-full overflow-auto" onClick={(e) => onContentClick(e, win.id, d)}>
|
||||
{d.pageLoading && <div className="flex flex-col items-center justify-center h-full gap-3 text-muted-foreground text-xs"><Loader /> Requesting page...</div>}
|
||||
{d.pageError && <span className="text-destructive text-xs">{d.pageError}</span>}
|
||||
{d.pageHtml && <div className="font-mono text-[11px] leading-tight" dangerouslySetInnerHTML={{ __html: d.pageHtml }} />}
|
||||
</div>
|
||||
</FloatingWindow>
|
||||
);
|
||||
}
|
||||
161
frontend/src/components/browse/BrowseSearchBar.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { DITHERED_SHADOW } from "@/components/shared/FloatingWindow";
|
||||
import type { NetworkNode } from "@/api/client";
|
||||
|
||||
interface BrowseSearchBarProps {
|
||||
filter: string;
|
||||
onFilterChange: (value: string) => void;
|
||||
onClear: () => void;
|
||||
allNodes: NetworkNode[];
|
||||
suggestions: NetworkNode[];
|
||||
onSelectNode: (node: NetworkNode) => void;
|
||||
onHighlightNode: (node: NetworkNode | null) => void;
|
||||
onSearchFocusChange: (focused: boolean) => void;
|
||||
focusedWinId: string | null;
|
||||
windowCount: number;
|
||||
}
|
||||
|
||||
export default function BrowseSearchBar({
|
||||
filter, onFilterChange, onClear,
|
||||
allNodes, suggestions, onSelectNode, onHighlightNode, onSearchFocusChange,
|
||||
focusedWinId, windowCount,
|
||||
}: BrowseSearchBarProps) {
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [selectedSuggestion, setSelectedSuggestion] = useState(-1);
|
||||
const [focused, setFocused] = useState(false);
|
||||
|
||||
// The visible list: when focused with no filter, show all nodes; otherwise filtered suggestions
|
||||
const visibleList = focused && !filter.trim() ? allNodes : suggestions;
|
||||
const maxVisible = 12;
|
||||
const displayList = visibleList.slice(0, maxVisible);
|
||||
const hasMore = visibleList.length > maxVisible;
|
||||
|
||||
// Reset selection when list changes
|
||||
useEffect(() => { setSelectedSuggestion(-1); }, [visibleList.length, filter]);
|
||||
|
||||
// Notify parent of highlight changes for camera fly-to
|
||||
useEffect(() => {
|
||||
const entry = selectedSuggestion >= 0 ? displayList[selectedSuggestion] : null;
|
||||
onHighlightNode(entry ?? null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedSuggestion, onHighlightNode]);
|
||||
|
||||
// Auto-focus search when no windows open
|
||||
useEffect(() => { searchInputRef.current?.focus(); }, []);
|
||||
useEffect(() => {
|
||||
if (windowCount === 0) searchInputRef.current?.focus();
|
||||
}, [windowCount]);
|
||||
|
||||
// Capture typing into search when no window is focused
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (focusedWinId) return;
|
||||
if (document.activeElement === searchInputRef.current) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
if (e.key.length !== 1) return;
|
||||
searchInputRef.current?.focus();
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [focusedWinId]);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
setFocused(true);
|
||||
onSearchFocusChange(true);
|
||||
}, [onSearchFocusChange]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
// Delay to allow click on suggestion to fire before closing
|
||||
setTimeout(() => {
|
||||
setFocused(false);
|
||||
onSearchFocusChange(false);
|
||||
setSelectedSuggestion(-1);
|
||||
}, 150);
|
||||
}, [onSearchFocusChange]);
|
||||
|
||||
// Draggable position
|
||||
const [searchPos, setSearchPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const searchDragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
|
||||
useEffect(() => { setSearchPos({ x: Math.round(window.innerWidth / 2 - 200), y: window.innerHeight - 307 }); }, []);
|
||||
|
||||
const onSearchDragStart = useCallback((e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).tagName === "INPUT") return;
|
||||
e.preventDefault();
|
||||
const pos = searchPos ?? { x: 0, y: 0 };
|
||||
searchDragRef.current = { startX: e.clientX, startY: e.clientY, origX: pos.x, origY: pos.y };
|
||||
const onMove = (ev: MouseEvent) => { if (!searchDragRef.current) return; setSearchPos({ x: searchDragRef.current.origX + (ev.clientX - searchDragRef.current.startX), y: Math.max(0, searchDragRef.current.origY + (ev.clientY - searchDragRef.current.startY)) }); };
|
||||
const onUp = () => { searchDragRef.current = null; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); };
|
||||
document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp);
|
||||
}, [searchPos]);
|
||||
|
||||
if (!searchPos) return null;
|
||||
|
||||
const showDropdown = focused && displayList.length > 0;
|
||||
|
||||
return createPortal(
|
||||
<div onMouseDown={onSearchDragStart}
|
||||
className="fixed z-999 flex flex-col bg-popover border-2 border-border focus-within:border-primary rounded-lg cursor-grab active:cursor-grabbing transition-[border-color] duration-150"
|
||||
style={{ left: searchPos.x, top: searchPos.y, width: 400, boxShadow: DITHERED_SHADOW }}>
|
||||
<div className="flex items-center gap-3 px-3 py-1.5">
|
||||
<input ref={searchInputRef} type="text" value={filter}
|
||||
onChange={(e) => onFilterChange(e.target.value)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") { onClear(); e.currentTarget.blur(); return; }
|
||||
if (e.key === "ArrowDown") { e.preventDefault(); setSelectedSuggestion(i => Math.min(i + 1, displayList.length - 1)); return; }
|
||||
if (e.key === "ArrowUp") { e.preventDefault(); setSelectedSuggestion(i => Math.max(i - 1, -1)); return; }
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const entry = selectedSuggestion >= 0 ? displayList[selectedSuggestion] : displayList[0];
|
||||
if (entry && entry.type !== "interface") { onSelectNode(entry); onClear(); }
|
||||
return;
|
||||
}
|
||||
}}
|
||||
placeholder="Search nodes..."
|
||||
className="flex-1 h-7 px-2 text-xs bg-background/60 border border-border rounded placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary cursor-text" />
|
||||
{filter && (
|
||||
<button onClick={onClear} className="text-muted-foreground hover:text-foreground transition-colors text-xs leading-none px-1" title="Clear search (Esc)">
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
{!filter && focused && (
|
||||
<span className="text-[9px] text-muted-foreground uppercase tracking-wider shrink-0">
|
||||
{allNodes.length} nodes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showDropdown && (
|
||||
<div className="border-t border-border max-h-[360px] overflow-y-auto">
|
||||
{displayList.map((entry, i) => (
|
||||
<button
|
||||
key={entry.hash}
|
||||
onMouseDown={(e) => { e.preventDefault(); if (entry.type !== "interface") { onSelectNode(entry); onClear(); } }}
|
||||
onMouseEnter={() => setSelectedSuggestion(i)}
|
||||
className={`w-full text-left px-3 py-1.5 text-xs font-mono flex items-center gap-2 transition-colors ${i === selectedSuggestion ? "bg-accent text-accent-foreground" : "text-foreground hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full shrink-0" style={{
|
||||
backgroundColor: (() => {
|
||||
const age = Date.now() / 1000 - (entry.last_seen ?? 0);
|
||||
if (age < 300) return "var(--primary)";
|
||||
if (age < 3600) return "var(--muted-foreground)";
|
||||
return "var(--border)";
|
||||
})(),
|
||||
}} />
|
||||
<span className="truncate">{entry.name}</span>
|
||||
<span className="ml-auto text-[9px] text-muted-foreground uppercase shrink-0">
|
||||
{entry.interface ?? "peer"}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{hasMore && (
|
||||
<div className="px-3 py-1 text-[9px] text-muted-foreground text-center border-t border-border/50">
|
||||
{visibleList.length - maxVisible} more — type to narrow
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>, document.body);
|
||||
}
|
||||
106
frontend/src/components/browse/buildGraph.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { NetworkNode } from "@/api/client";
|
||||
import { statusRGBA, rgbaToHex, type StatusColors } from "./graphColors";
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
name: string;
|
||||
entry: NetworkNode;
|
||||
type: "self" | "peer" | "interface";
|
||||
color: string;
|
||||
size: number;
|
||||
cluster?: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
z?: number;
|
||||
}
|
||||
|
||||
export interface GraphLink {
|
||||
source: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
export interface GraphData {
|
||||
nodes: GraphNode[];
|
||||
links: GraphLink[];
|
||||
clusterNames: string[];
|
||||
}
|
||||
|
||||
function findParentIface(
|
||||
entry: NetworkNode,
|
||||
interfaces: NetworkNode[],
|
||||
peerIndex: number,
|
||||
): NetworkNode | undefined {
|
||||
if (entry.interface) {
|
||||
const iface = interfaces.find(i => i.name === entry.interface);
|
||||
if (iface) return iface;
|
||||
}
|
||||
if (interfaces.length > 0) return interfaces[peerIndex % interfaces.length];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function buildGraphData(
|
||||
rawNodes: NetworkNode[],
|
||||
prevPositions: Map<string, { x: number; y: number; z: number }>,
|
||||
theme: StatusColors,
|
||||
): GraphData {
|
||||
const interfaces = rawNodes
|
||||
.filter(e => e.type === "interface")
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const selfNode = rawNodes.find(e => e.is_self && e.type !== "interface");
|
||||
const peers = rawNodes.filter(e => !e.is_self && e.type !== "interface");
|
||||
|
||||
const nodes: GraphNode[] = [];
|
||||
const links: GraphLink[] = [];
|
||||
|
||||
// Add interface nodes
|
||||
for (const iface of interfaces) {
|
||||
const prev = prevPositions.get(iface.hash);
|
||||
nodes.push({
|
||||
id: iface.hash,
|
||||
name: iface.name,
|
||||
entry: iface,
|
||||
type: "interface",
|
||||
color: rgbaToHex(theme.stale),
|
||||
size: 2,
|
||||
...(prev ? { x: prev.x, y: prev.y, z: prev.z } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
// Add self node
|
||||
if (selfNode) {
|
||||
const prev = prevPositions.get(selfNode.hash);
|
||||
nodes.push({
|
||||
id: selfNode.hash,
|
||||
name: selfNode.name,
|
||||
entry: selfNode,
|
||||
type: "self",
|
||||
color: "#ffffff",
|
||||
size: 2,
|
||||
...(prev ? { x: prev.x, y: prev.y, z: prev.z } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
// Add peer nodes + links to parent interface
|
||||
peers.forEach((peer, i) => {
|
||||
const prev = prevPositions.get(peer.hash);
|
||||
const parentIface = findParentIface(peer, interfaces, i);
|
||||
nodes.push({
|
||||
id: peer.hash,
|
||||
name: peer.name,
|
||||
entry: peer,
|
||||
type: "peer",
|
||||
color: rgbaToHex(statusRGBA(peer, theme)),
|
||||
size: 2,
|
||||
cluster: parentIface?.name,
|
||||
...(prev ? { x: prev.x, y: prev.y, z: prev.z } : {}),
|
||||
});
|
||||
|
||||
if (parentIface) {
|
||||
links.push({ source: parentIface.hash, target: peer.hash });
|
||||
}
|
||||
});
|
||||
|
||||
const clusterNames = interfaces.map(i => i.name);
|
||||
|
||||
return { nodes, links, clusterNames };
|
||||
}
|
||||
64
frontend/src/components/browse/graphColors.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { NetworkNode } from "@/api/client";
|
||||
|
||||
export type RGBA = [number, number, number, number];
|
||||
|
||||
export function cssVarToRGBA(varName: string): RGBA {
|
||||
const raw = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|
||||
if (!raw) return [0.5, 0.5, 0.5, 1];
|
||||
const ctx = document.createElement("canvas").getContext("2d")!;
|
||||
ctx.fillStyle = raw;
|
||||
// ctx.fillStyle normalizes to #rrggbb
|
||||
const hex = ctx.fillStyle;
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||
return [r, g, b, 1];
|
||||
}
|
||||
|
||||
export function lerpRGBA(a: RGBA, b: RGBA, t: number): RGBA {
|
||||
return [
|
||||
a[0] + (b[0] - a[0]) * t,
|
||||
a[1] + (b[1] - a[1]) * t,
|
||||
a[2] + (b[2] - a[2]) * t,
|
||||
a[3] + (b[3] - a[3]) * t,
|
||||
];
|
||||
}
|
||||
|
||||
export function brighten(c: RGBA, amount: number): RGBA {
|
||||
return [
|
||||
Math.min(1, c[0] + amount),
|
||||
Math.min(1, c[1] + amount),
|
||||
Math.min(1, c[2] + amount),
|
||||
c[3],
|
||||
];
|
||||
}
|
||||
|
||||
export interface StatusColors {
|
||||
online: RGBA;
|
||||
stale: RGBA;
|
||||
offline: RGBA;
|
||||
}
|
||||
|
||||
export function getThemeStatusColors(): StatusColors {
|
||||
const primary = brighten(cssVarToRGBA("--primary"), 0.15);
|
||||
const muted = cssVarToRGBA("--muted-foreground");
|
||||
return {
|
||||
online: primary,
|
||||
stale: lerpRGBA(primary, muted, 0.4),
|
||||
offline: muted,
|
||||
};
|
||||
}
|
||||
|
||||
export function statusRGBA(entry: NetworkNode, theme: StatusColors): RGBA {
|
||||
const age = Date.now() / 1000 - (entry.last_seen ?? 0);
|
||||
if (age < 300) return theme.online;
|
||||
if (age < 3600) return theme.stale;
|
||||
return theme.offline;
|
||||
}
|
||||
|
||||
export function rgbaToHex(c: RGBA): string {
|
||||
const r = Math.round(c[0] * 255);
|
||||
const g = Math.round(c[1] * 255);
|
||||
const b = Math.round(c[2] * 255);
|
||||
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
|
||||
}
|
||||
17
frontend/src/components/browse/types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { NetworkNode } from "@/api/client";
|
||||
|
||||
export interface HistoryEntry {
|
||||
path: string;
|
||||
html: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface BrowseWinData {
|
||||
node: NetworkNode;
|
||||
pageHtml: string | null;
|
||||
pageLoading: boolean;
|
||||
pageError: string | null;
|
||||
currentPath: string;
|
||||
history: HistoryEntry[];
|
||||
historyIndex: number;
|
||||
}
|
||||
12
frontend/src/components/dashboard/StatusBadge.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface Props {
|
||||
published: boolean;
|
||||
hasSource: boolean;
|
||||
}
|
||||
|
||||
export default function StatusBadge({ published, hasSource }: Props) {
|
||||
if (published && hasSource) return <Badge>Published</Badge>;
|
||||
if (!published && hasSource) return <Badge variant="secondary">Draft</Badge>;
|
||||
return <Badge variant="outline">Orphan</Badge>;
|
||||
}
|
||||
135
frontend/src/components/editor/EditorPane.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, Decoration, ViewPlugin } from "@codemirror/view";
|
||||
import { EditorState, RangeSetBuilder } from "@codemirror/state";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import type { DecorationSet } from "@codemirror/view";
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
|
||||
import { searchKeymap } from "@codemirror/search";
|
||||
import { oneDark } from "./oneDarkTheme";
|
||||
|
||||
function toRoman(n: number): string {
|
||||
const vals = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1];
|
||||
const syms = ["M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"];
|
||||
let result = "";
|
||||
for (let i = 0; i < vals.length; i++) {
|
||||
while (n >= vals[i]) {
|
||||
result += syms[i];
|
||||
n -= vals[i];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const dotDeco = Decoration.mark({ class: "cm-dot-space" });
|
||||
|
||||
function buildSpaceDeco(view: EditorView): DecorationSet {
|
||||
const builder = new RangeSetBuilder<Decoration>();
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
const text = view.state.sliceDoc(from, to);
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text[i] === " ") {
|
||||
builder.add(from + i, from + i + 1, dotDeco);
|
||||
}
|
||||
}
|
||||
}
|
||||
return builder.finish();
|
||||
}
|
||||
|
||||
const subtleWhitespace = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildSpaceDeco(view);
|
||||
}
|
||||
update(update: { docChanged: boolean; viewportChanged: boolean; view: EditorView }) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = buildSpaceDeco(update.view);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ decorations: (v) => v.decorations },
|
||||
);
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
extensions?: Extension[];
|
||||
}
|
||||
|
||||
export default function EditorPane({ value, onChange, extensions = [] }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView>(null);
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: value,
|
||||
extensions: [
|
||||
history(),
|
||||
lineNumbers({ formatNumber: toRoman }),
|
||||
highlightActiveLine(),
|
||||
subtleWhitespace,
|
||||
keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap, ...searchKeymap]),
|
||||
oneDark,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
onChangeRef.current(update.state.doc.toString());
|
||||
}
|
||||
if (update.selectionSet || update.docChanged) {
|
||||
const pos = update.state.selection.main.head;
|
||||
const coords = update.view.coordsAtPos(pos);
|
||||
if (coords) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("cm-cursor-move", { detail: { top: coords.top, left: coords.left } }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}),
|
||||
EditorView.theme({
|
||||
"&": { height: "100%", fontSize: "14px" },
|
||||
".cm-scroller": { overflow: "auto" },
|
||||
".cm-content": { fontFamily: "monospace", padding: "16px" },
|
||||
".cm-cursor, .cm-cursor-primary": {
|
||||
borderLeftColor: "var(--primary)",
|
||||
borderLeftWidth: "0.5em",
|
||||
},
|
||||
".cm-dot-space": {
|
||||
color: "transparent",
|
||||
position: "relative",
|
||||
},
|
||||
".cm-dot-space:before": {
|
||||
content: '"\\22C5"',
|
||||
position: "absolute",
|
||||
left: "0",
|
||||
right: "0",
|
||||
textAlign: "center",
|
||||
color: "var(--muted-foreground)",
|
||||
opacity: "0.5",
|
||||
pointerEvents: "none",
|
||||
},
|
||||
}),
|
||||
...extensions,
|
||||
],
|
||||
});
|
||||
|
||||
const view = new EditorView({ state, parent: containerRef.current });
|
||||
viewRef.current = view;
|
||||
|
||||
return () => view.destroy();
|
||||
}, []); // Only mount once
|
||||
|
||||
// Sync external value changes (e.g. loading a page)
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (view && view.state.doc.toString() !== value) {
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: value },
|
||||
});
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return <div ref={containerRef} style={{ height: "100%" }} />;
|
||||
}
|
||||
158
frontend/src/components/editor/EditorPointer.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import pointerSvg from "@/assets/pointer.min.svg";
|
||||
import { useLazyEyes } from "@/hooks/useLazyEyes";
|
||||
|
||||
/**
|
||||
* Floating pointer that tracks the CodeMirror cursor vertically,
|
||||
* pinned to the left edge of the editor. Positions relative to
|
||||
* containerRef (for floating windows).
|
||||
*/
|
||||
export default function EditorPointer({ containerRef, focused = true }: { containerRef?: React.RefObject<HTMLElement | null>; focused?: boolean }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [editorLeft, setEditorLeft] = useState<number | null>(null);
|
||||
const targetRef = useRef(0);
|
||||
const currentRef = useRef(0);
|
||||
const rafRef = useRef(0);
|
||||
const activeRef = useRef(false);
|
||||
const [clickKey, setClickKey] = useState(0);
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const cmRef = useRef<Element | null>(null);
|
||||
const pointerElRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const eyeAnchorRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const { registerIris, unregisterIris } = useLazyEyes({ anchorRef: eyeAnchorRef });
|
||||
|
||||
const iris1Ref = useRef<HTMLDivElement>(null);
|
||||
const iris2Ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Register/unregister iris elements
|
||||
useEffect(() => {
|
||||
const i1 = iris1Ref.current;
|
||||
const i2 = iris2Ref.current;
|
||||
if (i1) registerIris(i1);
|
||||
if (i2) registerIris(i2);
|
||||
return () => {
|
||||
if (i1) unregisterIris(i1);
|
||||
if (i2) unregisterIris(i2);
|
||||
};
|
||||
}, [visible, registerIris, unregisterIris]);
|
||||
|
||||
useEffect(() => {
|
||||
const ease = 0.09;
|
||||
|
||||
const getContainerRect = () =>
|
||||
containerRef?.current?.getBoundingClientRect() ?? { left: 0, top: 0 };
|
||||
|
||||
const updateLeft = () => {
|
||||
if (!cmRef.current || !containerRef?.current) return;
|
||||
const cmLeft = cmRef.current.getBoundingClientRect().left;
|
||||
const containerLeft = containerRef.current.getBoundingClientRect().left;
|
||||
setEditorLeft(cmLeft - containerLeft);
|
||||
};
|
||||
|
||||
const onEditorClick = () => {
|
||||
clearTimeout(clickTimerRef.current);
|
||||
setClickKey((k) => k + 1);
|
||||
clickTimerRef.current = setTimeout(() => setClickKey(0), 200);
|
||||
};
|
||||
|
||||
const onCursorMove = (e: Event) => {
|
||||
const { top } = (e as CustomEvent).detail;
|
||||
|
||||
if (!cmRef.current) {
|
||||
const scope = containerRef?.current ?? document;
|
||||
const cm = scope.querySelector(".cm-editor");
|
||||
if (cm) {
|
||||
cmRef.current = cm;
|
||||
cm.addEventListener("mousedown", onEditorClick);
|
||||
ro.observe(cm);
|
||||
}
|
||||
}
|
||||
|
||||
const containerTop = getContainerRect().top;
|
||||
targetRef.current = top - containerTop;
|
||||
updateLeft();
|
||||
|
||||
if (!activeRef.current) {
|
||||
currentRef.current = targetRef.current;
|
||||
if (pointerElRef.current) {
|
||||
pointerElRef.current.style.top = `${targetRef.current}px`;
|
||||
}
|
||||
setVisible(true);
|
||||
activeRef.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
const tick = () => {
|
||||
if (activeRef.current) {
|
||||
const diff = targetRef.current - currentRef.current;
|
||||
if (Math.abs(diff) < 0.5) {
|
||||
currentRef.current = targetRef.current;
|
||||
} else {
|
||||
currentRef.current += diff * ease;
|
||||
}
|
||||
|
||||
// Direct DOM update — no setState
|
||||
if (pointerElRef.current) {
|
||||
pointerElRef.current.style.top = `${currentRef.current}px`;
|
||||
}
|
||||
|
||||
const cmLeft = cmRef.current?.getBoundingClientRect().left ?? 0;
|
||||
const containerTop = getContainerRect().top;
|
||||
eyeAnchorRef.current = { x: cmLeft - 100, y: containerTop + currentRef.current };
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
window.addEventListener("cm-cursor-move", onCursorMove);
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
|
||||
const ro = new ResizeObserver(() => updateLeft());
|
||||
const scope = containerRef?.current ?? document;
|
||||
const existingCm = scope.querySelector(".cm-editor");
|
||||
if (existingCm) {
|
||||
cmRef.current = existingCm;
|
||||
ro.observe(existingCm);
|
||||
}
|
||||
window.addEventListener("resize", updateLeft);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("cm-cursor-move", onCursorMove);
|
||||
window.removeEventListener("resize", updateLeft);
|
||||
cmRef.current?.removeEventListener("mousedown", onEditorClick);
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [containerRef]);
|
||||
|
||||
if (!focused || !visible || editorLeft === null) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={pointerElRef}
|
||||
className={`editor-pointer${clickKey ? " editor-pointer-click" : ""}`}
|
||||
key={clickKey}
|
||||
style={{ left: editorLeft }}
|
||||
>
|
||||
<div
|
||||
className="editor-pointer-img"
|
||||
style={{
|
||||
WebkitMaskImage: `url(${pointerSvg})`,
|
||||
maskImage: `url(${pointerSvg})`,
|
||||
}}
|
||||
/>
|
||||
<div className="editor-pointer-eye" style={{ top: 33, left: 134 }}>
|
||||
<div
|
||||
ref={iris1Ref}
|
||||
className="editor-pointer-iris"
|
||||
/>
|
||||
</div>
|
||||
<div className="editor-pointer-eye" style={{ top: 29, left: 144 }}>
|
||||
<div
|
||||
ref={iris2Ref}
|
||||
className="editor-pointer-iris"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
frontend/src/components/editor/EditorStoreContext.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import { useStore, type StoreApi } from "zustand";
|
||||
import type { EditorStore } from "@/stores/editorStore";
|
||||
|
||||
export const EditorStoreContext = createContext<StoreApi<EditorStore> | null>(null);
|
||||
|
||||
export function useEditorCtx<T>(selector: (s: EditorStore) => T): T {
|
||||
const store = useContext(EditorStoreContext);
|
||||
if (!store) throw new Error("useEditorCtx must be used within EditorStoreContext.Provider");
|
||||
return useStore(store, selector);
|
||||
}
|
||||
163
frontend/src/components/editor/EditorWindow.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { autocompletion } from "@codemirror/autocomplete";
|
||||
import type { StoreApi } from "zustand";
|
||||
import { useStore } from "zustand";
|
||||
import * as api from "@/api/client";
|
||||
import { createEditorStore, type EditorStore } from "@/stores/editorStore";
|
||||
import { usePagesStore } from "@/stores/pagesStore";
|
||||
import { useCompile } from "@/hooks/useCompile";
|
||||
import { useUnsavedGuard } from "@/hooks/useUnsavedGuard";
|
||||
import { useKeyboardSave } from "@/hooks/useKeyboardSave";
|
||||
import { uframeHighlight } from "./uframeHighlight";
|
||||
import { uframeCommandSource, uframeValueHintSource, loadCommandsFromApi } from "./uframeCommands";
|
||||
import { keywordHoverTooltip } from "./uframeHover";
|
||||
import { EditorStoreContext } from "./EditorStoreContext";
|
||||
import EditorPointer from "./EditorPointer";
|
||||
import PreviewPane from "./PreviewPane";
|
||||
import SourcePane from "./SourcePane";
|
||||
import ToolBar from "./ToolBar";
|
||||
import FloatingWindow from "@/components/shared/FloatingWindow";
|
||||
import type { ManagedWindow } from "@/hooks/useWindowManager";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "@/components/ui/resizable";
|
||||
|
||||
export interface EditorWinData {
|
||||
pageName: string;
|
||||
isNew: boolean;
|
||||
}
|
||||
|
||||
interface EditorWindowProps {
|
||||
win: ManagedWindow<EditorWinData>;
|
||||
focused: boolean;
|
||||
onUpdate: (id: string, patch: Partial<ManagedWindow<EditorWinData>>) => void;
|
||||
onClose: (id: string) => void;
|
||||
onFocus: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function EditorWindow({ win, focused, onUpdate, onClose, onFocus }: EditorWindowProps) {
|
||||
const storeRef = useRef<StoreApi<EditorStore>>(null);
|
||||
if (!storeRef.current) storeRef.current = createEditorStore();
|
||||
const store = storeRef.current;
|
||||
const windowRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const ufSource = useStore(store, (s) => s.ufSource);
|
||||
const isDirty = useStore(store, (s) => s.isDirty);
|
||||
const setSource = useStore(store, (s) => s.setSource);
|
||||
const setDirty = useStore(store, (s) => s.setDirty);
|
||||
const setCurrentPage = useStore(store, (s) => s.setCurrentPage);
|
||||
|
||||
const { fetchPages } = usePagesStore();
|
||||
const [pageName, setPageName] = useState(win.data.pageName);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
...uframeHighlight(),
|
||||
autocompletion({
|
||||
override: [uframeCommandSource, uframeValueHintSource],
|
||||
icons: false,
|
||||
activateOnTyping: true,
|
||||
}),
|
||||
keywordHoverTooltip,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => { loadCommandsFromApi(); }, []);
|
||||
useCompile(store);
|
||||
useUnsavedGuard(store);
|
||||
|
||||
// Load page on mount
|
||||
useEffect(() => {
|
||||
if (!win.data.isNew && win.data.pageName) {
|
||||
api.fetchPage(win.data.pageName).then((data) => {
|
||||
if (data.source != null) {
|
||||
store.setState({ ufSource: data.source, isDirty: false });
|
||||
}
|
||||
});
|
||||
}
|
||||
return () => store.getState().reset();
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(
|
||||
async (publish: boolean) => {
|
||||
const slug = pageName.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-");
|
||||
if (!slug) { toast.error("Enter a page name."); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
const meta = await api.savePage(slug, ufSource, publish);
|
||||
setCurrentPage(meta);
|
||||
setDirty(false);
|
||||
fetchPages();
|
||||
toast.success(publish ? "Published" : "Draft saved");
|
||||
if (win.data.isNew) {
|
||||
setPageName(slug);
|
||||
onUpdate(win.id, { data: { pageName: slug, isNew: false } });
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(`Save failed: ${e}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[pageName, ufSource, win.data.isNew, win.id],
|
||||
);
|
||||
|
||||
useKeyboardSave(
|
||||
useCallback(() => handleSave(false), [handleSave]),
|
||||
useCallback(() => handleSave(true), [handleSave]),
|
||||
focused,
|
||||
);
|
||||
|
||||
// Confirm close if dirty
|
||||
const handleClose = useCallback((id: string) => {
|
||||
if (isDirty) {
|
||||
if (!window.confirm("You have unsaved changes. Close anyway?")) return;
|
||||
}
|
||||
onClose(id);
|
||||
}, [isDirty, onClose]);
|
||||
|
||||
return (
|
||||
<FloatingWindow
|
||||
id={win.id}
|
||||
title={pageName || "new page"}
|
||||
x={win.x} y={win.y} w={win.w} h={win.h}
|
||||
zIndex={win.zIndex}
|
||||
focused={focused}
|
||||
onUpdate={onUpdate}
|
||||
onClose={handleClose}
|
||||
onFocus={onFocus}
|
||||
minW={480} minH={300}
|
||||
containerRef={windowRef}
|
||||
>
|
||||
<EditorStoreContext.Provider value={store}>
|
||||
<EditorPointer containerRef={windowRef} focused={focused} />
|
||||
<div className="flex flex-col h-full">
|
||||
<ToolBar
|
||||
pageName={pageName}
|
||||
onNameChange={win.data.isNew ? setPageName : undefined}
|
||||
onSaveDraft={() => handleSave(false)}
|
||||
onPublish={() => handleSave(true)}
|
||||
saving={saving}
|
||||
isDirty={isDirty}
|
||||
/>
|
||||
<ResizablePanelGroup orientation="horizontal" className="flex-1 min-h-0">
|
||||
<ResizablePanel defaultSize={50} minSize={20}>
|
||||
<SourcePane ufSource={ufSource} setSource={setSource} extensions={extensions} />
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel defaultSize={50} minSize={20}>
|
||||
<PreviewPane />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</EditorStoreContext.Provider>
|
||||
</FloatingWindow>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
96
frontend/src/components/editor/PreviewPane.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useCallback } from "react";
|
||||
import { useEditorCtx } from "./EditorStoreContext";
|
||||
import { renderMicron } from "./micronRenderer";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Loader from "@/components/shared/Loader";
|
||||
|
||||
type PreviewMode = "micron" | "raw" | "script";
|
||||
|
||||
export default function PreviewPane() {
|
||||
const previewMode = useEditorCtx((s) => s.previewMode);
|
||||
const setPreviewMode = useEditorCtx((s) => s.setPreviewMode);
|
||||
const compiledMicron = useEditorCtx((s) => s.compiledMicron);
|
||||
const compiledScript = useEditorCtx((s) => s.compiledScript);
|
||||
const isDynamic = useEditorCtx((s) => s.isDynamic);
|
||||
const isCompiling = useEditorCtx((s) => s.isCompiling);
|
||||
const compileError = useEditorCtx((s) => s.compileError);
|
||||
|
||||
const handlePreviewClick = useCallback((e: React.MouseEvent) => {
|
||||
const anchor = (e.target as HTMLElement).closest("a");
|
||||
if (anchor) e.preventDefault();
|
||||
}, []);
|
||||
|
||||
const tabs: { value: PreviewMode; label: string; show: boolean }[] = [
|
||||
{ value: "micron", label: "Micron", show: true },
|
||||
{ value: "raw", label: "Raw", show: true },
|
||||
{ value: "script", label: "Script", show: isDynamic },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center px-4 py-2 border-b-2 border-border shrink-0 gap-2">
|
||||
<span className="font-medium text-foreground flex-1">
|
||||
Preview
|
||||
{isDynamic && (
|
||||
<span className="ml-1.5 text-primary/60 text-[10px]" title="Dynamic page">⚡</span>
|
||||
)}
|
||||
{isCompiling && (
|
||||
<Loader size={10} className="ml-1.5 inline-block" />
|
||||
)}
|
||||
{compileError && (
|
||||
<span className="ml-1 text-red-400 text-[10px]" title={compileError}>✗</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
{tabs
|
||||
.filter((t) => t.show)
|
||||
.map((tab, i, arr) => (
|
||||
<span key={tab.value} className="flex items-center">
|
||||
<button
|
||||
onClick={() => setPreviewMode(tab.value)}
|
||||
className={cn(
|
||||
"text-[10px] uppercase tracking-wider transition-colors cursor-pointer px-1.5",
|
||||
previewMode === tab.value
|
||||
? "text-foreground font-bold"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{i < arr.length - 1 && (
|
||||
<span className="text-muted-foreground text-[8px]">·</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<div className="flex-1 bg-background overflow-auto min-h-0" onClick={handlePreviewClick}>
|
||||
{previewMode === "micron" ? (
|
||||
compiledMicron ? (
|
||||
<div
|
||||
className="p-2 font-mono text-[11px] leading-tight"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderMicron(compiledMicron, true),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Micron preview will appear here…
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
) : previewMode === "script" ? (
|
||||
<pre className="p-4 font-mono text-xs whitespace-pre-wrap break-words text-amber-200/80 leading-relaxed">
|
||||
{compiledScript || "No dynamic script generated."}
|
||||
</pre>
|
||||
) : (
|
||||
<pre className="p-4 font-mono text-xs whitespace-pre-wrap break-words text-muted-foreground">
|
||||
{compiledMicron || "Raw Micron output will appear here…"}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
frontend/src/components/editor/SourcePane.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { BookOpen, Upload } from "lucide-react";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import * as api from "@/api/client";
|
||||
import EditorPane from "./EditorPane";
|
||||
import { EXAMPLES } from "./examples";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
interface SourcePaneProps {
|
||||
ufSource: string;
|
||||
setSource: (s: string) => void;
|
||||
extensions: Extension[];
|
||||
}
|
||||
|
||||
export default function SourcePane({ ufSource, setSource, extensions }: SourcePaneProps) {
|
||||
const [examplesOpen, setExamplesOpen] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const data = await api.uploadImage(file);
|
||||
toast.success(`Uploaded ${data.filename}`);
|
||||
setSource(`image "${data.path}" braille 30\n align center`);
|
||||
} catch (err) {
|
||||
toast.error(`Upload failed: ${err}`);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center px-4 py-2 border-b-2 border-border shrink-0 gap-2">
|
||||
<span className="font-medium text-foreground flex-1">Source</span>
|
||||
|
||||
<Popover open={examplesOpen} onOpenChange={setExamplesOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button className="text-[10px] text-muted-foreground hover:text-foreground transition-colors uppercase tracking-wider flex items-center gap-1 cursor-pointer">
|
||||
<BookOpen className="h-3 w-3" />
|
||||
Examples
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent side="bottom" align="end" sideOffset={8}>
|
||||
<PopoverHeader>
|
||||
<PopoverTitle>Insert Example</PopoverTitle>
|
||||
</PopoverHeader>
|
||||
<div className="flex flex-col gap-0.5 max-h-72 overflow-y-auto -mx-1">
|
||||
{EXAMPLES.map((ex) => (
|
||||
<button
|
||||
key={ex.name}
|
||||
onClick={() => { setSource(ex.source); setExamplesOpen(false); }}
|
||||
className="flex flex-col items-start px-2 py-1.5 text-left hover:bg-accent transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-sm font-medium">{ex.name}</span>
|
||||
<span className="text-xs text-muted-foreground leading-tight">{ex.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleUpload} />
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors uppercase tracking-wider flex items-center gap-1 disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<Upload className="h-3 w-3" />
|
||||
{uploading ? "Uploading\u2026" : "Image"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto min-h-0">
|
||||
<EditorPane value={ufSource} onChange={setSource} extensions={extensions} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
frontend/src/components/editor/ToolBar.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface Props {
|
||||
pageName: string;
|
||||
onNameChange?: (name: string) => void;
|
||||
onSaveDraft: () => void;
|
||||
onPublish: () => void;
|
||||
saving: boolean;
|
||||
isDirty: boolean;
|
||||
}
|
||||
|
||||
export default function ToolBar({
|
||||
pageName,
|
||||
onNameChange,
|
||||
onSaveDraft,
|
||||
onPublish,
|
||||
saving,
|
||||
isDirty,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 border-b-2 border-border shrink-0">
|
||||
<PageNameField pageName={pageName} onNameChange={onNameChange} />
|
||||
|
||||
{isDirty && (
|
||||
<span className="text-[10px] text-muted-foreground/50">●</span>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<Button variant="outline" size="sm" onClick={onSaveDraft} disabled={saving}>
|
||||
Save Draft
|
||||
</Button>
|
||||
<Button size="sm" onClick={onPublish} disabled={saving}>
|
||||
Publish
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function PageNameField({
|
||||
pageName,
|
||||
onNameChange,
|
||||
}: {
|
||||
pageName: string;
|
||||
onNameChange?: (name: string) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(!pageName || !!onNameChange);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
if (editing || onNameChange) {
|
||||
return (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={pageName}
|
||||
onChange={(e) => onNameChange?.(e.target.value)}
|
||||
onBlur={() => { if (pageName) setEditing(false); }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && pageName) setEditing(false); }}
|
||||
placeholder="page-name"
|
||||
className="font-mono w-36 h-6 text-xs border-0 bg-transparent px-1"
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="flex items-center gap-1 font-mono text-xs font-semibold hover:text-primary transition-colors cursor-pointer"
|
||||
>
|
||||
{pageName}
|
||||
<Pencil className="w-2.5 h-2.5 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
446
frontend/src/components/editor/examples.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
export interface Example {
|
||||
name: string;
|
||||
description: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export const EXAMPLES: Example[] = [
|
||||
{
|
||||
name: "Hello World",
|
||||
description: "Minimal page with a heading and text",
|
||||
source: `page "Hello" 50
|
||||
|
||||
heading 1 "Welcome"
|
||||
text "This is your first page."
|
||||
|
||||
spacer
|
||||
|
||||
heading 2 "About"
|
||||
text "Built with the uFrame DSL."
|
||||
|
||||
divider light
|
||||
|
||||
link "Home" "/page/index.mu"`,
|
||||
},
|
||||
{
|
||||
name: "Node Dashboard",
|
||||
description: "System gauges, status indicators, and network info",
|
||||
source: `page "Node Status" 60
|
||||
|
||||
box double "Relay Alpha-7"
|
||||
align center
|
||||
text "Reticulum Network Node"
|
||||
text "Online 14d 3h 22m"
|
||||
|
||||
spacer
|
||||
|
||||
heading 1 "Resources"
|
||||
|
||||
row 2
|
||||
col 28
|
||||
gauge "CPU" 62 100 24 warn=75 crit=90
|
||||
col 28
|
||||
gauge "MEM" 84 100 24 warn=80 crit=95
|
||||
|
||||
spacer
|
||||
|
||||
heading 1 "Network"
|
||||
|
||||
row 2
|
||||
col 30
|
||||
label "Peers" "7 / 12"
|
||||
label "Uptime" "14d 3h 22m"
|
||||
col 28
|
||||
status "East Relay" online
|
||||
status "South Bridge" online
|
||||
status "Node Gamma" degraded
|
||||
status "Hub North" offline
|
||||
|
||||
divider heavy
|
||||
|
||||
text "Press Ctrl+R to refresh"`,
|
||||
},
|
||||
{
|
||||
name: "Routing Table",
|
||||
description: "Box-drawn table with colored status indicators",
|
||||
source: `page "Routes" 60
|
||||
|
||||
heading 1 "Routing Table"
|
||||
|
||||
table "Active Routes"
|
||||
columns "Destination" 22 | "Hops" 6 | "RTT" 8 | "State" 12
|
||||
row "a7f2::relay-east" | "2" | "34ms" | "@color{0f0}{● alive}"
|
||||
row "c4e1::bridge-south" | "4" | "112ms" | "@color{0f0}{● alive}"
|
||||
row "01ab::node-gamma" | "7" | "580ms" | "@color{f00}{○ stale}"
|
||||
row "f390::hub-north" | "1" | "8ms" | "@color{0f0}{● alive}"
|
||||
|
||||
spacer
|
||||
|
||||
label "Total routes" "4"
|
||||
label "Average RTT" "183ms"
|
||||
|
||||
divider light
|
||||
|
||||
link "Refresh" "/page/routes.mu"`,
|
||||
},
|
||||
{
|
||||
name: "Nested Boxes",
|
||||
description: "Four box styles with nested content",
|
||||
source: `page "Box Styles" 50
|
||||
|
||||
heading 1 "Box Styles"
|
||||
|
||||
spacer
|
||||
|
||||
box light "Light Border"
|
||||
text "Standard border style"
|
||||
text "Good for general content"
|
||||
|
||||
spacer
|
||||
|
||||
box heavy "Heavy Border"
|
||||
text "Thick borders for emphasis"
|
||||
text "Use for alerts or highlights"
|
||||
|
||||
spacer
|
||||
|
||||
box double "Double Border"
|
||||
text "Double-line borders"
|
||||
text "Great for titles and headers"
|
||||
|
||||
spacer
|
||||
|
||||
box rounded "Rounded Border"
|
||||
text "Soft corners"
|
||||
text "A more modern feel"
|
||||
|
||||
spacer
|
||||
|
||||
heading 2 "Nested"
|
||||
|
||||
box double "Outer"
|
||||
text "This box contains another:"
|
||||
box light "Inner"
|
||||
text "Nested content here"`,
|
||||
},
|
||||
{
|
||||
name: "Data Visualization",
|
||||
description: "Gauges, sparklines, and status indicators",
|
||||
source: `page "Metrics" 60
|
||||
|
||||
box heavy "System Metrics"
|
||||
align center
|
||||
text "Real-time monitoring dashboard"
|
||||
|
||||
spacer
|
||||
|
||||
heading 1 "CPU & Memory"
|
||||
|
||||
gauge "CPU" 42 100 28
|
||||
gauge "GPU" 21 100 28
|
||||
gauge "MEM" 67 100 28 warn=80 crit=95
|
||||
gauge "SWP" 3 100 28
|
||||
|
||||
spacer
|
||||
|
||||
heading 1 "Network Traffic"
|
||||
|
||||
sparkline "Inbound" "1,3,5,8,7,5,3,2,1,3,6,8,7,4" 20
|
||||
sparkline "Outbound" "2,2,3,5,8,7,5,3,2,1,1,3,5,8" 20
|
||||
|
||||
spacer
|
||||
|
||||
heading 1 "Services"
|
||||
|
||||
row 2
|
||||
col 28
|
||||
status "NomadNet" online
|
||||
status "LXMF Router" online
|
||||
status "Sideband" online
|
||||
col 28
|
||||
status "Reticulum" online
|
||||
status "TCP Interface" degraded
|
||||
status "I2P Transport" offline
|
||||
|
||||
divider heavy
|
||||
text "Last updated: just now"`,
|
||||
},
|
||||
{
|
||||
name: "Full Node Page",
|
||||
description: "Complete node page with all primitives",
|
||||
source: `page "Relay Alpha-7" 64
|
||||
|
||||
box double "Relay Alpha-7"
|
||||
align center
|
||||
text "Reticulum Mesh Node"
|
||||
text "Sector 7 - Grid Reference 4E"
|
||||
|
||||
spacer
|
||||
|
||||
heading 1 "Resources"
|
||||
|
||||
row 2
|
||||
col 30
|
||||
gauge "CPU" 62 100 26 warn=75 crit=90
|
||||
gauge "MEM" 84 100 26 warn=80 crit=95
|
||||
col 30
|
||||
gauge "DISK" 45 100 26 warn=85 crit=95
|
||||
gauge "NET" 23 100 26
|
||||
|
||||
spacer
|
||||
|
||||
heading 1 "Network"
|
||||
|
||||
sparkline "Traffic IN" "1,4,6,8,7,5,3,2,3,5,7,8,6,4" 20
|
||||
sparkline "Traffic OUT" "2,3,5,7,8,6,4,3,4,6,8,7,5,3" 20
|
||||
|
||||
spacer
|
||||
|
||||
heading 1 "Routing"
|
||||
|
||||
table "Active Routes"
|
||||
columns "Destination" 20 | "Hops" 6 | "RTT" 8 | "State" 10
|
||||
row "relay-east" | "2" | "34ms" | "@color{0f0}{● up}"
|
||||
row "bridge-south" | "4" | "112ms" | "@color{0f0}{● up}"
|
||||
row "node-gamma" | "7" | "580ms" | "@color{f00}{○ down}"
|
||||
row "hub-north" | "1" | "8ms" | "@color{0f0}{● up}"
|
||||
|
||||
spacer
|
||||
|
||||
heading 1 "Peers"
|
||||
|
||||
row 2
|
||||
col 30
|
||||
status "East Relay" online
|
||||
status "South Bridge" online
|
||||
status "Backup Node" degraded
|
||||
col 30
|
||||
label "Active" "7 / 12"
|
||||
label "Uptime" "14d 3h"
|
||||
label "Version" "0.7.2"
|
||||
|
||||
divider heavy
|
||||
|
||||
row 2
|
||||
col 30
|
||||
link "Home" "/page/index.mu"
|
||||
col 30
|
||||
link "Settings" "/page/settings.mu"`,
|
||||
},
|
||||
{
|
||||
name: "Interactive Form",
|
||||
description: "Text fields, radio buttons, checkboxes, and submit",
|
||||
source: `page "Search" 56
|
||||
|
||||
box rounded "Node Search"
|
||||
align center
|
||||
text "Find peers and pages on the mesh"
|
||||
|
||||
spacer
|
||||
|
||||
form "search"
|
||||
field "query" 30 "Enter search term..."
|
||||
radio "scope" "Local" | "Network" | "All"
|
||||
checkbox "cache" "Include cached results"
|
||||
spacer
|
||||
button "Search" "/page/search.mu"
|
||||
|
||||
divider light
|
||||
|
||||
heading 2 "Quick Actions"
|
||||
|
||||
form "ping"
|
||||
field "target" 30 "Destination hash..."
|
||||
radio "mode" "Ping" | "Trace" | "Page"
|
||||
checkbox "verbose" "Verbose output"
|
||||
spacer
|
||||
button "Execute" "/page/action.mu"`,
|
||||
},
|
||||
{
|
||||
name: "Components",
|
||||
description: "Reusable component definitions with use std/dashboard",
|
||||
source: `page "Node Overview" 60
|
||||
|
||||
use std/dashboard
|
||||
|
||||
banner "Relay Alpha-7" "Reticulum Mesh Node"
|
||||
|
||||
spacer
|
||||
|
||||
resources 62 84
|
||||
|
||||
spacer
|
||||
|
||||
heading 2 "Peers"
|
||||
|
||||
peer_status "East Relay" online
|
||||
peer_status "South Bridge" online
|
||||
peer_status "Node Gamma" degraded
|
||||
|
||||
divider heavy
|
||||
link "Home" "/page/index.mu"`,
|
||||
},
|
||||
{
|
||||
name: "Navigation",
|
||||
description: "Horizontal bar + vertical sidebar navigation",
|
||||
source: `page "Dashboard" 64
|
||||
|
||||
hnav bar
|
||||
item "Status" "/page/status.mu" active
|
||||
item "Peers" "/page/peers.mu"
|
||||
item "Files" "/page/files.mu"
|
||||
item "Config" "/page/config.mu"
|
||||
|
||||
spacer
|
||||
|
||||
row 1
|
||||
col 18
|
||||
vnav boxed
|
||||
heading "Network"
|
||||
item "Overview" "/page/overview.mu" active
|
||||
item "Peers" "/page/peers.mu"
|
||||
item "Routes" "/page/routes.mu"
|
||||
separator
|
||||
heading "Tools"
|
||||
item "Ping" "/page/ping.mu"
|
||||
item "Trace" "/page/trace.mu"
|
||||
col
|
||||
heading 1 "Overview"
|
||||
gauge "CPU" 62 100 28 warn=75 crit=90
|
||||
gauge "MEM" 84 100 28 warn=80 crit=95
|
||||
spacer
|
||||
status "East Relay" online
|
||||
status "South Bridge" online
|
||||
status "Node Gamma" degraded`,
|
||||
},
|
||||
{
|
||||
name: "Image Art",
|
||||
description: "Convert images to character art (upload an image first)",
|
||||
source: `page "Image Embedding" 60
|
||||
|
||||
heading 1 "Image Embedding"
|
||||
text "Upload an image with the Image button above,"
|
||||
text "then use the image keyword to embed it."
|
||||
|
||||
spacer
|
||||
|
||||
box light "Syntax"
|
||||
text 'image "path.png" mode width'
|
||||
spacer
|
||||
label "mode" "braille | block | ascii | halfblock"
|
||||
label "width" "output width in characters"
|
||||
|
||||
spacer
|
||||
|
||||
heading 2 "Options"
|
||||
label "dither" "floyd | threshold | none"
|
||||
label "invert" "flip light and dark"
|
||||
label "align" "left | center | right"
|
||||
label "caption" "text below the image"
|
||||
|
||||
spacer
|
||||
|
||||
box rounded "Example"
|
||||
text 'image "logo.png" braille 30'
|
||||
text ' dither floyd'
|
||||
text ' align center'
|
||||
text ' caption "My logo"'`,
|
||||
},
|
||||
{
|
||||
name: "Big Title",
|
||||
description: "Large ASCII art text in block, thin, and pixel fonts",
|
||||
source: `page "Banner" 64
|
||||
|
||||
bigtitle "RELAY" block
|
||||
align center
|
||||
color 0cf
|
||||
|
||||
spacer
|
||||
|
||||
text "@center{Alpha-7 — Reticulum Network Node}"
|
||||
|
||||
spacer
|
||||
|
||||
bigtitle "STATUS" thin
|
||||
align center
|
||||
|
||||
spacer
|
||||
|
||||
gauge "CPU" 62 100 28 warn=75 crit=90
|
||||
gauge "MEM" 84 100 28 warn=80 crit=95
|
||||
|
||||
divider heavy
|
||||
|
||||
bigtitle "OK" pixel
|
||||
align center
|
||||
color 0f0`,
|
||||
},
|
||||
{
|
||||
name: "Themed Page",
|
||||
description: "Same layout with different visual themes (try: nouveau, gothic, bamboo, circuit, brutalist)",
|
||||
source: `page "Node Status" 50
|
||||
theme nouveau
|
||||
|
||||
box heavy "Relay Alpha-7"
|
||||
align center
|
||||
text "Reticulum Network Node"
|
||||
|
||||
spacer
|
||||
|
||||
heading 1 "Resources"
|
||||
|
||||
gauge "CPU" 62 100 24 warn=75 crit=90
|
||||
gauge "MEM" 84 100 24 warn=80 crit=95
|
||||
|
||||
spacer
|
||||
|
||||
heading 2 "Peers"
|
||||
|
||||
status "East Relay" online
|
||||
status "South Bridge" online
|
||||
status "Node Gamma" degraded
|
||||
|
||||
divider heavy
|
||||
|
||||
link "Home" "/page/index.mu"`,
|
||||
},
|
||||
{
|
||||
name: "Dynamic Dashboard",
|
||||
description: "Live data sources, conditionals, and cache control",
|
||||
source: `page "Live Status" 60
|
||||
cache 0
|
||||
|
||||
source cpu_pct : python "secrets.randbelow(60) + 20"
|
||||
source mem_pct : python "secrets.randbelow(40) + 50"
|
||||
source uptime : python "str(timedelta(seconds=secrets.randbelow(86400)))"
|
||||
source timestamp : python "datetime.now().strftime('%H:%M:%S')"
|
||||
|
||||
box double "Node Monitor"
|
||||
align center
|
||||
text "Live System Dashboard"
|
||||
text "Updated: $timestamp"
|
||||
|
||||
spacer
|
||||
|
||||
heading 1 "Resources"
|
||||
|
||||
gauge "CPU" $cpu_pct 100 28 warn=75 crit=90
|
||||
gauge "MEM" $mem_pct 100 28 warn=80 crit=95
|
||||
|
||||
spacer
|
||||
|
||||
if $cpu_pct > 90
|
||||
box heavy "ALERT"
|
||||
color f00
|
||||
text "CPU critical! Immediate action required."
|
||||
elif $cpu_pct > 75
|
||||
text "@color{ff0}{Warning: CPU usage elevated}"
|
||||
|
||||
spacer
|
||||
|
||||
label "Uptime" "$uptime"
|
||||
|
||||
divider heavy
|
||||
text "Press Ctrl+R to refresh"`,
|
||||
},
|
||||
];
|
||||
80
frontend/src/components/editor/iniHighlight.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
StreamLanguage,
|
||||
HighlightStyle,
|
||||
syntaxHighlighting,
|
||||
} from "@codemirror/language";
|
||||
import { tags } from "@lezer/highlight";
|
||||
|
||||
/**
|
||||
* CodeMirror 6 syntax highlighting for INI-style config files
|
||||
* (Reticulum .conf / NomadNet .conf).
|
||||
*
|
||||
* Supports: [sections], [[subsections]], key = value, # comments,
|
||||
* booleans, numbers, and quoted strings.
|
||||
*/
|
||||
|
||||
const BOOLEANS = new Set([
|
||||
"true", "false", "yes", "no", "on", "off", "none",
|
||||
]);
|
||||
|
||||
const iniLanguage = StreamLanguage.define({
|
||||
token(stream) {
|
||||
// Comments
|
||||
if (stream.match(/\s*#/)) {
|
||||
stream.skipToEnd();
|
||||
return "lineComment";
|
||||
}
|
||||
|
||||
// Skip whitespace
|
||||
if (stream.eatSpace()) return null;
|
||||
|
||||
// Subsection headers [[name]]
|
||||
if (stream.match(/\[\[.*?\]\]/)) return "heading";
|
||||
|
||||
// Section headers [name]
|
||||
if (stream.match(/\[.*?\]/)) return "typeName";
|
||||
|
||||
// Quoted strings
|
||||
if (stream.match(/"/)) {
|
||||
while (!stream.eol()) {
|
||||
if (stream.next() === '"') break;
|
||||
}
|
||||
return "string";
|
||||
}
|
||||
|
||||
// Assignment operator
|
||||
if (stream.match(/=/)) return "punctuation";
|
||||
|
||||
// Numbers (integers, floats, ports, IPs with dots)
|
||||
if (stream.match(/\b\d[\d.]*\b/)) return "number";
|
||||
|
||||
// Words
|
||||
if (stream.match(/[\w\-_.]+/)) {
|
||||
const word = stream.current().toLowerCase();
|
||||
if (BOOLEANS.has(word)) return "atom";
|
||||
// Keys appear before '=', values after — both are plain words
|
||||
return null;
|
||||
}
|
||||
|
||||
stream.next();
|
||||
return null;
|
||||
},
|
||||
startState: () => ({}),
|
||||
copyState: (s) => ({ ...s }),
|
||||
blankLine: () => {},
|
||||
languageData: {},
|
||||
});
|
||||
|
||||
const iniStyle = HighlightStyle.define([
|
||||
{ tag: tags.typeName, color: "#c792ea", fontWeight: "bold" }, // [section]
|
||||
{ tag: tags.heading, color: "#82aaff", fontWeight: "bold" }, // [[subsection]]
|
||||
{ tag: tags.lineComment, color: "#546e7a", fontStyle: "italic" },
|
||||
{ tag: tags.string, color: "#c3e88d" },
|
||||
{ tag: tags.number, color: "#f78c6c" },
|
||||
{ tag: tags.atom, color: "#89ddff" }, // booleans
|
||||
{ tag: tags.punctuation, color: "#89ddff" }, // =
|
||||
]);
|
||||
|
||||
export function iniHighlight() {
|
||||
return [iniLanguage, syntaxHighlighting(iniStyle)];
|
||||
}
|
||||
25
frontend/src/components/editor/micronRenderer.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Micron markup → HTML renderer using the micron-parser library.
|
||||
* Reference: https://github.com/RFnexus/micron-parser-js
|
||||
*
|
||||
* Uses convertMicronToFragment (DOM-based) instead of convertMicronToHtml
|
||||
* to avoid DOMPurify stripping nomadnetwork:// hrefs from link tags.
|
||||
*/
|
||||
|
||||
import MicronParser from "micron-parser";
|
||||
|
||||
let darkParser: MicronParser | null = null;
|
||||
let lightParser: MicronParser | null = null;
|
||||
|
||||
export function renderMicron(source: string, darkTheme: boolean = true): string {
|
||||
const parser = darkTheme
|
||||
? (darkParser ??= new MicronParser(true, true))
|
||||
: (lightParser ??= new MicronParser(false, true));
|
||||
|
||||
const fragment = parser.convertMicronToFragment(source);
|
||||
|
||||
// Serialize the fragment to HTML string
|
||||
const div = document.createElement("div");
|
||||
div.appendChild(fragment);
|
||||
return div.innerHTML;
|
||||
}
|
||||
30
frontend/src/components/editor/oneDarkTheme.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { EditorView } from "@codemirror/view";
|
||||
|
||||
export const oneDark = EditorView.theme(
|
||||
{
|
||||
"&": {
|
||||
backgroundColor: "var(--background)",
|
||||
color: "var(--foreground)",
|
||||
},
|
||||
".cm-cursor, .cm-cursor-primary": {
|
||||
borderLeftColor: "var(--primary)",
|
||||
borderLeftWidth: "0.5em",
|
||||
},
|
||||
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
|
||||
backgroundColor: "color-mix(in oklch, var(--primary) 25%, transparent)",
|
||||
},
|
||||
".cm-activeLine": {
|
||||
backgroundColor: "color-mix(in oklch, var(--primary) 8%, transparent)",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "var(--background)",
|
||||
color: "var(--muted-foreground)",
|
||||
borderRight: "1px solid var(--border)",
|
||||
},
|
||||
".cm-activeLineGutter": {
|
||||
backgroundColor: "color-mix(in oklch, var(--primary) 8%, transparent)",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
},
|
||||
{ dark: true }
|
||||
);
|
||||
190
frontend/src/components/editor/uframeCommands.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { snippet } from "@codemirror/autocomplete";
|
||||
import type {
|
||||
Completion,
|
||||
CompletionContext,
|
||||
CompletionResult,
|
||||
} from "@codemirror/autocomplete";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { fetchDslMeta } from "@/api/client";
|
||||
|
||||
/**
|
||||
* µFrame slash command palette — auto-populated from the backend DSL registry.
|
||||
*
|
||||
* On first load, uses a minimal fallback set. Once the API responds,
|
||||
* the full command list replaces it via loadCommandsFromApi().
|
||||
*/
|
||||
|
||||
interface CmdEntry {
|
||||
label: string;
|
||||
detail: string;
|
||||
section: string;
|
||||
apply: Completion["apply"];
|
||||
}
|
||||
|
||||
// Module-level cache — survives re-renders but not full page reload.
|
||||
let commands: CmdEntry[] | null = null;
|
||||
let loaded = false;
|
||||
|
||||
function insert(text: string): Completion["apply"] {
|
||||
return (view: EditorView, _c: Completion, from: number, to: number) => {
|
||||
view.dispatch({ changes: { from: from - 1, to, insert: text } });
|
||||
};
|
||||
}
|
||||
|
||||
function slashSnippet(template: string): Completion["apply"] {
|
||||
const snip = snippet(template);
|
||||
return (view: EditorView, c: Completion, from: number, to: number) => {
|
||||
snip(view, c, from - 1, to);
|
||||
};
|
||||
}
|
||||
|
||||
const FALLBACK_COMMANDS: CmdEntry[] = [
|
||||
{ label: "page", detail: 'page "Title" 64', section: "Layout", apply: slashSnippet('page "${title}" ${width:64}') },
|
||||
{ label: "box", detail: 'box light "Title"', section: "Layout", apply: slashSnippet('box ${weight:light} "${title}"') },
|
||||
{ label: "heading", detail: 'heading 1 "Text"', section: "Content", apply: slashSnippet('heading ${level:1} "${text}"') },
|
||||
{ label: "text", detail: 'text "Content"', section: "Content", apply: slashSnippet('text "${content}"') },
|
||||
{ label: "gauge", detail: "gauge label val max", section: "Data", apply: slashSnippet('gauge "${label}" ${value} ${max:100} ${width:28}') },
|
||||
{ label: "status", detail: "status label state", section: "Data", apply: slashSnippet('status "${label}" ${state:online}') },
|
||||
];
|
||||
|
||||
function getCommands(): CmdEntry[] {
|
||||
return commands ?? FALLBACK_COMMANDS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the full command list from the backend DSL registry.
|
||||
* Called once on editor mount.
|
||||
*/
|
||||
export async function loadCommandsFromApi(): Promise<void> {
|
||||
if (loaded) return;
|
||||
|
||||
try {
|
||||
const data = await fetchDslMeta();
|
||||
const apiCommands: CmdEntry[] = [];
|
||||
|
||||
for (const cmd of data.commands ?? []) {
|
||||
if (!cmd.detail || !cmd.snippet) continue;
|
||||
apiCommands.push({
|
||||
label: cmd.label,
|
||||
detail: cmd.detail,
|
||||
section: cmd.section,
|
||||
apply: cmd.snippet.includes("${")
|
||||
? slashSnippet(cmd.snippet)
|
||||
: insert(cmd.snippet),
|
||||
});
|
||||
}
|
||||
|
||||
// Dashboard template (not in registry)
|
||||
apiCommands.push({
|
||||
label: "dashboard",
|
||||
detail: "Full dashboard template",
|
||||
section: "Template",
|
||||
apply: insert(
|
||||
`page "Dashboard" 64\n box double "Node Status"\n align center\n text "Reticulum Network Node"\n\n spacer\n\n heading 1 "Resources"\n\n gauge "CPU" 0 100 28 warn=75 crit=90\n gauge "MEM" 0 100 28 warn=80 crit=95\n\n spacer\n\n heading 2 "Network"\n\n status "Relay East" online\n status "Bridge South" online\n\n divider heavy\n link "Home" "/page/index.mu"`,
|
||||
),
|
||||
});
|
||||
|
||||
commands = apiCommands;
|
||||
loaded = true;
|
||||
} catch {
|
||||
// Keep using fallback
|
||||
}
|
||||
}
|
||||
|
||||
export function uframeCommandSource(
|
||||
ctx: CompletionContext,
|
||||
): CompletionResult | null {
|
||||
const match = ctx.matchBefore(/\/\w*/);
|
||||
if (!match || (match.from === match.to && !ctx.explicit)) return null;
|
||||
|
||||
return {
|
||||
from: match.from + 1,
|
||||
filter: true,
|
||||
options: getCommands().map((cmd) => ({
|
||||
label: cmd.label,
|
||||
detail: cmd.detail,
|
||||
section: cmd.section,
|
||||
apply: cmd.apply,
|
||||
boost: 99,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribute value hints — suggests valid values based on the keyword
|
||||
* on the current line.
|
||||
*/
|
||||
const KEYWORD_VALUES: Record<string, { values: string[]; hint: string }> = {
|
||||
box: { values: ["light", "heavy", "double", "rounded"], hint: "border weight" },
|
||||
divider: { values: ["light", "heavy", "double", "dash", "dot"], hint: "divider style" },
|
||||
heading: { values: ["1", "2", "3"], hint: "heading level" },
|
||||
list: { values: ["bullet", "dash", "number", "arrow"], hint: "list style" },
|
||||
align: { values: ["left", "center", "right"], hint: "alignment" },
|
||||
status: { values: ["online", "offline", "degraded", "unknown", "alert"], hint: "state" },
|
||||
hnav: { values: ["bar", "tabs", "pills", "breadcrumb", "underline"], hint: "nav style" },
|
||||
vnav: { values: ["list", "boxed", "tree", "sidebar", "minimal"], hint: "nav style" },
|
||||
bigtitle: { values: ["block", "thin", "pixel"], hint: "font" },
|
||||
image: { values: ["braille", "block", "ascii", "halfblock"], hint: "render mode" },
|
||||
dither: { values: ["floyd", "threshold", "none"], hint: "dither algorithm" },
|
||||
source: { values: ["shell", "file", "json", "python", "rns", "param"], hint: "source type" },
|
||||
theme: { values: ["default", "nouveau", "gothic", "bamboo", "circuit", "brutalist"], hint: "theme" },
|
||||
};
|
||||
|
||||
export function uframeValueHintSource(
|
||||
ctx: CompletionContext,
|
||||
): CompletionResult | null {
|
||||
const line = ctx.state.doc.lineAt(ctx.pos);
|
||||
const textBefore = line.text.slice(0, ctx.pos - line.from);
|
||||
|
||||
if (textBefore.trimStart().startsWith("/")) return null;
|
||||
|
||||
const kwMatch = textBefore.match(/^\s*(\w+)\s/);
|
||||
if (!kwMatch) return null;
|
||||
|
||||
const keyword = kwMatch[1].toLowerCase();
|
||||
const entry = KEYWORD_VALUES[keyword];
|
||||
if (!entry) return null;
|
||||
|
||||
const quotesBefore = (textBefore.match(/"/g) || []).length;
|
||||
if (quotesBefore % 2 !== 0) return null;
|
||||
|
||||
const colonWordMatch = ctx.matchBefore(/\w+:\w*/);
|
||||
if (colonWordMatch) {
|
||||
return {
|
||||
from: colonWordMatch.from,
|
||||
filter: false,
|
||||
options: entry.values.map((v) => ({
|
||||
label: v,
|
||||
detail: entry.hint,
|
||||
type: "enum" as const,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const wordMatch = ctx.matchBefore(/\w+/);
|
||||
if (!wordMatch) {
|
||||
if (!ctx.explicit) return null;
|
||||
return {
|
||||
from: ctx.pos,
|
||||
options: entry.values.map((v) => ({
|
||||
label: v,
|
||||
detail: entry.hint,
|
||||
type: "enum" as const,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const typed = ctx.state.sliceDoc(wordMatch.from, wordMatch.to);
|
||||
if (typed.toLowerCase() === keyword) return null;
|
||||
|
||||
return {
|
||||
from: wordMatch.from,
|
||||
filter: true,
|
||||
options: entry.values.map((v) => ({
|
||||
label: v,
|
||||
detail: entry.hint,
|
||||
type: "enum" as const,
|
||||
boost: -1,
|
||||
})),
|
||||
};
|
||||
}
|
||||
135
frontend/src/components/editor/uframeHighlight.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
StreamLanguage,
|
||||
HighlightStyle,
|
||||
syntaxHighlighting,
|
||||
} from "@codemirror/language";
|
||||
import { tags } from "@lezer/highlight";
|
||||
|
||||
/**
|
||||
* CodeMirror 6 syntax highlighting for the µFrame .uf DSL.
|
||||
*
|
||||
* Keywords and values can be updated dynamically via setDslKeywords()
|
||||
* which is called when the frontend fetches /api/dsl-meta.
|
||||
*/
|
||||
|
||||
// Mutable sets — updated from /api/dsl-meta
|
||||
let KEYWORDS = new Set([
|
||||
// Layout
|
||||
"page", "box", "row", "col", "spacer", "pad",
|
||||
// Content
|
||||
"heading", "text", "label", "divider", "link", "list", "item",
|
||||
"bigtitle", "image", "dither", "invert", "caption",
|
||||
// Data
|
||||
"gauge", "sparkline", "status", "table", "columns",
|
||||
// Navigation
|
||||
"hnav", "vnav", "separator",
|
||||
// Form
|
||||
"form", "field", "radio", "checkbox", "button", "password",
|
||||
// Style
|
||||
"align", "color", "bg", "bold", "italic", "underline", "theme",
|
||||
// Dynamic
|
||||
"source", "let", "if", "elif", "else", "for", "cache", "state", "on_submit",
|
||||
"set", "append", "prepend",
|
||||
// Components
|
||||
"component", "use",
|
||||
]);
|
||||
|
||||
let WEIGHT_VALS = new Set([
|
||||
// Border weights
|
||||
"light", "heavy", "double", "rounded",
|
||||
// List styles
|
||||
"bullet", "dash", "number", "arrow",
|
||||
// Alignment
|
||||
"left", "center", "right",
|
||||
// Status states
|
||||
"online", "offline", "degraded", "unknown", "alert",
|
||||
// Source types
|
||||
"shell", "file", "json", "python", "rns", "param",
|
||||
// Themes
|
||||
"default", "nouveau", "gothic", "bamboo", "circuit", "brutalist",
|
||||
// Nav styles
|
||||
"bar", "tabs", "pills", "breadcrumb", "underline",
|
||||
"boxed", "tree", "sidebar", "minimal",
|
||||
// Fonts
|
||||
"block", "thin", "pixel",
|
||||
// Image modes
|
||||
"braille", "ascii", "halfblock",
|
||||
// Dither
|
||||
"floyd", "threshold", "none",
|
||||
// Divider styles
|
||||
"dot",
|
||||
// Misc
|
||||
"active", "compact",
|
||||
]);
|
||||
|
||||
/** Update keywords and values from /api/dsl-meta response. */
|
||||
export function setDslKeywords(keywords: string[], values: string[]) {
|
||||
if (keywords.length > 0) KEYWORDS = new Set(keywords);
|
||||
if (values.length > 0) WEIGHT_VALS = new Set(values);
|
||||
}
|
||||
|
||||
const uframeLanguage = StreamLanguage.define({
|
||||
token(stream) {
|
||||
// Comments
|
||||
if (stream.sol() && stream.match(/\s*#/)) {
|
||||
stream.skipToEnd();
|
||||
return "lineComment";
|
||||
}
|
||||
|
||||
// Skip whitespace
|
||||
if (stream.eatSpace()) return null;
|
||||
|
||||
// Quoted strings
|
||||
if (stream.match(/"/)) {
|
||||
while (!stream.eol()) {
|
||||
if (stream.next() === '"') break;
|
||||
}
|
||||
return "string";
|
||||
}
|
||||
|
||||
// @modifier{} syntax
|
||||
if (stream.match(/@\w+/)) return "keyword";
|
||||
|
||||
// $variable references
|
||||
if (stream.match(/\$[\w.]+/)) return "variableName";
|
||||
|
||||
// Numbers (integers, floats, hex colors)
|
||||
if (stream.match(/\b\d+(\.\d+)?\b/)) return "number";
|
||||
|
||||
// warn=N, crit=N parameters
|
||||
if (stream.match(/\b(warn|crit)=/)) return "attributeName";
|
||||
|
||||
// Pipe separator for inline lists
|
||||
if (stream.match(/\|/)) return "punctuation";
|
||||
|
||||
// Keywords and values
|
||||
if (stream.match(/\b\w+\b/)) {
|
||||
const word = stream.current();
|
||||
if (KEYWORDS.has(word)) return "keyword";
|
||||
if (WEIGHT_VALS.has(word)) return "atom";
|
||||
return null;
|
||||
}
|
||||
|
||||
stream.next();
|
||||
return null;
|
||||
},
|
||||
startState: () => ({}),
|
||||
copyState: (s) => ({ ...s }),
|
||||
blankLine: () => {},
|
||||
languageData: {},
|
||||
});
|
||||
|
||||
const uframeStyle = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: "#c792ea", fontWeight: "bold" },
|
||||
{ tag: tags.string, color: "#c3e88d" },
|
||||
{ tag: tags.lineComment, color: "#546e7a", fontStyle: "italic" },
|
||||
{ tag: tags.variableName, color: "#f78c6c" },
|
||||
{ tag: tags.number, color: "#f78c6c" },
|
||||
{ tag: tags.atom, color: "#89ddff" },
|
||||
{ tag: tags.attributeName, color: "#ffcb6b" },
|
||||
{ tag: tags.punctuation, color: "#89ddff" },
|
||||
]);
|
||||
|
||||
export function uframeHighlight() {
|
||||
return [uframeLanguage, syntaxHighlighting(uframeStyle)];
|
||||
}
|
||||
281
frontend/src/components/editor/uframeHover.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { hoverTooltip, type EditorView, type Tooltip } from "@codemirror/view";
|
||||
|
||||
/**
|
||||
* µFrame keyword hover tooltips — shows syntax, attributes, and examples
|
||||
* when hovering over a DSL keyword in the editor.
|
||||
*/
|
||||
|
||||
interface KeywordDoc {
|
||||
syntax: string;
|
||||
attrs: string;
|
||||
example: string;
|
||||
}
|
||||
|
||||
const KEYWORD_DOCS: Record<string, KeywordDoc> = {
|
||||
// Layout
|
||||
page: {
|
||||
syntax: 'page "title" [width]',
|
||||
attrs: "title: string — page title\nwidth: number — page width in chars (default: 64)",
|
||||
example: 'page "Node Status" 60',
|
||||
},
|
||||
box: {
|
||||
syntax: 'box [weight] "title"',
|
||||
attrs: "weight: light | heavy | double | rounded\ntitle: string — title in top border",
|
||||
example: 'box double "System"\n text "Content here"',
|
||||
},
|
||||
row: {
|
||||
syntax: "row [gap]",
|
||||
attrs: "gap: number — space between columns (default: 1)",
|
||||
example: "row 2\n col 20\n text \"Left\"\n col\n text \"Right\"",
|
||||
},
|
||||
col: {
|
||||
syntax: "col [width]",
|
||||
attrs: "width: number — column width in chars (auto if omitted)",
|
||||
example: "col 30\n text \"Fixed width column\"",
|
||||
},
|
||||
spacer: {
|
||||
syntax: "spacer [lines]",
|
||||
attrs: "lines: number — vertical space (default: 1)",
|
||||
example: "spacer 2",
|
||||
},
|
||||
pad: {
|
||||
syntax: "pad [top] [right] [bottom] [left]",
|
||||
attrs: "top, right, bottom, left: number — padding in chars",
|
||||
example: "pad 1 2 1 2\n text \"Padded content\"",
|
||||
},
|
||||
|
||||
// Content
|
||||
heading: {
|
||||
syntax: 'heading [level] "text"',
|
||||
attrs: "level: 1 | 2 | 3 — heading size\ntext: string — heading text",
|
||||
example: 'heading 1 "Main Title"',
|
||||
},
|
||||
text: {
|
||||
syntax: 'text "content"',
|
||||
attrs: "content: string — supports @bold{}, @italic{},\n @color{hex}{}, @bg{hex}{}, @under{}",
|
||||
example: 'text "Hello @bold{world} @color{0f0}{green}"',
|
||||
},
|
||||
label: {
|
||||
syntax: 'label "key" "value"',
|
||||
attrs: "key: string — label name (bold)\nvalue: string — label value",
|
||||
example: 'label "Uptime" "14d 3h 22m"',
|
||||
},
|
||||
divider: {
|
||||
syntax: "divider [style]",
|
||||
attrs: "style: light | heavy | double | dash | dot",
|
||||
example: "divider heavy",
|
||||
},
|
||||
link: {
|
||||
syntax: 'link "display" "destination"',
|
||||
attrs: "display: string — visible text\ndestination: string — Micron page path",
|
||||
example: 'link "Home" "/page/index.mu"',
|
||||
},
|
||||
list: {
|
||||
syntax: "list [style]",
|
||||
attrs: "style: bullet | dash | number | arrow",
|
||||
example: 'list bullet\n item "First entry"\n item "Second entry"',
|
||||
},
|
||||
item: {
|
||||
syntax: 'item "text" OR item "label" "dest" [active]',
|
||||
attrs: "text: string — list item content\nlabel, dest: nav item with link\nactive: flag — highlight as current",
|
||||
example: 'item "Entry" — in list\nitem "Home" "/page/index.mu" active — in nav',
|
||||
},
|
||||
|
||||
// Data Visualization
|
||||
gauge: {
|
||||
syntax: 'gauge "label" value max width [warn=N] [crit=N]',
|
||||
attrs: "label: string — gauge name\nvalue: number — current value\nmax: number — maximum\nwidth: number — bar width in chars\nwarn: number — warning threshold\ncrit: number — critical threshold",
|
||||
example: 'gauge "CPU" 62 100 28 warn=75 crit=90',
|
||||
},
|
||||
sparkline: {
|
||||
syntax: 'sparkline "label" "values" width',
|
||||
attrs: "label: string — chart name\nvalues: string — comma-separated numbers\nwidth: number — chart width in chars",
|
||||
example: 'sparkline "Traffic" "1,3,5,8,7,5,3" 20',
|
||||
},
|
||||
status: {
|
||||
syntax: 'status "label" state',
|
||||
attrs: "label: string — indicator name\nstate: online | offline | degraded | unknown | alert",
|
||||
example: 'status "East Relay" online',
|
||||
},
|
||||
table: {
|
||||
syntax: 'table "title"',
|
||||
attrs: "title: string — table caption\nchildren: columns + row entries",
|
||||
example: 'table "Routes"\n columns "Dest" 20 | "Hops" 6\n row "east" | "2"',
|
||||
},
|
||||
columns: {
|
||||
syntax: 'columns "name" width | "name" width | ...',
|
||||
attrs: "name: string — column header\nwidth: number — column width in chars\nseparated by | pipes",
|
||||
example: 'columns "Name" 20 | "Status" 10',
|
||||
},
|
||||
|
||||
// Navigation
|
||||
hnav: {
|
||||
syntax: "hnav [style]",
|
||||
attrs: "style: bar | tabs | pills | breadcrumb | underline\nchildren: item, separator",
|
||||
example: 'hnav bar\n item "Status" "/page/status.mu" active\n item "Peers" "/page/peers.mu"',
|
||||
},
|
||||
vnav: {
|
||||
syntax: "vnav [style] [width]",
|
||||
attrs: "style: list | boxed | tree | sidebar | minimal\nwidth: number — panel width (auto if omitted)\nchildren: item, separator, heading",
|
||||
example: 'vnav boxed\n heading "Section"\n item "Page" "/page/page.mu" active',
|
||||
},
|
||||
|
||||
// Forms
|
||||
form: {
|
||||
syntax: 'form "name"',
|
||||
attrs: "name: string — form identifier\nchildren: field, password, radio, checkbox, button",
|
||||
example: 'form "search"\n field "query" 30 "Search..."\n button "Go" "/page/search.mu"',
|
||||
},
|
||||
field: {
|
||||
syntax: 'field "name" [width] "placeholder"',
|
||||
attrs: "name: string — field name\nwidth: number — input width (default: 24)\nplaceholder: string — hint text",
|
||||
example: 'field "query" 30 "Enter search term..."',
|
||||
},
|
||||
password: {
|
||||
syntax: 'password "name" [width] "placeholder"',
|
||||
attrs: "name: string — field name\nwidth: number — input width (default: 24)\nplaceholder: string — hint text",
|
||||
example: 'password "pass" 24 "Enter password"',
|
||||
},
|
||||
radio: {
|
||||
syntax: 'radio "group" "opt1" | "opt2" | "opt3"',
|
||||
attrs: "group: string — radio group name\noptions: strings separated by | pipes",
|
||||
example: 'radio "mode" "Ping" | "Trace" | "Page"',
|
||||
},
|
||||
checkbox: {
|
||||
syntax: 'checkbox "name" "label"',
|
||||
attrs: "name: string — field name\nlabel: string — display text",
|
||||
example: 'checkbox "verbose" "Verbose output"',
|
||||
},
|
||||
button: {
|
||||
syntax: 'button "label" "destination"',
|
||||
attrs: "label: string — button text\ndestination: string — link target on click",
|
||||
example: 'button "Submit" "/page/submit.mu"',
|
||||
},
|
||||
|
||||
// Big Text & Image
|
||||
bigtitle: {
|
||||
syntax: 'bigtitle "text" [font]',
|
||||
attrs: "text: string — text to render large\nfont: block | thin | pixel\nmodifiers: align, color",
|
||||
example: 'bigtitle "RELAY" block\n align center\n color 0cf',
|
||||
},
|
||||
image: {
|
||||
syntax: 'image "path" [mode] [width]',
|
||||
attrs: "path: string — image file path\nmode: braille | block | ascii | halfblock\nwidth: number — output width in chars\nmodifiers: dither, invert, align, caption",
|
||||
example: 'image "logo.png" braille 30\n dither floyd\n caption "Logo"',
|
||||
},
|
||||
|
||||
// Style
|
||||
align: {
|
||||
syntax: "align [direction]",
|
||||
attrs: "direction: left | center | right",
|
||||
example: "align center",
|
||||
},
|
||||
color: {
|
||||
syntax: "color [hex]",
|
||||
attrs: "hex: 3-digit hex color (e.g. 0f0, f00, 0cf)",
|
||||
example: "color 0cf",
|
||||
},
|
||||
bold: {
|
||||
syntax: "bold",
|
||||
attrs: "no arguments — applies bold to parent",
|
||||
example: "box light \"Title\"\n bold\n text \"Bold content\"",
|
||||
},
|
||||
theme: {
|
||||
syntax: "theme [name]",
|
||||
attrs: "name: default | nouveau | gothic | bamboo | circuit | brutalist",
|
||||
example: "theme nouveau",
|
||||
},
|
||||
|
||||
// Dynamic
|
||||
source: {
|
||||
syntax: 'source name : type "command"',
|
||||
attrs: "name: string — variable name\ntype: shell | file | json | python | rns | param\ncommand: string — command to execute",
|
||||
example: 'source cpu : shell "cat /proc/loadavg"',
|
||||
},
|
||||
let: {
|
||||
syntax: 'let name = "value"',
|
||||
attrs: "name: string — variable name\nvalue: string or number",
|
||||
example: 'let node_name = "Relay Alpha"',
|
||||
},
|
||||
cache: {
|
||||
syntax: "cache [seconds]",
|
||||
attrs: "seconds: number — cache duration (0 = never cache)",
|
||||
example: "cache 0",
|
||||
},
|
||||
if: {
|
||||
syntax: "if condition",
|
||||
attrs: "condition: expression with $variables\nchildren: content to show when true",
|
||||
example: "if $cpu > 90\n text \"ALERT: CPU critical\"",
|
||||
},
|
||||
for: {
|
||||
syntax: "for var in $collection",
|
||||
attrs: "var: string — loop variable name\ncollection: $variable — iterable data",
|
||||
example: "for peer in $peers\n status \"$peer\" online",
|
||||
},
|
||||
on_submit: {
|
||||
syntax: 'on_submit "form_name"',
|
||||
attrs: "form_name: string — form to handle\nchildren: handler logic with $field vars",
|
||||
example: 'on_submit "search"\n source results : shell "search.py \'$query\'"',
|
||||
},
|
||||
state: {
|
||||
syntax: 'state "name" "path"',
|
||||
attrs: "name: string — state variable name\npath: string — JSON file path for persistence",
|
||||
example: 'state "counter" "/tmp/counter.json"',
|
||||
},
|
||||
|
||||
// Components
|
||||
component: {
|
||||
syntax: "component name(arg1, arg2, ...)",
|
||||
attrs: "name: string — component name\nargs: parameter names\nchildren: component body template",
|
||||
example: 'component stat(label, value, max)\n gauge "$label" $value $max 20',
|
||||
},
|
||||
use: {
|
||||
syntax: 'use "library"',
|
||||
attrs: "library: std/dashboard | std/status-bar | std/nav |\n std/network | std/form | or file path",
|
||||
example: "use std/dashboard\nbanner \"My Node\" \"Mesh\"",
|
||||
},
|
||||
};
|
||||
|
||||
function getWordAt(view: EditorView, pos: number): { word: string; from: number; to: number } | null {
|
||||
const line = view.state.doc.lineAt(pos);
|
||||
const text = line.text;
|
||||
const col = pos - line.from;
|
||||
|
||||
let start = col;
|
||||
let end = col;
|
||||
while (start > 0 && /\w/.test(text[start - 1])) start--;
|
||||
while (end < text.length && /\w/.test(text[end])) end++;
|
||||
|
||||
if (start === end) return null;
|
||||
return { word: text.slice(start, end), from: line.from + start, to: line.from + end };
|
||||
}
|
||||
|
||||
export const keywordHoverTooltip = hoverTooltip((view, pos) => {
|
||||
const result = getWordAt(view, pos);
|
||||
if (!result) return null;
|
||||
|
||||
const doc = KEYWORD_DOCS[result.word.toLowerCase()];
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
pos: result.from,
|
||||
end: result.to,
|
||||
above: true,
|
||||
create() {
|
||||
const dom = document.createElement("div");
|
||||
dom.className = "cm-keyword-tooltip";
|
||||
dom.innerHTML = `
|
||||
<div style="font-family:var(--font-mono);font-size:11px;max-width:360px;line-height:1.4">
|
||||
<div style="color:var(--primary);font-weight:bold;margin-bottom:4px;font-size:12px">${escHtml(doc.syntax)}</div>
|
||||
<div style="color:var(--muted-foreground);white-space:pre-wrap;margin-bottom:6px;border-bottom:1px solid var(--border);padding-bottom:6px">${escHtml(doc.attrs)}</div>
|
||||
<div style="color:var(--foreground);opacity:0.8;white-space:pre;background:var(--muted);padding:4px 6px;margin:-2px -6px -6px">${escHtml(doc.example)}</div>
|
||||
</div>
|
||||
`;
|
||||
return { dom };
|
||||
},
|
||||
} satisfies Tooltip;
|
||||
});
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
199
frontend/src/components/shared/AppShell.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import browserSvg from "@/assets/browser.min.svg";
|
||||
import NavMenu from "./NavMenu";
|
||||
import LazyEyes from "./LazyEyes";
|
||||
|
||||
const TITLE = `
|
||||
|
||||
▄▄▄▄███▄▄▄▄ ▄█ ▄████████ ▄████████ ▄██████▄ ███▄▄▄▄ ▄██████▄ ▄▄▄▄███▄▄▄▄ ▄█ ▄████████ ▄██████▄ ███▄▄▄▄
|
||||
▄██▀▀▀███▀▀▀██▄ ███ ███ ███ ███ ███ ███ ███ ███▀▀▀██▄ ███ ███ ▄██▀▀▀███▀▀▀██▄ ███ ███ ███ ███ ███ ███▀▀▀██▄
|
||||
███ ███ ███ ███▌ ███ █▀ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███▌ ███ █▀ ███ ███ ███ ███
|
||||
███ ███ ███ ███▌ ███ ▄███▄▄▄▄██▀ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███▌ ███ ███ ███ ███ ███
|
||||
███ ███ ███ ███▌ ███ ▀▀███▀▀▀▀▀ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███▌ ███ ███ ███ ███ ███
|
||||
███ ███ ███ ███ ███ █▄ ▀███████████ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ █▄ ███ ███ ███ ███
|
||||
███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███
|
||||
▀█ ███ █▀ █▀ ████████▀ ███ ███ ▀██████▀ ▀█ █▀ ▀██████▀ ▀█ ███ █▀ █▀ ████████▀ ▀██████▀ ▀█ █▀
|
||||
███ ███
|
||||
|
||||
`;
|
||||
|
||||
type Theme = "terra" | "azure";
|
||||
|
||||
function getStoredTheme(): Theme {
|
||||
try { return (localStorage.getItem("micronomicon-theme") as Theme) || "terra"; }
|
||||
catch { return "terra"; }
|
||||
}
|
||||
|
||||
function toRoman(n: number): string {
|
||||
const vals = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1];
|
||||
const syms = ["M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"];
|
||||
let r = "";
|
||||
for (let i = 0; i < vals.length; i++) {
|
||||
while (n >= vals[i]) { r += syms[i]; n -= vals[i]; }
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
function RomanDrum({ value }: { value: string }) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
overflow: "hidden",
|
||||
height: "1em",
|
||||
lineHeight: "1em",
|
||||
verticalAlign: "bottom",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
key={value}
|
||||
style={{
|
||||
display: "inline-block",
|
||||
animation: "romanSlideIn 0.25s cubic-bezier(0.22, 1, 0.36, 1)",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RomanClock() {
|
||||
const [time, setTime] = useState(new Date());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTime(new Date()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
const h = time.getHours();
|
||||
const m = time.getMinutes();
|
||||
const s = time.getSeconds();
|
||||
return (
|
||||
<span style={{ display: "inline-flex", alignItems: "baseline", gap: 0 }}>
|
||||
<RomanDrum value={toRoman(h || 12)} />
|
||||
<span>:</span>
|
||||
<RomanDrum value={toRoman(m || 1)} />
|
||||
<span>:</span>
|
||||
<RomanDrum value={toRoman(s || 1)} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-frame layout configs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FrameLayout {
|
||||
svg: string;
|
||||
frameHeight: number;
|
||||
containerMaxW: string;
|
||||
containerMinW: string;
|
||||
title: { paddingTop: number; height: number; paddingLeft: number; paddingRight: number; paddingBottom: number };
|
||||
content: { marginLeft: number; marginTop: number; marginRight: number; width: number; height: number; paddingTop: number; paddingBottom: number };
|
||||
nav: { top: number; left: number };
|
||||
}
|
||||
|
||||
const frameLayout: FrameLayout = {
|
||||
svg: browserSvg,
|
||||
frameHeight: 884,
|
||||
containerMaxW: "max-w-5xl",
|
||||
containerMinW: "min-w-5xl",
|
||||
title: { paddingTop: 65, height: 185, paddingLeft: 60, paddingRight: 500, paddingBottom: 25 },
|
||||
content: { marginLeft: 268, marginTop: 63, marginRight: 0, width: 476, height: 377, paddingTop: 0, paddingBottom: 0 },
|
||||
nav: { top: 150, left: -210 },
|
||||
};
|
||||
|
||||
export default function AppShell({ children }: { children: ReactNode }) {
|
||||
const navigate = useNavigate();
|
||||
const [theme, setTheme] = useState<Theme>(getStoredTheme);
|
||||
const layout = frameLayout;
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
// Remove all theme classes, then apply
|
||||
root.classList.remove("dark", "theme-azure");
|
||||
if (theme === "terra") {
|
||||
root.classList.add("dark");
|
||||
} else {
|
||||
root.classList.add("theme-azure");
|
||||
}
|
||||
localStorage.setItem("micronomicon-theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => setTheme(t => t === "terra" ? "azure" : "terra");
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-col h-screen bg-background text-foreground">
|
||||
<main className="flex-1 overflow-auto" data-scroll-root>
|
||||
<div className={`relative ${layout.containerMaxW} ${layout.containerMinW} mx-auto min-h-full`}>
|
||||
{/* Frame SVG — background */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 w-full min-w-full pointer-events-none select-none"
|
||||
style={{
|
||||
zIndex: 0,
|
||||
height: `${layout.frameHeight}px`,
|
||||
background: "var(--primary)",
|
||||
WebkitMaskImage: `url(${layout.svg})`,
|
||||
maskImage: `url(${layout.svg})`,
|
||||
WebkitMaskSize: "cover",
|
||||
maskSize: "cover",
|
||||
WebkitMaskRepeat: "no-repeat",
|
||||
maskRepeat: "no-repeat",
|
||||
objectFit: "fill"
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Lazy eyes on the frame figure */}
|
||||
<LazyEyes
|
||||
eyes={[
|
||||
{ top: 81, left: 554, size: 5, irisSize: 2 },
|
||||
{ top: 85, left: 562, size: 5, irisSize: 2 },
|
||||
]}
|
||||
maxShift={2}
|
||||
/>
|
||||
|
||||
{/* Top hole — ASCII title */}
|
||||
<div
|
||||
className="relative z-10 flex flex-col overflow-hidden"
|
||||
style={layout.title}
|
||||
>
|
||||
<pre
|
||||
className="select-none text-primary/70 hover:text-primary transition-colors whitespace-pre origin-center cursor-pointer bg-background"
|
||||
style={{ height: 60, fontSize: 6, lineHeight: 1.05, transform: "scale(0.50)", transformOrigin: "left center", alignContent: "center" }}
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
{TITLE}
|
||||
</pre>
|
||||
<span className="font-mono text-[10px] w-fit text-muted-foreground bg-background"><RomanClock /></span>
|
||||
</div>
|
||||
|
||||
{/* Bottom hole — main content */}
|
||||
<div
|
||||
className="relative z-10 overflow-auto"
|
||||
style={layout.content}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Nav menu — anchored below the frame, left side */}
|
||||
<div
|
||||
className="absolute z-20"
|
||||
style={layout.nav}
|
||||
>
|
||||
<NavMenu theme={theme} onToggleTheme={toggleTheme} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
<footer className="flex items-center justify-center gap-3 text-[10px] text-muted-foreground py-4 shrink-0 tracking-widest uppercase">
|
||||
<span>Created with hubris · MMXXVI</span>
|
||||
</footer>
|
||||
</div>
|
||||
<Toaster />
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
154
frontend/src/components/shared/FloatingWindow.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useCallback, useEffect, useRef, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export const DITHERED_SHADOW = `
|
||||
3px 3px 0 0 var(--border), 5px 3px 0 0 transparent, 7px 3px 0 0 var(--border),
|
||||
4px 4px 0 0 transparent, 6px 4px 0 0 var(--border),
|
||||
3px 5px 0 0 var(--border), 5px 5px 0 0 transparent, 7px 5px 0 0 var(--border),
|
||||
4px 6px 0 0 var(--border), 6px 6px 0 0 transparent
|
||||
`;
|
||||
|
||||
export interface FloatingWindowProps {
|
||||
id: string;
|
||||
title: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
zIndex: number;
|
||||
focused: boolean;
|
||||
onUpdate: (id: string, patch: { x?: number; y?: number; w?: number; h?: number }) => void;
|
||||
onClose: (id: string) => void;
|
||||
onFocus: (id: string) => void;
|
||||
minW?: number;
|
||||
minH?: number;
|
||||
addressBar?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
containerRef?: React.RefObject<HTMLDivElement | null>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function FloatingWindow({
|
||||
id, title, x, y, w, h, zIndex, focused,
|
||||
onUpdate, onClose, onFocus,
|
||||
minW = 320, minH = 200,
|
||||
addressBar, footer, containerRef, children,
|
||||
}: FloatingWindowProps) {
|
||||
const internalRef = useRef<HTMLDivElement>(null);
|
||||
const ref = containerRef ?? internalRef;
|
||||
const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
|
||||
const resizeRef = useRef<{ startX: number; startY: number; origW: number; origH: number } | null>(null);
|
||||
const velocityRef = useRef({ vx: 0, vy: 0, lastX: 0, lastY: 0, lastT: 0 });
|
||||
const inertiaRef = useRef(0);
|
||||
const posRef = useRef({ x, y });
|
||||
posRef.current = { x, y };
|
||||
|
||||
useEffect(() => { if (focused) ref.current?.focus(); }, [focused]);
|
||||
|
||||
// Cancel any running inertia animation
|
||||
const stopInertia = useCallback(() => {
|
||||
if (inertiaRef.current) { cancelAnimationFrame(inertiaRef.current); inertiaRef.current = 0; }
|
||||
}, []);
|
||||
|
||||
const onDragStart = useCallback((e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest("button")) return;
|
||||
e.preventDefault(); onFocus(id);
|
||||
ref.current?.focus();
|
||||
stopInertia();
|
||||
dragRef.current = { startX: e.clientX, startY: e.clientY, origX: x, origY: y };
|
||||
velocityRef.current = { vx: 0, vy: 0, lastX: e.clientX, lastY: e.clientY, lastT: performance.now() };
|
||||
document.documentElement.classList.add("cursor-grabbing");
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
const now = performance.now();
|
||||
const dt = now - velocityRef.current.lastT;
|
||||
if (dt > 0) {
|
||||
const smooth = 0.3;
|
||||
const rawVx = (ev.clientX - velocityRef.current.lastX) / dt * 16;
|
||||
const rawVy = (ev.clientY - velocityRef.current.lastY) / dt * 16;
|
||||
velocityRef.current.vx = velocityRef.current.vx * (1 - smooth) + rawVx * smooth;
|
||||
velocityRef.current.vy = velocityRef.current.vy * (1 - smooth) + rawVy * smooth;
|
||||
velocityRef.current.lastX = ev.clientX;
|
||||
velocityRef.current.lastY = ev.clientY;
|
||||
velocityRef.current.lastT = now;
|
||||
}
|
||||
onUpdate(id, {
|
||||
x: dragRef.current.origX + (ev.clientX - dragRef.current.startX),
|
||||
y: Math.max(0, dragRef.current.origY + (ev.clientY - dragRef.current.startY)),
|
||||
});
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
const { vx, vy } = velocityRef.current;
|
||||
dragRef.current = null;
|
||||
document.documentElement.classList.remove("cursor-grabbing");
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
|
||||
// Kick off inertia if there's meaningful velocity
|
||||
if (Math.abs(vx) > 0.5 || Math.abs(vy) > 0.5) {
|
||||
let curVx = vx;
|
||||
let curVy = vy;
|
||||
const friction = 0.92;
|
||||
const el = ref.current;
|
||||
const tick = () => {
|
||||
curVx *= friction;
|
||||
curVy *= friction;
|
||||
if (Math.abs(curVx) < 0.3 && Math.abs(curVy) < 0.3) {
|
||||
inertiaRef.current = 0;
|
||||
// Sync final position to React state once
|
||||
onUpdate(id, posRef.current);
|
||||
return;
|
||||
}
|
||||
posRef.current = { x: posRef.current.x + curVx, y: Math.max(0, posRef.current.y + curVy) };
|
||||
// Direct DOM update during animation — skip React reconciliation
|
||||
if (el) {
|
||||
el.style.left = `${posRef.current.x}px`;
|
||||
el.style.top = `${posRef.current.y}px`;
|
||||
}
|
||||
inertiaRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
inertiaRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
}, [id, x, y, onUpdate, onFocus, stopInertia]);
|
||||
|
||||
const onResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault(); e.stopPropagation(); onFocus(id);
|
||||
resizeRef.current = { startX: e.clientX, startY: e.clientY, origW: w, origH: h };
|
||||
document.documentElement.classList.add("cursor-nwse-resize");
|
||||
const onMove = (ev: MouseEvent) => { if (!resizeRef.current) return; onUpdate(id, { w: Math.max(minW, resizeRef.current.origW + (ev.clientX - resizeRef.current.startX)), h: Math.max(minH, resizeRef.current.origH + (ev.clientY - resizeRef.current.startY)) }); };
|
||||
const onUp = () => { resizeRef.current = null; document.documentElement.classList.remove("cursor-nwse-resize"); document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); };
|
||||
document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp);
|
||||
}, [id, w, h, minW, minH, onUpdate, onFocus]);
|
||||
|
||||
return createPortal(
|
||||
<div ref={ref} tabIndex={-1} onKeyDown={(e) => { if (e.key === "Escape") onClose(id); }} onMouseDown={() => onFocus(id)}
|
||||
className="fixed z-999 flex flex-col bg-popover text-popover-foreground border-2 rounded-lg outline-none transition-[border-color,opacity] duration-150"
|
||||
style={{ left: x, top: y, width: w, height: h, zIndex: 999 + zIndex, borderColor: focused ? "var(--primary)" : "var(--border)", opacity: focused ? 1 : 0.85, boxShadow: DITHERED_SHADOW }}>
|
||||
{/* Title bar */}
|
||||
<div onMouseDown={onDragStart} className="flex items-center gap-2 px-3 py-1.5 border-b-2 border-border cursor-grab active:cursor-grabbing select-none shrink-0 bg-muted/30 rounded-t-lg">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button onClick={() => onClose(id)} className="w-2.5 h-2.5 rounded-full bg-destructive hover:brightness-125 transition-all" />
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-muted-foreground/30" /><span className="w-2.5 h-2.5 rounded-full bg-muted-foreground/30" />
|
||||
</div>
|
||||
<span className="flex-1 text-[10px] font-semibold uppercase tracking-wider truncate text-center">{title}</span>
|
||||
</div>
|
||||
{/* Optional address bar */}
|
||||
{addressBar}
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{children}
|
||||
</div>
|
||||
{/* Optional footer */}
|
||||
{footer}
|
||||
{/* Resize handle */}
|
||||
<div onMouseDown={onResizeStart} className="absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize" style={{ touchAction: "none" }}>
|
||||
<svg viewBox="0 0 16 16" className="w-full h-full text-muted-foreground/50"><path d="M14 14L8 14L14 8Z" fill="currentColor" /><path d="M14 14L11 14L14 11Z" fill="currentColor" opacity="0.5" /></svg>
|
||||
</div>
|
||||
</div>, document.body);
|
||||
}
|
||||
77
frontend/src/components/shared/LazyEyes.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useRef, useEffect, useCallback } from "react";
|
||||
import { useLazyEyes } from "@/hooks/useLazyEyes";
|
||||
|
||||
interface EyeSpec {
|
||||
top: number;
|
||||
left: number;
|
||||
size?: number;
|
||||
irisSize?: number;
|
||||
}
|
||||
|
||||
interface LazyEyesProps {
|
||||
eyes: EyeSpec[];
|
||||
/** Viewport-relative anchor the eyes "live" at. If omitted, uses the component's own position. */
|
||||
anchor?: { x: number; y: number };
|
||||
maxShift?: number;
|
||||
ease?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function LazyEyes({ eyes, anchor, maxShift, ease, className }: LazyEyesProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const anchorRef = useRef<{ x: number; y: number } | null>(anchor ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
if (anchor) {
|
||||
anchorRef.current = anchor;
|
||||
return;
|
||||
}
|
||||
const update = () => {
|
||||
if (containerRef.current) {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
anchorRef.current = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
||||
}
|
||||
};
|
||||
update();
|
||||
window.addEventListener("scroll", update, true);
|
||||
window.addEventListener("resize", update);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", update, true);
|
||||
window.removeEventListener("resize", update);
|
||||
};
|
||||
}, [anchor]);
|
||||
|
||||
const { registerIris, unregisterIris } = useLazyEyes({ anchorRef, maxShift, ease });
|
||||
|
||||
const irisRef = useCallback((el: HTMLElement | null) => {
|
||||
if (el) registerIris(el);
|
||||
return () => { if (el) unregisterIris(el); };
|
||||
}, [registerIris, unregisterIris]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={className} style={{ position: "absolute", pointerEvents: "none" }}>
|
||||
{eyes.map((eye, i) => {
|
||||
const size = eye.size ?? 5;
|
||||
const irisSize = eye.irisSize ?? 2;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="editor-pointer-eye"
|
||||
style={{ top: eye.top, left: eye.left, width: size, height: size }}
|
||||
>
|
||||
<div
|
||||
ref={irisRef}
|
||||
className="editor-pointer-iris"
|
||||
style={{
|
||||
width: irisSize,
|
||||
height: irisSize,
|
||||
marginTop: -(irisSize / 2) - 0.5,
|
||||
marginLeft: -(irisSize / 2) - 0.5,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
frontend/src/components/shared/Loader.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import loadingSvg from "@/assets/loading.min.svg";
|
||||
|
||||
interface LoaderProps {
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Loader({ size = 120, className = "" }: LoaderProps) {
|
||||
return (
|
||||
<div
|
||||
className={`animate-spin-slow ${className}`}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
background: "var(--primary)",
|
||||
maskImage: `url(${loadingSvg})`,
|
||||
maskSize: "contain",
|
||||
maskRepeat: "no-repeat",
|
||||
maskPosition: "center",
|
||||
WebkitMaskImage: `url(${loadingSvg})`,
|
||||
WebkitMaskSize: "contain",
|
||||
WebkitMaskRepeat: "no-repeat",
|
||||
WebkitMaskPosition: "center",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
102
frontend/src/components/shared/NavMenu.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import menuSvg from "@/assets/menu.min.svg";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ label: "Browse", path: "/browse" },
|
||||
{ label: "Compose", path: "/" },
|
||||
{ label: "Settings", path: "/settings" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Smooth scroll-following: tracks the scroll container and lerps
|
||||
* the menu's vertical offset so it glides into place with eased acceleration.
|
||||
*/
|
||||
function useSmoothScroll(scrollSelector: string, ease = 0.08) {
|
||||
const [offset, setOffset] = useState(0);
|
||||
const targetRef = useRef(0);
|
||||
const currentRef = useRef(0);
|
||||
const rafRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const container = document.querySelector(scrollSelector);
|
||||
if (!container) return;
|
||||
|
||||
const onScroll = () => {
|
||||
targetRef.current = container.scrollTop;
|
||||
};
|
||||
container.addEventListener("scroll", onScroll, { passive: true });
|
||||
|
||||
const tick = () => {
|
||||
const diff = targetRef.current - currentRef.current;
|
||||
if (Math.abs(diff) < 0.5) {
|
||||
currentRef.current = targetRef.current;
|
||||
} else {
|
||||
currentRef.current += diff * ease;
|
||||
}
|
||||
setOffset(currentRef.current);
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("scroll", onScroll);
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [scrollSelector, ease]);
|
||||
|
||||
return offset;
|
||||
}
|
||||
|
||||
interface NavMenuProps {
|
||||
theme: "terra" | "azure";
|
||||
onToggleTheme: () => void;
|
||||
}
|
||||
|
||||
export default function NavMenu({ theme, onToggleTheme }: NavMenuProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const scrollY = useSmoothScroll("[data-scroll-root]", 0.07);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="nav-menu-root"
|
||||
style={{ transform: `translateY(${scrollY}px)` }}
|
||||
>
|
||||
{/* Frame — menu.min.svg as mask, colored by theme */}
|
||||
<div
|
||||
className="nav-menu-frame"
|
||||
style={{
|
||||
WebkitMaskImage: `url(${menuSvg})`,
|
||||
maskImage: `url(${menuSvg})`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Navigation links */}
|
||||
<nav className="nav-menu-links">
|
||||
{NAV_ITEMS.map(({ label, path }) => {
|
||||
const active = location.pathname === path;
|
||||
return (
|
||||
<button
|
||||
key={path}
|
||||
onClick={() => navigate(path)}
|
||||
className="nav-menu-item"
|
||||
data-active={active || undefined}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Theme toggle — bottom container */}
|
||||
<button
|
||||
onClick={onToggleTheme}
|
||||
className="nav-menu-theme-toggle"
|
||||
title={`Switch to ${theme === "terra" ? "Azure" : "Terracotta"} theme`}
|
||||
>
|
||||
{theme === "terra" ? "◐ AZURE" : "◑ TERRA"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
187
frontend/src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: AlertDialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Backdrop
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AlertDialogPrimitive.Popup.Props & {
|
||||
size?: "default" | "sm"
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Popup
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn(
|
||||
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogMedia({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-media"
|
||||
className={cn(
|
||||
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
return (
|
||||
<Button
|
||||
data-slot="alert-dialog-action"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
...props
|
||||
}: AlertDialogPrimitive.Close.Props &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Close
|
||||
data-slot="alert-dialog-cancel"
|
||||
className={cn(className)}
|
||||
render={<Button variant={variant} size={size} />}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
}
|
||||
52
frontend/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
59
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
20
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
88
frontend/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import * as React from "react"
|
||||
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: PopoverPrimitive.Popup.Props &
|
||||
Pick<
|
||||
PopoverPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-9999"
|
||||
>
|
||||
<PopoverPrimitive.Popup
|
||||
data-slot="popover-content"
|
||||
className={cn(
|
||||
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Positioner>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-header"
|
||||
className={cn("flex flex-col gap-0.5 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
|
||||
return (
|
||||
<PopoverPrimitive.Title
|
||||
data-slot="popover-title"
|
||||
className={cn("font-heading font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverDescription({
|
||||
className,
|
||||
...props
|
||||
}: PopoverPrimitive.Description.Props) {
|
||||
return (
|
||||
<PopoverPrimitive.Description
|
||||
data-slot="popover-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
}
|
||||
48
frontend/src/components/ui/resizable.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: ResizablePrimitive.GroupProps) {
|
||||
return (
|
||||
<ResizablePrimitive.Group
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full aria-[orientation=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: ResizablePrimitive.SeparatorProps & {
|
||||
withHandle?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ResizablePrimitive.Separator
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"relative flex w-px items-center justify-center bg-border ring-offset-background after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-6 w-1 shrink-0 rounded-lg bg-border" />
|
||||
)}
|
||||
</ResizablePrimitive.Separator>
|
||||
)
|
||||
}
|
||||
|
||||
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }
|
||||
47
frontend/src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: (
|
||||
<CircleCheckIcon className="size-4" />
|
||||
),
|
||||
info: (
|
||||
<InfoIcon className="size-4" />
|
||||
),
|
||||
warning: (
|
||||
<TriangleAlertIcon className="size-4" />
|
||||
),
|
||||
error: (
|
||||
<OctagonXIcon className="size-4" />
|
||||
),
|
||||
loading: (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
),
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: "cn-toast",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
114
frontend/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-7 px-1.5 text-left align-middle font-medium whitespace-nowrap text-foreground text-xs [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"px-1.5 py-1 align-middle whitespace-nowrap text-xs [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
66
frontend/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delay = 0,
|
||||
...props
|
||||
}: TooltipPrimitive.Provider.Props) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delay={delay}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
side = "top",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: TooltipPrimitive.Popup.Props &
|
||||
Pick<
|
||||
TooltipPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<TooltipPrimitive.Popup
|
||||
data-slot="tooltip-content"
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||
</TooltipPrimitive.Popup>
|
||||
</TooltipPrimitive.Positioner>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
67
frontend/src/hooks/useCompile.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useStore, type StoreApi } from "zustand";
|
||||
import { useEditorStore } from "@/stores/editorStore";
|
||||
import type { EditorStore } from "@/stores/editorStore";
|
||||
import { compile } from "@/api/client";
|
||||
|
||||
const DEBOUNCE_MS = 400;
|
||||
|
||||
/**
|
||||
* Debounced hook that compiles µFrame source via the API.
|
||||
* Automatically triggers on ufSource changes.
|
||||
* Accepts an optional store API for per-window instances; falls back to the global singleton.
|
||||
*/
|
||||
export function useCompile(storeApi?: StoreApi<EditorStore>) {
|
||||
const store = storeApi ?? useEditorStore;
|
||||
const ufSource = useStore(store, (s) => s.ufSource);
|
||||
const setCompileResult = useStore(store, (s) => s.setCompileResult);
|
||||
const setCompiling = useStore(store, (s) => s.setCompiling);
|
||||
const setCompileError = useStore(store, (s) => s.setCompileError);
|
||||
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const doCompile = useCallback(
|
||||
async (source: string) => {
|
||||
if (!source.trim()) {
|
||||
setCompileResult("", "", "", false, []);
|
||||
return;
|
||||
}
|
||||
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setCompiling(true);
|
||||
|
||||
try {
|
||||
const data = await compile(source, controller.signal);
|
||||
setCompileResult(
|
||||
data.ascii,
|
||||
data.micron,
|
||||
data.script || "",
|
||||
data.is_dynamic || false,
|
||||
data.warnings || [],
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof DOMException && e.name === "AbortError") return;
|
||||
setCompileError(e instanceof Error ? e.message : "Compile failed");
|
||||
}
|
||||
},
|
||||
[setCompileResult, setCompiling, setCompileError],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => doCompile(ufSource), DEBOUNCE_MS);
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [ufSource, doCompile]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
30
frontend/src/hooks/useDslMeta.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchDslMeta, type DslMeta } from "@/api/client";
|
||||
|
||||
export type { DslMeta } from "@/api/client";
|
||||
|
||||
const DEFAULT_META: DslMeta = {
|
||||
keywords: [],
|
||||
values: [],
|
||||
commands: [],
|
||||
themes: [],
|
||||
};
|
||||
|
||||
let cachedMeta: DslMeta | null = null;
|
||||
|
||||
export function useDslMeta(): DslMeta {
|
||||
const [meta, setMeta] = useState<DslMeta>(cachedMeta || DEFAULT_META);
|
||||
|
||||
useEffect(() => {
|
||||
if (cachedMeta) return;
|
||||
|
||||
fetchDslMeta()
|
||||
.then((data) => {
|
||||
cachedMeta = data;
|
||||
setMeta(data);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return meta;
|
||||
}
|
||||
27
frontend/src/hooks/useKeyboardSave.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Registers Ctrl/Cmd+S (save) and optionally Ctrl/Cmd+P (publish) keyboard shortcuts.
|
||||
* Pass `enabled = false` to temporarily disable (e.g. when a window is not focused).
|
||||
*/
|
||||
export function useKeyboardSave(
|
||||
onSave: () => void,
|
||||
onPublish?: () => void,
|
||||
enabled = true,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
onSave();
|
||||
}
|
||||
if (onPublish && (e.metaKey || e.ctrlKey) && e.key === "p") {
|
||||
e.preventDefault();
|
||||
onPublish();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onSave, onPublish, enabled]);
|
||||
}
|
||||
106
frontend/src/hooks/useLazyEyes.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
|
||||
interface LazyEyesOptions {
|
||||
/** Reference point the eyes "live" at (viewport coords). Eyes look away from this toward the mouse. */
|
||||
anchorRef: React.RefObject<{ x: number; y: number } | null>;
|
||||
/** Max pixel shift for the iris (default 1.5) */
|
||||
maxShift?: number;
|
||||
/** Lerp ease factor 0–1 for slow drift (default 0.04) */
|
||||
ease?: number;
|
||||
/** Saccade threshold — when target jumps more than this, snap fast (default 0.8) */
|
||||
saccadeThreshold?: number;
|
||||
/** Fast ease for saccade snap (default 0.35) */
|
||||
saccadeEase?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a register function to attach iris elements for direct DOM updates.
|
||||
* No React state is set per frame — transforms are applied directly.
|
||||
*
|
||||
* Movement model:
|
||||
* - Small mouse moves → slow, lazy drift (ease)
|
||||
* - Large jumps → quick saccade snap (saccadeEase), then settle
|
||||
* - Tiny random micro-drift to avoid perfectly still eyes
|
||||
*/
|
||||
export function useLazyEyes({
|
||||
anchorRef,
|
||||
maxShift = 1.5,
|
||||
ease = 0.04,
|
||||
saccadeThreshold = 0.8,
|
||||
saccadeEase = 0.35,
|
||||
}: LazyEyesOptions) {
|
||||
const targetRef = useRef({ x: 0, y: 0 });
|
||||
const currentRef = useRef({ x: 0, y: 0 });
|
||||
const velocityRef = useRef({ x: 0, y: 0 });
|
||||
const irisesRef = useRef<Set<HTMLElement>>(new Set());
|
||||
const offsetRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
const registerIris = useCallback((el: HTMLElement | null) => {
|
||||
if (el) {
|
||||
irisesRef.current.add(el);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const unregisterIris = useCallback((el: HTMLElement | null) => {
|
||||
if (el) {
|
||||
irisesRef.current.delete(el);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
const anchor = anchorRef.current;
|
||||
if (!anchor) return;
|
||||
const dx = e.clientX - anchor.x;
|
||||
const dy = e.clientY - anchor.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
targetRef.current = {
|
||||
x: (dx / dist) * maxShift,
|
||||
y: (dy / dist) * maxShift,
|
||||
};
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
|
||||
let raf = 0;
|
||||
const tick = () => {
|
||||
const ec = currentRef.current;
|
||||
const et = targetRef.current;
|
||||
const vel = velocityRef.current;
|
||||
|
||||
const dx = et.x - ec.x;
|
||||
const dy = et.y - ec.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
const e_ = dist > saccadeThreshold ? saccadeEase : ease;
|
||||
|
||||
vel.x = vel.x * 0.6 + dx * e_ * 0.4;
|
||||
vel.y = vel.y * 0.6 + dy * e_ * 0.4;
|
||||
ec.x += vel.x;
|
||||
ec.y += vel.y;
|
||||
|
||||
if (dist < 0.1) {
|
||||
ec.x += (Math.random() - 0.5) * 0.02;
|
||||
ec.y += (Math.random() - 0.5) * 0.02;
|
||||
}
|
||||
|
||||
offsetRef.current.x = ec.x;
|
||||
offsetRef.current.y = ec.y;
|
||||
|
||||
// Direct DOM updates — no React re-render
|
||||
for (const iris of irisesRef.current) {
|
||||
iris.style.transform = `translate(${ec.x}px, ${ec.y}px)`;
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [anchorRef, maxShift, ease, saccadeThreshold, saccadeEase]);
|
||||
|
||||
return { offsetRef, registerIris, unregisterIris };
|
||||
}
|
||||