Files
Glint-Runtime/docs/en/architecture/01-data-flow.md

304 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Data Flow in Glint Runtime
How source code turns into pixels on the screen.
```mermaid
flowchart TD
subgraph "Compilation"
A1[".gltm markup"] --> P["Parser: glt crate"]
A2[".glts style"] --> P
P --> M["ModuleSoA — flat arrays"]
M --> AST
AST --> C["Compiler: glt crate"]
C --> BC[".glbc bytecode"]
end
subgraph "Loading"
BC --> IR["Interpreter::run"]
IR --> R["Reader: parses the binary"]
R --> DOC["Document: tree + styles + variables"]
end
subgraph "Initialization"
DOC --> BOOT["iced::application boot"]
BOOT --> RC["RheiContext: compiles Rhai scripts"]
BOOT --> EV0["evaluate_vdom: builds the full VDOM"]
EV0 --> APP["GlintApp: ready to run"]
end
subgraph "Lifecycle: each frame"
APP --> LOOP{"iced event loop"}
LOOP -->|event received| MSG[Message]
MSG --> UPD["GlintApp::update"]
UPD --> SET["changes a variable"]
SET --> TV["tracker marks dependent elements as dirty"]
TV --> DIRTY["collect dirty_set"]
DIRTY --> VDOM["evaluate_vdom_incr: recalculate only dirty"]
VDOM --> STYLE["StyleSheet: apply styles (with cache)"]
STYLE --> NEW_VDOM["new VDOM"]
LOOP -->|on timer| VIEW["GlintApp::view"]
VIEW --> REND["render_element: Element → Iced widget"]
REND --> ICED["iced::Element tree"]
ICED --> DIFF["Iced: compares with previous frame"]
DIFF --> LAYOUT[Layout]
LAYOUT --> DRAW["GPU draws"]
end
subgraph "Styles — separate"
STYLE --> SI["StyleIndex: looks up rules in O(1)O(K)"]
SI --> SC["StyleCache: doesn't parse the same thing twice"]
end
```
---
## Stage 1: Compilation — from text to bytecode
It all starts with two file types:
- **`.gltm`** — markup: buttons, panels, texts, sliders, and so on.
- **`.glts`** — styles: CSS-like rules, selectors, colors, margins.
They are compiled by the external **`glt`** crate (not part of this repository). It does three things:
### 1.1 Parsing
`Parser` reads `.gltm` and `.glts` and stores everything in **`ModuleSoA`**.
**What is ModuleSoA?** SoA = Structure of Arrays. Instead of storing elements as a list of structs:
```text
// Array of Structures (AoS) — the usual way
Element { name: "Button", props: [...], children: [...] }
Element { name: "Text", props: [...], children: [...] }
```
the compiler stores them as a struct with parallel arrays:
```text
// Structure of Arrays (SoA) — more efficient for the compiler
ModuleSoA {
type_names: ["Button", "Text", ...],
properties_vec: [ [...], [...], ...],
hierarchy: [parent_id, parent_id, ...],
}
```
This way the compiler iterates over all names at once (CPU cache stays hot),
finds parent relationships faster, and applies optimizations more easily.
### 1.2 Building the AST
An AST tree is built from `ModuleSoA`. Components, if/each branches,
and parameters are resolved here.
### 1.3 Bytecode generation
`Compiler` walks the AST and turns it into the **`.glbc`** binary format:
- header with magic bytes (`"glBc"`)
- string pool (all names, classes, texts — one contiguous block)
- byte-encoded opcodes (see `opcodes.rs`: `OP_ELEM_PUSH`, `OP_PROP`, `OP_IF`, `OP_EACH`, etc.)
The result is a compact binary that can be loaded quickly and fed to the runtime.
---
## Stage 2: Loading — from bytecode to Document
The runtime takes `.glbc` and turns it into data structures that can be worked with.
### `Interpreter::run(bytecode) → Document`
Internally, `Reader` reads the bytecode sequentially:
1. Checks magic bytes (is this really `.glbc`?)
2. Reads the string pool
3. Executes opcodes, building the `Element` tree on the fly
Two important things happen in parallel:
**Styles:** each encountered style directive is parsed into a `StyleRule`,
then all rules are built into a `StyleIndex` — a catalog: "here are all rules for tag Button,
here for class primary, here for element with id=submit". This way style lookup
will later take not O(all rules), but O(a couple of items).
**Dependencies:** every property like `"text": "Hello $name"` is a hint:
the element depends on the variable `name`. `ReactiveTracker` scans all properties,
finds `$var` and remembers: "element ElementId(5) depends on variable 'name'".
The result is a **`Document`**:
```rust
Document {
roots: Vec<Element>, // root elements
components: HashMap<String, ComponentDef>, // components
variables: HashMap<String, Value>, // initial values
stylesheet: StyleSheet, // style sheet
rhei_scripts: Vec<String>, // init scripts
tracker: ReactiveTracker, // who depends on what
interner: Interner, // unique string pool
}
```
---
## Stage 3: Initialization — preparing for life
`Document` is ready, but it needs to be "started". The Iced boot function does this.
### 3.1 Cloning
`doc.clone()` — all strings inside Element have type `&'a str` with the original
lifetime. After cloning they become `&'static str` (the runtime
calls `Box::leak` so strings live forever — the application runs until the window is closed).
### 3.2 Rhai compilation
`RheiContext::new(scripts)`:
- Creates a Rhai engine (`Engine`)
- Compiles all init scripts into AST and saves them
- Collects all functions from the scripts into a global module
- Then `precompile_all_from_doc()` walks the entire Element tree and compiles
every `__on:click { ... }` and every `!rhei:expr` into cache.
**Now on click there's no need to recompile** — just grab the AST from cache.
### 3.3 Running init scripts
`initialize()`: synchronizes variables with the Rhai scope, executes init scripts,
pulls everything that changed from the scope.
### 3.4 First VDOM
`evaluate_vdom()` — a full traversal of the tree:
- Substitutes variables into strings (`$name` → actual value)
- Evaluates `@if` conditions
- Expands `@each` into the actual number of elements
- For each element, finds matching styles and computes `ComputedStyle`
- Assigns `content_hash`
Result: `GlintApp { doc, rhei, vdom_roots }`. The first frame is ready to display.
---
## Stage 4: Lifecycle — each frame
Iced runs in a loop: event → `update()``view()` → rendering.
### 4.1 Event received: update()
The user clicked a button, moved a slider, entered text — Iced sends a `Message`.
```rust
enum Message {
SliderChanged(Option<String>, f64), // slider: (bound variable, new value)
InputChanged(Option<String>, String), // text input
ToggleChanged(Option<String>, bool), // checkbox
EventTriggered(String), // button click: run Rhai script
WindowScrolled(f32), // window scroll
ScrollableScrolled(u64, f32), // scroll inside container
}
```
**GlintApp::update()** does the following:
1. **Changes the variable.** For example, `SliderChanged("volume", 75)``variables["volume"] = 75.0`.
2. **Notifies the tracker:** `tracker.on_variable_changed("volume")`. The tracker checks:
"elements with IDs 5, 12, 18 depend on this variable". It marks them as dirty.
3. **Collects the dirty_set:** `tracker.take_dirty_set()`.
4. **Recalculates VDOM:** `evaluate_vdom_incr(roots, &dirty_set)`. It walks the tree.
If an element is in dirty_set — recalculates it (variable substitution, style computation).
If not — leaves it as is. **Children of dirty elements are also recalculated** (cascade).
### 4.2 On timer: view()
Even if nothing happened, Iced calls `view()` every frame (60 times per second).
It needs to return Iced widgets for rendering.
**render_element()** — a recursive function that turns an Element into an Iced widget:
- `Button``iced::button(...).on_press(...)`
- `Text``iced::text("...").size(16).color(...)`
- `Panel``iced::column[...].spacing(10)`, wrapped in a container with background and border
- `Input``iced::text_input("placeholder", "value").on_input(...)`
- `Image``iced::image(path)` or `iced::svg(path)`
- Unknown type → just a column with children
Each widget is wrapped in **`apply_universal_box_model`**:
```text
container [margin]
container [padding, border, background]
scrollable (if overflow: scroll/auto)
container [padding]
the widget itself
```
**Problem:** `render_element` creates **all** widgets from scratch every frame, even if
the Element hasn't changed. Iced then diffs the new tree against the old one — but building
the tree itself takes ~7ms. This is the main optimization opportunity.
### 4.3 Iced does its thing
Iced receives the `iced::Element` tree, compares it with the previous one (diff), computes
the layout, and renders via GPU (wgpu). All of this happens without our code.
---
## Element anatomy
```rust
Element {
type_name: "Button", // what kind of element
properties: [("label", "Click"), ("color", "red"), ...], // its properties
computed_style: ComputedStyle { color: Some(Red), padding: Some(8px), ... }, // computed style
element_id: ElementId(42), // unique ID in the tree
content_hash: 0xABCD1234, // content hash (for widget cache)
children: [Element, ...], // child elements
}
```
## Style anatomy
Styles are stored in `StyleSheet` and work in three stages:
**1. Index (`StyleIndex`):** at load time, all CSS rules are sorted into buckets:
```text
Rule: "Button.primary#submit { color: red; padding: 10px }"
→ by_tag["Button"] = { RuleId(1) }
→ by_class["primary"] = { RuleId(1) }
→ by_id["submit"] = { RuleId(1) }
```
**2. Lookup:** when we need to find styles for a `Button.primary#submit` element,
we take the intersection of sets from all three buckets. Instead of checking 500 rules — 3 lookups.
**3. Cache:** even when styles are found, `ComputedStyle::compute()` parses all properties
(color, margins, fonts — about 40 fields). This is expensive. `StyleCache` remembers
the result: `hash(type_name, properties, epoch) → ComputedStyle`. If the element
hasn't changed — we get the ready-made style from cache, no parsing.
---
## Event loop with a slider example
```
1. User moves the volume slider
2. Iced: SliderChanged(Some("volume"), 75.0)
3. GlintApp::update:
a. variables["volume"] = Float(75.0)
b. tracker.on_variable_changed("volume")
→ dirty: ElementId(5) — text with "$volume", ElementId(12) — width from "$volume"
c. evaluate_vdom_incr(roots, &{5, 12})
→ Element 5: recalculate text (new volume)
→ Element 12: recalculate width
→ remaining 48 elements: don't touch
4. GlintApp::view:
→ render_element for all 50 root elements
→ recursively for all children (even for those 48 that didn't change)
→ Iced receives a completely new tree of 200+ widgets
5. Iced: diffs → finds 2 changes → redraws 2 areas
```
**Bottleneck:** step 4. VDOM recalculated only 2 out of 50 elements (thanks to ReactiveTracker).
But render_element creates widgets for all 200+ nodes. Iced then diffs anyway
and does nothing with 198 of them, but the time to create them has already been spent.