Compare commits
4 Commits
8776459ffb
...
175de893aa
| Author | SHA1 | Date | |
|---|---|---|---|
| 175de893aa | |||
| 8d3245b7b1 | |||
| 01a3e0095c | |||
| 0316e50233 |
465
CLAUDE.md
465
CLAUDE.md
@@ -1,290 +1,321 @@
|
|||||||
# Micronomicon → µFrame
|
# µFrame (Micronomicon)
|
||||||
|
|
||||||
A self-hosted web IDE for building rich terminal UIs that publish as `.mu` pages to a NomadNet node.
|
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.
|
||||||
|
|
||||||
> **Evolution:** Started as a raw Micron text editor (Phases 1–2, complete).
|
## What It Does
|
||||||
> Now pivoting to **µFrame** — a declarative DSL that compiles `.uf` files into
|
|
||||||
> both plain ASCII art and styled Micron `.mu` output from the same source.
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Phases 1–2 complete (raw Micron editor). Phase 3 (µFrame engine) is next.
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
| Layer | Technology |
|
|
||||||
|------------|--------------------------------------------------|
|
|
||||||
| Backend | Python 3.13 + FastAPI + uvicorn |
|
|
||||||
| µFrame | Pure Python: parser → IR → CharGrid → emitters |
|
|
||||||
| Frontend | React 19 + Vite + TypeScript |
|
|
||||||
| UI | shadcn/ui + Tailwind CSS v4 + tw-animate-css |
|
|
||||||
| Editor | CodeMirror 6 |
|
|
||||||
| Graph | React Flow (@xyflow/react) + dagre |
|
|
||||||
| State | Zustand |
|
|
||||||
| Container | Docker + Compose |
|
|
||||||
|
|
||||||
## Directory Layout
|
|
||||||
|
|
||||||
|
Write this:
|
||||||
```
|
```
|
||||||
micronomicon/
|
page "Node Status" 60
|
||||||
Dockerfile
|
box double "Relay Alpha-7"
|
||||||
compose.yml
|
align center
|
||||||
docs/
|
text "Reticulum Network Node"
|
||||||
framework-design-v3.md # µFrame DSL spec + rendering model
|
gauge "CPU" 62 100 28 warn=75 crit=90
|
||||||
dynamic-templates.md # Dynamic page addendum (Phases 5–6)
|
status "East Relay" online
|
||||||
backend/
|
table "Routes"
|
||||||
main.py # FastAPI app + static file serving
|
columns "Dest" 20 | "Hops" 6 | "Status" 10
|
||||||
converter.py # POST /api/compile (µFrame → ASCII + Micron)
|
row "relay-east" | "2" | "@color{0f0}{alive}"
|
||||||
pages.py # file management (CRUD /api/pages)
|
|
||||||
graph.py # link parser (GET /api/graph)
|
|
||||||
docker_utils.py # container restart (POST /api/restart)
|
|
||||||
requirements.txt
|
|
||||||
uframe/ # µFrame engine (Phase 3+)
|
|
||||||
__init__.py # compile(source, width) → CompileResult
|
|
||||||
errors.py # ParseError, LayoutError
|
|
||||||
ir.py # IR node dataclasses
|
|
||||||
parser.py # .uf DSL → IR tree
|
|
||||||
grid.py # CharGrid (2D char + style buffer)
|
|
||||||
chars.py # Unicode lookup tables (box-drawing, braille)
|
|
||||||
measure.py # bottom-up size computation
|
|
||||||
layout.py # top-down position assignment
|
|
||||||
paint.py # IR → CharGrid rendering
|
|
||||||
borders.py # junction merging post-pass
|
|
||||||
emit_ascii.py # CharGrid → plain text
|
|
||||||
emit_micron.py # CharGrid → Micron with style tags
|
|
||||||
viz.py # gauge, sparkline, status (Phase 4)
|
|
||||||
table.py # table layout + box-drawn grid (Phase 4)
|
|
||||||
frontend/
|
|
||||||
src/
|
|
||||||
App.tsx
|
|
||||||
routes/ # DashboardView, EditorView, GraphView
|
|
||||||
components/
|
|
||||||
dashboard/ # page list, status badges
|
|
||||||
editor/
|
|
||||||
EditorPane.tsx # CodeMirror host
|
|
||||||
PreviewPane.tsx # ASCII + Micron + Raw preview tabs
|
|
||||||
ToolBar.tsx # save/publish + backlinks
|
|
||||||
uframeHighlight.ts # CM6 .uf syntax highlighting (Phase 3+)
|
|
||||||
uframeCommands.ts # "/" command palette for .uf (Phase 3+)
|
|
||||||
micronRenderer.ts # Micron → HTML (renders compiled output)
|
|
||||||
BacklinkIndicator.tsx
|
|
||||||
shared/
|
|
||||||
ui/ # shadcn components
|
|
||||||
stores/ # editorStore, pagesStore (Zustand)
|
|
||||||
hooks/ # useCompile, useGraph, useUnsavedGuard
|
|
||||||
lib/ # utils (cn)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Local Development
|
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
|
||||||
|
|
||||||
**Backend:**
|
|
||||||
```bash
|
```bash
|
||||||
|
# Backend
|
||||||
cd backend
|
cd backend
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
PAGES_DIR=~/.nomadnetwork/storage/pages \
|
PAGES_DIR=~/.nomadnetwork/storage/pages \
|
||||||
SOURCES_DIR=~/.micron-editor/sources \
|
SOURCES_DIR=~/.micron-editor/sources \
|
||||||
uvicorn main:app --reload --port 8080
|
uvicorn main:app --reload --port 8080
|
||||||
```
|
|
||||||
|
|
||||||
**Frontend:**
|
# Frontend (separate terminal)
|
||||||
```bash
|
|
||||||
cd frontend
|
cd frontend
|
||||||
npm run dev # proxies /api -> localhost:8080
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Frontend at http://localhost:5173, backend at http://localhost:8080.
|
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
|
## API Endpoints
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|---------------------|------------------------------------------------------|
|
|--------|---------------------|------------------------------------------------------|
|
||||||
| GET | /api/health | Health check |
|
| GET | /api/health | Health check |
|
||||||
| POST | /api/compile | Compile `.uf` → `{ascii, micron, warnings}` |
|
| POST | /api/compile | Compile `.uf` → `{ascii, micron, script, is_dynamic}` |
|
||||||
| GET | /api/pages | List all pages with metadata |
|
| GET | /api/pages | List all pages with metadata |
|
||||||
| GET | /api/pages/{name} | Read page source (`.uf` or legacy `.mu`) |
|
| GET | /api/pages/{name} | Read page source |
|
||||||
| POST | /api/pages/{name} | Save page — body `{ source, publish: bool }` |
|
| POST | /api/pages/{name} | Save page — `{ source, publish }` |
|
||||||
| DELETE | /api/pages/{name} | Delete source and/or .mu file |
|
| DELETE | /api/pages/{name} | Delete source and/or .mu file |
|
||||||
| GET | /api/graph | Graph nodes + edges (parsed from links in source) |
|
| GET | /api/graph | Page link graph (nodes + edges) |
|
||||||
| POST | /api/restart | Restart NomadNet Docker container |
|
| POST | /api/restart | Restart NomadNet Docker container |
|
||||||
|
|
||||||
## Storage
|
## Storage
|
||||||
|
|
||||||
```
|
```
|
||||||
~/.micron-editor/sources/ ← .uf source files (draft + published)
|
~/.micron-editor/sources/ ← .uf source files (drafts + published)
|
||||||
~/.nomadnetwork/storage/pages/ ← Compiled .mu files served by NomadNet
|
~/.nomadnetwork/storage/pages/ ← Compiled .mu files served by NomadNet
|
||||||
```
|
```
|
||||||
|
|
||||||
On publish: `.uf` is compiled to `.mu` and copied to the NomadNet pages directory.
|
- **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)
|
||||||
|
|
||||||
## Conventions
|
NomadNet auto-detects the execute bit: static pages are served as-is, dynamic pages are executed and their stdout is served.
|
||||||
|
|
||||||
- Frontend UI components live in `frontend/src/components/ui/` (shadcn)
|
|
||||||
- Feature components grouped by domain: `dashboard/`, `editor/`, `shared/`
|
|
||||||
- State management via Zustand stores in `frontend/src/stores/`
|
|
||||||
- Backend is pure FastAPI; no ORM, flat file storage
|
|
||||||
- µFrame engine is pure Python stdlib — no external dependencies
|
|
||||||
|
|
||||||
## µFrame DSL Reference
|
## µFrame DSL Reference
|
||||||
|
|
||||||
Full spec: `docs/framework-design-v3.md`
|
### Layout
|
||||||
|
|
||||||
### Layout primitives
|
|
||||||
```
|
```
|
||||||
page "Title" [width] # root container (default 64)
|
page "Title" [width] # root (default width 64)
|
||||||
box [light|heavy|double|rounded] "Title" # bordered panel
|
box [light|heavy|double|rounded] "Title" # bordered panel
|
||||||
row [gap] # horizontal layout
|
row [gap] # horizontal layout
|
||||||
col [width] # column in a row
|
col [width] # column in a row
|
||||||
spacer [lines] # vertical whitespace
|
spacer [lines] # vertical whitespace
|
||||||
pad [t] [r] [b] [l] # inner margin
|
pad [t] [r] [b] [l] # inner margin
|
||||||
```
|
```
|
||||||
|
|
||||||
### Content primitives
|
### Content
|
||||||
```
|
```
|
||||||
heading [1|2|3] "Text" # styled heading
|
heading [1|2|3] "Text" # styled heading
|
||||||
text "Content with @bold{inline} @color{hex}{modifiers}"
|
text "Content with @bold{inline} @color{hex}{modifiers}"
|
||||||
label "Key" "Value" # aligned key-value pair
|
label "Key" "Value" # aligned key-value pair
|
||||||
list [bullet|dash]
|
list [bullet|dash]
|
||||||
item "Entry"
|
item "Entry"
|
||||||
link "Display text" "/dest.mu" # clickable in Micron
|
link "Display text" "/dest.mu" # clickable in Micron
|
||||||
divider [light|heavy|double|dash|dot] # horizontal rule
|
divider [light|heavy|double|dash|dot] # horizontal rule
|
||||||
# comment # ignored in output
|
# comment # ignored in output
|
||||||
```
|
```
|
||||||
|
|
||||||
### Data visualization (Phase 4)
|
### Data Visualization
|
||||||
```
|
```
|
||||||
gauge "Label" $val $max $width warn=N crit=N
|
gauge "Label" value max width [warn=N crit=N] # ████░░░░ bar with thresholds
|
||||||
sparkline "Label" $values $width # braille patterns
|
sparkline "Label" "1,3,5,8,7,5" width # ⣀⣤⣶⣿⣷⣤ braille chart
|
||||||
status "Label" [online|offline|degraded] # ●○◐ indicators
|
status "Label" [online|offline|degraded] # ●○◐ colored indicators
|
||||||
table "Title"
|
table "Title"
|
||||||
columns "Name" 24 | "Hops" 6 | "Status" 10
|
columns "Name" 20 | "Hops" 6 | "Status" 10
|
||||||
row "value" | "value" | "value"
|
row "relay" | "2" | "@color{0f0}{● alive}"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Forms (Phase 5)
|
### Forms
|
||||||
```
|
```
|
||||||
form "name"
|
form "name"
|
||||||
field "name" [width] "placeholder"
|
field "name" [width] "placeholder" # text input
|
||||||
radio "group" "Opt A" | "Opt B"
|
password "name" [width] "placeholder" # masked input
|
||||||
checkbox "name" "Label"
|
radio "group" "Opt A" | "Opt B" | "Opt C" # radio buttons
|
||||||
button "Label" "/action/path"
|
checkbox "name" "Label" # checkbox
|
||||||
|
button "Label" "/action/path" # submit link
|
||||||
```
|
```
|
||||||
|
|
||||||
### Dynamic features (Phase 6)
|
### Dynamic Features
|
||||||
```
|
```
|
||||||
source cpu : shell "cat /proc/loadavg"
|
cache 0 # never cache (re-execute)
|
||||||
on_submit "form_name"
|
source cpu : shell "cat /proc/loadavg" # live data at render time
|
||||||
# handle form data
|
source config : json "/path/config.json" # JSON file read
|
||||||
if $val > threshold
|
source ts : python "datetime.now().isoformat()" # Python expression
|
||||||
# conditional rendering
|
let name = "Relay Alpha" # variable assignment
|
||||||
for item in $collection
|
|
||||||
# iteration
|
if $cpu > 90
|
||||||
state "store" "/path.json"
|
text "ALERT: CPU critical"
|
||||||
cache 0 # never cache (re-execute per request)
|
elif $cpu > 75
|
||||||
|
text "Warning: elevated"
|
||||||
|
|
||||||
|
for peer in $peers
|
||||||
|
status "$peer.name" $peer.state
|
||||||
|
|
||||||
|
on_submit "search"
|
||||||
|
source results : shell "search.py '$query'"
|
||||||
|
text "$results"
|
||||||
|
|
||||||
|
state "counter" "/tmp/counter.json" # persistent JSON store
|
||||||
```
|
```
|
||||||
|
|
||||||
### Rendering pipeline
|
### Components
|
||||||
```
|
```
|
||||||
.uf source → Parse → IR Tree → Measure → Layout → Paint → CharGrid
|
# Define a reusable component
|
||||||
├→ ASCII emitter (plain text)
|
component stat(label, value, max)
|
||||||
└→ Micron emitter (styled .mu)
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
### Micron syntax (legacy raw editor, still used for compiled output)
|
### 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
|
||||||
|
|
||||||
```
|
```
|
||||||
>H1 >>H2 >>>H3 headings
|
.uf source
|
||||||
`!bold`! `*italic`* `_underline`_ formatting
|
│
|
||||||
`Fhex text`f `Bhex text`b colors (3-digit hex)
|
▼
|
||||||
`c text`a `r text`a `l text`a alignment
|
Parse ──→ IR Tree (30+ node types)
|
||||||
[label`slug] links
|
│
|
||||||
-─ -━ -═ -★ dividers
|
▼
|
||||||
`= ... `= literal mode
|
Measure (bottom-up: compute sizes)
|
||||||
# comment hidden in output
|
│
|
||||||
|
▼
|
||||||
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
## Roadmap
|
## Web IDE Features
|
||||||
|
|
||||||
### Phase 1 — Core Editor + Dashboard + Graph ✅
|
- **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
|
||||||
|
|
||||||
- Split-pane Micron editor with live preview
|
## Running Tests
|
||||||
- Save draft / publish to NomadNet pages dir
|
|
||||||
- Pages dashboard with status badges
|
|
||||||
- Page graph (React Flow + dagre)
|
|
||||||
- NomadNet restart, dark/light theme, keyboard shortcuts
|
|
||||||
|
|
||||||
### Phase 2 — Linking + Editor Enhancements ✅
|
```bash
|
||||||
|
cd backend
|
||||||
|
source .venv/bin/activate
|
||||||
|
python -m pytest uframe/tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
- `[[` page link autocomplete
|
42 tests covering:
|
||||||
- Backlink indicator with popover
|
- Static compilation (boxes, headings, text, gauges, tables, links, lists, spacers, dividers)
|
||||||
- Pivot to direct Micron editor (removed Markdown pipeline)
|
- Form elements (field, radio, checkbox, button)
|
||||||
- `/` slash command palette for Micron syntax
|
- Dynamic pages (source, if/for, let, state, on_submit, codegen)
|
||||||
- Micron syntax highlighting + HTML preview renderer
|
- Components (inline definitions, standard library, parameter substitution)
|
||||||
- Syntax completeness per micron-composer spec
|
|
||||||
|
|
||||||
### Phase 3 — µFrame Core Engine (next)
|
## Conventions
|
||||||
|
|
||||||
Build `backend/uframe/` — the rendering pipeline:
|
- µFrame engine is **pure Python stdlib** — zero external dependencies
|
||||||
|
- Backend is FastAPI; no ORM, flat file storage
|
||||||
1. `errors.py` + `ir.py` — data structures
|
- Frontend uses shadcn/ui components in `frontend/src/components/ui/`
|
||||||
2. `chars.py` — Unicode lookup tables (box-drawing, blocks, braille)
|
- Feature components grouped by domain: `dashboard/`, `editor/`, `shared/`
|
||||||
3. `parser.py` — `.uf` DSL → IR tree (indentation-based, line-oriented)
|
- State management via Zustand stores
|
||||||
4. `grid.py` — CharGrid class (2D char + style buffer)
|
- All `.uf` sources stored in `SOURCES_DIR`, compiled `.mu` in `PAGES_DIR`
|
||||||
5. `measure.py` + `layout.py` — size computation + position assignment
|
|
||||||
6. `paint.py` — IR nodes → CharGrid
|
|
||||||
7. `borders.py` — junction merging post-pass
|
|
||||||
8. `emit_ascii.py` + `emit_micron.py` — CharGrid → output strings
|
|
||||||
9. `__init__.py` — public `compile()` API
|
|
||||||
10. Update `converter.py` → `POST /api/compile` endpoint
|
|
||||||
11. Update `pages.py` — `.uf` sources, compile-on-publish
|
|
||||||
12. Update `graph.py` — parse `.uf` for links
|
|
||||||
|
|
||||||
### Phase 4 — Data Visualization
|
|
||||||
|
|
||||||
- gauge, meter, bar_h, bar_v (block elements)
|
|
||||||
- sparkline (braille sub-cell rendering)
|
|
||||||
- heatmap (shade blocks with per-cell color)
|
|
||||||
- status indicators (●○◐ with color)
|
|
||||||
- table (box-drawn with header separator)
|
|
||||||
- Border merging across nested tables
|
|
||||||
|
|
||||||
### Phase 5 — Web IDE Integration
|
|
||||||
|
|
||||||
- Replace Micron editor with µFrame DSL editor
|
|
||||||
- CodeMirror `.uf` syntax highlighting + autocomplete
|
|
||||||
- `useCompile` hook (debounced API calls)
|
|
||||||
- Triple preview: ASCII | Micron rendered | Raw Micron
|
|
||||||
- Page storage: `.uf` sources, `.mu` compiled output
|
|
||||||
- Migration script for legacy `.mu` sources
|
|
||||||
|
|
||||||
### Phase 6 — Forms & Interactivity
|
|
||||||
|
|
||||||
- Form primitives: field, password, radio, checkbox, button
|
|
||||||
- ASCII: visual placeholders; Micron: live fields
|
|
||||||
- @modifier inline syntax
|
|
||||||
- Variables and `let` bindings
|
|
||||||
|
|
||||||
### Phase 7 — Dynamic Pages
|
|
||||||
|
|
||||||
- `source` blocks for live data (shell, file, json, python, rns)
|
|
||||||
- Compile to executable Python scripts with embedded runtime
|
|
||||||
- `on_submit` form handling via `FIELD_*` env vars
|
|
||||||
- Conditionals (`if`/`elif`/`else`) and loops (`for`)
|
|
||||||
- State persistence (`state` + JSON store)
|
|
||||||
- Cache control headers
|
|
||||||
- CLI: `uframe compile` / `uframe deploy`
|
|
||||||
|
|
||||||
### Phase 8 — Components & Standard Library
|
|
||||||
|
|
||||||
- `component` definitions with argument bindings
|
|
||||||
- Standard library: `std/dashboard`, `std/filebrowser`, `std/board`
|
|
||||||
- Themes (`.uf-theme` color palette files)
|
|
||||||
- `uframe check` linter
|
|
||||||
- Production deployment: systemd + Tailscale
|
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- µFrame design: `docs/framework-design-v3.md`
|
- µFrame design spec: `docs/framework-design-v3.md`
|
||||||
- µFrame dynamic: `docs/dynamic-templates.md`
|
- Dynamic templates spec: `docs/dynamic-templates.md`
|
||||||
- micron-composer: https://github.com/fr33n0w/micron-composer
|
|
||||||
- micron-parser-js: https://rfnexus.github.io/micron-parser-js/
|
|
||||||
- NomadNet: https://github.com/markqvist/NomadNet
|
- NomadNet: https://github.com/markqvist/NomadNet
|
||||||
- md2txt (legacy): https://codeberg.org/randogoth/md2txt
|
- Micron syntax: https://github.com/fr33n0w/micron-composer
|
||||||
|
- Reticulum: https://github.com/markqvist/Reticulum
|
||||||
|
|||||||
281
README.md
281
README.md
@@ -1,71 +1,175 @@
|
|||||||
# 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
|
### Web IDE
|
||||||
- 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)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Start (Docker)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Build the frontend
|
# 1. Create directories
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
# 2. Create source directories
|
|
||||||
mkdir -p ~/.nomadnetwork/storage/pages ~/.micron-editor/sources
|
mkdir -p ~/.nomadnetwork/storage/pages ~/.micron-editor/sources
|
||||||
|
|
||||||
# 3. Start the stack
|
# 2. Backend
|
||||||
docker compose up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
App is available at `http://localhost:8080`.
|
|
||||||
|
|
||||||
### Tailscale HTTPS
|
|
||||||
|
|
||||||
```bash
|
|
||||||
tailscale serve --bg https+insecure://localhost:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Local Development
|
|
||||||
|
|
||||||
Run backend and frontend separately with hot reload.
|
|
||||||
|
|
||||||
**Backend**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
cd backend
|
||||||
python -m venv .venv && source .venv/bin/activate
|
python -m venv .venv && source .venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
PAGES_DIR=~/.nomadnetwork/storage/pages \
|
PAGES_DIR=~/.nomadnetwork/storage/pages \
|
||||||
SOURCES_DIR=~/.micron-editor/sources \
|
SOURCES_DIR=~/.micron-editor/sources \
|
||||||
uvicorn main:app --reload --port 8080
|
uvicorn main:app --reload --port 8080
|
||||||
```
|
|
||||||
|
|
||||||
**Frontend**
|
# 3. Frontend (separate terminal)
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
npm install
|
||||||
npm run dev # proxies /api → localhost:8080
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:5173`.
|
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
|
||||||
|
cd frontend && npm install && npm run build && cd ..
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
App at http://localhost:8080. Add Tailscale: `tailscale serve --bg https+insecure://localhost:8080`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
.uf source → Parse → IR Tree → Measure → Layout → Paint → CharGrid
|
||||||
|
├→ ASCII (plain text)
|
||||||
|
├→ Micron (.mu with styles)
|
||||||
|
└→ Script (dynamic: executable Python)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Static pages**: `.uf` compiles to `.mu` (Micron markup). NomadNet serves the file directly.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 +178,24 @@ Open `http://localhost:5173`.
|
|||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------------------|------------------|--------------------------------------|
|
|----------------------|------------------|--------------------------------------|
|
||||||
| `PAGES_DIR` | `/data/pages` | NomadNet pages directory |
|
| `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 |
|
| `NOMADNET_CONTAINER` | `nomadnet` | Docker container name to restart |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API Reference
|
## Tests
|
||||||
|
|
||||||
| Method | Path | Description |
|
```bash
|
||||||
|----------|---------------------|-----------------------------------------------|
|
cd backend && source .venv/bin/activate
|
||||||
| `GET` | `/api/health` | Health check |
|
python -m pytest uframe/tests/ -v # 42 tests
|
||||||
| `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)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Page Lifecycle
|
## References
|
||||||
|
|
||||||
```
|
- [NomadNet](https://github.com/markqvist/NomadNet) — decentralized communication
|
||||||
New Page → /editor/new → Save Draft → .md saved to sources/
|
- [Reticulum](https://github.com/markqvist/Reticulum) — mesh networking stack
|
||||||
→ Publish → .md saved + .mu written to pages/
|
- [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
|
||||||
- **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)
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"""µFrame compile endpoint — POST /api/compile."""
|
"""µFrame compile + DSL metadata endpoints."""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
import uframe
|
import uframe
|
||||||
|
import uframe.keywords # noqa: F401 — triggers keyword registration
|
||||||
from uframe.errors import UFrameError
|
from uframe.errors import UFrameError
|
||||||
|
from uframe.registry import get_dsl_meta
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -36,3 +38,9 @@ async def compile_source(req: CompileRequest):
|
|||||||
)
|
)
|
||||||
except UFrameError as e:
|
except UFrameError as e:
|
||||||
raise HTTPException(status_code=422, detail=str(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()
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from uframe.ir import (
|
|||||||
IRNode, Field, Password, Radio, Checkbox, FormButton,
|
IRNode, Field, Password, Radio, Checkbox, FormButton,
|
||||||
Source, IfBlock, ForLoop, OnSubmit, StateDecl, CacheControl,
|
Source, IfBlock, ForLoop, OnSubmit, StateDecl, CacheControl,
|
||||||
)
|
)
|
||||||
|
from uframe.themes import get_theme, ThemeDef
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -79,7 +80,7 @@ def _micron_form_line(node: IRNode) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def compile(source: str, width: int = 64) -> CompileResult:
|
def compile(source: str, width: int = 64, theme: str = "") -> CompileResult:
|
||||||
"""Compile a µFrame .uf source string into ASCII and Micron output.
|
"""Compile a µFrame .uf source string into ASCII and Micron output.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -102,6 +103,10 @@ def compile(source: str, width: int = 64) -> CompileResult:
|
|||||||
|
|
||||||
w = page.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
|
# 2. Measure
|
||||||
measure(page, w)
|
measure(page, w)
|
||||||
|
|
||||||
@@ -110,7 +115,7 @@ def compile(source: str, width: int = 64) -> CompileResult:
|
|||||||
|
|
||||||
# 4. Create grid and paint
|
# 4. Create grid and paint
|
||||||
grid = CharGrid(w, max(total_h, 1))
|
grid = CharGrid(w, max(total_h, 1))
|
||||||
paint(page, grid)
|
paint(page, grid, theme_def)
|
||||||
|
|
||||||
# 5. Merge borders
|
# 5. Merge borders
|
||||||
merge_borders(grid)
|
merge_borders(grid)
|
||||||
|
|||||||
@@ -1,148 +1,19 @@
|
|||||||
"""Border merging post-pass — fix junction characters where borders meet.
|
"""Border merging post-pass (currently no-op).
|
||||||
|
|
||||||
Scans the CharGrid for adjacent border cells and replaces with the
|
The draw_border and _paint_table functions produce correct border characters
|
||||||
correct junction character (T-junctions, crosses, corners) from the
|
directly. The original merge pass caused garbled junctions when borders from
|
||||||
Unicode box-drawing set.
|
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 __future__ import annotations
|
||||||
|
|
||||||
from uframe.grid import CharGrid
|
from uframe.grid import CharGrid
|
||||||
from uframe.ir import BorderWeight
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Connection detection
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# For each border cell, check which directions have adjacent borders.
|
|
||||||
# Direction flags:
|
|
||||||
UP = 1
|
|
||||||
DOWN = 2
|
|
||||||
LEFT = 4
|
|
||||||
RIGHT = 8
|
|
||||||
|
|
||||||
# Junction lookup: connections bitmask → character
|
|
||||||
# Only light weight for now (most common case)
|
|
||||||
_LIGHT_JUNCTIONS: dict[int, str] = {
|
|
||||||
UP | DOWN: "│",
|
|
||||||
LEFT | RIGHT: "─",
|
|
||||||
DOWN | RIGHT: "┌",
|
|
||||||
DOWN | LEFT: "┐",
|
|
||||||
UP | RIGHT: "└",
|
|
||||||
UP | LEFT: "┘",
|
|
||||||
UP | DOWN | RIGHT: "├",
|
|
||||||
UP | DOWN | LEFT: "┤",
|
|
||||||
LEFT | RIGHT | DOWN: "┬",
|
|
||||||
LEFT | RIGHT | UP: "┴",
|
|
||||||
UP | DOWN | LEFT | RIGHT: "┼",
|
|
||||||
RIGHT: "─",
|
|
||||||
LEFT: "─",
|
|
||||||
UP: "│",
|
|
||||||
DOWN: "│",
|
|
||||||
}
|
|
||||||
|
|
||||||
_HEAVY_JUNCTIONS: dict[int, str] = {
|
|
||||||
UP | DOWN: "┃",
|
|
||||||
LEFT | RIGHT: "━",
|
|
||||||
DOWN | RIGHT: "┏",
|
|
||||||
DOWN | LEFT: "┓",
|
|
||||||
UP | RIGHT: "┗",
|
|
||||||
UP | LEFT: "┛",
|
|
||||||
UP | DOWN | RIGHT: "┣",
|
|
||||||
UP | DOWN | LEFT: "┫",
|
|
||||||
LEFT | RIGHT | DOWN: "┳",
|
|
||||||
LEFT | RIGHT | UP: "┻",
|
|
||||||
UP | DOWN | LEFT | RIGHT: "╋",
|
|
||||||
RIGHT: "━",
|
|
||||||
LEFT: "━",
|
|
||||||
UP: "┃",
|
|
||||||
DOWN: "┃",
|
|
||||||
}
|
|
||||||
|
|
||||||
_DOUBLE_JUNCTIONS: dict[int, str] = {
|
|
||||||
UP | DOWN: "║",
|
|
||||||
LEFT | RIGHT: "═",
|
|
||||||
DOWN | RIGHT: "╔",
|
|
||||||
DOWN | LEFT: "╗",
|
|
||||||
UP | RIGHT: "╚",
|
|
||||||
UP | LEFT: "╝",
|
|
||||||
UP | DOWN | RIGHT: "╠",
|
|
||||||
UP | DOWN | LEFT: "╣",
|
|
||||||
LEFT | RIGHT | DOWN: "╦",
|
|
||||||
LEFT | RIGHT | UP: "╩",
|
|
||||||
UP | DOWN | LEFT | RIGHT: "╬",
|
|
||||||
RIGHT: "═",
|
|
||||||
LEFT: "═",
|
|
||||||
UP: "║",
|
|
||||||
DOWN: "║",
|
|
||||||
}
|
|
||||||
|
|
||||||
_JUNCTION_TABLES = {
|
|
||||||
BorderWeight.LIGHT: _LIGHT_JUNCTIONS,
|
|
||||||
BorderWeight.HEAVY: _HEAVY_JUNCTIONS,
|
|
||||||
BorderWeight.DOUBLE: _DOUBLE_JUNCTIONS,
|
|
||||||
BorderWeight.ROUNDED: _LIGHT_JUNCTIONS, # rounded uses light junctions
|
|
||||||
}
|
|
||||||
|
|
||||||
# Weight priority for mixed-weight junctions
|
|
||||||
_WEIGHT_PRIORITY = {
|
|
||||||
BorderWeight.DOUBLE: 3,
|
|
||||||
BorderWeight.HEAVY: 2,
|
|
||||||
BorderWeight.LIGHT: 1,
|
|
||||||
BorderWeight.ROUNDED: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def merge_borders(grid: CharGrid) -> None:
|
def merge_borders(grid: CharGrid) -> None:
|
||||||
"""Scan the grid for adjacent border cells and fix junction characters.
|
"""No-op — borders are correctly painted by draw_border and _paint_table."""
|
||||||
|
pass
|
||||||
This pass resolves cases where two boxes share an edge or corner,
|
|
||||||
replacing the overlapping border characters with proper junctions.
|
|
||||||
"""
|
|
||||||
for row in range(grid.height):
|
|
||||||
for col in range(grid.width):
|
|
||||||
cell = grid.cells[row][col]
|
|
||||||
if not cell.is_border:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Detect connections in 4 directions
|
|
||||||
connections = 0
|
|
||||||
max_weight = cell.border_weight or BorderWeight.LIGHT
|
|
||||||
|
|
||||||
# Check each neighbor
|
|
||||||
if row > 0 and grid.cells[row - 1][col].is_border:
|
|
||||||
connections |= UP
|
|
||||||
nw = grid.cells[row - 1][col].border_weight or BorderWeight.LIGHT
|
|
||||||
if _WEIGHT_PRIORITY.get(nw, 0) > _WEIGHT_PRIORITY.get(max_weight, 0):
|
|
||||||
max_weight = nw
|
|
||||||
|
|
||||||
if row < grid.height - 1 and grid.cells[row + 1][col].is_border:
|
|
||||||
connections |= DOWN
|
|
||||||
nw = grid.cells[row + 1][col].border_weight or BorderWeight.LIGHT
|
|
||||||
if _WEIGHT_PRIORITY.get(nw, 0) > _WEIGHT_PRIORITY.get(max_weight, 0):
|
|
||||||
max_weight = nw
|
|
||||||
|
|
||||||
if col > 0 and grid.cells[row][col - 1].is_border:
|
|
||||||
connections |= LEFT
|
|
||||||
nw = grid.cells[row][col - 1].border_weight or BorderWeight.LIGHT
|
|
||||||
if _WEIGHT_PRIORITY.get(nw, 0) > _WEIGHT_PRIORITY.get(max_weight, 0):
|
|
||||||
max_weight = nw
|
|
||||||
|
|
||||||
if col < grid.width - 1 and grid.cells[row][col + 1].is_border:
|
|
||||||
connections |= RIGHT
|
|
||||||
nw = grid.cells[row][col + 1].border_weight or BorderWeight.LIGHT
|
|
||||||
if _WEIGHT_PRIORITY.get(nw, 0) > _WEIGHT_PRIORITY.get(max_weight, 0):
|
|
||||||
max_weight = nw
|
|
||||||
|
|
||||||
# Skip rounded corners — they should preserve ╭╮╰╯
|
|
||||||
if cell.border_weight == BorderWeight.ROUNDED and connections in (
|
|
||||||
DOWN | RIGHT, DOWN | LEFT, UP | RIGHT, UP | LEFT
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Look up the junction character
|
|
||||||
if connections:
|
|
||||||
table = _JUNCTION_TABLES.get(max_weight, _LIGHT_JUNCTIONS)
|
|
||||||
junction = table.get(connections)
|
|
||||||
if junction:
|
|
||||||
cell.char = junction
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
import stat
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -24,7 +23,7 @@ def cmd_render(args: argparse.Namespace) -> int:
|
|||||||
source = Path(args.file).read_text(encoding="utf-8")
|
source = Path(args.file).read_text(encoding="utf-8")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = uframe.compile(source, width=args.width)
|
result = uframe.compile(source, width=args.width, theme=getattr(args, 'theme', ''))
|
||||||
except UFrameError as e:
|
except UFrameError as e:
|
||||||
print(f"Error: {e}", file=sys.stderr)
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
@@ -53,7 +52,7 @@ def cmd_compile(args: argparse.Namespace) -> int:
|
|||||||
source = source_path.read_text(encoding="utf-8")
|
source = source_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = uframe.compile(source, width=args.width)
|
result = uframe.compile(source, width=args.width, theme=getattr(args, 'theme', ''))
|
||||||
except UFrameError as e:
|
except UFrameError as e:
|
||||||
print(f"Error: {e}", file=sys.stderr)
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
@@ -76,7 +75,7 @@ def cmd_check(args: argparse.Namespace) -> int:
|
|||||||
source = Path(args.file).read_text(encoding="utf-8")
|
source = Path(args.file).read_text(encoding="utf-8")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = uframe.compile(source, width=args.width)
|
result = uframe.compile(source, width=args.width, theme=getattr(args, 'theme', ''))
|
||||||
except UFrameError as e:
|
except UFrameError as e:
|
||||||
print(f"Error: {e}", file=sys.stderr)
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
@@ -98,7 +97,7 @@ def cmd_deploy(args: argparse.Namespace) -> int:
|
|||||||
dest_dir = Path(args.dest or os.path.expanduser("~/.nomadnetwork/storage/pages"))
|
dest_dir = Path(args.dest or os.path.expanduser("~/.nomadnetwork/storage/pages"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = uframe.compile(source, width=args.width)
|
result = uframe.compile(source, width=args.width, theme=getattr(args, 'theme', ''))
|
||||||
except UFrameError as e:
|
except UFrameError as e:
|
||||||
print(f"Error: {e}", file=sys.stderr)
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
@@ -131,12 +130,14 @@ def main() -> int:
|
|||||||
p_render.add_argument("--ascii", action="store_true", help="Output ASCII only")
|
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("--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("--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
|
# compile
|
||||||
p_compile = sub.add_parser("compile", help="Compile to .mu file")
|
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("file", help="Path to .uf source file")
|
||||||
p_compile.add_argument("--out", help="Output file path (default: <name>.mu)")
|
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("--width", type=int, default=64, help="Page width")
|
||||||
|
p_compile.add_argument("--theme", default="", help="Theme name")
|
||||||
|
|
||||||
# check
|
# check
|
||||||
p_check = sub.add_parser("check", help="Validate a .uf file")
|
p_check = sub.add_parser("check", help="Validate a .uf file")
|
||||||
@@ -148,6 +149,7 @@ def main() -> int:
|
|||||||
p_deploy.add_argument("file", help="Path to .uf source file")
|
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("--dest", help="Destination directory (default: ~/.nomadnetwork/storage/pages)")
|
||||||
p_deploy.add_argument("--width", type=int, default=64, help="Page width")
|
p_deploy.add_argument("--width", type=int, default=64, help="Page width")
|
||||||
|
p_deploy.add_argument("--theme", default="", help="Theme name")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
|||||||
@@ -12,16 +12,13 @@ state) and generates a self-contained Python script that:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import textwrap
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from uframe.ir import (
|
from uframe.ir import (
|
||||||
IRNode, Page, Box, Row, Col, Spacer, Pad,
|
IRNode, Page, Box, Spacer,
|
||||||
Heading, Text, Label, Divider, Link, ListNode, ListItem,
|
Heading, Text, Label, Divider, Link,
|
||||||
Gauge, Sparkline, Status, Table,
|
Gauge, Status,
|
||||||
Form, Field, Password, Radio, Checkbox, FormButton,
|
Form, Field, FormButton,
|
||||||
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
|
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
|
||||||
SourceType, BorderWeight, HeadingLevel, DividerStyle, ListStyle,
|
SourceType,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -70,11 +67,14 @@ def _emit_node(node: IRNode, indent_level: int = 0) -> list[str]:
|
|||||||
elif node.source_type == SourceType.JSON:
|
elif node.source_type == SourceType.JSON:
|
||||||
lines.append(f"{ind}{var} = _read_json({node.command!r})")
|
lines.append(f"{ind}{var} = _read_json({node.command!r})")
|
||||||
elif node.source_type == SourceType.PYTHON:
|
elif node.source_type == SourceType.PYTHON:
|
||||||
lines.append(f"{ind}{var} = eval({node.command!r})")
|
# Restricted eval — only datetime/secrets modules available
|
||||||
|
lines.append(f"{ind}{var} = eval({node.command!r}, {{'datetime': datetime, 'secrets': secrets}})")
|
||||||
elif node.source_type == SourceType.PARAM:
|
elif node.source_type == SourceType.PARAM:
|
||||||
lines.append(f"{ind}{var} = _get_param({node.command!r})")
|
lines.append(f"{ind}{var} = _get_param({node.command!r})")
|
||||||
elif node.source_type == SourceType.RNS:
|
elif node.source_type == SourceType.RNS:
|
||||||
lines.append(f"{ind}{var} = _shell('rnstatus {node.command}', timeout={node.timeout})")
|
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 isinstance(node, CacheControl):
|
elif isinstance(node, CacheControl):
|
||||||
lines.append(f"{ind}_cache_seconds = {node.seconds}")
|
lines.append(f"{ind}_cache_seconds = {node.seconds}")
|
||||||
|
|||||||
@@ -54,15 +54,7 @@ _EMPTY_STYLE = CellStyle()
|
|||||||
|
|
||||||
|
|
||||||
def emit_micron(grid: CharGrid, page_title: str = "") -> str:
|
def emit_micron(grid: CharGrid, page_title: str = "") -> str:
|
||||||
"""Emit the CharGrid as Micron markup.
|
"""Emit the CharGrid as Micron markup."""
|
||||||
|
|
||||||
Args:
|
|
||||||
grid: the rendered character grid
|
|
||||||
page_title: optional page title for a leading >Title line
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Micron source string
|
|
||||||
"""
|
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
|
|
||||||
for row in range(grid.height):
|
for row in range(grid.height):
|
||||||
|
|||||||
209
backend/uframe/fonts.py
Normal file
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
|
||||||
@@ -39,17 +39,19 @@ class Cell:
|
|||||||
style: CellStyle = field(default_factory=CellStyle)
|
style: CellStyle = field(default_factory=CellStyle)
|
||||||
is_border: bool = False # True for box-drawing characters (for merge pass)
|
is_border: bool = False # True for box-drawing characters (for merge pass)
|
||||||
border_weight: BorderWeight | None = None
|
border_weight: BorderWeight | None = None
|
||||||
|
border_id: int = 0 # Identifies which box this border belongs to
|
||||||
link: str | None = None # Micron link destination
|
link: str | None = None # Micron link destination
|
||||||
|
|
||||||
|
|
||||||
class CharGrid:
|
class CharGrid:
|
||||||
"""2D buffer of cells. Origin (0,0) is top-left."""
|
"""2D buffer of cells. Origin (0,0) is top-left."""
|
||||||
|
|
||||||
__slots__ = ("width", "height", "cells")
|
__slots__ = ("width", "height", "cells", "_border_counter")
|
||||||
|
|
||||||
def __init__(self, width: int, height: int):
|
def __init__(self, width: int, height: int):
|
||||||
self.width = width
|
self.width = width
|
||||||
self.height = height
|
self.height = height
|
||||||
|
self._border_counter = 0
|
||||||
self.cells: list[list[Cell]] = [
|
self.cells: list[list[Cell]] = [
|
||||||
[Cell() for _ in range(width)]
|
[Cell() for _ in range(width)]
|
||||||
for _ in range(height)
|
for _ in range(height)
|
||||||
@@ -62,6 +64,7 @@ class CharGrid:
|
|||||||
style: CellStyle | None = None,
|
style: CellStyle | None = None,
|
||||||
is_border: bool = False,
|
is_border: bool = False,
|
||||||
border_weight: BorderWeight | None = None,
|
border_weight: BorderWeight | None = None,
|
||||||
|
border_id: int = 0,
|
||||||
link: str | None = None) -> None:
|
link: str | None = None) -> None:
|
||||||
"""Write a single character to the grid."""
|
"""Write a single character to the grid."""
|
||||||
if not self.in_bounds(x, y):
|
if not self.in_bounds(x, y):
|
||||||
@@ -72,6 +75,8 @@ class CharGrid:
|
|||||||
cell.style = style
|
cell.style = style
|
||||||
cell.is_border = is_border
|
cell.is_border = is_border
|
||||||
cell.border_weight = border_weight
|
cell.border_weight = border_weight
|
||||||
|
if border_id:
|
||||||
|
cell.border_id = border_id
|
||||||
if link is not None:
|
if link is not None:
|
||||||
cell.link = link
|
cell.link = link
|
||||||
|
|
||||||
@@ -101,7 +106,9 @@ class CharGrid:
|
|||||||
def draw_border(self, x: int, y: int, w: int, h: int,
|
def draw_border(self, x: int, y: int, w: int, h: int,
|
||||||
weight: BorderWeight = BorderWeight.LIGHT,
|
weight: BorderWeight = BorderWeight.LIGHT,
|
||||||
title: str = "",
|
title: str = "",
|
||||||
title_style: CellStyle | None = None) -> None:
|
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.
|
"""Draw a box border. Interior is not cleared.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -114,33 +121,38 @@ class CharGrid:
|
|||||||
if w < 2 or h < 2:
|
if w < 2 or h < 2:
|
||||||
return
|
return
|
||||||
|
|
||||||
ch = BOX_CHARS[weight]
|
self._border_counter += 1
|
||||||
|
bid = self._border_counter
|
||||||
|
|
||||||
|
ch = border_chars or BOX_CHARS[weight]
|
||||||
border_style = CellStyle()
|
border_style = CellStyle()
|
||||||
|
|
||||||
# Corners
|
# Corners
|
||||||
self.put(x, y, ch["tl"], border_style, is_border=True, border_weight=weight)
|
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)
|
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)
|
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)
|
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
|
# Top and bottom edges
|
||||||
for col in range(x + 1, x + w - 1):
|
for col in range(x + 1, x + w - 1):
|
||||||
self.put(col, y, ch["h"], border_style, is_border=True, border_weight=weight)
|
self.put(col, y, 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)
|
self.put(col, y + h - 1, ch["h"], border_style, is_border=True, border_weight=weight, border_id=bid)
|
||||||
|
|
||||||
# Left and right edges
|
# Left and right edges
|
||||||
for row in range(y + 1, y + h - 1):
|
for row in range(y + 1, y + h - 1):
|
||||||
self.put(x, row, ch["v"], border_style, is_border=True, border_weight=weight)
|
self.put(x, 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)
|
self.put(x + w - 1, row, ch["v"], border_style, is_border=True, border_weight=weight, border_id=bid)
|
||||||
|
|
||||||
# Title in top border
|
# Title in top border
|
||||||
if title and w > 4:
|
if title and w > 4:
|
||||||
title_text = f" {title} "
|
lc = title_caps[0] if title_caps else " "
|
||||||
max_title = w - 4 # leave room for corners + padding
|
rc = title_caps[1] if title_caps else " "
|
||||||
|
title_text = f"{lc}{title}{rc}"
|
||||||
|
max_title = w - 4
|
||||||
if len(title_text) > max_title:
|
if len(title_text) > max_title:
|
||||||
title_text = title_text[:max_title]
|
title_text = title_text[:max_title]
|
||||||
|
|
||||||
start_x = x + 2
|
start_x = x + 1
|
||||||
ts = title_style or CellStyle(bold=True)
|
ts = title_style or CellStyle(bold=True)
|
||||||
self.put_text(start_x, y, title_text, style=ts)
|
self.put_text(start_x, y, title_text, style=ts)
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -119,6 +118,7 @@ class Page(IRNode):
|
|||||||
"""Root container. One per .uf file."""
|
"""Root container. One per .uf file."""
|
||||||
title: str = ""
|
title: str = ""
|
||||||
width: int = 64
|
width: int = 64
|
||||||
|
theme_name: str = ""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -361,6 +361,13 @@ class StateDecl(IRNode):
|
|||||||
# Components (Phase 8)
|
# Components (Phase 8)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BigTitle(IRNode):
|
||||||
|
"""Large multi-line ASCII art text."""
|
||||||
|
text: str = ""
|
||||||
|
font: str = "block" # block, thin, pixel
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ComponentDef(IRNode):
|
class ComponentDef(IRNode):
|
||||||
"""Component definition: component name(arg1, arg2)."""
|
"""Component definition: component name(arg1, arg2)."""
|
||||||
|
|||||||
260
backend/uframe/keywords.py
Normal file
260
backend/uframe/keywords.py
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
"""µ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,
|
||||||
|
ComponentDef, ComponentUse,
|
||||||
|
)
|
||||||
|
from uframe.themes import BUILTIN_THEMES
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Layout
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
register_keyword("page", node_class=Page, section="Layout",
|
||||||
|
detail='page "Title" 64', snippet='page "${title}" ${width:64}',
|
||||||
|
is_container=True)
|
||||||
|
|
||||||
|
register_keyword("box", node_class=Box, section="Layout",
|
||||||
|
detail='box light "Title"', snippet='box ${weight:light} "${title}"',
|
||||||
|
highlight_values=["light", "heavy", "double", "rounded"],
|
||||||
|
is_container=True)
|
||||||
|
|
||||||
|
register_keyword("row", node_class=Row, section="Layout",
|
||||||
|
detail="row [gap]", snippet="row ${gap:2}",
|
||||||
|
is_container=True)
|
||||||
|
|
||||||
|
register_keyword("col", node_class=Col, section="Layout",
|
||||||
|
detail="col [width]", snippet="col ${width}",
|
||||||
|
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 ${top:1} ${right:1} ${bottom:1} ${left:1}",
|
||||||
|
is_container=True)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Content
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
register_keyword("heading", node_class=Heading, section="Content",
|
||||||
|
detail='heading 1 "Text"', snippet='heading ${level:1} "${text}"',
|
||||||
|
is_leaf=True)
|
||||||
|
|
||||||
|
register_keyword("text", node_class=Text, section="Content",
|
||||||
|
detail='text "Content"', snippet='text "${content}"',
|
||||||
|
is_leaf=True)
|
||||||
|
|
||||||
|
register_keyword("label", node_class=Label, section="Content",
|
||||||
|
detail='label "Key" "Value"', snippet='label "${key}" "${value}"',
|
||||||
|
is_leaf=True)
|
||||||
|
|
||||||
|
register_keyword("divider", node_class=Divider, section="Content",
|
||||||
|
detail="divider heavy", snippet="divider ${style: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 "${display}" "${dest}"',
|
||||||
|
is_leaf=True)
|
||||||
|
|
||||||
|
register_keyword("list", node_class=ListNode, section="Content",
|
||||||
|
detail="list bullet", snippet='list ${style:bullet}\n item "${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 "${label}" ${value} ${max:100} ${width:28} warn=${warn:75} crit=${crit:90}',
|
||||||
|
is_leaf=True)
|
||||||
|
|
||||||
|
register_keyword("sparkline", node_class=Sparkline, section="Data",
|
||||||
|
detail="sparkline label values width",
|
||||||
|
snippet='sparkline "${label}" "${values}" ${width:20}',
|
||||||
|
is_leaf=True)
|
||||||
|
|
||||||
|
register_keyword("status", node_class=Status, section="Data",
|
||||||
|
detail="status label state",
|
||||||
|
snippet='status "${label}" ${state: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("bigtitle", node_class=BigTitle, section="Content",
|
||||||
|
detail='bigtitle "TEXT" block',
|
||||||
|
snippet='bigtitle "${text}" ${font:block}',
|
||||||
|
highlight_values=["block", "thin", "pixel"],
|
||||||
|
is_leaf=True)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Style
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
register_keyword("align", section="Style",
|
||||||
|
detail="align center", snippet="align ${align:center}",
|
||||||
|
highlight_values=["left", "center", "right"],
|
||||||
|
is_style_directive=True)
|
||||||
|
|
||||||
|
register_keyword("color", section="Style",
|
||||||
|
detail="color hex", snippet="color ${hex}",
|
||||||
|
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 "${name}"',
|
||||||
|
is_container=True)
|
||||||
|
|
||||||
|
register_keyword("field", node_class=Field, section="Form",
|
||||||
|
detail='field "name" 24 "placeholder"',
|
||||||
|
snippet='field "${name}" ${width:24} "${placeholder}"',
|
||||||
|
is_leaf=True)
|
||||||
|
|
||||||
|
register_keyword("password", node_class=Password, section="Form",
|
||||||
|
detail='password "name" 24 "placeholder"',
|
||||||
|
snippet='password "${name}" ${width:24} "${placeholder}"',
|
||||||
|
is_leaf=True)
|
||||||
|
|
||||||
|
register_keyword("radio", node_class=Radio, section="Form",
|
||||||
|
detail='radio "group" "A" | "B" | "C"',
|
||||||
|
snippet='radio "${group}" "${opt1}" | "${opt2}"',
|
||||||
|
is_leaf=True)
|
||||||
|
|
||||||
|
register_keyword("checkbox", node_class=Checkbox, section="Form",
|
||||||
|
detail='checkbox "name" "Label"',
|
||||||
|
snippet='checkbox "${name}" "${label}"',
|
||||||
|
is_leaf=True)
|
||||||
|
|
||||||
|
register_keyword("button", node_class=FormButton, section="Form",
|
||||||
|
detail='button "Label" "/action"',
|
||||||
|
snippet='button "${label}" "${dest}"',
|
||||||
|
is_leaf=True)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dynamic
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
register_keyword("let", node_class=Let, section="Dynamic",
|
||||||
|
detail='let name = "value"', snippet='let ${name} = "${value}"',
|
||||||
|
is_metadata=True)
|
||||||
|
|
||||||
|
register_keyword("source", node_class=Source, section="Dynamic",
|
||||||
|
detail='source name : shell "cmd"',
|
||||||
|
snippet='source ${name} : shell "${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 ${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 ${var} in ${iterable}',
|
||||||
|
is_container=True)
|
||||||
|
|
||||||
|
register_keyword("cache", node_class=CacheControl, section="Dynamic",
|
||||||
|
detail="cache 0", snippet="cache ${seconds:0}",
|
||||||
|
is_metadata=True)
|
||||||
|
|
||||||
|
register_keyword("on_submit", node_class=OnSubmit, section="Dynamic",
|
||||||
|
detail='on_submit "form"', snippet='on_submit "${form_name}"',
|
||||||
|
is_container=True)
|
||||||
|
|
||||||
|
register_keyword("state", node_class=StateDecl, section="Dynamic",
|
||||||
|
detail='state "name" "/path.json"',
|
||||||
|
snippet='state "${name}" "${path}"',
|
||||||
|
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="")
|
||||||
@@ -8,6 +8,7 @@ from uframe.ir import (
|
|||||||
Gauge, Sparkline, Status, Table,
|
Gauge, Sparkline, Status, Table,
|
||||||
Form, Field, Password, Radio, Checkbox, FormButton,
|
Form, Field, Password, Radio, Checkbox, FormButton,
|
||||||
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
|
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
|
||||||
|
BigTitle,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -135,7 +136,8 @@ def layout(node: IRNode, x: int, y: int, w: int, h: int) -> int:
|
|||||||
|
|
||||||
elif isinstance(node, (Heading, Text, Label, Divider, Link, ListItem,
|
elif isinstance(node, (Heading, Text, Label, Divider, Link, ListItem,
|
||||||
Gauge, Sparkline, Status, Table,
|
Gauge, Sparkline, Status, Table,
|
||||||
Field, Password, Radio, Checkbox, FormButton)):
|
Field, Password, Radio, Checkbox, FormButton,
|
||||||
|
BigTitle)):
|
||||||
node.rect.h = node.pref_height
|
node.rect.h = node.pref_height
|
||||||
return node.pref_height
|
return node.pref_height
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ from uframe.ir import (
|
|||||||
Gauge, Sparkline, Status, Table,
|
Gauge, Sparkline, Status, Table,
|
||||||
Form, Field, Password, Radio, Checkbox, FormButton,
|
Form, Field, Password, Radio, Checkbox, FormButton,
|
||||||
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
|
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
|
||||||
|
BigTitle,
|
||||||
)
|
)
|
||||||
|
from uframe.fonts import FONT_HEIGHTS, get_text_width
|
||||||
|
|
||||||
|
|
||||||
def _text_height(text: str, width: int) -> int:
|
def _text_height(text: str, width: int) -> int:
|
||||||
@@ -243,6 +245,22 @@ def measure(node: IRNode, available_width: int) -> None:
|
|||||||
node.pref_height = 1
|
node.pref_height = 1
|
||||||
node.min_height = 1
|
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)):
|
elif isinstance(node, (Let, Source, CacheControl, StateDecl)):
|
||||||
# Zero-height metadata nodes — no visual output
|
# Zero-height metadata nodes — no visual output
|
||||||
node.pref_width = 0
|
node.pref_width = 0
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ own structure (borders, etc.).
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
import textwrap
|
import textwrap
|
||||||
|
|
||||||
from uframe.chars import (
|
from uframe.chars import (
|
||||||
BOX_CHARS, DIVIDER_CHARS, GAUGE_FILLED, GAUGE_EMPTY,
|
BOX_CHARS, DIVIDER_CHARS, sparkline_chars,
|
||||||
STATUS_CHARS, STATUS_COLORS, sparkline_chars,
|
|
||||||
)
|
)
|
||||||
from uframe.grid import CharGrid, CellStyle
|
from uframe.grid import CharGrid, CellStyle
|
||||||
from uframe.ir import (
|
from uframe.ir import (
|
||||||
@@ -19,8 +19,11 @@ from uframe.ir import (
|
|||||||
Heading, Text, Label, Divider, Link, ListNode, ListItem,
|
Heading, Text, Label, Divider, Link, ListNode, ListItem,
|
||||||
Gauge, Sparkline, Status, Table,
|
Gauge, Sparkline, Status, Table,
|
||||||
Form, Field, Password, Radio, Checkbox, FormButton,
|
Form, Field, Password, Radio, Checkbox, FormButton,
|
||||||
|
BigTitle,
|
||||||
HeadingLevel, DividerStyle, ListStyle, Align, BorderWeight,
|
HeadingLevel, DividerStyle, ListStyle, Align, BorderWeight,
|
||||||
)
|
)
|
||||||
|
from uframe.themes import ThemeDef, THEME_DEFAULT
|
||||||
|
from uframe.fonts import render_big_text, get_text_width, FONT_HEIGHTS
|
||||||
|
|
||||||
|
|
||||||
def _align_text(text: str, width: int, align: Align) -> str:
|
def _align_text(text: str, width: int, align: Align) -> str:
|
||||||
@@ -45,8 +48,9 @@ def _style_from_node(node: IRNode) -> CellStyle:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def paint(node: IRNode, grid: CharGrid) -> None:
|
def paint(node: IRNode, grid: CharGrid, theme: ThemeDef | None = None) -> None:
|
||||||
"""Recursively paint an IR node and its children into the grid."""
|
"""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
|
x, y, w = node.rect.x, node.rect.y, node.rect.w
|
||||||
|
|
||||||
# Ensure grid is tall enough
|
# Ensure grid is tall enough
|
||||||
@@ -54,42 +58,48 @@ def paint(node: IRNode, grid: CharGrid) -> None:
|
|||||||
|
|
||||||
if isinstance(node, Page):
|
if isinstance(node, Page):
|
||||||
for child in node.children:
|
for child in node.children:
|
||||||
paint(child, grid)
|
paint(child, grid, th)
|
||||||
|
|
||||||
elif isinstance(node, Box):
|
elif isinstance(node, Box):
|
||||||
# Draw the border
|
# Draw the border with themed characters
|
||||||
title_style = CellStyle(bold=True, fg=node.style.fg)
|
title_style = CellStyle(bold=True, fg=node.style.fg or th.palette.accent)
|
||||||
grid.draw_border(x, y, w, node.rect.h,
|
grid.draw_border(x, y, w, node.rect.h,
|
||||||
weight=node.weight,
|
weight=node.weight,
|
||||||
title=node.title,
|
title=node.title,
|
||||||
title_style=title_style)
|
title_style=title_style,
|
||||||
# Paint children inside the border
|
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:
|
for child in node.children:
|
||||||
paint(child, grid)
|
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):
|
elif isinstance(node, Row):
|
||||||
for child in node.children:
|
for child in node.children:
|
||||||
paint(child, grid)
|
paint(child, grid, th)
|
||||||
|
|
||||||
elif isinstance(node, Col):
|
elif isinstance(node, Col):
|
||||||
for child in node.children:
|
for child in node.children:
|
||||||
paint(child, grid)
|
paint(child, grid, th)
|
||||||
|
|
||||||
elif isinstance(node, Spacer):
|
elif isinstance(node, Spacer):
|
||||||
pass # Just empty space
|
pass # Just empty space
|
||||||
|
|
||||||
elif isinstance(node, Pad):
|
elif isinstance(node, Pad):
|
||||||
for child in node.children:
|
for child in node.children:
|
||||||
paint(child, grid)
|
paint(child, grid, th)
|
||||||
|
|
||||||
elif isinstance(node, Heading):
|
elif isinstance(node, Heading):
|
||||||
style = CellStyle(bold=True)
|
style = CellStyle(bold=True)
|
||||||
if node.level == HeadingLevel.H1:
|
if node.level == HeadingLevel.H1:
|
||||||
style.fg = "0f0" # green
|
style.fg = th.palette.accent
|
||||||
elif node.level == HeadingLevel.H2:
|
elif node.level == HeadingLevel.H2:
|
||||||
style.fg = "0cf" # cyan
|
style.fg = th.palette.accent2
|
||||||
elif node.level == HeadingLevel.H3:
|
elif node.level == HeadingLevel.H3:
|
||||||
style.fg = "88f" # light blue
|
style.fg = th.palette.accent3
|
||||||
|
|
||||||
# Underline-style heading
|
# Underline-style heading
|
||||||
grid.put_text(x, y, node.text[:w], style=style)
|
grid.put_text(x, y, node.text[:w], style=style)
|
||||||
@@ -138,26 +148,24 @@ def paint(node: IRNode, grid: CharGrid) -> None:
|
|||||||
|
|
||||||
elif isinstance(node, Divider):
|
elif isinstance(node, Divider):
|
||||||
ds = node.divider_style
|
ds = node.divider_style
|
||||||
char = DIVIDER_CHARS.get(ds.name.lower(), "─")
|
char = getattr(th.dividers, ds.name.lower(), th.dividers.light)
|
||||||
style = CellStyle(fg="555")
|
style = CellStyle(fg=th.palette.muted)
|
||||||
for col in range(x, x + w):
|
for col in range(x, x + w):
|
||||||
grid.put(col, y, char, style=style)
|
grid.put(col, y, char, style=style)
|
||||||
|
|
||||||
elif isinstance(node, Link):
|
elif isinstance(node, Link):
|
||||||
style = CellStyle(fg="0cf", underline=True)
|
style = CellStyle(fg=th.palette.info, underline=True)
|
||||||
# In ASCII mode, display as [text]. In Micron, the emitter wraps with link syntax.
|
|
||||||
# Write just the display text — the link metadata goes on cells for Micron emission.
|
|
||||||
grid.put_text(x, y, node.display[:w], style=style, link=node.dest)
|
grid.put_text(x, y, node.display[:w], style=style, link=node.dest)
|
||||||
|
|
||||||
elif isinstance(node, ListNode):
|
elif isinstance(node, ListNode):
|
||||||
for child in node.children:
|
for child in node.children:
|
||||||
paint(child, grid)
|
paint(child, grid, th)
|
||||||
|
|
||||||
elif isinstance(node, ListItem):
|
elif isinstance(node, ListItem):
|
||||||
style = _style_from_node(node)
|
style = _style_from_node(node)
|
||||||
# Parent determines bullet style — use a simple bullet for now
|
# Bullet to the left of the content (safe: put_text clips to bounds)
|
||||||
bullet = "• "
|
bullet_x = max(0, x - 2)
|
||||||
grid.put_text(x - 2, y, bullet, style=CellStyle(fg="888"))
|
grid.put_text(bullet_x, y, f"{th.ornaments.bullet} ", style=CellStyle(fg=th.palette.label))
|
||||||
# Wrap content
|
# Wrap content
|
||||||
wrapped = textwrap.wrap(node.content, width=w) if node.content else [""]
|
wrapped = textwrap.wrap(node.content, width=w) if node.content else [""]
|
||||||
for i, line in enumerate(wrapped):
|
for i, line in enumerate(wrapped):
|
||||||
@@ -178,17 +186,17 @@ def paint(node: IRNode, grid: CharGrid) -> None:
|
|||||||
filled = int(bar_w * pct)
|
filled = int(bar_w * pct)
|
||||||
|
|
||||||
# Determine color based on thresholds
|
# Determine color based on thresholds
|
||||||
fg = "0f0" # green
|
fg = th.palette.success
|
||||||
if node.crit is not None and node.value >= node.crit:
|
if node.crit is not None and node.value >= node.crit:
|
||||||
fg = "f00" # red
|
fg = th.palette.danger
|
||||||
elif node.warn is not None and node.value >= node.warn:
|
elif node.warn is not None and node.value >= node.warn:
|
||||||
fg = "ff0" # yellow
|
fg = th.palette.warning
|
||||||
|
|
||||||
for i in range(bar_w):
|
for i in range(bar_w):
|
||||||
if i < filled:
|
if i < filled:
|
||||||
grid.put(bar_x + i, y, GAUGE_FILLED, style=CellStyle(fg=fg))
|
grid.put(bar_x + i, y, th.gauge.filled, style=CellStyle(fg=fg))
|
||||||
else:
|
else:
|
||||||
grid.put(bar_x + i, y, GAUGE_EMPTY, style=CellStyle(fg="555"))
|
grid.put(bar_x + i, y, th.gauge.empty, style=CellStyle(fg=th.palette.muted))
|
||||||
|
|
||||||
# Percentage
|
# Percentage
|
||||||
pct_text = f" {int(pct * 100)}%"
|
pct_text = f" {int(pct * 100)}%"
|
||||||
@@ -201,77 +209,115 @@ def paint(node: IRNode, grid: CharGrid) -> None:
|
|||||||
|
|
||||||
spark_x = x + len(label_text)
|
spark_x = x + len(label_text)
|
||||||
chars = sparkline_chars(node.values, node.spark_width)
|
chars = sparkline_chars(node.values, node.spark_width)
|
||||||
spark_style = CellStyle(fg="0cf")
|
spark_style = CellStyle(fg=th.palette.info)
|
||||||
for i, ch in enumerate(chars):
|
for i, ch in enumerate(chars):
|
||||||
grid.put(spark_x + i, y, ch, style=spark_style)
|
grid.put(spark_x + i, y, ch, style=spark_style)
|
||||||
|
|
||||||
elif isinstance(node, Status):
|
elif isinstance(node, Status):
|
||||||
char = STATUS_CHARS.get(node.state, "◌")
|
char = getattr(th.indicators, node.state, th.indicators.unknown)
|
||||||
color = STATUS_COLORS.get(node.state, "888")
|
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(x, y, char, style=CellStyle(fg=color))
|
||||||
grid.put_text(x + 2, y, node.label)
|
grid.put_text(x + 2, y, node.label)
|
||||||
|
|
||||||
|
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):
|
elif isinstance(node, Table):
|
||||||
_paint_table(node, grid, x, y, w)
|
_paint_table(node, grid, x, y, w)
|
||||||
|
|
||||||
elif isinstance(node, Form):
|
elif isinstance(node, Form):
|
||||||
for child in node.children:
|
for child in node.children:
|
||||||
paint(child, grid)
|
paint(child, grid, th)
|
||||||
|
|
||||||
elif isinstance(node, Field):
|
elif isinstance(node, Field):
|
||||||
label_style = CellStyle(fg="888")
|
label_style = CellStyle(fg=th.palette.label)
|
||||||
field_style = CellStyle(fg="0cf")
|
field_style = CellStyle(fg=th.palette.form)
|
||||||
label_text = f"{node.field_name}: "
|
label_text = f"{node.field_name}: "
|
||||||
grid.put_text(x, y, label_text, style=label_style)
|
grid.put_text(x, y, label_text, style=label_style)
|
||||||
# Draw [ placeholder_______ ]
|
fl = th.form.field_l
|
||||||
|
fr = th.form.field_r
|
||||||
fx = x + len(label_text)
|
fx = x + len(label_text)
|
||||||
fw = min(node.field_width, w - len(label_text) - 2)
|
fw = min(node.field_width, w - len(label_text) - len(fl) - len(fr))
|
||||||
grid.put(fx, y, "[", style=field_style)
|
grid.put_text(fx, y, fl, style=field_style)
|
||||||
placeholder = node.placeholder or node.field_name
|
placeholder = node.placeholder or node.field_name
|
||||||
inner = f" {placeholder}".ljust(fw - 1)[:fw - 1]
|
inner = placeholder.ljust(fw)[:fw]
|
||||||
grid.put_text(fx + 1, y, inner, style=CellStyle(fg="555"))
|
grid.put_text(fx + len(fl), y, inner, style=CellStyle(fg=th.palette.muted))
|
||||||
grid.put(fx + fw, y, "]", style=field_style)
|
grid.put_text(fx + len(fl) + fw, y, fr, style=field_style)
|
||||||
|
|
||||||
elif isinstance(node, Password):
|
elif isinstance(node, Password):
|
||||||
label_style = CellStyle(fg="888")
|
label_style = CellStyle(fg=th.palette.label)
|
||||||
field_style = CellStyle(fg="0cf")
|
field_style = CellStyle(fg=th.palette.form)
|
||||||
label_text = f"{node.field_name}: "
|
label_text = f"{node.field_name}: "
|
||||||
grid.put_text(x, y, label_text, style=label_style)
|
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)
|
fx = x + len(label_text)
|
||||||
fw = min(node.field_width, w - len(label_text) - 2)
|
fw = min(node.field_width, w - len(label_text) - len(fl) - len(fr))
|
||||||
grid.put(fx, y, "[", style=field_style)
|
grid.put_text(fx, y, fl, style=field_style)
|
||||||
inner = " " + "•" * (fw - 2)
|
inner = "•" * fw
|
||||||
grid.put_text(fx + 1, y, inner[:fw - 1], style=CellStyle(fg="555"))
|
grid.put_text(fx + len(fl), y, inner[:fw], style=CellStyle(fg=th.palette.muted))
|
||||||
grid.put(fx + fw, y, "]", style=field_style)
|
grid.put_text(fx + len(fl) + fw, y, fr, style=field_style)
|
||||||
|
|
||||||
elif isinstance(node, Radio):
|
elif isinstance(node, Radio):
|
||||||
label_style = CellStyle(fg="888")
|
label_style = CellStyle(fg=th.palette.label)
|
||||||
label_text = f"{node.group}: "
|
label_text = f"{node.group}: "
|
||||||
grid.put_text(x, y, label_text, style=label_style)
|
grid.put_text(x, y, label_text, style=label_style)
|
||||||
rx = x + len(label_text)
|
rx = x + len(label_text)
|
||||||
for i, opt in enumerate(node.options):
|
for i, opt in enumerate(node.options):
|
||||||
dot = "(•)" if i == 0 else "( )"
|
dot = th.form.radio_on if i == 0 else th.form.radio_off
|
||||||
opt_style = CellStyle(fg="0cf" if i == 0 else "888")
|
opt_style = CellStyle(fg=th.palette.form if i == 0 else th.palette.label)
|
||||||
grid.put_text(rx, y, dot, style=opt_style)
|
grid.put_text(rx, y, dot, style=opt_style)
|
||||||
rx += 4
|
rx += len(dot) + 1
|
||||||
grid.put_text(rx, y, opt, style=CellStyle())
|
grid.put_text(rx, y, opt, style=CellStyle())
|
||||||
rx += len(opt) + 2
|
rx += len(opt) + 2
|
||||||
|
|
||||||
elif isinstance(node, Checkbox):
|
elif isinstance(node, Checkbox):
|
||||||
check_style = CellStyle(fg="0cf")
|
check_style = CellStyle(fg=th.palette.form)
|
||||||
box_char = "[✓]" if node.checked else "[ ]"
|
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, y, box_char, style=check_style)
|
||||||
grid.put_text(x + 4, y, node.checkbox_label)
|
grid.put_text(x + len(box_char) + 1, y, node.checkbox_label)
|
||||||
|
|
||||||
elif isinstance(node, FormButton):
|
elif isinstance(node, FormButton):
|
||||||
btn_style = CellStyle(bold=True, fg="0f0")
|
btn_style = CellStyle(bold=True, fg=th.palette.button)
|
||||||
btn_text = f"[ {node.button_label} ]"
|
btn_text = f"[ {node.button_label} ]"
|
||||||
grid.put_text(x, y, btn_text, style=btn_style, link=node.dest)
|
grid.put_text(x, y, btn_text, style=btn_style, link=node.dest)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Generic: paint children
|
# Generic: paint children
|
||||||
for child in node.children:
|
for child in node.children:
|
||||||
paint(child, grid)
|
paint(child, grid, th)
|
||||||
|
|
||||||
|
|
||||||
def _paint_table(node: Table, grid: CharGrid, x: int, y: int, w: int) -> None:
|
def _paint_table(node: Table, grid: CharGrid, x: int, y: int, w: int) -> None:
|
||||||
@@ -357,9 +403,7 @@ def _paint_table(node: Table, grid: CharGrid, x: int, y: int, w: int) -> None:
|
|||||||
|
|
||||||
# Check for @color{hex}{text} modifiers in cell content
|
# Check for @color{hex}{text} modifiers in cell content
|
||||||
if "@" in cell_text:
|
if "@" in cell_text:
|
||||||
spans = []
|
pattern = re.compile(r"@color\{([0-9a-fA-F]{3})\}\{([^}]*)\}")
|
||||||
import re as _re
|
|
||||||
pattern = _re.compile(r"@color\{([0-9a-fA-F]{3})\}\{([^}]*)\}")
|
|
||||||
pos = 0
|
pos = 0
|
||||||
styled_parts: list[tuple[str, CellStyle]] = []
|
styled_parts: list[tuple[str, CellStyle]] = []
|
||||||
for m in pattern.finditer(cell_text):
|
for m in pattern.finditer(cell_text):
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
from typing import Sequence
|
|
||||||
|
|
||||||
from uframe.errors import ParseError
|
from uframe.errors import ParseError
|
||||||
from uframe.ir import (
|
from uframe.ir import (
|
||||||
@@ -20,6 +19,7 @@ from uframe.ir import (
|
|||||||
Gauge, Sparkline, Status, Table, TextSpan,
|
Gauge, Sparkline, Status, Table, TextSpan,
|
||||||
Form, Field, Password, Radio, Checkbox, FormButton,
|
Form, Field, Password, Radio, Checkbox, FormButton,
|
||||||
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
|
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
|
||||||
|
BigTitle,
|
||||||
ComponentDef, ComponentUse,
|
ComponentDef, ComponentUse,
|
||||||
SourceType,
|
SourceType,
|
||||||
BorderWeight, HeadingLevel, DividerStyle, ListStyle, Align, Style,
|
BorderWeight, HeadingLevel, DividerStyle, ListStyle, Align, Style,
|
||||||
@@ -427,6 +427,16 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
|
|||||||
content = " ".join([keyword] + args)
|
content = " ".join([keyword] + args)
|
||||||
return Text(content=content, source_line=line_num)
|
return Text(content=content, source_line=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":
|
elif keyword == "component":
|
||||||
# component name(arg1, arg2)
|
# component name(arg1, arg2)
|
||||||
raw = " ".join(args)
|
raw = " ".join(args)
|
||||||
@@ -451,6 +461,13 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
|
|||||||
return ComponentUse(comp_name=keyword, args=args, source_line=line_num)
|
return ComponentUse(comp_name=keyword, args=args, source_line=line_num)
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
class _UseDirective(IRNode):
|
||||||
"""Temporary node — triggers library loading during tree building."""
|
"""Temporary node — triggers library loading during tree building."""
|
||||||
def __init__(self, lib_path: str, line_num: int):
|
def __init__(self, lib_path: str, line_num: int):
|
||||||
@@ -594,6 +611,18 @@ component resources(cpu, mem)
|
|||||||
|
|
||||||
component peer_status(name, state)
|
component peer_status(name, state)
|
||||||
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": '''\
|
"std/status-bar": '''\
|
||||||
component status_bar(label, value, max)
|
component status_bar(label, value, max)
|
||||||
@@ -601,6 +630,13 @@ component status_bar(label, value, max)
|
|||||||
|
|
||||||
component status_item(name, state)
|
component status_item(name, state)
|
||||||
status "$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": '''\
|
"std/nav": '''\
|
||||||
component nav_link(label, dest)
|
component nav_link(label, dest)
|
||||||
@@ -608,6 +644,36 @@ component nav_link(label, dest)
|
|||||||
|
|
||||||
component nav_divider()
|
component nav_divider()
|
||||||
divider light
|
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"
|
||||||
''',
|
''',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -708,12 +774,17 @@ def parse(source: str, components: dict[str, ComponentDef] | None = None) -> Pag
|
|||||||
stack.pop()
|
stack.pop()
|
||||||
|
|
||||||
if isinstance(node, _StyleDirective):
|
if isinstance(node, _StyleDirective):
|
||||||
# Apply style directive to the current top of stack (parent)
|
|
||||||
if stack:
|
if stack:
|
||||||
parent = stack[-1][1]
|
parent = stack[-1][1]
|
||||||
setattr(parent.style, node.attr, node.value)
|
setattr(parent.style, node.attr, node.value)
|
||||||
continue
|
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
|
# Table children: columns and rows are absorbed by the Table node
|
||||||
if isinstance(node, _TableColumns):
|
if isinstance(node, _TableColumns):
|
||||||
if stack and isinstance(stack[-1][1], Table):
|
if stack and isinstance(stack[-1][1], Table):
|
||||||
|
|||||||
130
backend/uframe/registry.py
Normal file
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,
|
||||||
|
}
|
||||||
241
backend/uframe/themes.py
Normal file
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)
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { useEditorStore } from "@/stores/editorStore";
|
import { useEditorStore } from "@/stores/editorStore";
|
||||||
import { renderMicron } from "./micronRenderer";
|
import { renderMicron } from "./micronRenderer";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -62,9 +61,9 @@ export default function PreviewPane() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="flex-1 bg-background">
|
<div className="flex-1 bg-background overflow-auto">
|
||||||
{previewMode === "ascii" ? (
|
{previewMode === "ascii" ? (
|
||||||
<pre className="p-4 font-mono text-sm whitespace-pre leading-tight text-green-100/90">
|
<pre className="p-2 font-mono text-[11px] whitespace-pre leading-tight text-green-100/90">
|
||||||
{compiledAscii || (
|
{compiledAscii || (
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
ASCII preview will appear here…
|
ASCII preview will appear here…
|
||||||
@@ -74,7 +73,7 @@ export default function PreviewPane() {
|
|||||||
) : previewMode === "micron" ? (
|
) : previewMode === "micron" ? (
|
||||||
compiledMicron ? (
|
compiledMicron ? (
|
||||||
<div
|
<div
|
||||||
className="p-4 font-mono text-sm whitespace-pre leading-tight"
|
className="p-2 font-mono text-[11px] whitespace-pre leading-tight"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: renderMicron(compiledMicron),
|
__html: renderMicron(compiledMicron),
|
||||||
}}
|
}}
|
||||||
@@ -91,11 +90,11 @@ export default function PreviewPane() {
|
|||||||
{compiledScript || "No dynamic script generated."}
|
{compiledScript || "No dynamic script generated."}
|
||||||
</pre>
|
</pre>
|
||||||
) : (
|
) : (
|
||||||
<pre className="p-4 font-mono text-sm whitespace-pre-wrap break-words text-muted-foreground">
|
<pre className="p-4 font-mono text-xs whitespace-pre-wrap break-words text-muted-foreground">
|
||||||
{compiledMicron || "Raw Micron output will appear here…"}
|
{compiledMicron || "Raw Micron output will appear here…"}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -278,6 +278,64 @@ export const EXAMPLES: Example[] = [
|
|||||||
peer_status "Node Gamma" degraded
|
peer_status "Node Gamma" degraded
|
||||||
|
|
||||||
divider heavy
|
divider heavy
|
||||||
|
link "Home" "/page/index.mu"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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"`,
|
link "Home" "/page/index.mu"`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,59 +15,82 @@ function escapeHtml(text: string): string {
|
|||||||
function renderInline(raw: string): string {
|
function renderInline(raw: string): string {
|
||||||
let out = "";
|
let out = "";
|
||||||
let i = 0;
|
let i = 0;
|
||||||
const openTags: string[] = [];
|
|
||||||
|
|
||||||
const closeAll = () => {
|
// Track open/close state for toggle-style tags
|
||||||
while (openTags.length) out += openTags.pop()!;
|
let boldOpen = false;
|
||||||
};
|
let italicOpen = false;
|
||||||
|
let underOpen = false;
|
||||||
|
let fgOpen = false;
|
||||||
|
let bgOpen = false;
|
||||||
|
let alignOpen = false;
|
||||||
|
|
||||||
while (i < raw.length) {
|
while (i < raw.length) {
|
||||||
// Backtick formatting codes
|
// Backtick formatting codes
|
||||||
if (raw[i] === "`") {
|
if (raw[i] === "`") {
|
||||||
const code = raw[i + 1];
|
const code = raw[i + 1];
|
||||||
if (code === "!") {
|
if (code === "!") {
|
||||||
out += "<strong>"; openTags.push("</strong>"); i += 2; continue;
|
if (boldOpen) { out += "</strong>"; boldOpen = false; }
|
||||||
|
else { out += "<strong>"; boldOpen = true; }
|
||||||
|
i += 2; continue;
|
||||||
} else if (code === "*") {
|
} else if (code === "*") {
|
||||||
out += "<em>"; openTags.push("</em>"); i += 2; continue;
|
if (italicOpen) { out += "</em>"; italicOpen = false; }
|
||||||
|
else { out += "<em>"; italicOpen = true; }
|
||||||
|
i += 2; continue;
|
||||||
} else if (code === "_") {
|
} else if (code === "_") {
|
||||||
out += "<u>"; openTags.push("</u>"); i += 2; continue;
|
if (underOpen) { out += "</u>"; underOpen = false; }
|
||||||
|
else { out += "<u>"; underOpen = true; }
|
||||||
|
i += 2; continue;
|
||||||
} else if (code === "`") {
|
} else if (code === "`") {
|
||||||
closeAll(); i += 2; continue;
|
// Reset all
|
||||||
} else if (code === "f" || code === "b") {
|
if (boldOpen) { out += "</strong>"; boldOpen = false; }
|
||||||
out += "</span>"; i += 2; continue;
|
if (italicOpen) { out += "</em>"; italicOpen = false; }
|
||||||
|
if (underOpen) { out += "</u>"; underOpen = false; }
|
||||||
|
if (fgOpen) { out += "</span>"; fgOpen = false; }
|
||||||
|
if (bgOpen) { out += "</span>"; bgOpen = false; }
|
||||||
|
if (alignOpen) { out += "</span>"; alignOpen = false; }
|
||||||
|
i += 2; continue;
|
||||||
|
} else if (code === "f") {
|
||||||
|
if (fgOpen) { out += "</span>"; fgOpen = false; }
|
||||||
|
i += 2; continue;
|
||||||
|
} else if (code === "b") {
|
||||||
|
if (bgOpen) { out += "</span>"; bgOpen = false; }
|
||||||
|
i += 2; continue;
|
||||||
} else if (code === "a") {
|
} else if (code === "a") {
|
||||||
out += "</span>"; i += 2; continue;
|
if (alignOpen) { out += "</span>"; alignOpen = false; }
|
||||||
|
i += 2; continue;
|
||||||
} else if (code === "F") {
|
} else if (code === "F") {
|
||||||
// Foreground color — 3-digit hex only per spec
|
// Foreground color — 3-digit hex only per spec
|
||||||
const hexMatch = raw.slice(i + 2).match(/^([0-9a-fA-F]{3})(?![0-9a-fA-F])/);
|
const hexMatch = raw.slice(i + 2).match(/^([0-9a-fA-F]{3})/);
|
||||||
if (hexMatch) {
|
if (hexMatch) {
|
||||||
|
if (fgOpen) { out += "</span>"; }
|
||||||
const [r, g, b] = hexMatch[1].split("");
|
const [r, g, b] = hexMatch[1].split("");
|
||||||
const hex = r + r + g + g + b + b;
|
const hex = r + r + g + g + b + b;
|
||||||
out += `<span style="color:#${hex}">`;
|
out += `<span style="color:#${hex}">`;
|
||||||
openTags.push("</span>");
|
fgOpen = true;
|
||||||
i += 2 + hexMatch[1].length;
|
i += 2 + hexMatch[1].length;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
} else if (code === "B") {
|
} else if (code === "B") {
|
||||||
// Background color — 3-digit hex only per spec
|
// Background color — 3-digit hex only per spec
|
||||||
const hexMatch = raw.slice(i + 2).match(/^([0-9a-fA-F]{3})(?![0-9a-fA-F])/);
|
const hexMatch = raw.slice(i + 2).match(/^([0-9a-fA-F]{3})/);
|
||||||
if (hexMatch) {
|
if (hexMatch) {
|
||||||
|
if (bgOpen) { out += "</span>"; }
|
||||||
const [r, g, b] = hexMatch[1].split("");
|
const [r, g, b] = hexMatch[1].split("");
|
||||||
const hex = r + r + g + g + b + b;
|
const hex = r + r + g + g + b + b;
|
||||||
out += `<span style="background:#${hex}">`;
|
out += `<span style="background:#${hex}">`;
|
||||||
openTags.push("</span>");
|
bgOpen = true;
|
||||||
i += 2 + hexMatch[1].length;
|
i += 2 + hexMatch[1].length;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
} else if (code === "c") {
|
} else if (code === "c") {
|
||||||
out += `<span style="display:block;text-align:center">`;
|
out += `<span style="display:block;text-align:center">`;
|
||||||
openTags.push("</span>"); i += 2; continue;
|
alignOpen = true; i += 2; continue;
|
||||||
} else if (code === "r") {
|
} else if (code === "r") {
|
||||||
out += `<span style="display:block;text-align:right">`;
|
out += `<span style="display:block;text-align:right">`;
|
||||||
openTags.push("</span>"); i += 2; continue;
|
alignOpen = true; i += 2; continue;
|
||||||
} else if (code === "l") {
|
} else if (code === "l") {
|
||||||
out += `<span style="display:block;text-align:left">`;
|
out += `<span style="display:block;text-align:left">`;
|
||||||
openTags.push("</span>"); i += 2; continue;
|
alignOpen = true; i += 2; continue;
|
||||||
} else if (code === "<") {
|
} else if (code === "<") {
|
||||||
// Form element: `<...> or `<!...> or `<?...> or `<^...>
|
// Form element: `<...> or `<!...> or `<?...> or `<^...>
|
||||||
const closeAngle = raw.indexOf(">", i + 2);
|
const closeAngle = raw.indexOf(">", i + 2);
|
||||||
@@ -116,7 +139,13 @@ function renderInline(raw: string): string {
|
|||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|
||||||
closeAll();
|
// Close any remaining open tags
|
||||||
|
if (boldOpen) out += "</strong>";
|
||||||
|
if (italicOpen) out += "</em>";
|
||||||
|
if (underOpen) out += "</u>";
|
||||||
|
if (fgOpen) out += "</span>";
|
||||||
|
if (bgOpen) out += "</span>";
|
||||||
|
if (alignOpen) out += "</span>";
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -155,6 +155,44 @@ const COMMANDS: CmdEntry[] = [
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Themes — type /theme to filter all 6
|
||||||
|
{
|
||||||
|
label: "theme_default",
|
||||||
|
detail: "clean box-drawing ┌─┐●█░",
|
||||||
|
section: "Theme",
|
||||||
|
apply: insert("theme default"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "theme_nouveau",
|
||||||
|
detail: "flowing ornament ☙❧❀▐",
|
||||||
|
section: "Theme",
|
||||||
|
apply: insert("theme nouveau"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "theme_gothic",
|
||||||
|
detail: "blackletter ╬═║⚑▓",
|
||||||
|
section: "Theme",
|
||||||
|
apply: insert("theme gothic"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "theme_bamboo",
|
||||||
|
detail: "minimal brush 〔〕◉┄",
|
||||||
|
section: "Theme",
|
||||||
|
apply: insert("theme bamboo"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "theme_circuit",
|
||||||
|
detail: "digital neon ╒▰◈⚡",
|
||||||
|
section: "Theme",
|
||||||
|
apply: insert("theme circuit"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "theme_brutalist",
|
||||||
|
detail: "raw blocks █▌■□",
|
||||||
|
section: "Theme",
|
||||||
|
apply: insert("theme brutalist"),
|
||||||
|
},
|
||||||
|
|
||||||
// Templates
|
// Templates
|
||||||
{
|
{
|
||||||
label: "dashboard",
|
label: "dashboard",
|
||||||
@@ -186,15 +224,39 @@ const COMMANDS: CmdEntry[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Dynamic commands from /api/dsl-meta — merged into COMMANDS
|
||||||
|
let dynamicCommands: CmdEntry[] = [];
|
||||||
|
|
||||||
|
/** Update slash commands from /api/dsl-meta response. */
|
||||||
|
export function setDslCommands(
|
||||||
|
commands: { label: string; detail: string; section: string; snippet: string }[],
|
||||||
|
) {
|
||||||
|
// Build dynamic commands from API data, only for entries not already in COMMANDS
|
||||||
|
const existingLabels = new Set(COMMANDS.map((c) => c.label));
|
||||||
|
dynamicCommands = commands
|
||||||
|
.filter((c) => c.detail && c.snippet && !existingLabels.has(c.label))
|
||||||
|
.map((c) => ({
|
||||||
|
label: c.label,
|
||||||
|
detail: c.detail,
|
||||||
|
section: c.section,
|
||||||
|
apply: c.snippet.includes("${")
|
||||||
|
? slashSnippet(c.snippet)
|
||||||
|
: insert(c.snippet),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export function uframeCommandSource(
|
export function uframeCommandSource(
|
||||||
ctx: CompletionContext,
|
ctx: CompletionContext,
|
||||||
): CompletionResult | null {
|
): CompletionResult | null {
|
||||||
const match = ctx.matchBefore(/\/\w*/);
|
const match = ctx.matchBefore(/\/\w*/);
|
||||||
if (!match || (match.from === match.to && !ctx.explicit)) return null;
|
if (!match || (match.from === match.to && !ctx.explicit)) return null;
|
||||||
|
|
||||||
|
const allCommands = [...COMMANDS, ...dynamicCommands];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
from: match.from + 1,
|
from: match.from + 1,
|
||||||
filter: true,
|
filter: true,
|
||||||
options: COMMANDS.map((cmd) => ({
|
options: allCommands.map((cmd) => ({
|
||||||
label: cmd.label,
|
label: cmd.label,
|
||||||
detail: cmd.detail,
|
detail: cmd.detail,
|
||||||
section: cmd.section,
|
section: cmd.section,
|
||||||
|
|||||||
@@ -8,14 +8,13 @@ import { tags } from "@lezer/highlight";
|
|||||||
/**
|
/**
|
||||||
* CodeMirror 6 syntax highlighting for the µFrame .uf DSL.
|
* CodeMirror 6 syntax highlighting for the µFrame .uf DSL.
|
||||||
*
|
*
|
||||||
* Keywords: page, box, row, col, spacer, pad, heading, text, label,
|
* Keywords and values can be updated dynamically via setDslKeywords()
|
||||||
* divider, link, list, item, gauge, sparkline, status,
|
* which is called when the frontend fetches /api/dsl-meta.
|
||||||
* table, columns, form, field, radio, checkbox, button,
|
|
||||||
* source, let, if, elif, else, for, align, color, bg,
|
|
||||||
* bold, italic, underline, cache, state, on_submit
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const KEYWORDS = new Set([
|
// Mutable sets — updated from /api/dsl-meta
|
||||||
|
let KEYWORDS = new Set([
|
||||||
|
// Fallback defaults (used before API response arrives)
|
||||||
"page", "box", "row", "col", "spacer", "pad",
|
"page", "box", "row", "col", "spacer", "pad",
|
||||||
"heading", "text", "label", "divider", "link",
|
"heading", "text", "label", "divider", "link",
|
||||||
"list", "item", "gauge", "sparkline", "status",
|
"list", "item", "gauge", "sparkline", "status",
|
||||||
@@ -23,17 +22,24 @@ const KEYWORDS = new Set([
|
|||||||
"checkbox", "button", "source", "let", "if",
|
"checkbox", "button", "source", "let", "if",
|
||||||
"elif", "else", "for", "align", "color", "bg",
|
"elif", "else", "for", "align", "color", "bg",
|
||||||
"bold", "italic", "underline", "cache", "state",
|
"bold", "italic", "underline", "cache", "state",
|
||||||
"on_submit", "meter", "bar_h", "bar_v", "bar",
|
"on_submit", "theme", "component", "use",
|
||||||
"heatmap", "component", "use",
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const WEIGHT_VALS = new Set([
|
let WEIGHT_VALS = new Set([
|
||||||
"light", "heavy", "double", "rounded",
|
"light", "heavy", "double", "rounded",
|
||||||
"bullet", "dash", "number", "arrow",
|
"bullet", "dash", "number", "arrow",
|
||||||
"left", "center", "right",
|
"left", "center", "right",
|
||||||
"online", "offline", "degraded", "unknown",
|
"online", "offline", "degraded", "unknown",
|
||||||
|
"shell", "file", "json", "python", "rns", "param",
|
||||||
|
"default", "nouveau", "gothic", "bamboo", "circuit", "brutalist",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/** 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({
|
const uframeLanguage = StreamLanguage.define({
|
||||||
token(stream) {
|
token(stream) {
|
||||||
// Comments
|
// Comments
|
||||||
|
|||||||
35
frontend/src/hooks/useDslMeta.ts
Normal file
35
frontend/src/hooks/useDslMeta.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export interface DslMeta {
|
||||||
|
keywords: string[];
|
||||||
|
values: string[];
|
||||||
|
commands: { label: string; detail: string; section: string; snippet: string }[];
|
||||||
|
themes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
fetch("/api/dsl-meta")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: DslMeta) => {
|
||||||
|
cachedMeta = data;
|
||||||
|
setMeta(data);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
@@ -127,4 +127,34 @@
|
|||||||
html {
|
html {
|
||||||
@apply font-sans;
|
@apply font-sans;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark-mode scrollbars */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: oklch(0.35 0 0) transparent;
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background: oklch(0.35 0 0);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: oklch(0.45 0 0);
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark *::-webkit-scrollbar-thumb {
|
||||||
|
background: oklch(0.3 0 0);
|
||||||
|
}
|
||||||
|
.dark *::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: oklch(0.4 0 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -6,8 +6,9 @@ import { useEditorStore } from "@/stores/editorStore";
|
|||||||
import { usePagesStore } from "@/stores/pagesStore";
|
import { usePagesStore } from "@/stores/pagesStore";
|
||||||
import { useUnsavedGuard } from "@/hooks/useUnsavedGuard";
|
import { useUnsavedGuard } from "@/hooks/useUnsavedGuard";
|
||||||
import { useCompile } from "@/hooks/useCompile";
|
import { useCompile } from "@/hooks/useCompile";
|
||||||
import { uframeHighlight } from "@/components/editor/uframeHighlight";
|
import { useDslMeta } from "@/hooks/useDslMeta";
|
||||||
import { uframeCommandSource } from "@/components/editor/uframeCommands";
|
import { uframeHighlight, setDslKeywords } from "@/components/editor/uframeHighlight";
|
||||||
|
import { uframeCommandSource, setDslCommands } from "@/components/editor/uframeCommands";
|
||||||
import EditorPane from "@/components/editor/EditorPane";
|
import EditorPane from "@/components/editor/EditorPane";
|
||||||
import PreviewPane from "@/components/editor/PreviewPane";
|
import PreviewPane from "@/components/editor/PreviewPane";
|
||||||
import ToolBar from "@/components/editor/ToolBar";
|
import ToolBar from "@/components/editor/ToolBar";
|
||||||
@@ -40,11 +41,22 @@ export default function EditorView() {
|
|||||||
autocompletion({
|
autocompletion({
|
||||||
override: [uframeCommandSource],
|
override: [uframeCommandSource],
|
||||||
icons: false,
|
icons: false,
|
||||||
|
activateOnTyping: true,
|
||||||
|
maxOptions: 50,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Load DSL metadata (keywords, values, commands) from backend
|
||||||
|
const dslMeta = useDslMeta();
|
||||||
|
useEffect(() => {
|
||||||
|
if (dslMeta.keywords.length > 0) {
|
||||||
|
setDslKeywords(dslMeta.keywords, dslMeta.values);
|
||||||
|
setDslCommands(dslMeta.commands);
|
||||||
|
}
|
||||||
|
}, [dslMeta]);
|
||||||
|
|
||||||
// Auto-compile on source changes
|
// Auto-compile on source changes
|
||||||
useCompile();
|
useCompile();
|
||||||
useUnsavedGuard();
|
useUnsavedGuard();
|
||||||
|
|||||||
Reference in New Issue
Block a user