Architecture
How a .rux file becomes pixels, and which crate owns which stage.
How the Rux runtime turns a .rux file into pixels and keeps them live. The spec defines what the language is; this defines what we build.
Everything here is downstream of the rationale, especially Law 4: reuse mature Rust crates; write only the glue that is uniquely ours.
Status: v0.1 architecture proposal. Concrete crate choices are recommendations, not commitments. The milestone plan is designed so each can be swapped without redesign.
Contents
- The pipeline at a glance
- What we reuse vs. what we build
- Stage 1: Parse
- Stage 2: Cascade
- Stage 3: Reactive graph
- Stage 4: Layout
- Stage 5: Paint & present
- Input & event routing
- The script tier & host bridge
- Hot-reload
- Crate layout
- Milestone plan
- Open questions
The pipeline at a glance
One frame's worth of data flow, source file to screen:
┌──────────────────────── hot-reload ────────────────────────┐
│ │
.rux file ▼ │
┌─────────┐ ┌─────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────┴─────┐
│ template│──►│ PARSE │──►│ CASCADE │──►│ REACTIVE │──►│ LAYOUT │──►│ PAINT + │
│ style │ │ 3 srcs │ │ CSS→style│ │ GRAPH │ │ taffy │ │ PRESENT │
│ script │ │ →1 doc │ │ per node │ │ signals │ │ boxes │ │ vello/wgpu │
└─────────┘ └─────────┘ └──────────┘ └────┬─────┘ └──────────┘ └──────┬─────┘
│ │
signal change─┘ winit window ◄─────────┘
dirties only input events ─────────┐
affected nodes │
▼
event routing
→ script handlers
The important property: a signal change does not re-run parse or cascade. It marks the specific nodes that subscribed to it dirty, and only those nodes re-layout/repaint. Parse and cascade run at load and on hot-reload; the reactive loop runs every interaction.
What we reuse vs. what we build
Law 4 in one table. The "build" column is the actual surface area of the project.
| Concern | Reuse (crate) | We build |
|---|---|---|
| Windowing / input events | winit | thin adapter to our event model |
| GPU surface | wgpu | n/a |
| Vector painting | vello (or tiny-skia CPU fallback) | scene builder from our render tree |
| Text shaping / layout | parley + swash (or cosmic-text) | glyph → paint integration |
| CSS parsing | lightningcss | property → our style-struct mapping |
| Flexbox/grid layout | taffy | style → taffy-node translation |
| Script interpreter | rhai | host bindings, signal integration |
| File watching | notify | debounce + reload orchestration |
| Template parsing | (none, ours) | XML+directive parser → node tree |
| Reactive graph | (ours; Leptos-inspired) | signals, subscriptions, dirty tracking |
| The glue | n/a | the document model tying all stages |
Roughly: template parser + reactive graph + the document model are the genuinely new code Everything else is integration.
Stage 1: Parse
Input: the raw .rux text. Output: three parsed artifacts bundled into a Document.
- Split the SFC. Extract
<template>,<style>,<script>regions. Cheap pre-pass; each section then goes to its own parser. - Template → node tree. Our own parser (the one piece with no off-the-shelf answer). It' XML-shaped but must recognize our attributes specially:
{{ expr }}interpolations in text and attribute values → binding nodes, not literal strings.r-for,r-if/r-elif/r-else,r-show→ structural directives attached to the node, not attributes.:attr,r-model,@event→ binding metadata on the node.- Custom element tags (kebab-case, not one of the six) → component instantiation points Output is a tree of
TemplateNodes where every dynamic piece is already distinguished from static text. Directive expressions are stored as unparsed source strings here; they compile against the script scope in Stage 3.
- Style → stylesheet. Hand the CSS to
lightningcss; keep its parsed rule list. No evaluation yet. - Script → program. Hand the script to
rhai's parser; keep the compiled AST. Top-levellet signal(...)declarations andfns are catalogued so the template compiler can resolve names. Parse errors do not panic. They produce aDiagnosticSetthat the hot-reload layer renders as a dev overlay, the accepted cost of runtime documents.
Stage 2: Cascade
Input: the node tree + the parsed stylesheet. Output: a resolved style struct per node.
- Match selectors against each node. Selector kinds we honor:
class (
.x), id (#x), element (view,text, …), and attribute for roles ([role="list"]), plus descendant/child combinators. - Apply the cascade: specificity + source order, exactly as CSS defines. We lean
on
lightningcss's parsed representation; the cascade algorithm is small and ours. - Map matched declarations onto a fixed
ComputedStylestruct holding only the honored subset. Unhonored properties are dropped and logged in dev mode. - Split
ComputedStyleconceptually into layout props (feed Stage 4) and paint props (feed Stage 5).
Cascade re-runs on style hot-reload or when a node's classes change reactively, never on a plain signal value change.
Stage 3: Reactive graph
This is the heart, and it's ours (modeled on Leptos/Solid, proven in Rust, per Law 4). Input: the node tree + compiled script. Output: a live graph where value changes propagate to exactly the affected nodes.
- Signals are the leaves:
signal(v)registers a reactive cell in the runtime.rhai's host functions exposesignal,.get(),.set(),.update()into script scope. - Binding compilation. Each
{{ expr }},:attr,r-if,r-for, etc. is compiled once into a reactive computation: a closure that reads signals and produces a value. The read establishes the subscription: reading a signal inside a binding records "this binding depends on that signal." - Dirty propagation.
signal.set()marks its subscribers dirty and schedules a frame. On the frame, dirty computations re-run; a text binding updates its node's text, anr-ifadds/removes a subtree, anr-fordiffs its keyed list. - No virtual DOM. The binding is the subscription; there is no whole-tree diff. Only dirty computations do work.
The structural directives are reactive computations with side effects on tree shape:
| Directive | Reactive effect |
|---|---|
{{ }} / :attr | update a node's text / attribute |
r-if/r-elif/r-else | mount/unmount a subtree |
r-show | toggle a visibility flag (node stays, layout slot kept) |
r-for | keyed reconcile of child instances against the data |
r-model | two-way: bind value in, write signal on input event |
Stage 4: Layout
Input: layout props + tree shape. Output: a box (x, y, w, h) per node.
- Translate each node's
ComputedStylelayout props into ataffystyle and build a paralleltaffytree.taffyis a flexbox/grid engine, sodisplay: flex,flex-direction,gap,justify-content,align-items,padding,margin, sizing, andoverflowmap almost 1:1. - Text nodes need intrinsic sizing:
parleyshapes the text to measure it, and that measurement feedstaffyas a leaf's content size. - Run
taffyto produce absolute rects. Cache them; only re-run for the dirty subtree when a reactive change altered size-affecting props or tree shape. overflow: auto/scrollproduces a clipping+scrollable region and enables the node'sscrollcapability.
Stage 5: Paint & present
Input: paint props + layout rects. Output: pixels on the window.
- Walk the laid-out tree building a scene for
vello: rounded rects (background,border-radius,border), text runs (fromparley/swashglyphs), images, shadows, opacity, clips for scroll regions. vellorenders the scene onwgpu;winitowns the window/surface.- A
tiny-skiaCPU backend is the fallback for platforms/targets without a usable GPU (and the likely path for the embedded target later). - Only the dirty region repaints where the backend allows; otherwise repaint the frame but skip re-layout of clean subtrees.
Input & event routing
The reverse path: winit raw input → our event model → script handlers.
winitdelivers pointer/touch/keyboard events.- Gesture recognition turns raw pointer streams into our gesture-honest
capabilities: a press+release within slop/time =
tap; held =longpress; moved =drag/swipe; wheel/touch-move over anoverflownode =scroll. This is where "not a browser" is enforced:hoveronly exists when a pointer device is present. - Hit-testing uses the layout rects to find the target node; the event bubbles up ancestors (like DOM bubbling) until a handler consumes it.
- If the node bound that capability with
@event, the handler runs in the script tier. Handlers mutate signals; signal changes re-enter Stage 3. The loop closes.
The script tier & host bridge
Two tiers, one boundary. See the decision.
-
Script (rhai). Runs the component's handlers and holds its signals. Each component instance gets a
rhaiscope seeded with its top-levellets/fns. Interpreted, so it hot-reloads. -
Host (compiled Rust). A
HostRegistrymaps names → Rust closures, injected into therhaiengine as thehostmodule. This is the registry contract:// illustrative; final shape decided during build let mut host = HostRegistry::new(); host.function("load_devices", || db::all()); host.function("read_battery", || sensors::battery()); host.function("open", |id: i64| nav::open(id)); engine.register_static_module("host", host.into_module()); -
Boundary rules. Script may only call registered
host::names (unknown → diagnostic). All native capability (fs, net, sensors, navigation, native pickers) crosses here. Values marshal asrhaidynamics; the host validates. -
Threading. Host functions that block (I/O) run off the UI thread and resolve back onto it before touching signals, so the render loop never stalls. (Async model is an open question.)
Hot-reload
The feature that drove the whole runtime-document decision.
-
notifywatches the.ruxfile(s); a short debounce coalesces editor saves. -
On change, re-run only the stages the edit affects:
Edited section Re-run from <style>Cascade (Stage 2) → relayout/repaint <template>Parse template → Cascade → rebuild reactive graph <script>Reparse script → re-seed scopes → rebuild bindings host (Rust) rebuild required: not hot -
State preservation is best-effort: on a script/template reload we attempt to carry signal values forward by name; when the shape changed too much we reset to initial. (Exact policy is an open question.)
-
Parse/eval failures show the
DiagnosticSetas an overlay over the last good frame, never a crash and never a blank window.
Crate layout
A workspace of focused crates so pieces stay swappable (and testable without a GPU):
rux/
├─ rux-parser # template XML+directive parser → TemplateNode tree (ours)
├─ rux-style # lightningcss integration, cascade, ComputedStyle (ours+reuse)
├─ rux-reactive # signals, subscriptions, dirty scheduling (ours)
├─ rux-script # rhai engine, host registry, scope wiring (ours+reuse)
├─ rux-layout # ComputedStyle → taffy, text measwith parley (integration)
├─ rux-paint # render tree → vello scene; tiny-skia fallback (integration)
├─ rux-runtime # the Document model; owns the pipeline + hot-reload (ours)
├─ rux-shell # winit window, input→event, frame loop (integration)
└─ rux-cli # `rux run app.rux`, dev overlay, watcher (glue)
rux-reactive and rux-parser have zero GPU/OS deps and are unit-testable in
isolation, deliberately, since they're the novel logic.
Milestone plan
Each milestone is independently demoable and de-risks the next. The ordering front-loads the thesis (layout-is-CSS, hot-reload) before the elaborate parts.
| # | Milestone | Proves | Rough surface |
|---|---|---|---|
| M0 | Blank winit+wgpu window, clear color | GPU/window path works on Windows | rux-shell |
| M1 | Hardcoded node tree → taffy → vello paints rects | layout+paint pipeline | rux-layout, rux-paint |
| M2 | Parse a static .rux template+style → render it | our parser + cascade + literal CSS | rux-parser, rux-style |
| M3 | notify watcher → edit .rux, window repaints | hot-reload thesis | rux-runtime, rux-cli |
| M4 | Text via parley; real content sizing | text is a first-class citizen | rux-layout |
| M5 | Signals + {{ }} bindings update on change | reactive graph | rux-reactive |
| M6 | Input → gestures → @tap handler mutates a signal | full interaction loop | rux-shell, rux-script |
| M7 | r-for, r-if, r-model, <input> controls | directive set | across |
| M8 | Host registry; host:: calls from script | native-capability boundary | rux-script |
| M9 | Component import + embed (<device-tile>) | reuse/module system | rux-parser, rux-runtime |
The guide's finished dashboard is the acceptance test for M9: when that file renders and behaves, v0.1 is real.
Open questions
Deferred to build-time decisions, flagged so we don't pretend they're solved:
- Async/host concurrency: the exact model for non-blocking host calls and how their results re-enter the signal graph.
- State preservation policy on hot-reload: how aggressively to carry signal values across a reload before resetting.
- Text/
parleyvscosmic-text: pick after M4 measures both for our needs. - Vello maturity on the CPU/embedded path: may push
tiny-skiaearlier for the eventualthumbv7emtarget. - Event object shape: the concrete gesture payload passed to handlers
(
$event), finalized alongside M6. - Grid/table: still deferred; revisit only after v0.1 ships.