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

11 KiB
Raw Blame History

Data Flow in Glint Runtime

How source code turns into pixels on the screen.

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:

// 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:

// 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:

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.

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:

  • Buttoniced::button(...).on_press(...)
  • Texticed::text("...").size(16).color(...)
  • Paneliced::column[...].spacing(10), wrapped in a container with background and border
  • Inputiced::text_input("placeholder", "value").on_input(...)
  • Imageiced::image(path) or iced::svg(path)
  • Unknown type → just a column with children

Each widget is wrapped in apply_universal_box_model:

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

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:

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.