azul Documentation

Rust c & cpp repository by @fschutt. Docker Compose & container deployment with 6.1k stars.

RAW Doc

Docker & Self-Hosted Deployment Verified OCI

Deploy azul on any server, homelab, or cloud container platform.

1-Click Terminal Command
bash (docker run)
docker run -d --name azul --restart always fschutt/azul:latest

Technical Documentation: fschutt/azul

ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference

Overview (README.md)

AZUL - Desktop GUI framework





Azul is a free, functional, reactive GUI framework for Rust, C and C++,
built using the WebRender rendering engine and a CSS / HTML-like document
object model for rapid development of beautiful, native desktop applications

[Website](https://azul.rs/) | [Releases](https://azul.rs/ui/releases) | [User guide](https://azul.rs/ui/guide) | [API documentation](https://azul.rs/ui/api)

Current Status

[!WARNING]
This repository is currently under heavy development. Azul is NOT usable yet.

APIs may change frequently and features may be incomplete or unstable.

If you are looking for the old README, see README-OLD.md

The current release is from 2+ years ago, see the releases page.

Visit https://azul.rs/reftest in order to see the current testing and development
of the core rendering / HTML layouting engine.

Building from source

bash
cargo build -r -p azul-doc
./target/release/azul-doc codegen all                       # generates the API surface of every binding
cargo build -r -p azul-dll --features build-dll             # libazul.{so,dylib,dll} in target/release

azul-doc is a multitool that generates all the code necessary for having a stable public API that works across multiple languages.

bash
# a Rust app against the freshly built library, from the in-repo crate
export AZ_LINK_PATH=/path/to/azul/target/release
cargo add azul-dll --rename azul --path /path/to/azul/dll --no-default-features --features link-dynamic
cargo run --release

Doc README (`doc/README.md`)

azul-doc

Build tool, FFI code generator, and W3C spec verification pipeline for the
Azul CSS layout engine.

bash
cargo build --release -p azul-doc
# output: target/release/azul-doc

`api.json`

api.json is the master schema defining the entire Azul public API. It
describes every module, struct, enum, function, callback, and constant that
is exposed through FFI. The codegen system reads this file and generates
language bindings for Rust, C, C++, and Python.

The typical workflow is:

  1. Modify Rust source code (add/change/remove public types or functions)
  2. Run autofix to synchronize api.json with the workspace
  3. Run codegen all to regenerate all language bindings
  4. Build the DLL: cargo build -p azul-dll --features build-dll --release

`autofix` — Synchronize api.json with Source Code

Autofix scans all public types and functions in the Rust workspace, compares
them against api.json, and generates patches to bring them in sync. It
handles FFI safety checks, transitive type dependencies, and module placement.

bash
azul-doc autofix                     # Full run: generate patches into target/autofix/
azul-doc autofix explain             # Preview pending patches without applying
azul-doc autofix apply               # Apply all patches to api.json
azul-doc autofix apply <dir>         # Apply patches from a specific directory
azul-doc autofix apply safe <dir>    # Apply only safe (path-correction) patches

# Add/remove individual items
azul-doc autofix list <Type>         # Compare Type's functions: source vs api.json
azul-doc autofix add <Type.method>   # Add function to api.json (+ transitive deps)
azul-doc autofix add <Type.*>        # Add all functions for a type
azul-doc autofix remove <Type.method>  # Remove function from api.json

# Debugging / analysis
azul-doc autofix difficult           # Rank types by FFI difficulty
azul-doc autofix internal            # Show types that should be internal-only
azul-doc autofix modules             # Show types in the wrong module
azul-doc autofix deps                # Analyze function deps on difficult/internal types
azul-doc autofix debug type <T>      # Debug a type in the workspace index
azul-doc autofix debug chain <T>     # Debug type resolution chain
azul-doc autofix debug api <T>       # Debug type: api.json vs workspace
azul-doc autofix debug file <path>   # Debug parsing a specific Rust source file

`normalize` — Clean Up api.json

bash
azul-doc normalize    # Canonicalize types + remove cross-module duplicates

Performs all api.json cleanup in one pass:

  • Canonicalize array types ("[f32; 20]" → {type: "f32", arraysize: 20})
  • Normalize pointer aliases ("*mut c_void" → {target: "c_void", ref_kind: "mutptr"})
  • Extract embedded generics ("Foo" → {target: "Foo", generic_args: ["Bar"]})
  • Normalize enum variant pointer types
  • Remove duplicate type entries across modules (keeps canonical location only)

Only writes if content actually changed. Always produces the same output on subsequent runs.

`codegen` — Generate Language Bindings

Reads api.json and generates FFI bindings for all target languages.
Output goes to target/codegen/.

bash
azul-doc codegen all      # Generate all targets (recommended)
azul-doc codegen rust     # Rust public API (azul.rs)
azul-doc codegen c        # C header (azul.h)
azul-doc codegen cpp      # C++ headers (azul03..23.hpp, one per C++ standard)
azul-doc codegen python   # Python/PyO3 bindings (python_api.rs)

codegen all generates 14 files: static/dynamic/build DLL APIs, re-exports,
C header, 6 C++ headers (C++03 through C++23), Rust API, Python bindings,
and memory layout tests.

How `codegen` Output Is Used

dll/src/lib.rs pulls in generated code via include!() macros pointing at
target/codegen/. Which files are included depends on the active cargo
feature:

azul-dll feature Generated code File contains
build-dll dll_api_build.rs #[no_mangle] extern "C" functions for the shared library
link-static dll_api_static.rs Types + trait impls via transmute (full Rust stack compiled in)
link-dynamic dll_api_dynamic.rs Types + extern "C" declarations (links against pre-built .dylib/.so/.dll at runtime)
python-extension python_api.rs PyO3 extension module (import azul from Python)
(always) reexports.rs Rust-friendly re-exports without Az prefix (use azul::prelude::*)
(test) memtest.rs Size and alignment verification tests

build-dll, link-static, and link-dynamic are the three mutually exclusive
link modes. build-dll compiles the full desktop stack (windowing, OpenGL,
fonts) and exports C-ABI symbols. link-static does the same but without
#[no_mangle] exports. link-dynamic compiles no internal crates — it only
declares extern "C" stubs that resolve at runtime against a pre-built shared
library.

python-extension is independent of these three modes. It gates the
python_api.rs include behind #[cfg(feature = "python-extension")] and
uses PyO3 to wrap each API function as a Python method. The generated Python
module is compiled as a native extension (.so/.pyd) that Python loads
directly via import azul — no C shared library or dynamic linking involved.

`reftest` - Run Visual Reference Tests

azul-layout is tested against Google Chrome via pixel-comparison layout tests
that render XHTML through both Azul and Chrome, then compare the results.

Test files live in doc/working/ and doc/xhtml1 (inactive tests). The idea is
that we copy more and more tests from xhtml1 to working to activate them.

bash
azul-doc reftest                     # Run all reftests, generate HTML report
azul-doc reftest <test_name>         # Run a single test, open in browser
azul-doc reftest headless <test>     # Run single test without Chrome, print debug to stdout

`debug` - LLM-Assisted Test Debugging

When a reftest fails, debug bundles everything needed to diagnose the
problem into a single Gemini prompt:

  • The XHTML test source
  • Screenshots (Azul rendering, Chrome reference, pixel diff)
  • Chrome's layout tree (JSON from DevTools)
  • Azul's display list output
  • Layout engine source code (budget-limited to fit token window)
  • CSS parse warnings and debug messages
  • An optional user question for focused analysis

Gemini receives this context and returns an analysis of what the layout
engine is doing wrong and how to fix it. You can then pass this analysis
to Claude for fixing or fix it yourself.

bash
azul-doc debug <test> [question]        # Debug a failing reftest with Gemini
azul-doc debug <test> --dry-run         # Generate prompt without sending to Gemini
azul-doc debug <test> --no-screenshots  # Skip screenshot capture
azul-doc debug <test> --working-diff    # Include git diff of doc/working/

`debug-regression` - Track Reftests Across Commits

Sometimes (note: only sometimes!) we want to debug when exactly a test started
to regress. We can use debug-regression to track how reftest results change
across a set of git commits. Provide either a file containing commit hashes (one per line)
or a single git ref. For each commit, the tool checks out the code in a worktree,
builds the layout engine (azul-layout has to be buildable at the commit),
runs all reftests, and saves the pixel-diff results. The collected data shows
visual and statistical reports.

bash
# Run reftests at each commit listed in the file (one hash per line)
azul-doc debug-regression <commits.txt>

# Run reftests at a single git ref
azul-doc debug-regression <git-ref>

# Generate reports from previously collected data
azul-doc debug-regression visual                     # HTML report with side-by-side images
azul-doc debug-regression statistics                 # Text diff statistics (stdout)
azul-doc debug-regression statistics prompt          # Generate Gemini prompt from statistics
azul-doc debug-regression statistics send            # Send statistics to Gemini for analysis
azul-doc debug-regression statistics send -o <path>  # Save Gemini response to file

`autodebug` — Automated Reftest Bug-Finding Pipeline

autodebug combines the reftest infrastructure with the parallel Claude agent
executor to automatically discover failing reftests, generate rich diagnostic
prompts, and dispatch agents to analyze and fix each bug.

The pipeline runs each .xht test at three screen sizes (mobile 375×667,
tablet 768×1024, desktop 1920×1080), compares Azul's render against Chrome
pixel-by-pixel, and for any test exceeding 0.5% pixel difference generates a
structured agent prompt containing:

  • Codebase orientation (solver3, text3, cpurender)
  • XHTML source, CSS warnings, layout debug trace
  • Screenshot file paths (agents view images via Claude's Read tool)
  • Pixel diff analysis: connected-component regions with position descriptions
    and heuristic cause classification (border offset, margin collapse, missing
    content, anti-aliasing, layout shift)
  • Chrome layout JSON vs Azul display list

Each agent receives one failing test, works in an isolated git worktree, and
produces a commit with the fix (or a diagnostic report if the fix isn't clear).

bash
# Full run on the default 'working' directory
azul-doc autodebug claude-exec

# Run on the xhtml1/ test directory (9000+ tests)
azul-doc autodebug claude-exec doc/xhtml1

# Generate prompts only (inspect before dispatching agents)
azul-doc autodebug claude-exec --dry-run

# Run a single test
azul-doc autodebug claude-exec --test=abspos-containing-block-initial-001

# Customize parallelism and timeout
azul-doc autodebug claude-exec --agents=8 --timeout=900

# Only test specific screen sizes
azul-doc autodebug claude-exec --sizes=mobile,desktop

# Reuse cached Chrome screenshots (faster re-runs)
azul-doc autodebug claude-exec --skip-chrome

# Re-queue previously failed tests
azul-doc autodebug claude-exec --retry-failed

# Monitor progress (in a separate terminal)
azul-doc autodebug claude-exec --status

# Collect patches from completed agents
azul-doc autodebug claude-exec --collect

# Clean up worktrees and branches
azul-doc autodebug claude-exec --cleanup

# Override the Claude model
azul-doc autodebug claude-exec --model=claude-sonnet-4-6

The last positional argument is the test directory (defaults to doc/working/).

Performance

Phase 1 uses two key optimizations for large test sets:

  • Chrome CDP (DevTools Protocol): A single persistent Chrome instance
    communicates via WebSocket instead of spawning one process per screenshot.
    This reduces per-screenshot overhead from ~1-2s to ~130ms.
    Falls back to per-process mode if CDP launch fails.
  • Async font registry: FcFontRegistry from rust-fontconfig spawns
    scout + builder threads in the background during Chrome Phase 1a. By the
    time Azul renders start, all system fonts are already loaded (0s wait).
    The cache is cloned once and shared across all rayon render threads.
  • Parallel rendering: Azul CPU renders and pixel diffs run in parallel
    via rayon.

Output goes to doc/target/autodebug/:

text
doc/target/autodebug/
├── screenshots/    # Chrome PNGs + Azul WebPs per test per size
├── prompts/        # Generated .md prompts + status files (.done/.failed/.taken)
├── patches/        # Extracted .patch files from agent commits
├── reports/        # Agent analysis reports (when fix is uncertain)
└── summary.json    # Overall results

`apply-midlevel` — Interactive Commit Replay

Walks every commit on a reference branch (e.g. midlevel-fixes-reference)
and prompts you, one at a time, whether to apply, refine, skip, or reject
each one. A pre-analyzer Claude agent reads the commit + current tree and
recommends an action; you decide.

bash
azul-doc autoreview apply-midlevel --reference=<branch-or-tag> [options]

Options:

Flag Default Notes
--reference=<ref> required Branch or tag to replay
--base=<ref> origin/layout-debug-clean Diverge point
--model=<name> opus Apply-agent model
--analyzer-model=<name> same as --model Pre-analyzer model
--no-analyze off Skip the pre-analysis agent
--no-telegram off Don't mirror prompts to Telegram (see below)

Per commit you choose:

text
[y] yes      apply using the current plan
[p] plan     refine the plan: add feedback, analyzer revises
[s] skip     don't apply now, come back later
[r] reject   don't apply, record as rejected with reason
[d] diff     checkout the commit so your editor refreshes
[q] quit     save progress and exit

Progress is saved to doc/target/autoreview/apply-midlevel/progress.json
after every decision. Re-running resumes where you left off.

Answer prompts from your phone (Telegram)

A long replay can take days. To answer prompts when you're away from the
PC, pair azul-doc with a Telegram bot — once paired, every prompt is
mirrored to a chat with you and any reply (tap a button, or type
free-form feedback) feeds straight into the running apply-midlevel.
Local stdin still works; whichever input arrives first wins.

One-time setup:

  1. Create a bot. Open Telegram, message @BotFather,
    send /newbot, follow the prompts. Pick any name and any handle ending
    in bot. BotFather replies with a token like 1234567890:ABCdef-....

  2. Pair. Run the wizard:

    bash
    azul-doc telegram-setup

    Paste the token when asked. The wizard verifies the token, prints your
    bot's @handle, and waits for you to message /start to it from your
    phone. As soon as it sees your message, it stores the chat-id and sends
    a confirmation.

  3. Done. Future runs of apply-midlevel automatically mirror prompts
    to your bot. Each prompt comes with a tap-keyboard:

    Stage Buttons Free-form text
    Pre-apply apply refine diff skip reject quit becomes analyzer feedback
    Post-apply accept edit revert quit becomes "edit" instructions

    The legacy single-letter shortcuts (y/p/s/r/d/q and y/e/r/q) still
    work too — useful when typing on the local terminal.

Diff preview on the phone.

  • Tap diff at the pre-apply prompt → the bot ships the reference
    commit's full patch as <short-sha>.patch. Telegram renders it inline
    for scrolling, and "Open in…" lets you pass it to GitHub mobile,
    GitKraken, or any diff viewer that handles .patch files.
  • Right after a successful apply, the bot automatically sends the
    applied diff (applied-<short>.patch) before showing the
    accept/edit/revert prompt — no extra tap needed. This is the diff
    the agent actually produced; review it on your phone before deciding.

Configuration locations (checked in this order):

Source Vars / Path
Env vars AZ_DOC_TG_TOKEN + AZ_DOC_TG_CHAT_ID (also TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID)
File ~/.config/azul-doc/telegram.toml (created by telegram-setup, mode 0600)

Disable per run: add --no-telegram to the apply-midlevel invocation.

Notifications missing on your phone? The bot always sends with
disable_notification: false, so the message itself is alerting. If you
don't hear pings, the cause is almost always client-side: long-press
the bot chat in Telegram → check the bell icon isn't muted, and verify
the Telegram app has notification permission at the OS level.

Notes:

  • [d] diff from the local terminal still does the editor checkout
    (detached HEAD on the commit, restored on Enter). From Telegram it
    ships a .patch document instead — no working-tree changes.
  • The phone message itself is just commit metadata + the analyzer's
    [CATEGORY] / Why: / Plan: / Suggested user action: tail; the full
    diff goes through the document attachment, not the message body.
  • A network blip on the long-poll prints to stderr, sleeps 5s, and retries
    — it won't kill the run.
  • Diff documents are capped at 1 MiB to keep slow phone connections
    responsive; oversized diffs get truncated with an explanatory tail line.

`autodoc` — Generate the User Guide in Parallel

Reads doc/autodoc-groups.toml and dispatches one Claude agent per group.
Each agent writes the markdown pages declared in its group to
doc/guide/en/, with YAML frontmatter that records the git SHA and tracked
source files at generation time. The companion autodoc-check command
later flags pages whose tracked files have changed since.

Full operational reference: AUTODOC.md. Manifest:
autodoc-groups.toml.

bash
azul-doc autoreview autodoc                  # Dispatch one agent per group
azul-doc autoreview autodoc --agents=N       # Throttle concurrency
azul-doc autoreview autodoc --file=GROUP_ID  # Run a single group
azul-doc autoreview autodoc --dry-run        # Generate prompts only

azul-doc autoreview autodoc-check            # Flag stale pages → outdated.md
azul-doc autoreview autodoc-check --strict   # Same, exit non-zero on any stale page (CI)
azul-doc autoreview autodoc-screenshots      # Render azul-render fences to PNG

Pipeline stages

text
autoreview ──► autodoc ──► autodoc-screenshots ──► deploy
(per-file)     (per-group)  (PNG renders)         (website)
  1. autoreview produces per-file reports under doc/target/autoreview/reports/.
  2. autodoc reads autodoc-groups.toml, builds one prompt per group with
    the group's tracked source, the per-group design_docs (intent, not
    truth), and the global writing-style + max-effort thinking rules from
    [meta].
  3. Agents write pages to their declared paths under doc/guide/en/.
  4. autodoc-screenshots renders any azul-render fences via HeadlessWindow
    into doc/guide/en/screenshots//<name>.png.
  5. deploy walks doc/guide/en/ at runtime, parses each page's
    frontmatter for ordering, and emits the final HTML. The guide index
    page is built from the frontmatter (title, short_desc, audience,
    guide_order, slug hierarchy) — no separate SUMMARY file is involved.

Manifest at a glance

doc/autodoc-groups.toml (schema v3) defines three trees:

Tree Audience Contents
getting-started external Linear teaching path: hello-world (per-language) → architecture → understanding-refany → DOM → styling → layout → text → images → widgets → events → timers → animations → scrolling → windowing
advanced external Non-core systems, ramps to web: debugging / profiling / e2e-testing → I/O (xml, file-dialogs, clipboard) → background-tasks + networking → a11y + headless → codegen + bindings → deploying-web → security
contributor contributor Code organization, build-and-codegen, per-system internals (DOM, CSS, layout, text, images, events, rendering, shell2, menus, a11y, async, web)

Each [[group]] declares id, tree, audience, agent_strategy,
tracked_files (or tracked_globs), an optional design_docs list,
and one or more [[group.outputs]] with slug, path, title,
maturity, optional guide_order and prerequisites. The global
[meta.writing_style] and [meta.agent_thinking] blocks ship into
every prompt; agents are anchored on the Servo book style and the
project's existing shared_context_files (AUTODOC.md,
architecture.md, api.json).

design_docs lists files in scripts/ (e.g. TEXT_INPUT_ARCHITECTURE_V4.md)
that capture the original system design. They are intent, not authoritative
source — the prompt explicitly tells the agent: the code is truth, the
design docs are context
. The agent must verify each claim against
tracked_files and document divergences inline (e.g. "the original design
proposed X; the implementation took approach Y"
) rather than reproducing
stale content.

Two-signal staleness check

autodoc-check writes doc/target/autoreview/autodoc/outdated.md listing:

  • Source-changed pages — tracked_files had commits since
    last_generated_rev in the page's frontmatter (git log query).
  • Translation-stale pages — translated page's source_hash no longer
    matches the SHA-256 of its canonical English body. CI fails on either.

Translations

Translations live in language-prefixed sibling directories under
doc/guide/. The English tree is canonical; every other language mirrors
it page-for-page with localized slugs.

text
doc/guide/
├── en/                                  # canonical (the only tree autodoc writes)
│   ├── architecture.md                  # slug: architecture
│   ├── hello-world.md                   # slug: hello-world
│   ├── layout/flex.md                   # slug: layout/flex
│   └── screenshots/<page>/<name>.png
└── de/                                  # German translation (manual or future `translate` agent)
    ├── architektur.md                   # slug: architektur,    canonical_slug: architecture
    ├── hallo-welt.md                    # slug: hallo-welt,     canonical_slug: hello-world
    └── layout/flexbox.md                # slug: layout/flexbox, canonical_slug: layout/flex

A translated page's frontmatter carries five extra fields beyond the
canonical set:

yaml
---
slug: architektur                        # localized URL — used in the URL path
title: Architektur
language: de
canonical_slug: architecture             # English-side identity (the page this mirrors)
audience: external
maturity: mature
prerequisites: [hello-world]             # always English canonical_slugs; deploy localizes
source_rev: 99be42b08ac44fcb01f1508e8...  # git SHA of canonical/architecture.md at translation time
source_hash: a1b2c3d4...                  # SHA-256 of canonical body bytes (post-frontmatter)
generated_at: 2026-05-02T12:00:00Z
---

How the pieces interact:

Field Role
slug URL segment under /<lang>/. Localized so /de/architektur reads naturally in German.
canonical_slug Anchor to the English page. Deploy uses it to find sibling translations and resolve prerequisites.
prerequisites Always lists English canonical slugs. Deploy maps each to the localized slug for the current language at render time, so prerequisites: [hello-world] on a German page links to /de/hallo-welt, not /en/hello-world.
source_rev Git SHA of the canonical at translation time. Cheap drift signal (grep git log for changes).
source_hash SHA-256 of the canonical body bytes (after frontmatter). Authoritative drift signal — autodoc-check --strict fails CI when this no longer matches the current canonical.
tracked_files Empty on translations. Translations inherit staleness from their canonical, not from source files.

When you edit a canonical English page, its body bytes change → SHA-256
changes → every translation's source_hash no longer matches → next CI
deploy fails until each translation is regenerated.

There is no translate subcommand yet. Translations are produced
manually (or by a future agent dispatch); the spec above is what the
website and autodoc-check already understand.

Outputs

text
doc/target/autoreview/autodoc/
├── prompts/<group-id>.md          # Generated agent prompts
├── prompts/<group-id>.md.taken    # Sentinel: agent in flight
├── prompts/<group-id>.md.done     # Sentinel: agent succeeded
├── prompts/<group-id>.md.failed   # Sentinel: agent failed
├── outdated.md                    # autodoc-check report
└── screenshots-manifest-<lang>.json  # Cached render manifest

doc/guide/<lang>/
├── architecture.md                # Canonical architecture doc (en only)
├── <slug>.md                      # Generated guide pages
├── <parent>/<child>.md            # Nested pages render as a sub-tree under <parent>.md
└── screenshots/<page>/<name>.png  # Rendered azul-render fences (en only)

Website Deployment

Builds the full azul.rs website (API docs, release pages, examples, reftest
results) into doc/target/deploy/. For the CI usage, this would expect the dll
files to exist, so for debugging CSS / doc / blog posts, etc. the deploy debug
command builds the website files without this command.

bash
azul-doc deploy                      # Production build (inlined CSS, absolute URLs)
azul-doc deploy debug                # Debug build (external CSS, relative paths)
azul-doc deploy with-reftests        # Debug build + run reftests

`spec` - W3C Spec Verification Pipeline

The spec subcommand verifies and improves CSS layout compliance against W3C
specifications. It auto-downloads W3C spec HTML sources (CSS Display 3, CSS 2.2
visuren/visudet/box/tables, CSS Text 3), extracts individual paragraphs by
keyword matching, and generates one prompt per paragraph.

Parallel Claude agents then review each paragraph against the layout engine source
code and produce patches. Gemini analyzes the patches for quality, architecture,
and merge planning before agents apply them.

The patches will always contain // +spec comments that link the source code back to
the paragraph the implementation came from, so that humans or agents can quickly verify
where a certain W3C feature is implemented or where a certain line of code came from:

rust
// +spec:block-formatting-context-p001 - BFC establishment rules
// +spec:css22-box-8.3.1-p1 - margin collapsing between siblings
if block.get_containing_width() > block.escaped_margin() {
   // ...
}

The spec status command scans for these markers to track coverage.

[!NOTE]

Gemini has a much larger context window (1M) than Claude (200K) and is good for a
more "holistic" architecture analysis. So, Gemini is used for the ".md reviews"
and Claude agents are used for the actual implementation.

For sending the generated .md files to Gemini, use AIStudio,
this way you don't need to spend money on Gemini. Since the Claude Code agents would
use a lot of tokens, it's generally only advisable to use this on a Max 20x Plan, azul-doc
will early-exit if it detects API usage.

Full Pipeline

text
┌─ review-arch ─→ --review-arch ──┐
claude-exec ──> review-md ───┤                                 ├──> agent-apply
 (patches)    (patch review) ├─ refactor-md ─→ --refactor-md ──┤   (Claude agents)
                             └─ groups-json ─→ --groups-json ──┘

Each analysis step (--review-md, --review-arch, --refactor-md, --groups-json)
generates a prompt .md file that is fed to Gemini later. Gemini's response then
becomes an input flag for subsequent steps and ultimately for agent-apply.

Command names match flag names:

Command What it does agent-apply flag
review-md Patch quality review (CODE/ANNOT, conflicts) --review-md
review-arch Cross-patch architecture review (tunnel vision fix) --review-arch
refactor-md Refactoring plan (groundwork before applying) --refactor-md
groups-json Merge groups JSON (APPLY/MERGE/PICK_ONE/SKIP) --groups-json

Stage 1: Generate Patches

Spec paragraphs are auto-extracted from auto-downloaded W3C HTML sources. The
extractor matches paragraphs to features using keyword lists defined in the
skill tree (e.g. "block formatting context", "margin collapsing", "normal flow"
→ block-formatting-context feature). Each matched paragraph becomes a
self-contained prompt file containing the spec text, feature context, and
source files to review.

bash
# Downloads specs, builds prompts, and runs parallel Claude agents
# Each agent sees ONE paragraph + the layout source code
azul-doc spec claude-exec # --agents=8 // default is 8 parallel agents
azul-doc spec claude-exec --retry-failed  # Retry timed out / failed prompts
# In a separate terminal
azul-doc spec claude-exec --status        # Check progress

Stage 2: Analyze Patches with Gemini

Each command generates a prompt file, feed it to Gemini via AIStudio,
save the output. Each step receives the outputs of all previous steps.

bash
# 2a. Patch quality review (CODE/ANNOT categorization, conflict clusters)
azul-doc spec review-md --no-src <patch-dir>

# 2b. Architecture review — solves the "tunnel vision" problem:
#   Each claude-exec agent only saw one spec paragraph. This gives Gemini
#   all patches + original paragraphs to find cross-cutting concerns.
azul-doc spec review-arch \
  --review-md <REVIEW.md> \
  <patch-dir>

# 2c. Refactoring plan (groundwork abstractions before applying patches)
azul-doc spec refactor-md \
  --review-md <REVIEW.md> \
  --review-arch <ARCH.md> 
  <patch-dir>

# 2d. Merge groups — receives ALL prior analysis for well-informed grouping
azul-doc spec groups-json \
  --review-md <REVIEW.md> \
  --review-arch <ARCH.md> \
  --refactor-md <REFACTOR.md> \
  <patch-dir>

Stage 3: Apply Patches via Agents

Finally, after doing all of this analysis of the patches, their quality and
what we need to do to cleanly refactor them, the groups-json outputs a .json
file where the patches are "grouped", because some patches might cover similar
topics. So if we have 800 patches from an agent run, we can then group them into
a "merge group" if similar patches patch the same functionality (this happens
because the claude-exec agents don't see each other since they run in parallel).

bash
azul-doc spec agent-apply \
  --groups-json <GROUPS.json> \
  --refactor-md <REFACTOR.md> \
  --review-md <REVIEW.md> \
  --review-arch <ARCH.md> \
  <patch-dir>

Important is the GROUPS.json here, which contains the overview of how the (sequential) agents
will run. Each agent processes one "merge group" through 5 phases:

  1. Refactoring — implement groundwork relevant to this group
  2. LLM-apply — apply patch semantic intent to current code (similar to cherry-pick, but with semantics)
  3. Compile — cargo check -p azul-dll --features build-dll
  4. Review — verify again against the original W3C spec, fix issues and compile again
  5. Commit — use git add -p to create ~2-5 semantic commits by hunk (each must compile independently)

Original .patch files are never moved or deleted. Progress is tracked in
/agent-apply-status.json, which records the outcome (applied /
skipped / failed), commit count, and failure reason for each group. This
status file also enables resuming — re-running agent-apply skips groups
that were already successfully processed. A group counts as "applied" only
if the agent exits successfully AND makes at least one git commit.

Spec Utilities

bash
azul-doc spec download              # Download W3C spec HTML sources
azul-doc spec tree                  # Display CSS feature skill tree
azul-doc spec build-all             # Build per-paragraph agent prompts
azul-doc spec status                # Verification progress (scans +spec: markers)
azul-doc spec paragraphs            # All paragraphs grouped by feature
azul-doc spec paragraphs <feature>  # Paragraphs for one feature (with text)
azul-doc spec annotations           # Scan source for +spec: annotation comments

Finally, you can always get help a spec command with:

bash
azul-doc spec <command> --help      # Detailed help for any subcommand

Adding New W3C Spec Documents

The goal of the spec command is to bring the source code up to what the original
W3C .html files say the source code should do (esp. checking the various if / else statements,
box model, semantic restrictions). Now, it might be that some new feature needs to be implemented.

  1. Register the spec in doc/src/spec/downloader.rs — add the URL to
    SPECS. The downloader stores HTML in doc/target/w3c_specs/.

  2. Add a feature node in doc/src/spec/skill_tree.rs — define keywords
    used to match spec paragraphs to the feature.

  3. Run agents: azul-doc spec claude-exec
    (automatically runs download + build-all if prompts are missing)

  4. Repeat Stage 2 & 3

The overall assumptions is: if a source code marker is in the Rust code, the feature has
been implemented correctly (since each claude-exec agent prompt contains only ~2k tokens,
the likelyhood that the patch matches the source code is very high, the hard part is in
correctly merging the patches due to the "tunnel vision" problem). For final verification,
run the reftest again and activate more tests from the xhtml1 source.

Other Utilities

bash
azul-doc print [path]               # Print API tree (e.g. "print app.App.new")
azul-doc discover [pattern]         # List all public functions in workspace
azul-doc nfpm [version]             # Generate nfpm.yaml for OS packaging

CI Pipeline

The GitHub Actions workflow (.github/workflows/rust.yml) uses azul-doc
in two modes: ci (on push/PR) and deploy (manual trigger).

CI Mode (automatic)

  1. Lint & Static Checks — runs autofix, applies patches, runs
    normalize, verifies no uncommitted changes remain. Then runs
    codegen all and cargo test on the DLL.
  2. Build DLL — codegen all + cargo build -p azul-dll --features build-dll
    on Linux, macOS, and Windows.
  3. Reftests — codegen all + reftest with Chrome screenshot caching.
  4. C/C++ Examples — copies azul.h and C++ headers from
    target/codegen/, compiles all example files with clang/clang++.
  5. Website Build — codegen all + nfpm + deploy (production).
  6. OS Packaging — uses nfpm config to build .deb and .rpm packages.

Deploy Mode (manual)

Downloads all CI artifacts (DLLs, website, reftests, packages), assembles
them into a single website directory, and deploys to GitHub Pages.

Project Structure

text
doc/src/
├── main.rs             # CLI entry point and command dispatch
├── api.rs              # normalize (type canonicalization)
├── autofix/            # api.json ↔ workspace synchronization
├── codegen/            # Language binding generators
│   └── v2/             # IR builder → Rust/C/C++/Python generators
├── patch/              # dedup, api.json patch application
├── reftest/            # Pixel-comparison layout tests + LLM debugging
│   └── autodebug.rs    # Automated bug-finding pipeline (multi-res diff + agent dispatch)
├── docgen/             # Website documentation generator
├── dllgen/             # DLL build + deploy
└── spec/               # W3C spec verification pipeline
    ├── mod.rs           # Command routing and CLI parsing
    ├── executor.rs      # Agent execution, all pipeline commands
    ├── skill_tree.rs    # 16-feature skill tree with dependency ordering
    ├── downloader.rs    # W3C spec HTML fetcher
    ├── extractor.rs     # Paragraph extraction from HTML specs
    ├── reviewer.rs      # Prompt generation for review
    └── paragraphs.rs    # Known spec paragraph registry

Key layout source files reviewed by spec agents:

text
layout/src/solver3/
├── fc.rs           # Formatting context solver (BFC, IFC, floats)
├── sizing.rs       # Width/height calculation (CSS 2.2 §10.3/§10.6)
├── positioning.rs  # Absolute/relative positioning
├── geometry.rs     # Box model geometry structs
├── layout_tree.rs  # DOM-to-layout tree conversion
├── getters.rs      # CSS property accessors
├── mod.rs          # Containing block resolution
└── taffy_bridge.rs # Flexbox integration

layout/src/text3/
├── cache.rs        # Text layout cache, constraint builder
├── knuth_plass.rs  # Knuth-Plass line breaking algorithm
└── glyphs.rs       # Glyph metrics, line height

Doc THEME CHAIN ANALYSIS 2026 09 12 (`doc/THEME_CHAIN_ANALYSIS_2026_09_12.md`)

The light/dark theme chain: why it is whack-a-mole (2026-09-12)

Branch fix/compilation-1009. State of today's five fixes: 1 and 2 are committed in
621e7fb8c, 3 in faa2f6c6a; 4 (paint_defaults_fingerprint in the DL key) and 5
(compact rebuild on any theme flip) are uncommitted, together with new tests in
layout/src/window.rs and [THEME-DBG] eprintln!s in event.rs:9568, layout.rs:476/1701
and macos/system_style.rs:929 that must not ship. Every claim below is file:line on this tree.

1. The pipeline as it actually is

1.1 Three theme types, three sources, two precedence rules

Type Lives in Written by Default
css::system::Theme (css/src/system.rs:161) SystemStyle.theme (system.rs:192) shell probes: macOS macos/system_style.rs:276-287, Windows windows/system_style.rs:271, Linux linux/system_style.rs:1456,1840; Android/iOS/headless never write it Light (system.rs:252)
WindowTheme (core/src/window.rs:1277) FullWindowState.theme (layout/src/window_state.rs:135) adopt_probed_theme (macos/system_style.rs:928), linux/system_style.rs:2883, windows/mod.rs:6733-6736, android/mod.rs:1093, ios/mod.rs:1145, headless/mod.rs:1986; app via ModifyWindowState (event.rs:4701-4707) LightMode (window_state.rs:371)
ThemeCondition (css/src/dynamic_selector.rs:813) DynamicSelectorContext.theme (dynamic_selector.rs:991) the builders in 1.3 Light (dynamic_selector.rs:1078)

AZ_THEME is read once (dynamic_selector.rs:846-857) but applied twice: inside
from_system_style (:1116-1117, AZ_THEME > SystemStyle.theme) and again in
LayoutWindow::dynamic_selector_context (layout/src/window.rs:5135-5139,
AZ_THEME > FullWindowState.theme, unconditionally overwriting the first answer). The effective
rule is therefore AZ_THEME > window theme > (nothing): SystemStyle.theme never reaches the
cascade, yet it still drives three other decisions: whether a style change is a full
regeneration (window.rs:1901-1903), the window background (event.rs:9576-9587), and the
display list's no-context fallback (display_list.rs:7627-7630).

Fixed 2026-09-14. The pin now also reaches SystemStyle:
azul_css::system::apply_env_theme_pin runs at the end of all three platform discover()s
(linux/system_style.rs, macos/system_style.rs, windows/system_style.rs) and swaps the
polarity-dependent visuals — palette, focus ring, scrollbar — to the matching built-in family,
keeping the desktop's fonts, metrics, extras and accent. Found by taking the website's Linux
screenshots on a Breeze Dark session: AZ_THEME=light produced light-theme TEXT on the
desktop's DARK background, because the cascade followed the pin and the background followed
SystemStyle. The three decisions above are exactly why a half-pin is not a pin.

Window creation seeds ws.theme from options.window_state.theme (macos/mod.rs:5563,
windows/mod.rs:619, x11/mod.rs:3873, wayland/mod.rs:1972), i.e. from the LightMode
default, never from the probed SystemStyle. WindowCreateOptions.theme
(window_state.rs:46) has no reader anywhere.

1.2 From "OS appearance changed" to "glyph painted", in order

  1. Shell probe → window theme. macOS: KVO effective_appearance_changed
    (macos/mod.rs:985-1003) or the 2 s poll (:1426-1439, throttle system_style.rs:851) →
    adopt_probed_theme (system_style.rs:909-936): early-out when ws.theme already equals the
    probe (:919), else update_unsynced_state(|ws| ws.theme = theme) (:928) and a
    re-discover()ed SystemStyle (:946-965). Android is the only shell that requests the
    regeneration itself (android/mod.rs:1094-1096); every other shell hands the style to step 2.
  2. adopt_system_style (dll/src/desktop/shell2/common/event.rs:9552-9624). Bails when
    the whole SystemStyle is unchanged (:9556-9559). needs_full (:9563-9565) asks
    system_style_change_needs_full_regeneration (window.rs:1894-1906): a theme flip is always
    a full rebuild (:1901-1903); anything else consults what the layout callback declared it
    read (callbacks.rs:1182-1216, recorded by get_theme callbacks.rs:1446, harvested at
    layout.rs:496-497). Full → request_regeneration(ThemeChange) (:9597-9601); otherwise
    reset_incremental (drops the DL cache, cache.rs:720-723) + IncrementalRelayout::Restyle
    (:9603-9608 → layout.rs:1662, which re-enters the funnel at :1704-1711). There is no
    engine handler for the ThemeChanged window event
    : grep ThemeChanged in event.rs finds
    only the app path (:4848) and the two ThemeChange requests above. So a ws.theme write
    whose rediscovered style equals the held one triggers nothing (a dark desktop at startup:
    discover() is already dark, ws.theme is LightMode, the poll writes DarkMode, step 2
    returns false, and the only follow-up is request_redraw at macos/mod.rs:1435-1439).
  3. regenerate_layout (layout.rs:236). The callback sees current_window_state.theme
    (:449). The pre-cascade fingerprint skip (:543-553) excludes ThemeChange (:545) but
    its "unchanged" exit (:630-660) returns without entering the funnel, so a theme that
    changed without the reason being set keeps the retained DOM's old context. The warm exit
    (:668-680) and the full path both reach the funnel.
  4. StyledDom creation cascade (core/src/styled_dom.rs:1322-1365), with
    dynamic_context = None (prop_cache.rs:2367): restyle gates @theme/@media blocks on
    the context (prop_cache.rs:1531-1538 — no context, no conditional rule), apply_ua_css
    (:1343; themed lookup at prop_cache.rs:4911-4915 but the context is None, so the light
    table; the property list :4859-4891 contains no TextColor), compute_inherited_values
    (:1344; Step 4 applies only unconditional inline properties, prop_cache.rs:5056-5060),
    the compact build (:1354; conditional inline props are evaluated against the context
    compact.rs:955-957 — None → dropped; UA defaults go in through the unthemed
    get_ua_property compact.rs:1194-1196; children copy the parent's text tier :768), then
    prune_compact_normal_props (:1365, prop_cache.rs:1253-1257 drops Normal compact-encoded
    entries from cascaded_props).
  5. The funnel installs the context (window.rs:5252, builder :5120-5141).
    set_dynamic_selector_context (styled_dom.rs:2349-2404): equal → return (:2361-2364);
    theme_changed (:2358-2370); the author cascade is re-run only when the author sheet has
    conditional rules (:2379-2387; restyle_retained is a no-op for an empty sheet
    :1760-1770); otherwise recompute_inheritance_and_compact_cache (:2389-2404) which,
    despite its name, rebuilds the compact cache only (:1659-1684) — apply_ua_css and
    compute_inherited_values are not re-run.
  6. Query-time getters. get_property (prop_cache.rs:2579) → get_property_slow
    (:2624): user overrides → inline fold, last match wins, non-pseudo conditions need the
    context (:2673-2685, Normal at :2992-3023) → css_props → global * (:3025) →
    cascaded_props (:3046) → computed_values for inheritable props (:3045-3053) →
    get_ua_property_themed(node_type, prop, dynamic_context) (:3058-3062; dark twins only for
    <hr>/`` borders, ua_css.rs:961-982). get_text_color_or_default seeds black
    (:2497-2507). Normal-state fast paths read the compact cache instead: text colour
    getters.rs:3134-3157, border colours :2262-2280.
  7. Text layout bakes the colour. getters.rs:3164 (unwrap_or(ColorU::BLACK)) → :3424
    → text3::StyleProperties.color (text3/cache.rs:4568,4575; hashed at :4661; excluded from
    the shaping key :4698-4736; re-stamped on a shaping hit :9035-9036) → glyph runs
    (glyphs.rs:275,312,327) → CachedInlineLayout.glyph_runs (layout_tree.rs:292, built once
    at :586-588, kept across frames).
  8. Display list re-resolves it. live_color (display_list.rs:7570-7639): ancestor
    user-override walk (:7587-7605), then cache.get_text_color on the slow path (:7617-7619),
    then evaluate_ua_root_text_color with the cascade's stored context or a freshly built
    system-only/default one (:7626-7634). The baked run colour is used only for runs without a
    source node (:7639). Pushed at :7666/:7691.
  9. DL cache. Key tuple cache.rs:567-574; inputs mod.rs:770-779; hit condition
    :802-808; dl_input_fingerprint (:470-500) now folds paint_defaults_fingerprint
    (:488-490; dynamic_selector.rs:1185-1190 hashes the theme only). A second gate,
    cascade_ctx_unchanged (mod.rs:1520-1566 against cache.last_dynamic_context,
    cache.rs:472), protects DL patching. The DL-only re-emit path
    (window.rs:17934, key at :17992-18004, store :18119-18133; 14 callers such as
    event.rs:5302,6236,6664) never installs a context — it reads whatever the last layout pass left.
  10. Layout hash. Colour is excluded from relayout by design (property.rs:1700-1715,
    :1807-1808); NodeDataFingerprint hashes inline declarations, never resolved values
    (diff.rs:2085-2160, inline at :2109-2111); it becomes the subtree hash at
    cache.rs:1577-1584, 2196-2201, 3612-3617. Two cascades of one DOM under two themes hash
    identically — the documented consequence is at cache.rs:1623-1651.

1.3 Every place with its own idea of the theme or the default colour

Context builders: DynamicSelectorContext::default (Light, dynamic_selector.rs:1071-1099);
from_system_style (:1103-1117); LayoutWindow::dynamic_selector_context (window.rs:5120);
the DL fallback (display_list.rs:7626-7631); get_scrollbar_style (getters.rs:5609-5611,
SystemStyle only — the window theme is ignored for scrollbars) and
ComputedScrollbarStyle::default (:5493-5497, Light); the four shells' own contexts
(macos/mod.rs:5693-5705, windows/mod.rs:775-787, x11/mod.rs:3985-3997,
wayland/mod.rs:2134-2146) — never installed on any StyledDom (dll/ contains no
set_dynamic_selector_context call).

Default-colour answers: DEFAULT_TEXT_COLOR (prop_cache.rs:2506, ua_css.rs:1364/1380),
ColorU::BLACK (getters.rs:3164), the static UA table (ua_css.rs:747, 801-804), its themed
twins (:961-982), UA_ROOT_TEXT_COLOR_CSS (:1342-1363, consulted only at
display_list.rs:7634), the compact builder's unthemed UA pass (compact.rs:1195), the window
background (wr_translate2.rs:319, headless/mod.rs:593-596, event.rs:9578), and in the
widgets the Bootstrap palette (button.rs:146-201, light only) beside the LIGHT_*/DARK_*
tokens (flat.rs:49-548).

1.4 Caches on the path and what their keys see

Cache Key / invalidation Sees the theme?
cascaded_props, computed_values (prop_cache.rs) rebuilt by restyle() only (styled_dom.rs:1772-1830) no — built with no context at creation; a theme flip does not rebuild them (styled_dom.rs:2389-2404)
compact cache rebuilt on context change or has_dynamic_conditions (styled_dom.rs:2389-2404) partly: conditional inline props yes (compact.rs:955), UA colours no (:1195)
StyleCache (getters.rs:2942-2995) (node, state, viewport); per-LayoutContext no (lifetime-mitigated)
scrollbar_style_cache (getters.rs:5815-5833) node id no
text shaping cache (text3/cache.rs:8963-8971) excludes colour; colour re-stamped :9035 n/a
CachedInlineLayout (layout_tree.rs:257,320) includes colour via inline_content_hash (fc.rs:3815-3845) indirectly
inline_content_cache (fc.rs:3647-3697) node fingerprints + tier-1 enums + font hash no
cache_map, float_cache, subtree hash geometry / inline declarations no
cached_display_list (cache.rs:567-574) subtree hash, viewport, gpu, scroll, input fp only via fix 4's theme hash
pre-cascade last_dom_fingerprints (layout.rs:531-553) structure + inline style no; ThemeChange reason excluded (:545)

2. Why it is whack-a-mole

R1 — UA colour defaults are answered at query time, not cascaded. They never enter a node's
resolved style, so no hash, fingerprint or cache can see them (fixes 4 and 5 exist only because
of this). Worse, there are three answer sites that disagree: the slow path is themed
(prop_cache.rs:3058), the cascade-time pass is themed but runs before a context exists
(:4911 with :2367), and the compact builder is unthemed (compact.rs:1195). The <hr> and
native-button border twins from fix 2 are therefore unreachable in the normal state, which reads
the compact cold tier (getters.rs:2272). The default text colour is not in the UA property
list at all (:4859-4891) and lives in a fourth place consulted only by the display list.

R2 — The cascade runs before the context exists and is not re-run when it arrives.
styled_dom.rs:1343 vs prop_cache.rs:2367; then set_dynamic_selector_context re-runs the
author cascade only for a conditional author sheet (:2379-2387) and otherwise rebuilds the
compact cache only. For the common DOM (inline-styled widgets, empty sheet) cascaded_props and
computed_values are permanently the no-context answers. Fix 5 patches the compact half only.

R3 — Two readers with different inheritance semantics. The compact builder evaluates
conditional inline declarations against the context (compact.rs:955-957) and inherits by
copying the parent's text tier (:768); compute_inherited_values applies only unconditional
inline declarations (prop_cache.rs:5056-5060). Every dark_theme(TextColor(..)) on a container
is inherited on one path and not the other. The display list's live_color uses the slow path
(display_list.rs:7617) and overrides the compact-baked glyph colour (:7639) — symptom (a).

R4 — "Colour is paint-only" is a relayout decision that leaked into cache keys. Excluding
colour from can_trigger_relayout (property.rs:1700-1715) is right for layout; but the DL
cache and DL patching serve painted output keyed on the same colour-blind subtree hash
(diff.rs:2109-2111). Three separate compensations now exist for one missing "cascade
generation" key: paint_defaults_fingerprint (mod.rs:488), cascade_ctx_unchanged
(mod.rs:1520-1566) and reset_incremental in adopt_system_style (event.rs:9603). Each was
added when its own path bit.

R5 — Three theme sources, two precedence implementations, seeding from the default.
window.rs:5135 re-implements what dynamic_selector.rs:1116 already decided; window creation
seeds LightMode regardless of the probe (macos/mod.rs:5563); adopt_system_style keys its
decision on SystemStyle equality (:9556-9559) although the cascade follows the window theme;
no ThemeChanged handler exists. Fix 1 made the window theme the truth for the cascade but left
SystemStyle.theme as the trigger (window.rs:1901-1903) and the background source
(event.rs:9578).

R6 — Widget styling is split across three mechanisms with an unenforced ordering invariant.
build_button_container_style emits light Bootstrap values (button.rs:204-380); the theme
function appends dark_theme twins and states (flat.rs:651-678, flora.rs:895-941); style
bundles say None = "no opinion" (button.rs:108-116, :440-453). Resolution is last-match-wins
(prop_cache.rs:2992-3023, and the comment at :3012-3016 says widgets rely on it), so a
twin pushed after a type's face silently replaces it. The per-type classification exists in
button_states (flat.rs:2103-2107, neutral) but not in the resting-face code (:663-671).
Fix 3 made this live — symptom (b).

R7 — Dead and duplicate paths. Button::dom bypassed both theme functions until
faa2f6c6a; the shells' contexts are dead weight; scrollbars use a fourth builder; the e2e
runner never calls set_dynamic_selector_context (styled_dom.rs:1084-1085). Nothing pinned
the theme path end to end, so each fix could only be found by looking at the screen.

Root causes vs symptoms: fixes 1 and 5 (and half of 2) treat R2/R5; fix 4 treats R4; fix 2's
twins treat R1's surface; fix 3 exposes R6; symptom (a) is R3; the startup-on-dark hazard is R5.

3. Invariants that should exist

  • I1 — One function owns the theme decision. LayoutWindow::dynamic_selector_context
    (window.rs:5120) is the only constructor of a context that reaches a StyledDom; the DL
    fallback (display_list.rs:7626), get_scrollbar_style (getters.rs:5609) and the shells'
    contexts read cache.dynamic_context or go away. Enforce: make from_system_style/default
    non-pub outside css + a grep lint in scripts/check.sh.
  • I2 — The context exists before the first cascade, and a theme flip is a full restyle.
    debug_assert!(dynamic_context.is_some()) in apply_ua_css and the compact builders;
    set_dynamic_selector_context on theme_changed runs the same sequence as restyle()
    (styled_dom.rs:1772-1830). Test: create under Light, install Dark, assert cascaded_props
    and computed_values carry the dark UA values.
  • I3 — UA defaults are cascaded like any other property, from one themed table. One
    &[CssPropertyWithConditions] per node type (the shape UA_SCROLLBAR_CSS already has,
    ua_css.rs:1342), with TextColor on the root, consumed by apply_ua_css and
    apply_ua_css_to_compact through one resolver. Then get_ua_property_themed's twin match,
    UA_ROOT_TEXT_COLOR_CSS, display_list.rs:7620-7635 and getters.rs:3164 are unreachable.
    Test: ua_css_test.rs:1590 rewritten to assert through a StyledDom under both themes.
  • I4 — The two readers agree. For every node in the normal state, compact == slow for every
    compact-encoded property. Enforce: an AZ_VALIDATE cross-check at the end of
    build_compact_cache_with_inheritance; make compute_inherited_values Step 4 evaluate
    conditional inline declarations against the context with the same last-match rule.
  • I5 — Every cache that can serve painted output keys on the cascade generation. Add
    CssPropertyCache::cascade_epoch, bumped by restyle, set_dynamic_selector_context, and user
    overrides; put it in the DL key (mod.rs:770-779, window.rs:17992) and StyleCache; retire
    paint_defaults_fingerprint and the last_dynamic_context equality. Test: the uncommitted
    a_theme_switch_recolours_the_retained_dom plus a variant through
    regenerate_display_list_for_dom.
  • I6 — The theme is paint-only. Node rects are identical under both themes and across a
    switch: the uncommitted the_theme_does_not_change_layout and
    a_theme_switch_in_one_window_does_not_change_layout (window.rs diff) — commit them; add a
    debug assertion that a compact rebuild equals a fresh build tier by tier.
  • I7 — One trigger. A ws.theme write decides regeneration vs restyle on the window theme
    delta, not on SystemStyle equality (event.rs:9556-9559); creation seeds ws.theme from the
    probe. Test: headless set_system_theme (headless/mod.rs:1978) on a window whose
    SystemStyle already matches must still restyle; dll/tests/headless_lifecycle.rs:476 is the
    place.
  • I8 — Ordering by construction. Dark twins are emitted with their light value
    (hover_bg_both shape, flat.rs:2160) and states after resting values; the widget lint
    manifest (layout/tests/widget_lint_manifest_is_exhaustive.rs) rejects a dark_theme(X) that
    precedes an unconditional X of the same property type in any *_style vector.
  • I9 — One integration test per path: fresh DOM; retained DOM through the pre-cascade skip's
    unchanged exit; Restyle; full regeneration; DL-only re-emit; startup with a dark
    SystemStyle and default window state. Fixtures exist in window.rs's test module.
  1. Theme flip = restyle (high leverage, low risk, ~1 day). In set_dynamic_selector_context,
    when theme_changed (or the DOM carries any conditional declaration), reset
    cascaded_props, re-run apply_ua_css → compute_inherited_values → compact rebuild (the
    tail of restyle()), and have the funnel install the context before StyledDom::create's
    cascade (or pass it in). Completes fix 5; removes R2. Fix 5 as written is the patch this replaces.
  2. Inherit conditional inline declarations on the slow path (high, low, ~1 day).
    prop_cache.rs:5056-5060 evaluates conds against self.dynamic_context, last match wins,
    plus the I4 cross-check. Fixes symptom (a) for every widget at once; without it every widget
    needs its own label-colour workaround, which is the whack-a-mole in miniature.
  3. One themed UA table consumed by both cascades (high, medium, ~2-3 days). Replaces fix 2's
    twin match and UA_ROOT_TEXT_COLOR_CSS, deletes the DL fallback and the BLACK/DEFAULT_TEXT_COLOR
    seeds (keep them as debug_assert!s). After this, fix 4's theme hash is redundant.
  4. cascade_epoch in the DL and style-cache keys (high, low, ~½ day). Replaces fix 4 and the
    last_dynamic_context gate with one key that also covers @media, restyles and user overrides.
  5. Single theme source and trigger (medium, low, ~½ day). Seed ws.theme from the probe at
    creation in all shells (or make it Option, None = follow system); key adopt_system_style
    on the window-theme delta; delete the shells' dead contexts and WindowCreateOptions.theme;
    route scrollbars through the stored context; remove the [THEME-DBG] prints.
  6. Button surface classification (medium, low, ~½ day). ButtonType::surface() →
    Neutral | OwnColour | NoSurface, used by flat::button, flora::button and button_states
    for face, text and border twins alike. Fixes symptom (b) without touching a light value.
  7. Pair builder + lint for widget styles (medium, medium, ~1-2 days): I8.

Of today's five fixes, 1 and 3 are the right long-term shape (1 needs I1 to finish); 2, 4 and 5
are patches over R1/R4/R2 and should be retired by items 3, 4 and 1 respectively.

5. The two live symptoms (diagnosed from code)

(a) Widgets demo, Default button label dark-on-dark after the switch. The "Click me!" button
(examples/c/widgets.c:217, AzButton_create → ButtonType::Default, button.rs:410) carries
on its container: simple(TextColor(rgb(33,37,41))) (button.rs:262, from
get_button_text_color :193-201), then, because container_style is None (flat.rs:605),
dark_theme(BackgroundContent(DARK_BG)) and dark_theme(TextColor(DARK_FG)) (flat.rs:663-671),
then button_states (:678). The label is a widget_p() (`flat.rs:632-640`, `widgets/mod.rs:473`) whose style declares no colour (`button.rs:390-403`), with a text child. Under Dark the container itself resolves `DARK_FG` on both readers (inline fold, last match). For the *text node* the two readers diverge: the compact builder copies `DARK_FG` down (`compact.rs:955-957`, `:768`), so the baked glyph colour is right; but `live_color` (`display_list.rs:7617-7619`) asks the slow path, which finds nothing inline or in `css_props` on the text node and answers from `computed_values` (`prop_cache.rs:3045-3053`) — the value inherited through Step 4 of `compute_inherited_values`, which took only the unconditional `rgb(33,37,41)` (`:5056-5060`). Result: near-black text on `DARK_SUR`. The declarations are correct under the migration rule (a page-neutral surface takes `DARK_SUR`/`DARK_INK`); the engine loses the dark text colour in inheritance. Principled fix: item 4.2 (conditional inline declarations participate in `compute_inherited_values`). Setting the colour on the label would hide it
for this widget only and would have to be repeated in every widget with a text child.

(b) hello-world, Primary button lost its blue face; label wraps. The container gets
simple(BackgroundContent(bootstrap_primary)) (button.rs:307), simple(TextColor(WHITE))
(:262), and blue borders (:312-313, border_color = bg_normal). flat::button then appends
dark_theme(BackgroundContent(DARK_BG)) for every type (flat.rs:663-671, "we just override
the background and text color for dark mode"), and last-match-wins (prop_cache.rs:2992-3023)
makes the face DARK_SUR under Dark. The text node keeps WHITE (same inheritance gap as (a)),
the borders stay blue, and button_states(Primary) keeps the blue hover/active
(flat.rs:2103-2107, neutral == false), so a dark-grey box with a blue outline flashes blue on
hover. Under the rule "a surface that is its own colour keeps its light colour", the resting-face
twins must be gated the way button_states already gates the states: push DARK_BG/DARK_FG
only for ButtonType::Default (and no face for Link); flora::button classifies the face
per type (flora.rs:895-913) but still pushes dark_theme(TextColor(DARK_INK)) and DARK_BD
borders for every type (:918-932), which is the same defect one layer down. Item 4.6.

The wrap cannot be derived from the code alone. The DOM is identical to the pre-dispatch tree
(the old Button::dom already used widget_p(): faa2f6c6a^:button.rs:679-686 vs
flat.rs:632-640), no stylesheet keys on __azul-theme-flat, and the theme is paint-only by
construction (property.rs:1700-1715) — so a wrap means an invalidation seam treats the colour
flip inconsistently. Two candidate seams: (i) the compact cache is now rebuilt on context
install for every button (has_dynamic_conditions, styled_dom.rs:2389-2404) after
prune_compact_normal_props ran (:1365), and a rebuild has never been proven equal to a fresh
build; (ii) colour is hashed into inline_content_hash (fc.rs:3815-3845 via
text3/cache.rs:4661), so the label is re-shaped on the theme pass while box geometry comes
from caches blind to colour (cache_map, subtree hash). The uncommitted
the_theme_does_not_change_layout / a_theme_switch_in_one_window_does_not_change_layout tests
are the correct pin; run them first (cargo test --release -p azul-layout the_theme_does_not_change_layout)
and, if they pass, reproduce with a Primary button in the headless harness through the
Restyle path, which the unit fixture does not take.


Doc Orphan Allowlist (`doc/orphan_allowlist.txt`)

Public functions in the dll's integration glue that nothing calls.

This file is the difference between a lint that fires and a lint that fails.

Anything listed here is a KNOWN orphan with a reason; anything NOT listed is

an error, so a new one cannot join quietly.

The lint exists because three bugs in one day had this shape and none of them

could produce a compiler warning: `pub` silences dead-code analysis, and the

binding that decides which function ships is a string in api.json.

- save_to_disk_cache: zero callers -> the font cache was never written

- map_widget_dom: zero callers -> the map never fetched a tile

- video_widget_dom: same, one widget over -> video never decoded

Format: # reason

To remove an entry: wire the function up, or delete it.

--- MVT helpers, superseded by the tile worker's own path -------------------

calculate_extent # mvt.rs helper; the worker computes its own extent
calculate_zoom_level # mvt.rs helper
get_tile_coordinates_for_extent # mvt.rs helper
tile_coords_to_urls # mvt.rs helper; MapTileCache builds URLs itself
stitch_features_to_collection # mvt.rs helper

--- capability probes / one-shot actions called from other languages --------

probe_last_fix # geolocation probe, reachable from the C API surface
sqlite_version # version probe
peer_left # webtransport event hook

The send half of the WebTransport v1 loopback stub (961bb4b68): api.json binds

the class but not these five yet, so no fn_body reaches them. Wire them into

the AzWebTransport module when its send/keyframe surface lands; until then the

stub returns `false` and they are reachable from nothing.

send_video # webtransport v1 stub, unbound in api.json
send_audio # webtransport v1 stub, unbound in api.json
send_chat # webtransport v1 stub, unbound in api.json
send_system # webtransport v1 stub, unbound in api.json
request_keyframe # webtransport v1 stub, unbound in api.json
reboot_now # provisioning action, invoked by the user not by us


Doc Reftest Baseline (`doc/reftest_baseline.txt`)

Reftests known to PASS at the time this baseline was recorded.

A test listed here MUST keep passing.

block-float-clear-complex-001
block-margin-collapse-complex-001
block-padding-border-001
block-positioning-complex-001
block-width-height-001
calc-width-001
cascade-font-weight-inherit-001
cascade-global-star-001
cascade-inline-style-001
cascade-multiple-classes-001
cascade-nested-selectors-001
cascade-specificity-001
color-background-001
display-none-visibility-001
flexbox-flex-grow-shrink-001
flexbox-justify-align-001
flexbox-simple
flexbox-wrap-alignment-001
font-hinting-001
font-properties-001
grid-alignment-001
grid-auto-flow-dense-001
grid-minmax-fr-001
grid-template-areas-001
inline-block-text-002
list-style-001
margin-auto-centering-001
overflow-scroll-001
padding-debug-001
padding-debug-002
padding-debug-003
position-absolute-001
position-relative-001
ribbonbug-border-around-text-box-001
ribbonbug-border-box-metrics-001
ribbonbug-flex-root-auto-height-001
ribbonbug-mini-ribbon-composite-001
ribbonbug-text-center-in-wide-box-001
ribbonbug-text-maxcontent-nowrap-001
ribbonbug-unset-border-color-001
showcase-flexbox-complex-001
table-basic-001
text-basic-001
text-in-box-debug-001
text-overflow-001
inline-background-001
inline-elements-001


Doc Blog 2025 12 13 Hello World (`doc/blog/2025-12-13-hello-world.md`)

Hello World - First Blog Post

Welcome to the Azul GUI Framework blog! This is our first post where we'll share updates,
tutorials, and news about the project.

What is Azul?

Azul is a cross-platform MIT-licensed Desktop GUI framework for C, C++, Python and Rust,
using the Mozilla WebRender rendering engine. It focuses on:

  • Performance: GPU-accelerated rendering with WebRender
  • Simplicity: DOM-based layout with CSS styling
  • Cross-platform: Works on Windows, macOS, and Linux
  • Multi-language: Bindings for C, C++, Python, and native Rust support

Getting Started

Check out our getting-started guide to get started with Azul.

Stay Tuned

We'll be posting more updates soon, including:

  • New feature announcements
  • Performance improvements
  • Community highlights
  • Tutorial series

Thanks for reading!


Doc Fonts EBGaramond OFL (`doc/fonts/EBGaramond-OFL.txt`)

Copyright 2017 The EB Garamond Project Authors (https://github.com/octaviopardo/EBGaramond12)

This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org


SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007

PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.

The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.

DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.

"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).

"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).

"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.

"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.

PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:

  1. Neither the Font Software nor any of its individual components,
    in Original or Modified Versions, may be sold by itself.

  2. Original or Modified Versions of the Font Software may be bundled,
    redistributed and/or sold with any software, provided that each copy
    contains the above copyright notice and this license. These can be
    included either as stand-alone text files, human-readable headers or
    in the appropriate machine-readable metadata fields within text or
    binary files as long as those fields can be easily viewed by the user.

  3. No Modified Version of the Font Software may use the Reserved Font
    Name(s) unless explicit written permission is granted by the corresponding
    Copyright Holder. This restriction only applies to the primary font name as
    presented to the users.

  4. The name(s) of the Copyright Holder(s) or the Author(s) of the Font
    Software shall not be used to promote, endorse or advertise any
    Modified Version, except to acknowledge the contribution(s) of the
    Copyright Holder(s) and the Author(s) or with their explicit written
    permission.

  5. The Font Software, modified or unmodified, in part or in whole,
    must be distributed entirely under this license, and must not be
    distributed under any other license. The requirement for fonts to
    remain under this license does not apply to any document created
    using the Font Software.

TERMINATION
This license becomes null and void if any of the above conditions are
not met.

DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.


Doc Fonts Grenze OFL (`doc/fonts/Grenze-OFL.txt`)

Copyright 2019, The Grenze Project Authors (https://github.com/Omnibus-Type/Grenze)

This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org


SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007

PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.

The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.

DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.

"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).

"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).

"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.

"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.

PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:

  1. Neither the Font Software nor any of its individual components,
    in Original or Modified Versions, may be sold by itself.

  2. Original or Modified Versions of the Font Software may be bundled,
    redistributed and/or sold with any software, provided that each copy
    contains the above copyright notice and this license. These can be
    included either as stand-alone text files, human-readable headers or
    in the appropriate machine-readable metadata fields within text or
    binary files as long as those fields can be easily viewed by the user.

  3. No Modified Version of the Font Software may use the Reserved Font
    Name(s) unless explicit written permission is granted by the corresponding
    Copyright Holder. This restriction only applies to the primary font name as
    presented to the users.

  4. The name(s) of the Copyright Holder(s) or the Author(s) of the Font
    Software shall not be used to promote, endorse or advertise any
    Modified Version, except to acknowledge the contribution(s) of the
    Copyright Holder(s) and the Author(s) or with their explicit written
    permission.

  5. The Font Software, modified or unmodified, in part or in whole,
    must be distributed entirely under this license, and must not be
    distributed under any other license. The requirement for fonts to
    remain under this license does not apply to any document created
    using the Font Software.

TERMINATION
This license becomes null and void if any of the above conditions are
not met.

DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.


Doc Fonts GrenzeGotisch OFL (`doc/fonts/GrenzeGotisch-OFL.txt`)

Copyright 2020 The Grenze Gotisch Project Authors (https://github.com/Omnibus-Type/Grenze-Gotisch)

This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL


SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007

PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.

The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.

DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.

"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).

"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).

"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.

"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.

PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:

  1. Neither the Font Software nor any of its individual components,
    in Original or Modified Versions, may be sold by itself.

  2. Original or Modified Versions of the Font Software may be bundled,
    redistributed and/or sold with any software, provided that each copy
    contains the above copyright notice and this license. These can be
    included either as stand-alone text files, human-readable headers or
    in the appropriate machine-readable metadata fields within text or
    binary files as long as those fields can be easily viewed by the user.

  3. No Modified Version of the Font Software may use the Reserved Font
    Name(s) unless explicit written permission is granted by the corresponding
    Copyright Holder. This restriction only applies to the primary font name as
    presented to the users.

  4. The name(s) of the Copyright Holder(s) or the Author(s) of the Font
    Software shall not be used to promote, endorse or advertise any
    Modified Version, except to acknowledge the contribution(s) of the
    Copyright Holder(s) and the Author(s) or with their explicit written
    permission.

  5. The Font Software, modified or unmodified, in part or in whole,
    must be distributed entirely under this license, and must not be
    distributed under any other license. The requirement for fonts to
    remain under this license does not apply to any document created
    using the Font Software.

TERMINATION
This license becomes null and void if any of the above conditions are
not met.

DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.


Doc Fonts RedHatMono OFL (`doc/fonts/RedHatMono-OFL.txt`)

Copyright 2024 The Red Hat Project Authors (https://github.com/RedHatOfficial/RedHatFont)

This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org


SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007

PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.

The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.

DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.

"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).

"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).

"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.

"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.

PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:

  1. Neither the Font Software nor any of its individual components,
    in Original or Modified Versions, may be sold by itself.

  2. Original or Modified Versions of the Font Software may be bundled,
    redistributed and/or sold with any software, provided that each copy
    contains the above copyright notice and this license. These can be
    included either as stand-alone text files, human-readable headers or
    in the appropriate machine-readable metadata fields within text or
    binary files as long as those fields can be easily viewed by the user.

  3. No Modified Version of the Font Software may use the Reserved Font
    Name(s) unless explicit written permission is granted by the corresponding
    Copyright Holder. This restriction only applies to the primary font name as
    presented to the users.

  4. The name(s) of the Copyright Holder(s) or the Author(s) of the Font
    Software shall not be used to promote, endorse or advertise any
    Modified Version, except to acknowledge the contribution(s) of the
    Copyright Holder(s) and the Author(s) or with their explicit written
    permission.

  5. The Font Software, modified or unmodified, in part or in whole,
    must be distributed entirely under this license, and must not be
    distributed under any other license. The requirement for fonts to
    remain under this license does not apply to any document created
    using the Font Software.

TERMINATION
This license becomes null and void if any of the above conditions are
not met.

DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.


Doc Guide En Animations (`doc/guide/en/animations.md`)


slug: animations
title: Animations
language: en
canonical_slug: animations
audience: external
maturity: wip
guide_order: 100
topic_only: false
short_desc: Transitions, @keyframes, and the enter/exit animation properties
prerequisites: [hello-world, events]
tracked_files:

  • css/src/props/basic/animation.rs
  • core/src/animation.rs
  • layout/src/window.rs
  • core/src/task.rs
    last_generated_rev: e33ef81cc56d8a7dac357d6c0610ff5e26d02e26
    generated_at: 2026-08-20T00:00:00Z
    default-search-keys:
  • StyleAnimation
  • AnimationTiming
  • AnimationTimingBezier
  • AnimationIterationCount
  • Keyframes
  • CssProperty
  • Timer
  • TimerCallbackInfo
  • TimerCallbackReturn
  • Update

Animations

Three properties drive everything the engine animates:

Property Runs when Names
animation a property's computed value changes between DOM rebuilds all, or the property to scope to
-azul-animation-in the node mounts a @keyframes block or an attached function
-azul-animation-out the node unmounts a @keyframes block or an attached function

There is no transition property. animation is where that job lives.

The shorthand

text
<name> <duration> [<delay>] [<timing>] [infinite | <count>] [no-clip]
css
animation: all 200ms ease-out;               /* every change transitions */
animation: width 1s, background-color 2s;    /* per-property scopes      */
-azul-animation-in:  slideIn 220ms spring;
-azul-animation-out: slideOut 180ms 50ms ease-in no-clip;

The first time value is the duration, the second the delay — CSS order. A
list is read last-match-wins, so a later entry overrides an earlier one for
the properties it covers.

Transitions

animation turns covered property changes into timed interpolations instead
of instant updates. The property list is read off the old cascade: adding
animation in the same rebuild that changes a value does not retro-animate
that change.

css
.row          { background-color: #fff; animation: all 150ms ease-out; }
.row.selected { background-color: #dce8ff; }

Rebuild the DOM with selected on the row and the colour walks over 150 ms.
Change it again mid-flight and the animation retargets from its current
value rather than stacking a second run — rapid A→B→C stays smooth. A
transition on a paint-only property (colour, opacity, transform) patches the
display list; one on width or font-size re-runs layout per frame.

Presence: enter and exit

-azul-animation-in and -azul-animation-out animate a node's arrival and
departure. Both name a @keyframes block or a function attached to the node,
resolved in that order.

css
.toast {
    -azul-animation-in:  toastIn 200ms spring-snappy;
    -azul-animation-out: toastOut 150ms ease-in;
}

An exit needs something to draw after layout has removed the node, so a
declared -azul-animation-out makes the engine retain the previous frame's
subtree for the duration — the zombie. Retention, catching an exit that
remounts mid-flight, and per-frame native animation functions are
Zombie Animations.

infinite on an enter track is how a spinner is expressed; on an exit it is
clamped to one run.

`@keyframes`

css
@keyframes toastIn {
    from { transform: translateY(24px); opacity: 0; }
    to   { transform: translateY(0);    opacity: 1; }
}
@keyframes pulse {
    0%   { opacity: 1.0; }
    50%  { opacity: 0.4; }
    100% { opacity: 1.0; }
}

Stops accept from, to, and percentages. The compiled track reads
transform (translate, translateX, translateY, scale, rotate),
opacity, width and height; percentage translations resolve against the
node's own rect. Other properties in a stop are ignored. When two blocks
share a name, the last definition wins.

Timing

ease (the default), linear, ease-in, ease-out, ease-in-out,
cubic-bezier(x1, y1, x2, y2), and three springs: spring,
spring-gentle, spring-snappy.

Springs settle on physics rather than on the clock — the declared duration is
their retarget time base, not a stop watch. Reach for one when the animation
can be interrupted (a panel the user re-opens mid-close); reach for a bezier
when the motion has to land on an exact beat.

Driving an animation yourself

Motion that follows application state — a simulation, a game loop, a value
arriving over the network — belongs in a timer that mutates your model and
returns Update::RefreshDom per frame:

rust,ignore
extern "C" fn animate(data: RefAny, info: TimerCallbackInfo) -> TimerCallbackReturn {
    let mut state = data.downcast_mut::<State>().unwrap();
    let Some(start) = state.anim_start else {
        return TimerCallbackReturn::terminate_unchanged();
    };
    let end = start.clone().add_duration(&state.anim_duration);
    let t = info.frame_start.linear_interpolate(start, end);
    state.current_opacity = state.anim_from + (state.anim_to - state.anim_from) * ease_out(t);
    if t >= 1.0 {
        state.anim_start = None;
        TimerCallbackReturn::terminate_and_refresh_dom()
    } else {
        TimerCallbackReturn::continue_and_refresh_dom()
    }
}

Instant::linear_interpolate(start, end) returns the clamped 0..=1
fraction; layer easing on top. The layout callback then reads
state.current_opacity and writes it into the style it builds. See
Timers for scheduling, cancellation, and the 60 fps
pattern end to end.

Animating images, not the DOM

When the only thing that changes is pixels — a sprite sheet, a video frame, a
GL texture — info.update_all_image_callbacks() re-invokes every image
callback without touching layout or the display list.

Cross-references

  • Zombie Animations: exit retention and
    native per-frame animation functions.
  • Timers: the timer mechanics the manual path builds
    on.
  • Reconciliation: the node identity that decides
    what counts as a mount, an unmount, and a move.

Doc Guide En Architecture (`doc/guide/en/architecture.md`)


slug: architecture
title: Application Architecture
language: en
canonical_slug: architecture
audience: external
maturity: mature
guide_order: 40
topic_only: false
short_desc: Explains the concepts of architecting a larger Azul application
prerequisites: [hello-world]
tracked_files:

  • core/src/callbacks.rs
  • core/src/dom.rs
  • core/src/refany.rs
  • doc/guide/en/architecture.md
    last_generated_rev: 7ecd570e4c0c3584e5107e770058c16cb59fa6e7
    generated_at: 2026-05-02T05:48:36Z
    default-search-keys:
  • App
  • RefAny
  • Dom
  • Callback
  • CallbackInfo
  • Update
  • LayoutCallbackInfo
  • Button
  • TextInput
  • NumberInput

Application Architecture

Introduction

Building graphical user interfaces has, despite its perceived simplicity, been
a difficult problem in computer science. Despite a constant progress in languages,
libraries, design patterns and compilers, developers face the same fundamental
problems in user interfaces that they did 40 years ago: managing state, synchronizing
application data with what users see, and enabling communication between distant
components without creating a spaghettified mess.

The core thesis of Azul is that this struggle lies in a core conflict that nearly every
toolkit fails to properly address: a conflict between the "Visual Tree" (the hierarchy of object on the screen, such as div.foo > p > ::text)
and the "State Graph" (the logical relations between components interacting with each other,
such as Toolbar <-> TreeView <-> Table).

  • The "Visual Tree" is the hierarchy of elements as they appear on the screen. It is always
    a tree: a window contains a panel, which contains a button. This hierarchy is pretty much
    present in all but the most primitive UI frameworks, because you need some form of logical
    grouping for layouting, unless the layout is calculated manually.
  • The "State Graph" is the map of how application data and logic are connected. A filter
    control in a toolbar (Visual Tree -> Toolbar -> Filter Data) needs to alter the
    data displayed in a completely separate table (Visual Tree -> MainPanel -> Table) without
    the two pieces being merged in a "FilterDataTableWithToolbar", as that will create a
    complete mess once the interactions become more complex.

The second biggest problem of UIs is the question: How do we handle the inherent mutability of UI components? Mutable APIs such as in web browsers only hand you a dom.addChild(c) function and expect you to do the synchronization back to your application data model yourself. Other solutions such as SolidJS trigger hooks whenever your data model changes, or create a diff between two full virtual DOM trees (such as in React).

Prior Art

The key insight of Azul is that this network of dependencies is a complex graph, not a
simple tree and fusing them together (via a "minimum common ancestor approach") creates
the unmaintainable mess of most modern UIs.

mermaid
---
title: Visual Tree
---
graph LR
    v_App[App] --> v_Toolbar[Toolbar] & v_MainPanel[MainPanel]
    v_Toolbar --> v_SaveButton[SaveButton]
    v_MainPanel --> v_Sidebar[Sidebar] & v_Table[Table]
    v_Sidebar --> v_FilterControl[FilterControl]

    classDef app fill:#f9f,stroke:#333,stroke-width:2px
    classDef button fill:#ccf,stroke:#333,stroke-width:2px
    classDef control fill:#cfc,stroke:#333,stroke-width:2px

    class v_App app
    class v_SaveButton button
    class v_FilterControl control
mermaid
---
title: State Graph
---
graph TD
    s_SaveButton[SaveButton] -- "needs validity from" --> s_FormState[FormState]
    s_FilterControl[FilterControl] -- "updates data for" --> s_TableData[TableData]
    s_FormState -- "is part of" --> s_AppLogic[AppLogic]
    s_TableData -- "is part of" --> s_AppLogic

    classDef button fill:#ccf,stroke:#333,stroke-width:2px
    classDef control fill:#cfc,stroke:#333,stroke-width:2px
    classDef state fill:#ccf,stroke:#333,stroke-width:2px,stroke-dasharray: 5 5
    classDef data fill:#cfc,stroke:#333,stroke-width:2px,stroke-dasharray: 5 5

    class s_SaveButton button
    class s_FilterControl control
    class s_FormState state
    class s_TableData data

The "pain" of UI programming stems from frameworks that either fuse them
together or awkwardly force a node graph to conform to the shape of the tree.

Fused Hierarchy [OOP]

The first generation of toolkits (Qt, GTK, MFC, Swing) were built on an
object-oriented model - not because it was necessary, but because it was
considered „best practice“. The paradigm was simple: the UI is a tree of
stateful objects, therefore would be logical to put the custom behaviour of a Button right on an extended instance - hence the need for "object-oriented" inheritance. A MyCustomPanel
object inherits from Panel and adds its own data and logic, objects are
then composed in a hierarchy of types (not values) until you get to the parent „window“ object.

python
# OOP Paradigm
class MyPanel(othertoolkit.Panel):
    # ...
    def on_click():
        # text_input implicitly comes from othertoolkit.App
        input = self.text_input.getText()
        calculated = do_somthing_with_input(input)
        self.output.setText(calculated)
        self.text_input.setText("")
mermaid
graph TD
    A[MyApp extends App] -->|inherits| B[Panel extends Widget]
    B -->|inherits| C[Button extends Widget]
    A -.->|holds reference| C
    
    style A fill:#f9f,stroke:#333,stroke-width:2px
    style B fill:#ccf,stroke:#333,stroke-width:2px
    style C fill:#cfc,stroke:#333,stroke-width:2px

In this model, the Visual Tree and the State Graph are fused. The object inheritance
hierarchy is equal to the visual hierarchy. This immediately creates real problems:

  • Communication between logically related but visually distant components requires complex
    pointer management (in JS, reference management - no crash but not much better), global
    mediator objects, or a web of signal-and-slot connections that are difficult to
    trace and maintain (Qts meta-object-compiler).
  • Changing the visual layout in this paradigm forces a refactoring of the class hierarchy,
    which makes developing applications in such toolkits painful and creates hard dependencies
    on the toolkit itself (leading to „toolkit wars“, like the battle over GTK vs Qt).
    The application logic is not testable in isolation without the framework
    because it is fundamentally inseparable from the UI objects themselves.
  • It creates a hard dependency on the toolkit itself. Your application logic is not
    portable or reusable because it is fundamentally intertwined with the toolkit's base
    classes, rendering system, and event model.

Constrained Hierarchy [Elm, React]

The next major step, led by frameworks like React, Angular and the Elm Architecture,
introduced a new functional paradigm: UI = f(data). The UI is a declarative, pure
function of the application's state. When the data is changed, the framework efficiently updates
the view to match instead of manually needing a setText() call ("two-way data binding").

python
# React Paradigm Model
def MyApp():
    input_value, set_input_value = useState("")
    output_value, set_output_value = useState("")

    def handle_click():
        calculated = do_something_with_input(input_value)
        set_output_value(calculated)
        set_input_value("")

    return Page(children=[
        TextInput(value=input_value, on_change=set_input_value),
        Button(on_click=handle_click),
        Label(text=output_value)
    ])
mermaid
graph TD
    A[MyApp State] -->|props down| B[Toolbar]
    A -->|props down| C[MainPanel]
    B -->|callback up| A
    C -->|callback up| A
    
    style A fill:#f9f,stroke:#333,stroke-width:2px
    style B fill:#ccf,stroke:#333,stroke-width:2px
    style C fill:#cfc,stroke:#333,stroke-width:2px

However, while these frameworks finally decouple the view from imperative manipulation, they
still constrain the flow of data to the shape of the Visual Tree. The example above works
because TextInput, Button, and Label are all siblings, children of MyApp. But what if
the Button were in a Toolbar and the TextInput and Label were in a MainContent panel?

React's solution is to „lift state up“ to their lowest common ancestor, MyApp. The MyApp
component must now hold the state and pass both the data and the callback functions down through the
intermediate components.

python
def MyApp():
    # State is lifted to the common ancestor
    input_value, set_input_value = useState("")

    # ... logic also lives in the ancestor ...

    return Page(children=[
        # Toolbar is now forced to accept and pass down a prop it doesn't use
        Toolbar(on_button_click=handle_click),
        # MainContent is also forced to pass props
        MainContent(
            input_value=input_value,
            on_input_change=set_input_value,
            output_value=output_value
        )
    ])

Here, the State Graph is still being forced into the tree structure of the view, leading
to "prop drilling" and components with indirect APIs. The existence of "escape hatches"
like Redux or the Context API is evidence of this core constraint - they are patterns invented
to work around this default tree-based data flow.

Elms solution goes even further to „lift all state up“ to the root ancestor and route everything
in a single, top-level "update" function. Elm therefore represents the philosophical extreme
of the constrained hierarchy:

  1. Model: The entire state of the application is held in a single, immutable data structure.
  2. View: A pure function that takes the Model and returns a description of the UI.
  3. Update: A single, central function that is the only entity allowed to modify the state.

Elm does this by taking an incoming Msg (a message from the UI) and the current state, and
producing a new state.

Ignoring Hierarchy (IMGUI)

Immediate Mode toolkits (IMGUI) have no persistent
UI objects at all; the UI is redrawn from scratch from application data every single frame. This
solves synchronization issue by brute force but shoves the problem of application architecture
onto the developer instead of the framework - programmers now have to store lots of "UI state" as
part of their application data model, such as focus state, scroll positions, caches, etc. Immediate
mode toolkits are mainly popular with game development overlays, but not much outside of that,
simply because the inherent complexity of UIs is now shoved onto the application instead of the
framework, which isn't much of a help.

python
# IMGUI Paradigm Model
class AppState:
    input_buffer = ""
    output_text = ""

# Inside the main application loop, every frame
def render_ui(app_state):
    ui.text_input("Input:", &app_state.input_buffer)
    if ui.button("Calculate"):
        calculated = do_something_with_input(&app_state.input_buffer)
        app_state.output_text = calculated
        app_state.input_buffer.clear()
    ui.label(&app_state.output_text)
mermaid
graph TD
    A[AppState] -->|reads| B[render_ui]
    B -->|writes| A
    B -->|draws| C[UI Every Frame]
    
    style A fill:#f9f,stroke:#333,stroke-width:2px
    style B fill:#ccf,stroke:#333,stroke-width:2px
    style C fill:#cfc,stroke:#333,stroke-width:2px

IMGUI doesn't solve the Visual Tree vs. State Graph problem - it just largely ignores the problem
and instead creates a hidden data binding in a "closure with captured arguments" instead of a
"class with state and functions".

While the form is different from OOP, the operation (and the problem) is the same. A closure is
just a function on a struct containing all captured variables. The effect is the same as a class-with-methods,
but on top of that, it provides even less layout flexibility than object-oriented code, because the UI drawing functions juggles the raw values around (to calculate the layout) instead of abstracting them in more "declarative" objects and letting the framework solve the layout.

What is the essence of a UI toolkit?

A question that sometimes comes up in programming discussions is how a "GUI toolkit" differs from a "rendering library".
One could mainly categorize GUI toolkits or libraries by their approach to handling the
following three "hard problems":

  1. Data Access / Model-View separation: Somehow a callback needs access to both the data model (i.e.
    the application data) and the stateful UI object of the framework (to scrape the text out), but at the same time the „data model“
    should be as far removed from the UI as possible, so that logic functions do not depend on view data (my_ui_object.getText()).
  2. Synchronization: It is very easy for the visual UI state and the data model to go out of sync.
    Solutions so far include "Observer patterns" (callbacks that run when something changes, granular
    reactivity), React-like reconciliation of entire DOM trees or "just redraw everything, and throw
    the state away" (IMGUI).
  3. Inter-widget communication: This is the hardest problem to solve, as it's not directly obvious
    in TodoMVC-esque applications. Existing toolkits assume that the widget hierarchy (visual tree) and
    the inheritance (or function call) hierarchy are the same (using the least common ancestor as a
    channel, either via OOP inheritance or via React-style prop drilling). Other solutions involve
    observable cells of functionality (useMemo / useEffect in SolidJS), which the framework then
    coordinates.

Overall, rendering libraries do not solve these problems at all while frameworks usually do - sometimes
using special features of the language itself such as in SwiftUI and Flutter.

Starting again from scratch

So, if we could free our mind conceptually from both OOP and Reactive programming, what would a
"proper" toolkit look like? By "proper" it means that it solves the problems above and scales to
larger (500K - 1 million lines of code) applications without becoming an unmaintainable mess.

Encoding Visual Hierarchy

The first thing we'd need to decide is whether we'd like to serialize the UI or render it directly
(IMGUI). The choice here is relatively obvious, because the former creates
instant opportunity for introspection of the visual state (such as in a HTML debugger). The
counter-argument against this has been traditionally "performance", but when testing Azuls memory
profile, this effectively came up as a non-issue: the entire DOM with styling in even a large
application is only ~500KB - 1MB of actual data.

In terms of efficiency there is also a massive upside to this: we can diff the DOMs state instead
of just getting new pixels, which gives the framework more semantic information to work with for
caching. Second, it also
nicely maps to how computers execute - compare the XML hierarchy to function call stacks:

html
<div class="parent">
    <div class="child"></div>
    <div class="child"></div>
    <div class="child"></div>
</div>

with:

python
div(class="parent", children = [
    div(class="child")
    div(class="child")
    div(class="child")
])

Composing UI hierarchies via functions makes much more sense than composing UI hierarchies via
inheritance (OOP) or types (xilem) because the latter are often language-specific and not supported
in all languages, whereas C-callback style function pointers are language agnostic.

Encoding Data Access

The second decision is where to store the UI data, so that the callbacks may access it again.
Widget-specific data has to be either stored on the programmer side (in the application, using
inheritance or traits) or in the framework.
What format should we use?

Inheritance-based toolkits only allow one format: You have to inherit from a UI object and then
construct your application as a hierarchy of UI objects. Azul stores the application data
as an implementation-agnostic RefAny struct: similar to PyObject or Javascripts Object it
just stores "some data as bytes" - together with minimal information about the class / data type.
You can upcast any piece of data, wrap it via RefAny::new and then get immutable or mutable access again via .downcast_ref()
or .downcast_mut(), respectively:

rust
let data = RefAny::new(5); // owns the data
let data_clone = data.clone(); // only bumps the reference count

let data_ref: &usize = data.downcast_ref::<usize>().unwrap(); // ok
println!("{}", *data); // prints 5

// error: data_ref still held
let data_mut: &mut usize = data.downcast_ref::<usize>().unwrap();
// object destroyed when last reference to it is dropped

Effectively this is similar to an Observable, however, since RefAnys are usually connected to a
Callback, a Dom, a Task or a Thread, the topology of how they are connected is more obvious.

While the up- and downcasting loses some type safety (we'll discover later how to work around this
fact), the biggest upside here is that this model makes the framework C-compatible. If the framework
would use Rust closures or traits, this would effectively make it unusable outside of Rust.

Using [insert language]s module system, we can minimize (and in practice completely avoid)
any errors related to up / downcasting by controlling the visibility of the thing we're downcasting
to - effectively making the "blast radius" of a type casting error so minimal that it becomes
irrelevant in practice:

rust
// number_input.rs (private internals)
struct NumberInputInternal { /* ... */ }

// number_input.rs (public API)
pub struct NumberInput {
    internal: NumberInputInternal,
}

impl NumberInput {
    pub fn dom(self) -> Dom {
        let on_the_fly = RefAny::new(self.internal); // upcast
        Dom::new().with_callback(private_callback, on_the_fly)
    }
}

extern „C“
fn private_callback(data: RefAny, info: CallbackInfo) -> Update {
    // downcast - as NumberInputInternal is private to this module,
    // only code in this module can downcast to NumberInputInternal
    // external code can't even name the type, so no downcast error
    let d = data.downcast::<NumberInputInternal>().unwrap();
}

This way, once a decent amount of test coverage is done, the „internals“ of any widget
are hidden from the outside completely. In the API of Azuls default widget set you'll often see FooConfig structs that carry the public "configuration" of said widget, which reflects this pattern. When all references to a RefAny are deleted,
the internal object is then destroyed, running either a default null or custom destructor function. Additionally, the framework can keep "heavy" RefAny objects alive between two layout() calls, e.g. for storing things like a video decoder handle or a map tile cache, where the cached data belongs neither in the application data model nor in the framework built-in as a primitive - read more in the Merge Callbacks guide.

Building a State Graph

The architectural pattern that naturally emerges from this RefAny + Callback is the „backreference“ or "dependency injection" pattern: you can store a RefAny + Callback inside of a RefAny, which allows you to design public APIs of widgets
in a „dependency injection“ style, without the need for "prop drilling".

The goal of this is to pass data / callbacks of a higher-level data model directly down to
a lower-level component during DOM construction, without having to „prop drill“ any data /
callbacks through intermediary components / middleware, but also, in difference to OOP, keep
both components „in the dark“ about each other. The only way where it's necessary to know the
relations is in the executed callback: exactly where we'd expect some form of complexity, and
where we could use a debugger to „step through“ a potential downcast failure. Since this is all very theoretical, let's look at some examples:

Examples

Simple: Input Validation

To explain this concept more concretely, let's build a number input that wraps a text input
and validates that the user typed a number > 18 in a VerifyAgeApplication. This demonstrates the dependency injection pattern in its simplest form - a linear chain from low-level (TextInput)
through mid-level (NumberInput) to high-level application logic (VerifyAgeApplication), all extending each other without inheritance.

At its core, text input is handled by marking a text node contenteditable. On top of that, the TextInput then provides a relatively basic API on top, similar to what a web browser would offer. It provides a hook for "any" validation when receiving a focusout event - whether that callback then validates input as a number, a regex match or a date validation - the TextInput itself doesn't care. It doesn't know anything about number validation or age validation. The only thing it knows is „I should call this callback when the focus was lost, which will return true or false“.

python
class TextInput:
    text: String
    user_focus_lost_callback: Optional[Tuple[RefAny, Callback]]

    def __init__(self, text):
        self.text = text
        self.user_focus_lost_callback = None

    def set_on_focus_lost(self, data, callback):
        # Allow higher-level widgets to hook into focus loss
        self.user_focus_lost_callback = tuple(data, callback)

    def dom(self):
        dom = Dom.text(self.text)
        refany = RefAny(self)
        dom.set_callback(On.TextInput, refany, _on_text_input)
        dom.add_callback(On.FocusLost, refany, _on_focus_lost)
        return dom

# PRIVATE to TextInput module, updates TextInput.text internal state
def _on_text_input(data, callbackinfo):
    data.text += callbackinfo.get_text_changeset().text
    # no callbackinfo.preventDefault(), so text input will continue
    return Update.DoNothing

# PRIVATE to TextInput module, calls the user-provided validation callback
def _on_focus_lost(data, callbackinfo):
    # When focus is lost, invoke the user-provided callback if it exists
    if data.user_focus_lost_callback is None:
        return Update.DoNothing
    
    user_data, user_callback = data.user_focus_lost_callback
    return user_callback(user_data, callbackinfo, data.text)

NumberInput as a "superclass" of TextInput now wraps TextInput and adds number-specific validation logic. It again
holds a field for a dependency-injected callback to its superclass (in this case VerifyAgeApplication) via
on_number_input:

python
class NumberInput:
    number: Integer
    on_number_input: Optional[Tuple[RefAny, Callable]]

    # default NumberInput() constructor
    def __init__(self, number):
        self.number = number
        self.on_number_input = None

    def set_on_number_input(self, data, callback):
        # Store a backreference to the application‚s callback
        self.on_number_input = (data, callback)

    def dom(self):
        ti = TextInput(str(self.number))
        # Pass a backreference to *this* NumberInput down to TextInput
        ti.set_on_focus_lost(RefAny(self), _validate_text_input_as_number)
        return ti.dom()

# PRIVATE to NumberInput module
def _validate_text_input_as_number(data, callbackinfo, string):
    # This callback receives the NumberInput's data
    if data.on_number_input is None:
        return Update.DoNothing

    number = string_to_number(string)
    if number is None:
        return Update.DoNothing  # Invalid input; ignore silently

    # Validation passed! Now invoke the application callback
    app_data, app_callback = data.on_number_input
    return app_callback(app_data, callbackinfo, number)

Finally, the top-level application logic of VerifyAgeApplication is then completely
decoupled from UI concerns. The application can expect the NumberInput to call it back
with a number, not a string (so the validation logic has already passed). It can therefore concentrate completely on the "business logic" aspect while the input validation is abstracted away and already done by the time this callback is reached:

python
class VerifyAgeApplication:
    user_age: int

    def __init__(self, initial_age):
        self.user_age = initial_age

def layout_func(data, layoutinfo):
    # uses the public interface of NumberInput
    # doesn't care about how it's implemented internally
    ni = NumberInput(data.user_age)
    # Pass a backreference to the application down to NumberInput
    ni.set_on_number_input(data, _on_age_input)
    # turns NumberInput into a plain DOM subtree
    return Dom.createBody()
        .withChild(ni.dom())

# PRIVATE to VerifyAgeApplication
def _on_age_input(data, callbackinfo, new_age):
    # This callback only runs if the input was a valid number
    if new_age < 18:
        MsgBox.ok("You must be older than 18 to proceed")
        return Update.DoNothing
    else:
        data.user_age = new_age
        return Update.RefreshDom

app = App(VerifyAgeApplication(18), AppConfig(LayoutSolver.Default))
app.run(WindowCreateOptions(layout_func))

The key here is that no matter how complex the application gets in its „State Graph“, it is now decoupled from any „Visual Tree“, i.e. from the visual hierarchy, the Dom, itself.

mermaid
graph TD
    A[VerifyAgeApplication State] -.->|backreference| B[NumberInput State]
    B -.->|backreference| C[TextInput State]
    C -->|event follows backref| B
    B -->|event follows backref| A
    
    linkStyle 2 stroke:#0a0,stroke-width:2px
    linkStyle 3 stroke:#0a0,stroke-width:2px
    
    style A fill:#f9f,stroke:#333,stroke-width:2px
    style B fill:#ccf,stroke:#333,stroke-width:2px
    style C fill:#cfc,stroke:#333,stroke-width:2px

When the user now finishes editing and the input loses focus, the event flows
through the backreferences:

  1. _on_focus_lost(RefAny<TextInput>, text_string)
  2. _validate_text_input_as_number(RefAny<NumberInput>, text_string)
  3. _on_age_input(RefAny<MyApplication>, validated_number)

Each level knows only about its immediate parent via the backreference and can be unit-tested individually. TextInput has
no knowledge of VerifyAgeApplication, and VerifyAgeApplication has no knowledge of the specific UI widget
being used. Additionally, its data model does not contain any handle to the input widget - instead it only stores the user's age and re-creates the text input widget. The framework will then take care of keeping scroll positions, focus and cursor positions in the exact same place.

This pattern scales to arbitrary depth. You could create an EmailInput that wraps
TextInput and validates email format, or a CreditCardInput that validates card IDs.
Each layer simply adds logic without coupling to the layers above or below.

Complex: Node Graph Editor

The power of backreferences becomes even clearer with non-hierarchical state dependencies.
Consider a node graph editor, where the logical connections between nodes (a complex graph)
have no real relation to their visual layout (a flat list of sibling elements on a canvas).

In the
NodeGraph, when a user clicks an input port on a node, how does the widget tell the top-level
NodeGraph state to create a connection? It doesn't send a message „up“ the Visual Tree.
Similar to the TextInput, it follows a pre-defined „chain of backreferences“ (operating on the „Logical Graph“):

  1. The Dom for the input port has a callback holding a PortWidget's data.
  2. This PortWidget contains a backreference to its logical parent, the NodeWidget.
  3. The NodeWidget in turn holds a backreference to the top-level NodeGraphWidget, which contains
    the entire application state.

The callback for the click event on a visual nodes' Input / Output simply follows
this chain of references, making a direct jump from the event source to the top-level data model.

python
# Pseudo-code illustrating the backreference chain
# These are not UI elements, but only the logical controllers for them

class NodeGraphWidget:
    def __init__(self, graph_state):
        self.graph_state = graph_state  # The actual application data

    # Logic that lives at the top level
    def on_port_clicked(self, port_id):
        print(f"LOGIC(NodeGraph): Port {port_id} clicked. Updating global state.")
        # ... logic to connect nodes in self.graph_state ...

class NodeWidget:
    def __init__(self, node_id, graph_widget_ref):
        self.node_id = node_id
        self.graph_widget_ref = graph_widget_ref  # Backreference to the graph

    # This method is „lent“ to the PortWidget
    def on_port_clicked(self, port_id):
        print(f"LOGIC(Node): Click received for port {port_id}. Forwarding to graph."")
        # Uses its backreference to call the top-level logic
        self.graph_widget_ref.on_port_clicked(port_id)

class PortWidget:
    def __init__(self, port_id, node_widget_ref):
        self.port_id = port_id
        self.node_widget_ref = node_widget_ref  # Backreference to the node

    # This would be the callback attached to the UI element
    def handle_click_event(self):
        print(f"EVENT on Port {self.port_id}"")
        # Uses its backreference to start the logical chain
        self.node_widget_ref.on_port_clicked(self.port_id)

Wiring it all up:

python
# Top-level state and logic controller
app_state = {"nodes": {}, "connections": []}
graph_controller = NodeGraphWidget(app_state)

# 2. Create controllers for child components, passing down backreferences
node_a_controller = NodeWidget("NodeA", graph_controller)
port_a1_controller = PortWidget("PortA1", node_a_controller)

# 3. Simulate a user clicking the visual port
port_a1_controller.handle_click_event()

The flow of control again follows the logical graph:

  1. Event -> PortWidget.handle_click_event()
  2. PortWidget.handle_click_event() -> NodeWidget.on_port_clicked()
  3. NodeWidget.on_port_clicked() -> NodeGraphWidget.on_port_clicked()

During testing, you can simply pass in a mock RefAny and test that this part of your application or this widget hits all branches (in code coverage) and that it modifies the state / calls the expected callback - making it "UI testable" without there being any actual UI. The key is that the PortWidget doesn't know what the NodeGraphWidget is, only that
it must call a function on the reference it was given.

In the "real-world" NodeGraph, the flow is more complex, but the pattern scales horizontally,
irrespective of the number of events that the graph needs to handle or the complexity of the
graphs features.

text
/* Detailed source-code truncated for AI context efficiency. */

Inter-widget communication

The last problem is a rare occurrence but occasionally happens: How do we connect two widgets, where the modification of a widget has to lead to a visual change of the other, without the other widget knowing what the UI structure looks like? In the previous examples, we used a full layout() call to make sure the UI is always synchronized and relied on the framework to keep things like focus, cursor and scroll positions in-place across multiple layout() calls. However, for very fast changes, Azul offers things like info.set_css_property() to quickly modify a CSS property.

In the AzPaint demo we have a canvas which can respond to pen input at up to 140 packets per second - on the side of the canvas we also have a ProgressBar to reflect the pen pressure (i.e. from 0 to 100). So, how do we keep the UI in sync here? The "easy way" would be to put the pressure in the application data, update it on every On::Touch event and call layout() and rely that the frameworks caching is good enough.

  1. Store the pressure in the app's data model
  2. Return Update::RefreshDom
  3. layout() runs again, the DOM is diffed, the bar is rebuilt at the new width

This would work - but it would be a bit wasteful. Instead, we want to "connect" the Canvas and the ProgressBar showing the pressure strength without calling layout() and then keep the logic for updating the ProgressBar contained in its own API, so the Canvas doesn't need to know anything about the UI structure of the ProgressBar.

Azul offers a "fast path" for these scenarios:

1. During layout(), the app creates a
fresh Uuid::short() string and annotates the "connected" Dom node with this marker. Then, it copies this Uuid into the callback-local RefAny. The marker UUID here acts like an HTML id, except that it is invisible to CSS and doesn't affect node equality - creating a fresh UUID on every layout() doesn't affect the diff, so no Mount / Unmount events fire by accident.

rust,no_run
// layout(): mint the address and stamp it on the widget...
let marker = Uuid::short();
let bar = ProgressBar::create(0.0)
    .dom()
    .with_marker(Some(marker.clone()).into());
// ...and keep the same string in the state the canvas callbacks get.

2. When the canvas now receives pressure, its
callback resolves the marker to a node id, then hands that node to a
function that the ProgressBar widget exports:

rust,no_run
extern "C" fn on_pen_move(mut data: RefAny, mut info: CallbackInfo) -> Update {
    let pressure = 63.0; // from info.get_pen_state()
    let marker = match data.downcast_ref::<PaintState>() {
        Some(s) => s.pressure_marker.clone(), // the Uuid layout() minted
        None => return Update::DoNothing,
    };
    if let Some(node) = info.get_node_id_by_marker(marker).into_option() {
        ProgressBar::update_progress(info, node, pressure);
    }
    Update::DoNothing // nothing else changed - and the bar STILL moves
}

Almost like magic, the ProgressBar now updates on pressure, without any new calls to layout() - so what's going on here?

3. Internally, ProgressBar::dom() creates a minimal VirtualView for the actual bar - a VirtualView is usually used for infinite content, but can also be used for "sections" of a Dom, that can create a new sub-Dom and update it dynamically.

mermaid
graph TD
    L["layout(): ProgressBar::dom()<br/>.with_marker(Some(uuid))"] -->|stamps| N["VirtualView node<br/>marker: uuid, dataset: private"]
    E["Pen pressure event<br/>(canvas callback)"] -->|"get_node_id_by_marker(uuid)"| N
    E -->|"ProgressBar::update_progress(info, node, 63.0)"| P["widget downcasts its PRIVATE dataset<br/>stores 63.0"]
    P -->|trigger_virtual_view_rerender| F["framework re-invokes ONE VirtualView callback"]
    F -->|"returns new bar DOM"| R["restyle + layout inside existing bounds<br/>damage rect = just the bar"]

    classDef event fill:#cfc,stroke:#333,stroke-width:2px
    classDef dataset fill:#ccf,stroke:#333,stroke-width:2px
    classDef data fill:#f9f,stroke:#333,stroke-width:2px
    class E event
    class N,P dataset
    class F,R data

This is, however, an exception for performance reasons, not the usual case. The ProgressBar then triggers a info.trigger_virtual_view_rerender(node_id), which queues its "bar" for being recreated - similar to fine-grained reactivity, the framework now knows that only this section of the Dom was updated without doing a full diff over the entire Dom. To sum it up:

  • Real "application state" belongs in the data model and updates via layout() and RefreshDom
  • Simple UI presentation values (live meters, previews, scrub positions) belong on the fast path

Summary

The main takeaway from this document is that Azuls „reason d'etre“ (reason for existence) lies less in its technical merit ("Like X but in Rust!", "you should use webrender over vello, it's so much better"), but rather in its distinct architecture.

The goal is not just to show how Azul solves these problems, but also to reflect on the „why“ of existing pain points in GUI programming. Beyond Azuls solution, we hope this helps programmers to articulate the core three problems in GUI programming - because someone who cannot articulate a problem cannot solve it.


Doc Guide En Debugging (`doc/guide/en/debugging.md`)


slug: debugging
title: Debugging
language: en
canonical_slug: debugging
audience: external
maturity: wip
guide_order: 200
topic_only: false
short_desc: Debug overlays, the inspector, and structured logging
prerequisites: [hello-world]
tracked_files:

  • core/src/debug.rs
  • dll/src/desktop/logging.rs
  • dll/src/desktop/shell2/common/debug_server.rs
    last_generated_rev: 7ecd570e4c0c3584e5107e770058c16cb59fa6e7
    generated_at: 2026-05-02T00:00:00Z
    default-search-keys:
  • App
  • CallbackInfo
  • take_screenshot
  • take_native_screenshot
  • Dom

Debugging

Introduction

WIP. The flag set, the HTTP debug server, and the in-browser debugger all work today; names of endpoints and env vars may shift.

Azul ships an HTTP debug server that runs inside your application process. Set AZ_DEBUG= and a thread binds 127.0.0.1:, serves an inspector UI at /, and accepts JSON commands at POST / that drive the application as if a user were clicking it. The same channel powers programmatic E2E tests (covered in End-to-End Testing) and memory probes (covered in Memory and Profiling).

bash
AZ_DEBUG=8765 ./my_app &
curl -s http://localhost:8765/health
curl -s -X POST http://localhost:8765/ -d '{"op":"get_dom_tree"}'

Environment flags

Every flag is read once at process start. Unset means off — except AZ_LOG, which is ON by default (see below). All are independent and can be combined.

  • AZ_LOG=<level>. Controls Azul's built-in stderr logger, enabled by default. Azul installs a logger automatically at App::create so the platform layer (windowing, event loop, layout, device backends) is never silent — if your app exits unexpectedly, the reason is on stderr. Levels: off/0/false silences it entirely; error, warn, info, debug (the default), trace (everything, including per-frame). It honors NO_COLOR and only colorizes a TTY. If your host already installs a logger (Python's pyo3-log, Android's android_logger, your own env_logger), Azul's logger steps aside and does not override it.
  • AZ_DEBUG=. Binds the HTTP debug server on 127.0.0.1:. A bind failure exits the process.
  • AZ_BACKEND=<mode>. One of auto, gpu, cpu, or headless. Resolves the rendering backend. headless skips the OS window and is required by the E2E runner. Default auto.
  • AZUL_HEADLESS=1. Legacy alias for AZ_BACKEND=headless.
  • AZ_RECORD=. Appends every internal log message to `` as plain text.
  • AZ_E2E=. Reads JSON tests from ``, runs them, exits 0 (all pass) or 1 (any fail). See End-to-End Testing.
  • AZ_PROFILE=<tokens>. Comma-separated profiler tokens for per-frame instrumentation. See Memory and Profiling.
  • AZ_PROFILE_OUT=. JSONL output destination paired with AZ_PROFILE=heap,jsonl.
  • RUST_LOG=<filter>. Standard log crate filter (env_logger syntax).

AZ_DEBUG and AZUL_HEADLESS compose: a CI run with AZUL_HEADLESS=1 AZ_DEBUG=8765 ./my_app boots a windowless process you can drive over HTTP. This is the supported configuration for screenshot diffing in CI.

The HTTP debug server

When AZ_DEBUG= is set, the server binds the port and registers a per-window timer that drains the request channel during the normal event loop. Commands therefore execute on the same thread that runs the layout, callback, and render passes. No shared-state races, no need to think about thread safety in your callback.

  • GET /. Serves the inspector UI.
  • GET /health. Status JSON: port, pending log count, recent log lines.
  • GET /material-icons.ttf. Embedded Material Icons font used by the inspector.
  • POST /. One JSON command. Blocks until the timer responds.
  • POST /debug/compile?lang=<rust|cpp|python>. Compiles a CSS source body to a standalone project ZIP.

A request body is one debug event plus optional window_id, wait_for_render, and timeout_secs:

json
{
  "op": "click",
  "selector": ".increment-btn",
  "wait_for_render": true,
  "timeout_secs": 30
}

The response is wrapped in a { "status": "ok" | "error", "request_id": , "data": {...}, "window_state": {...} } envelope. The server pretty-prints application/json with Connection: close, so curl and jq work without ceremony:

bash
curl -s -X POST http://localhost:8765/ \
     -H 'Content-Type: application/json' \
     -d '{"op":"click","selector":"button"}' | jq

The command vocabulary

Each command's op field selects one debug event variant. Categories overlap with the in-browser inspector's panels.

  • Mouse. mouse_move, mouse_down, mouse_up, click, double_click, scroll (programmatic scroll_to on the node under the point), wheel (a hardware wheel notch through the shells' hit-test + scroll-physics path).
  • Keyboard. key_down, key_up, text_input.
  • Window. resize, move, focus, blur, close, dpi_changed.
  • Queries. get_state, get_dom_tree, get_node_hierarchy, get_layout_tree, get_display_list, get_html_string, hit_test, get_logs.
  • DOM mutation. insert_node, delete_node, set_node_text, set_node_classes, set_node_css_override.
  • Scrolling. get_scroll_states, get_scrollable_nodes, scroll_node_by, scroll_node_to, scroll_into_view.
  • Frame control. wait_frame, wait, relayout, redraw.
  • Screenshots. take_screenshot (CPU compositor), take_native_screenshot (current framebuffer).
  • Component and library introspection. get_component_registry, get_libraries, get_library_components, get_function_pointers.
  • E2E. run_e2e_tests.

click accepts whichever of selector, node_id, text, or (x, y) you pass. It resolves to a node, fires the click, and triggers a refresh if your callback returns one. This is the building block every E2E click step uses.

wait_frame is a barrier: the next step runs only after the window has prepared a frame that follows the request (the frame clock is the per-window ContentJournal::frame_seq, bumped once per frame on every backend). After any command that mutates state (click, resize, set_node_text, text_input, …) call wait_frame before reading state back, otherwise queries can race the relayout pass or the virtual-view re-renders queued for the next paint. A scenario step cannot hang on it: after ~2 s without a frame the barrier logs a warning and opens.

A simple driver script

Drive a running app from bash. The Hello World sample's tests/e2e/hello-world.sh is built on the same five primitives:

bash
#!/usr/bin/env bash
set -e
PORT=8765
APP=./target/release/hello-world
AZ_DEBUG=$PORT "$APP" &
APP_PID=$!
trap 'kill $APP_PID 2>/dev/null || true' EXIT

post() { curl -s -X POST "http://127.0.0.1:$PORT/" -d "$1"; }

# 1. Wait for the server to come up
until post '{"op":"get_state"}' >/dev/null 2>&1; do sleep 0.1; done

# 2. Wait for the first frame
post '{"op":"wait_frame"}' >/dev/null

# 3. Click a button by CSS selector
post '{"op":"click","selector":"button"}' | jq -r '.status'

# 4. Read the rendered HTML back
post '{"op":"get_html_string"}' | jq -r '.data.value.html'

# 5. Capture a PNG (base64 data URI)
post '{"op":"take_native_screenshot"}' \
  | jq -r '.data.value' \
  | sed 's|^data:image/png;base64,||' \
  | base64 -d > out.png

This pattern — AZ_DEBUG, wait, drive, query — is the foundation for both ad-hoc debugging and the JSON-described E2E tests in End-to-End Testing.

The in-browser inspector

Navigate to http://localhost:/ in any browser and the server returns the bundled inspector: DOM tree, layout box overlay, computed CSS, scroll-state monitor, log stream, and an E2E test designer. The same POST / endpoints power its panels, so anything you see in the browser can be reproduced from a script.

The inspector is a single HTML/JS bundle compiled into the binary and served brotli-compressed. Disabling it means stripping the AZ_DEBUG codepath in the build. There is no runtime toggle.

Logging and crash handling

By default Azul installs a built-in stderr logger at App::create (the AZ_LOG flag above), so every log-crate message — including the platform-layer traces (App::run entry, the selected display backend and WAYLAND_DISPLAY/DISPLAY on Linux, window creation, each layout pass, font loading) — prints to stderr without any extra setup. This is why an app that previously "just exited with no error" now tells you exactly which step it reached. Set AZ_LOG=trace for the full firehose, AZ_LOG=off to silence it, or install your own logger (which takes precedence). RUST_LOG further filters by target/level once a logger is installed, and AZ_RECORD= mirrors every internal message (including the debug-server categories) to disk. The debug server also keeps its own ring buffer of recent entries; query it with {"op":"get_logs"} to see what fired during the last command.

If App::run returns an error (e.g. no display server could be opened), it is always written to stderr on every platform — on Linux the GUI message box silently no-ops without zenity/kdialog, so stderr is the guaranteed channel.

App::create installs a panic handler that captures and demangles the backtrace, logs the formatted panic at error level (visible in stdout, in RUST_LOG, in AZ_RECORD, and in {"op":"get_logs"}), and optionally opens a native MsgBox summarising the failure for the end user.

When the timer is not running

AZ_DEBUG requires that the application reaches the event loop. If App::run is never called — for example, in a Rust unit test that builds a Dom and asserts its shape — the debug timer is never registered, and a POST / request hangs until timeout_secs elapses (default 30 s). For pure layout assertions, prefer the headless renderer covered in End-to-End Testing or the reftest harness rather than AZ_DEBUG.


Doc Guide En Dom (`doc/guide/en/dom.md`)


slug: dom
title: Document Object Model
language: en
canonical_slug: dom
audience: external
maturity: mature
guide_order: 50
topic_only: false
short_desc: Node types, hierarchy, and CSS scoping
tracked_files:

  • core/src/dom.rs
  • core/src/styled_dom.rs
  • core/src/xml.rs
    last_generated_rev: 7ecd570e4c0c3584e5107e770058c16cb59fa6e7
    generated_at: 2026-05-02T05:53:30Z
    default-search-keys:
  • Dom
  • NodeData
  • NodeType
  • DomVec
  • NodeHierarchyItem
  • AttributeType
  • Css
  • CssPropertyWithConditionsVec
  • SmallAriaInfo
  • AccessibilityInfo
  • CallbackInfo
  • RefAny
  • EventFilter
  • Update

Document Object Model

Azul's DOM differs from a browser DOM in four places:

  1. Hierarchy lives separately from node data. The relationships
    (parent, prev_sibling, next_sibling, last_child) are in one
    array. The content (tag, class, inline CSS, callbacks) is in a
    parallel array. They're indexed by the same node id.
  2. Both arrays are flat Vecs in DOM tree order. Parent before
    children. So a slice data[self..self.last_child + 1] is the
    subtree rooted at self. No pointer-chasing to walk a subtree.
  3. The DOM is frozen after layout() returns. There is no
    insertChild, no setAttribute, no mutation observers. To change
    the tree, the next layout() call returns a new Dom. The
    framework diffs old against new and migrates state across.
  4. CSS is stored in a compact, layout-hot cache. Common enum
    properties (display, position, float, overflow) are
    bit-packed into a single u64 per node. The numbers and the cold
    paint properties live in two more arrays. The point is to make the
    per-node working set small enough that the layout pass stays in L2
    instead of round-tripping to RAM.

The result is a tree like this:

text
hierarchy[0..5]                  data[0..5]
       ┌───────────────┐               ┌──────────────────────┐
   0   │ parent: -     │ <body>        │ NodeData { ... }     │
   1   │ parent: 0     │   <div>       │ NodeData { ... }     │
   2   │ parent: 1     │     <span>    │ NodeData { ... }     │
   3   │ parent: 0     │   <p>         │ NodeData { ... }     │
   4   │ parent: 3     │     "text"    │ NodeData { ... }     │
       └───────────────┘               └──────────────────────┘

Indices into both arrays match. The layout engine traverses by index,
not by pointer, and reads from compact arrays whose memory layout it
controls.

Cache hierarchy

Layout itself isn't algorithmically hard. It's a tree walk plus a lot
of if/else. The expensive part isn't the math; it's pulling each
node's properties out of memory.

A modern CPU has a tiered memory hierarchy. Cycle counts are
approximate but the order of magnitude is right:

  • L1 data cache — 32 to 128 KB per core. ~4 cycles to read.
  • L2 — 256 KB to several MB per core. ~12 cycles.
  • L3 — 4 to 32 MB shared. ~30 to 60 cycles. Doesn't exist on
    embedded targets.
  • Main RAM — gigabytes. ~100 to 300 cycles. A full miss costs
    more than running 100 instructions.

Layout reads the same per-node fields once per relayout pass. If the
working set fits in L2, the second pass is essentially free. If it
spills to RAM, every node fetch stalls the pipeline.

The relevant per-node sizes in the layout hot path:

text
NodeHierarchyItem            32 B    parent + 3 sibling/child indices
StyledNodeState              10 B    :hover / :focus / :active per node
NodeFlags                     4 B    contenteditable, tab index, anonymous
compact-cache tier 1          8 B    display/position/float/etc bit-packed
compact-cache tier 2 (hot)   68 B    width, height, margin, padding, ...
compact-cache tier 2 (cold)  28 B    paint-only properties (color, opacity)
compact-cache tier 2b (text) 24 B    text-related layout

per-node total (hot)        ~150 B
per-node total (warm)       ~170 B   add cold + text tiers
NodeData (cold during layout) 152 B  read once for inline CSS, classes

For 1,000 nodes the layout-hot working set is ~150 KB. That fits in L2
on every desktop chip and most embedded ones. For 10,000 nodes it's
~1.5 MB, still L2 on a modern Apple/Intel core. For 100,000 nodes it's
~15 MB, which is L3 on desktop and main memory on embedded.

The numbers indicate when virtual views, lazy panels, and other ways
to keep the rendered subtree small start to matter. See
Virtual Views.

What's in a node

Each node is split across the two parallel arrays.

NodeHierarchyItem carries four indices into the same array — the
node's parent, its previous and next siblings, and its last
descendant:

rust,ignore
pub struct NodeHierarchyItem {
    pub parent: usize,            // 0 means "no parent"
    pub previous_sibling: usize,
    pub next_sibling: usize,
    pub last_child: usize,        // index of last descendant
}

Because children sit contiguously after their parent in tree order,
data[self_idx ..= last_child] is the whole subtree rooted at
self_idx. No pointer-chasing, no recursion needed to copy a subtree.

NodeData carries everything that defines a single node:

rust,ignore
pub struct NodeData {
    pub node_type: NodeType,                          // HTML tag or leaf (Text/Image/Icon/VirtualView)
    pub callbacks: CoreCallbackDataVec,               // event handlers; empty for ~80% of nodes
    pub style: Css,                                   // inline CSS with implicit :scope, INLINE priority
    pub flags: NodeFlags,                             // tab index, contenteditable, anonymous
    pub accessibility: Option<Box<AccessibilityInfo>>, // ARIA, only on accessible nodes
    pub extra: Option<Box<NodeDataExt>>,              // attributes, dataset, menus, virtual view, ...
}

style: Css is the same struct the cascade uses everywhere else;
inline rules carry their conditions (:hover, :focus, @theme dark,
@os macos) directly. The two Option> fields keep the
common case small — a typical paragraph or div pays nothing for the
accessibility or extras boxes. About 95% of nodes never trigger the
extra allocation.

A function of state

It helps to remember what the browser DOM was originally for. In the
1990s, web pages arrived over slow modems as a stream of HTML, and
the browser had to render while the document was still being
received. document.write injected new nodes mid-parse;
appendChild, removeChild, and the rest of the mutation API let
scripts patch the tree as more bytes arrived. Mutability wasn't a
design goal — it was a constraint of streaming over a 14.4k modem.

When SPAs took over, the streaming use case mostly went away, but the
mutation API stayed. React's contribution was to talk users out of
using it: model the UI as a function of state, render the whole tree
on every change, and let a reconciler diff old against new. Vue,
Solid, Svelte, and Elm all converged on the same shape. The browser's
imperative DOM became an implementation detail the framework hid.

Azul has no streaming parser to support and no legacy mutation API to
preserve, so it makes "UI is a function of state" the rule from the
start. The Dom returned from layout() becomes the framework's
copy:

  1. A callback returns Update::RefreshDom.
  2. The framework re-invokes the layout function.
  3. A fresh Dom is built from the application data.
  4. The framework diffs the new tree against the previous one and
    migrates focus, scroll, dataset, and merge-callback state across
    matched nodes.

There is no handle to the live tree, no insertChild /
setAttribute / mutation observer surface. Removing the mutation API
has two payoffs: half the bugs that show up in any non-trivial UI
come from "this listener saw stale state because something else
mutated the tree first," and a tree the framework owns is far
easier to lay out incrementally than a tree the application can
change at any time.

The reconciliation algorithm — what counts as "matching" old and new
nodes, what migrates, what fires lifecycle events — is documented in
Reconciliation.

State that has to survive a tree rebuild (a video decoder, a GL
texture, the cursor inside a focused input) doesn't live in the tree
shape. It hangs off the node as a dataset. See
Datasets and Merge Callbacks.

Building DOMs

The recursive Dom value

Dom is the form actually constructed in user code:

rust,ignore
pub struct Dom {
    pub root: NodeData,
    pub children: DomVec,
    pub css: CssVec,
    pub estimated_total_children: usize,
}

A Dom is a subtree: a root NodeData, its children, and any
component-level stylesheets attached via .with_component_css(Css).
The framework
flattens the recursive form into the parallel NodeHierarchyItem /
NodeData arrays once, at the start of the cascade. Every builder
method on Dom (with_class, with_callback, with_css) is a
shorthand that delegates to the same method on self.root.

Node constructors

Each HTML element has a Dom::create_<tag>() constructor. Most are
const fn and don't allocate until a child is added:

rust,no_run
use azul::prelude::*;

let _ = Dom::create_div();
let _ = Dom::create_section();
let _ = Dom::create_article();
let _ = Dom::create_main();
let _ = Dom::create_nav();
let _ = Dom::create_header();
let _ = Dom::create_footer();

Text-bearing constructors take a string and wrap a Text child
inside the element:

rust,no_run
use azul::prelude::*;

let _ = Dom::create_h1_with_text("Title");
let _ = Dom::create_h2_with_text("Section");
let _ = Dom::create_p_with_text("A paragraph.");
let _ = Dom::create_span_with_text("inline");
let _ = Dom::create_strong_with_text("important");
let _ = Dom::create_code_with_text("println!()");
let _ = Dom::create_text_do_not_use_without_block_level_wrapper("standalone text node");

Why the scary name? Browsers silently wrap a raw text run in an
anonymous block box whenever one is needed; azul does not. A bare text
node therefore has no box of its own — no rect, no clip, no layout
constraints — and any box-model CSS property, callback, tab_index or
dataset you attach to it is silently inert. This has shipped real bugs
(text escaping its widget, click targets that never fire). Put the text
inside a block-level wrapper that carries the styling instead: use the
create_*_with_text family (create_p_with_text, create_div_with_text,
create_span_with_text, create_h1_with_text, ...) and style the wrapper.
The engine also warns on stderr after layout when it finds a text node
used without a containing block; once you have read and accepted the
warnings, silence them with AZ_SUPPRESS=bare_text.

NodeType (in core/src/dom.rs) lists every variant. The set covers
all HTML elements plus the SVG subset plus four leaf types: Text,
Image, Icon, and VirtualView.

For elements with non-trivial accessibility surface, the primary
constructor takes an a11y struct as a required argument. There's a
matching _no_a11y variant that opts out explicitly. The longer
name on the opt-out is the point: it signals that a11y was skipped
on purpose, and it makes the absence visible during code review —
the soft-force pattern.

rust
use azul::prelude::*;

// Primary form: a11y is part of the call signature.
let save = Dom::create_button("Save", SmallAriaInfo::label("Save document"));

// Explicit opt-out, longer name.
let ok = Dom::create_button_no_a11y("OK".into());

Most interactive elements use the generic SmallAriaInfo (label,
role, description). A few (``, <meter>, <dialog>)
have type-specific structs because their a11y surface needs more
than that. Static, non-interactive elements (div, span, p,
the headings, inline text formatters) don't take a11y info — their
role is implicit from the element type.

See Accessibility for the full list of elements
that follow the soft-force pattern, the type-specific aria structs,
and how the framework translates them into the platform-specific
accessibility trees (UIA, AT-SPI, NSAccessibility).

IDs, classes, attributes

rust,no_run
use azul::prelude::*;

let _ = Dom::create_div()
    .with_id("sidebar".into())
    .with_class("panel".into())
    .with_class("scrollable".into())
    .with_attribute(AttributeType::AriaLabel("notification banner".into()))
    .with_attribute(AttributeType::Lang("en".into()));

IDs and classes aren't separate fields. They're stored as
AttributeType::Id and AttributeType::Class entries in the node's
attribute list. The selector .panel { ... } matches every node
whose attribute list contains Class("panel").

AttributeType (in core/src/dom.rs) is a strongly-typed enum:
Href, Src, Alt, AriaLabel, Required, MaxLength(i32),
ContentEditable(bool), and so on. There's a Custom fallback for
arbitrary name="value" pairs. Attributes aren't inline CSS — they
feed accessibility, attribute selectors like [lang="en"], and
HTML/XML serialization.

Adding children

Three ways to attach children:

rust,no_run
use azul::prelude::*;

// 1. One at a time. Each call grows .children by one.
let a = Dom::create_div()
    .with_child(Dom::create_h2_with_text("Title"))
    .with_child(Dom::create_p_with_text("Body"));

// 2. Replace the child vec wholesale.
let kids: DomVec = vec![Dom::create_span_with_text("x"), Dom::create_span_with_text("y"), Dom::create_span_with_text("z")].into();
let b = Dom::create_div().with_children(kids);

// 3. Collect from an iterator into a parent.
let c: Dom = (0..3).map(|i| Dom::create_li_with_text(format!("Item {}", i))).collect();
// Produces a NodeType::Div containing three <li> children.

with_child calls add_child, which pushes onto the underlying
Vec and updates estimated_total_children. That's amortised O(1)
per call. with_children(DomVec) is one allocation total.

estimated_total_children is maintained by every add_child and
set_children call. The framework reads it to pre-size the flat
arena during conversion. If children is mutated directly, call
fixup_children_estimated() before returning.

Defining a clipping path

Two public mechanisms cover the common cases:

  • with_clip_mask(ImageMask) takes a raster alpha mask. Use it for
    irregular shapes that already exist as image data.
  • with_css("clip-path: ...;") parses the CSS property into the
    node's inline-CSS list. Applied during the cascade.
rust,no_run
use azul::prelude::*;

fn build(mask: ImageMask) -> Dom {
  let raster = Dom::create_image(ImageRef::null_image(0, 0, RawImageFormat::R8, U8VecRef::from(&[][..])))
    .with_clip_mask(mask);

  let css_form = Dom::create_div()
    .with_css("clip-path: circle(40px at 50% 50%);");

  Dom::create_body().with_child(raster).with_child(css_form)
}

A clip-path set on a parent applies to every descendant.

Inline CSS

The primary way to attach CSS is .with_css(...) on the node itself.
The method takes a string, parses it through the same pipeline the
cascade uses elsewhere, and stores the result in
NodeData::style: Css.

rust,no_run
use azul::prelude::*;

let item = Dom::create_div().with_css("
    color: blue;
    font-size: 14px;
    :hover { color: red; }
    @theme dark { color: white; background: #222; }
");

The parsed rules carry their conditions directly: :hover,
:focus, :active, @os, and @theme blocks all live inside the
same Css value. Conditions are re-evaluated per frame, so
@theme dark { ... } flips when the user toggles dark mode without
any re-layout. Inline rules are tagged rule_priority::INLINE so
they win the cascade against author CSS.

After the recent unification, the inline store is a regular Css —
the legacy css_props: CssPropertyWithConditionsVec field is gone.
with_css_props(vec) still works as a compatibility shim that maps
each property to a single-declaration rule at INLINE priority.

Component-level stylesheets

Reusable components ship a parsed stylesheet that travels with the
subtree. Attach it on the component's root with .with_component_css(Css):

rust,no_run
use azul::prelude::*;

let widgets = Css::from_string(".panel { padding: 8px; }".into());
let panel: Dom = Dom::create_div()
    .with_class("panel".into())
    .with_component_css(widgets);

A browser cascades every stylesheet against every node in one global
pass — .panel { ... } in one tab can match a .panel in any iframe
that imports the same stylesheet, and changes there force a global
restyle. Azul's component CSS travels with the subtree. The
framework merges every component-level Css together when it
flattens the tree, but the rules a component ships only have a
chance to match the nodes the component itself owns. Anything
outside the component's subtree is invisible to its selectors. This
is a soft scope (the framework doesn't enforce a Shadow-DOM
boundary), but it follows from the way components label their roots
and avoids the cross-component restyle storms a global cascade
produces. For hard scoping, write selectors that nest under the
component's root class.

User-level theming sits at the outermost layer: the system
@theme dark block, the system:* color keywords, and the optional
end-user ricing file all target the framework-wide hooks. Component
CSS doesn't fight user theming because the two layers target
different selectors. See Components for the
component-pack model and Theming for the full
theming model and the AZ_RICING opt-out.

The cascade runs once, after the LayoutCallback returns.
NodeData::style: Css and Dom::css: CssVec are opaque state
during layout(). The framework collects the rules at the end of
the callback, sorts by (priority, specificity), and walks the tree
once to fill the compact cache. Selector matching, inheritance, and
the compact-cache build all happen there. CSS work inside layout()
is cheap because each call is just a parse and a push.

Inside the layout callback

A layout() callback receives application data and a
LayoutCallbackInfo describing the window. Returning a Dom
finishes the pass; the framework reconciles, lays out, and renders.

rust,no_run
use azul::prelude::*;

struct AppModel {
    user_name: String,
    locale: Locale,
}

extern "C" fn layout(data: &mut RefAny, info: LayoutCallbackInfo) -> StyledDom {
    let model = match data.downcast_ref::<AppModel>() {
        Some(m) => m,
        None => return StyledDom::default(),
    };

    let strings = Strings::for_locale(model.locale);

    // Window-aware layout: switch to a single-column layout below 768px.
    let body = if info.window_width_less_than(768.0) {
        Dom::create_body()
            .with_css("display:flex; flex-direction:column;")
            .with_child(navbar_compact(&model.user_name, &strings))
            .with_child(content_area(&strings))
    } else {
        Dom::create_body()
            .with_css("display:grid; grid-template-columns:240px 1fr;")
            .with_child(sidebar(&model.user_name, &strings))
            .with_child(content_area(&strings))
    };

    body.with_component_css(app_stylesheet())
}

The LayoutCallbackInfo exposes everything needed to make the
returned Dom adapt to the running window. Responsive sizing
(window_width_less_than, window_width_between, window_height_*,
and the raw get_window_width / get_window_height) covers the
per-tree branch cases (a hamburger menu vs a sidebar) — the
per-property cases (@media, @theme) are handled by inline CSS.
The framework re-invokes layout() whenever the window crosses a
breakpoint, the system theme flips, or a route switch fires, so the
width branch is always re-evaluated against the live window. The
callback can read info.relayout_reason() to find out why it was
called — Resize, ThemeChange, RouteChange, RefreshDom, or
Initial — and skip work that doesn't need to repeat (analytics
fetches, locale-pack loading) when the trigger was just a resize.

Other helpers: get_dpi_factor returns 1.0 / 2.0 / etc. for asset
selection; get_route_pattern() / get_route_param(key) for
router-driven trees; get_monitors / get_max_monitor_size for the
screens; get_gl_context() for canvas-backed nodes.

A worked example covering window-size, DPI, theme, route, and
Fluent localization in a single layout pass:

text
/* Detailed source-code truncated for AI context efficiency. */

The callback returns the Dom itself. Handing it back gives the
framework ownership: it runs the cascade once over the whole tree,
reconciles the result against the previous frame, and schedules
layout + paint.

Routing

A multi-page app registers a layout callback per URL pattern on the
AppConfig — the framework picks the right one for the active
route and re-runs it on switch_route:

rust,no_run
use azul::prelude::*;

extern "C" fn layout_home(_: &mut RefAny, _: LayoutCallbackInfo) -> Dom { todo!() }
extern "C" fn layout_user(_: &mut RefAny, info: LayoutCallbackInfo) -> Dom {
    let id = info.get_route_param("id".into());
    Dom::create_h1_with_text(format!("User #{}", id.as_str()).into())
}

fn main() {
    let mut config = AppConfig::create();
    config.add_route("/".into(), layout_home.into());
    config.add_route("/user/:id".into(), layout_user.into());

    let app = App::create(initial_data, config);
    app.run(WindowCreateOptions::new(layout_home));
}

A :name segment captures the path component as a parameter
readable via info.get_route_param("name"). On desktop the route
is in-memory state; on a web build the same routes also map to
HTTP endpoints with history.pushState() integration.

A user callback navigates with CallbackInfo::switch_route —
info.set_route_param(key, value) modifies a single param in place
without changing the active pattern:

rust,ignore
extern "C" fn open_user(data: RefAny, mut info: CallbackInfo) -> Update {
    let id = match data.downcast_ref::<u64>() {
        Some(i) => *i,
        None => return Update::DoNothing,
    };
    let params = vec![StringPair {
        key: "id".into(),
        value: id.to_string().into(),
    }].into();
    info.switch_route("/user/:id".into(), params);
    Update::RefreshDom
}

The framework swaps the active layout callback on the next frame
and reconciles the new tree against the previous one. See
Routing for the full pattern syntax, multi-route
layouts, and the web-vs-desktop differences.

Parsing from XHTML

Dom::create_from_parsed_xml is the public entry point. Given an
Xml value, it returns a Dom ready to return from layout():

rust,no_run
use azul::prelude::*;

let xml_text = "";
let parsed = Xml::from_str(xml_text.into()).unwrap();
let dom: Dom = Dom::create_from_parsed_xml(parsed);

The XML parser walks <html><head><style>...</style></head>...,
parses each <style> block into a Css, and attaches it scoped to the
node it was found inside. The cascade runs on the next layout pass like
any other DOM.

ComponentMap is the registry of XML-defined components. See
Components for how the framework
looks up <card title="..."/> against a registered library.

Parsing from SVG

The same XML pipeline accepts <svg> tags inside the body and turns
them into vector nodes that render alongside the rest of the Dom.
No extra wiring is required: the parser recognises the SVG namespace,
walks the geometry, and stamps an SvgNodeData on each shape. A
clip-mask attribute on an SVG element resolves the same way as a CSS
clip-path: (see Defining a clipping path
above).

rust,no_run
use azul::prelude::*;

let xhtml = r#"
<html>
  <body>
    <svg viewBox="0 0 100 100" width="200" height="200">
      <circle cx="50" cy="50" r="40" fill="#1d4f8b"/>
      <rect x="10" y="10" width="40" height="40" fill="#5dade2"/>
    </svg>
  </body>
</html>
"#;
let parsed = Xml::from_str(xhtml.into()).unwrap();
let dom: Dom = Dom::create_from_parsed_xml(parsed);

The same dom is renderable without a window. Run the binary with
AZ_BACKEND=headless and the framework rasterises into an in-memory
framebuffer instead of opening a window. Combine with
AZ_DEBUG= to drive take_screenshot from a shell script and
get a base64 PNG back. That's the path snapshot tests, PDF export,
and CI machines without a display server use.

For the full SVG geometry model (paths, tessellation, GPU
tessellated nodes), the standalone Svg::from_string parser that
returns a RawImage, and the GPU stroke pipeline, see
SVG. For the headless backend in detail, see
Headless Rendering.

Callbacks

rust,no_run
use azul::prelude::*;

struct Counter { value: i64 }
extern "C" fn on_click(mut data: RefAny, _info: CallbackInfo) -> Update {
    let mut c = match data.downcast_mut::<Counter>() { Some(c) => c, None => return Update::DoNothing };
    c.value += 1;
    Update::RefreshDom
}

fn build(state: RefAny) -> Dom {
  Dom::create_button_no_a11y("+1".into())
    .with_callback(EventFilter::Hover(HoverEventFilter::MouseUp), state, on_click)
}

with_callback(filter, data, callback) attaches a RefAny and a
function pointer. The handler fires when the matching event reaches
the node. The callback returns an Update that tells the framework
whether to re-run layout, re-render, or do nothing.

What the callback can actually do — read the dataset, query the
hit-test, dispatch to siblings, focus another node, schedule a
timer, post a thread message — lives in
Callbacks. Event filtering and propagation order
are in Events and Input.

The framework's reconciler matches new nodes against old ones when a
fresh tree is returned. Cursor position, focus, and dataset state
migrate across the diff for matched nodes. See
Reconciliation.

Datasets

with_dataset(OptionRefAny) attaches arbitrary user data to a node.
Callbacks read it via CallbackInfo::get_dataset. The dataset is the
canonical place for UI-layer state. The cursor inside an input. The
expansion flag on a tree-view row. A marker struct that says "I am the
save button" so a generic callback can dispatch. See
Datasets for the navigation patterns.

For state that must survive a subtree rebuild, pair the dataset with
with_merge_callback. The framework calls it with the old and new
RefAny values during reconciliation. Heavy resources can move from
the old node to the new one. See Merge Callbacks
for a worked FFmpeg example.

Virtual Views

A VirtualView is a node whose contents come from a separate callback
that runs only when the framework needs them. Use it for infinite lists,
lazy panels, and embedded sub-DOMs that own their own scroll math. The
callback receives a VirtualViewCallbackReason (InitialRender,
DomRecreated, BoundsExpanded, EdgeScrolled(_),
ScrollBeyondContent). Use the reason to skip work when the call is
just a parent re-render. See Virtual Views for
the rendered-vs-virtual coordinate model and a virtualised-table
walkthrough.

Debugging

Run the binary with AZ_DEBUG= set and App::create starts
an HTTP debug server on that port. It accepts JSON commands and
returns JSON responses. The inspector sees the same tree the
renderer is about to draw, so a query reflects what's on screen.

bash
AZ_DEBUG=8765 cargo run --bin my_app

# Synchronous queries:
curl -X POST http://localhost:8765/ -d '{"type":"get_state"}'
curl -X POST http://localhost:8765/ -d '{"type":"get_dom_tree"}'
curl -X POST http://localhost:8765/ -d '{"type":"get_node_hierarchy"}'
curl -X POST http://localhost:8765/ -d '{"type":"get_node_css_properties", "selector":".panel"}'
curl -X POST http://localhost:8765/ -d '{"type":"get_layout_tree"}'
curl -X POST http://localhost:8765/ -d '{"type":"get_display_list"}'

# Synthesised events (dispatched into the same callback path real input uses):
curl -X POST http://localhost:8765/ -d '{"type":"click", "selector":".button-primary"}'
curl -X POST http://localhost:8765/ -d '{"type":"text_input", "text":"hello"}'

The synthesised events go through the exact same dispatch path as
real input. A scripted click runs the same hit test, the same
event filters, and the same callback as a user mouse click. That
makes the debug server the basis for end-to-end tests: drive the
app from a shell script or a Python harness, assert on the JSON
responses, and the result is an integration test that exercises the
real layout, the real callbacks, and the real reconciliation pass.
The test pattern, the assertion vocabulary, and the CI recipe are
covered in End-to-End Testing.

For tests that don't need a window (snapshot tests, PDF export, CI
machines without a display server), the same debug API is also
reachable in Headless Rendering, which
runs the full layout and rendering pipeline into a Vec
framebuffer.

The get_node_hierarchy response carries a component field for
each node, allowing navigation back to the component that produced
it. The inspector uses it to draw a Component Tree alongside the DOM
Tree:

json
{
  "index": 17, "node_type": "div", "tag": "card",
  "id": null, "classes": ["card", "card--info"],
  "parent": 12, "children": [18, 19, 20],
  "component": {
    "component_id": "shadcn:card",
    "data_model": { "title": "First", "body": "alpha" }
  },
  "rect": { "x": 16.0, "y": 16.0, "width": 320.0, "height": 88.0 },
  "events": ["MouseUp"], "tab_index": 0
}

Picking a node in the DOM Tree surfaces the component that produced
it, and (if its render function lives in a registered library) the
inspector links back to the source. See
Components for how a library
wires its components into the registry.


Doc Guide En Events (`doc/guide/en/events.md`)


slug: events
title: Events
language: en
canonical_slug: events
audience: external
maturity: mature
guide_order: 60
topic_only: false
short_desc: Callbacks, event filters, and how state triggers relayout
prerequisites: [hello-world]
tracked_files:

  • core/src/events.rs
  • core/src/callbacks.rs
    last_generated_rev: 7ecd570e4c0c3584e5107e770058c16cb59fa6e7
    generated_at: 2026-05-02T05:51:14Z
    default-search-keys:
  • Dom
  • CallbackInfo
  • RefAny
  • Update
  • EventFilter
  • HoverEventFilter
  • FocusEventFilter
  • WindowEventFilter
  • ComponentEventFilter
  • KeyboardState
  • MouseState
  • add_callback
  • stop_propagation
  • prevent_default

Events

Overview

A callback is a function pointer plus a RefAny, registered against an event filter on a DOM node. When an event matches a registered filter, the framework borrows your RefAny, calls the callback, and reads the returned Update to decide whether to re-run the layout.

rust,no_run
use azul::prelude::*;

struct Counter { n: usize }

extern "C" fn on_click(_: RefAny, _: CallbackInfo) -> Update { Update::DoNothing }

fn build(mut data: RefAny) -> Dom {
    Dom::create_div()
        .with_callback(EventFilter::Hover(HoverEventFilter::MouseUp), data, on_click)
}

Adding a callback

The primitive is Dom::add_callback (and the builder form Dom::with_callback). Both take three arguments: the filter, a RefAny (your data), and a function pointer with the signature extern "C" fn(RefAny, CallbackInfo) -> Update.

rust,no_run
use azul::prelude::*;

struct State;

extern "C" fn handler(_: RefAny, _: CallbackInfo) -> Update { 
    Update::DoNothing 
}

fn main() {
    let data: RefAny = RefAny::new(State);
    let node = Dom::create_div()
        .with_callback(EventFilter::Hover(HoverEventFilter::MouseUp), data.clone(), handler)
        .with_callback(EventFilter::Focus(FocusEventFilter::FocusReceived), data, handler);   
}

The Update enum has three variants:

  • Update::DoNothing: no re-layout. The current frame stays on screen.
  • Update::RefreshDom: re-run the layout callback for this window.
  • Update::RefreshDomAllWindows: re-run the layout callback for every window.

Returning RefreshDom from a non-mutating handler is wasteful. Returning DoNothing from a handler that mutated the model leaves the screen out of sync. Match the return value to what you actually changed.

Event filters

EventFilter is the enum you pass to add_callback. It has five variants, each scoped to a different fire condition:

  • EventFilter::Hover(HoverEventFilter): fires when the cursor is over this node.
  • EventFilter::Focus(FocusEventFilter): fires when this node has keyboard focus. The node needs a tab index.
  • EventFilter::Window(WindowEventFilter): fires for events anywhere in the focused window.
  • EventFilter::Component(ComponentEventFilter): fires when the node is mounted, unmounted, resized, or updated.
  • EventFilter::Application(ApplicationEventFilter): fires when a device or monitor is plugged in or removed.

The same physical event can match multiple filters. A left mouse-button release while the cursor is inside a button matches Hover(MouseUp), Hover(LeftMouseUp), and, if the button has focus, Focus(MouseUp) and Focus(LeftMouseUp). All registered handlers fire.

HoverEventFilter

The element the mouse is currently over. Common variants:

rust,ignore
HoverEventFilter::MouseOver        // mouse moves while over the node
HoverEventFilter::MouseDown        // any button pressed
HoverEventFilter::LeftMouseDown    // left only (also Right/Middle)
HoverEventFilter::MouseUp          // any button released
HoverEventFilter::LeftMouseUp      // left only; use this for "click"
HoverEventFilter::DoubleClick      // double-click detected
HoverEventFilter::MouseEnter       // cursor crossed into the node
HoverEventFilter::MouseLeave       // cursor crossed out of the node
HoverEventFilter::Scroll           // wheel / trackpad over the node
HoverEventFilter::DroppedFile      // a file was dropped on the node

For "click", use LeftMouseUp rather than MouseDown. It matches the W3C activation pattern: press, move out, release does not click.

FocusEventFilter

Same vocabulary as HoverEventFilter, but the node must currently hold keyboard focus. Set a tab index (or focus programmatically) for the filter to fire:

rust,no_run
use azul::prelude::*;

let input = Dom::create_div()
    .with_tab_index(TabIndex::Auto);

TabIndex::Auto makes the node focusable in source order. TabIndex::NoKeyboardFocus makes it focusable programmatically but skips it in tab navigation. TabIndex::OverrideInParent(n) pins the node at slot n within its parent's tab order.

Common focus filters:

rust,ignore
FocusEventFilter::FocusReceived    // node became the focused element
FocusEventFilter::FocusLost        // focus moved elsewhere
FocusEventFilter::TextInput        // a unicode character arrived
FocusEventFilter::VirtualKeyDown   // a non-text key (arrow, F1, ...)
FocusEventFilter::VirtualKeyUp

FocusEventFilter::TextInput carries the produced character, respecting the OS keyboard layout (German ä, IME composition, etc.). VirtualKeyDown carries a layout-independent key code. Use it for shortcuts and games.

WindowEventFilter

Fires on any node registered with this filter regardless of where the cursor or focus is, as long as the window has OS focus:

rust,ignore
WindowEventFilter::Resized           // window dimensions changed
WindowEventFilter::Moved             // window position changed
WindowEventFilter::CloseRequested    // user clicked the close button
WindowEventFilter::ThemeChanged      // OS switched light/dark mode
WindowEventFilter::DpiChanged        // window moved to monitor with different DPI
WindowEventFilter::VirtualKeyDown    // any keypress in the window

Use Window for global shortcuts (Ctrl+S, Esc) where the source node doesn't matter.

ComponentEventFilter

Lifecycle events fire after a new DOM is reconciled against the previous frame:

rust,ignore
ComponentEventFilter::AfterMount     // node appeared this frame
ComponentEventFilter::BeforeUnmount  // node will disappear next frame
ComponentEventFilter::NodeResized    // layout bounds of this node changed
ComponentEventFilter::Updated        // a keyed node's content changed

Reconciliation matches nodes across frames by stable key (Dom::with_id("id")) first, then by content hash. Keyed nodes track identity across reorders, so Updated only fires when keyed content actually changes.

ApplicationEventFilter

Fires for global hardware changes. Useful only on the root DOM node:

rust,ignore
ApplicationEventFilter::DeviceConnected
ApplicationEventFilter::DeviceDisconnected
ApplicationEventFilter::MonitorConnected
ApplicationEventFilter::MonitorDisconnected

Event propagation

For each event the framework computes the path from root to target and calls handlers in three phases (W3C DOM Level 2):

  1. Capture: root to target. Rare; only nodes with capture-phase handlers are visited.
  2. Target: handlers on the target node itself.
  3. Bubble: target to root.

A click on a deeply nested span walks back up through its ancestors, firing Hover(MouseUp) handlers on every node along the way that registered one. To stop the walk, call one of the propagation methods on CallbackInfo:

rust,no_run
use azul::prelude::*;

extern "C" 
fn handler(_: RefAny, mut info: CallbackInfo) -> Update {
    // remaining handlers on the same node still run
    info.stop_propagation();
    // nothing else runs
    info.stop_immediate_propagation();
    Update::DoNothing
}

stop_propagation matches W3C event.stopPropagation(). stop_immediate_propagation matches event.stopImmediatePropagation().

Default actions

Some events have built-in behaviour that runs after every callback returns, unless a callback prevented it:

  • Tab: move focus to next focusable element.
  • Shift+Tab: move focus to previous focusable element.
  • Enter / Space on focused button: synthetic click on the button.
  • Escape: clear focus or close modal.
  • Ctrl+A in text input: select all.
  • Arrow keys in scroll container: scroll by line.

To suppress the default action from a callback:

rust,no_run
use azul::prelude::*;

extern "C" fn on_keydown(_: RefAny, mut info: CallbackInfo) -> Update {
    info.prevent_default();
    Update::DoNothing
}

prevent_default corresponds to W3C event.preventDefault(). The W3C semantics: the default action doesn't fire, but other callbacks for the same event still run. Combine with stop_propagation to also halt the propagation walk.

Reading input state

CallbackInfo exposes the current input snapshot:

rust,ignore
let kbd: KeyboardState = info.get_current_keyboard_state();
let mouse: MouseState  = info.get_current_mouse_state();
let win:  WindowFlags  = info.get_current_window_flags();
let state: &FullWindowState = info.get_current_window_state();

KeyboardState carries pressed_virtual_keycodes (a vec of currently held keys) and current_virtual_keycode (the most recent). MouseState carries cursor_position, left_down, right_down, middle_down.

Read state inside the callback to check modifier keys for shortcuts:

rust,no_run
use azul::prelude::*;

struct App;

extern "C" fn on_key(mut data: RefAny, mut info: CallbackInfo) -> Update {
    let kbd = info.get_current_keyboard_state();
    let pressed = kbd.pressed_virtual_keycodes.as_slice();
    let ctrl = pressed.iter().any(|k| matches!(k, VirtualKeyCode::LControl | VirtualKeyCode::RControl));
    let s = pressed.iter().any(|k| *k == VirtualKeyCode::S);
    if ctrl && s {
        info.prevent_default();
        // ... save ...
        return Update::RefreshDom;
    }
    Update::DoNothing
}

Common patterns

Click:

rust,no_run
use azul::prelude::*;

struct S;

extern "C" fn click(_: RefAny, _: CallbackInfo) -> Update { 
    Update::DoNothing 
}

fn main() {
    let data: RefAny = RefAny::new(S);
    Dom::create_div().with_callback(
        EventFilter::Hover(HoverEventFilter::LeftMouseUp),
        data,
        click,
    );
}

Hover effect (use CSS :hover for visual change; use a callback only when you need to mutate state):

rust,no_run
use azul::prelude::*;

struct S;

extern "C" 
fn enter(_: RefAny, _: CallbackInfo) -> Update { 
    Update::DoNothing 
}

extern "C" 
fn leave(_: RefAny, _: CallbackInfo) -> Update { 
    Update::DoNothing 
}

fn main() {
    let data: RefAny = RefAny::new(S);
    Dom::create_div()
        .with_callback(EventFilter::Hover(HoverEventFilter::MouseEnter), data.clone(), enter)
        .with_callback(EventFilter::Hover(HoverEventFilter::MouseLeave), data,        leave);   
}

Window-level keyboard shortcut:

rust,no_run
use azul::prelude::*;

struct S;

extern "C" fn on_key(_: RefAny, _: CallbackInfo) -> Update { 
    Update::DoNothing 
}

fn main() {
    let data: RefAny = RefAny::new(S);
    Dom::create_body().with_callback(
        EventFilter::Window(WindowEventFilter::VirtualKeyDown),
        data,
        on_key,
    );
}

Tab order: call .with_tab_index(TabIndex::Auto) to make a node focusable. Tab and Shift+Tab move through nodes in DOM order. Use TabIndex::NoKeyboardFocus to make a node focusable programmatically but skip it in tab navigation.

Potential Problems

  • Callback never fires. Check the filter scope. Hover(LeftMouseUp) only fires when the cursor is over the node at the moment of release. If the user pressed inside, dragged out, and released outside, no click event fires on either node.
  • Focus(...) never fires. The node has no tab index. Add .with_tab_index(TabIndex::Auto) so the node can receive focus.
  • Counter doesn't update. The callback returned Update::DoNothing. Return Update::RefreshDom after mutating the model.
  • Default action still happens after prevent_default. Verify the call is on CallbackInfo, not on a stale copy. The change is applied after the callback returns. Calling it twice is harmless.

Doc Guide En Hello World (`doc/guide/en/hello-world.md`)


slug: hello-world
title: Hello World
language: en
canonical_slug: hello-world
audience: external
maturity: mature
guide_order: 10
topic_only: false
short_desc: Window setup, state management, DOM creation, handling mouse clicks for a "counter" app
prerequisites: []
tracked_files:

  • api.json
  • core/src/callbacks.rs
  • core/src/lib.rs
  • dll/src/lib.rs
    last_generated_rev: 2660b0c45c9ea401ad6777a203f468755167e62e
    generated_at: 2026-09-16T00:00:00Z
    default-search-keys:
  • App
  • Dom
  • Css

Hello World

Introduction

Welcome to the Azul framework. In this guide you will learn how to write a simple 50-line program that produces a window with a counter and a button to increase said counter - showcasing how data models, click callbacks, and the installation and running of Azul applications work.

azul-render screenshot=hello-world width=400 height=240 subtitle="azul window"
<body style="background-color: #efefef; margin: 0;">
  <p style="font-size: 50px; margin: 0;">5</p>
  <button>Increase counter</button>
</body>

Because each programming language is different, there's no such thing as "one hello world guide" as every language has differences in setup, installation methods, and code style.

Each guide is self-contained, you do not need to read the others. Each page walks you through the same five-step path:

  1. Installation or linking the Azul library.
  2. Defining a data model.
  3. Writing a layout callback that returns a Dom.
  4. Attaching a click callback that mutates the model.
  5. Building, running, notes for common pitfalls.

Supported languages

Pick the "Hello World" / Setup guide for your language:


Doc Guide En Images (`doc/guide/en/images.md`)


slug: images
title: Images
language: en
canonical_slug: images
audience: external
maturity: wip
guide_order: 90
topic_only: false
short_desc: Loading raster images and CSS backgrounds
prerequisites: [dom]
tracked_files:

  • core/src/svg.rs
  • core/src/svg_path_parser.rs
    last_generated_rev: 7ecd570e4c0c3584e5107e770058c16cb59fa6e7
    generated_at: 2026-05-02T12:00:00Z
    default-search-keys:
  • ImageRef
  • RawImage
  • RawImageData
  • RawImageFormat
  • Texture
  • RenderImageCallbackInfo
  • Dom
  • encode_png
  • encode_jpeg
  • get_size

Images

Introduction

WIP. APIs around ImageRef, SVG, and GPU textures are stable enough to use, but some helpers (e.g. PNG/JPEG decoding) live behind feature flags and may move between crates.

Azul has three sources of pixel content in a DOM tree:

  • Raster image. Backed by a CPU pixel buffer (RawImageFormat). PNG, JPEG, BMP, raw bytes.
  • Vector / SVG. Backed by SvgMultiPolygon plus tessellation. Icons, charts, diagrams.
  • GPU texture. Backed by a Texture produced by callback. OpenGL scenes, custom shaders.

All three end up wrapped in a single ImageRef and inserted into the tree via Dom::create_image(image_ref). The framework hashes the handle for caching and uploads the backing data to the renderer the first time it's shown.

ImageRef

ImageRef is a reference-counted handle to decoded image data. It's Send + Sync. Cloning bumps a refcount; the underlying buffer is freed when the last clone drops. Construct one via:

  • ImageRef::null_image(w, h, format, tag). A placeholder of known size.
  • ImageRef::create_rawimage(raw). Wraps a CPU pixel buffer.
  • ImageRef::gl_texture(texture) (also new_gltexture). Wraps an existing GL texture.
  • ImageRef::callback(cb, data). Defers rendering until layout knows the size.

Inspect with is_null_image, is_raw_image, is_gl_texture, is_callback, is_invalid, get_size, get_hash.

Raster images

Raw pixel data flows through RawImage. The pixel layout is RawImageFormat: R8, RG8, RGB8, RGBA8, R16, RG16, RGB16, RGBA16, BGR8, BGRA8, RGBF32, RGBAF32. Pick the one that matches the source bytes; the renderer converts to its internal format on upload.

rust,no_run
use azul::prelude::*;

fn load_pixels() -> Vec<u8> { vec![] }

let bytes: Vec<u8> = load_pixels();
let raw = RawImage {
    pixels: RawImageData::U8(bytes.into()),
    width: 256,
    height: 256,
    premultiplied_alpha: true,
    data_format: RawImageFormat::RGBA8,
    tag: Vec::new().into(),
};
let image_ref = ImageRef::create_rawimage(raw).expect("invalid pixel data");
let dom = Dom::create_image(image_ref);

RawImageData is one of U8, U16, F32. Decoded image dimensions are reported by ImageRef::get_size(). The size is fixed once the buffer is created. To resize at render time, wrap the image in a styled element and let CSS scale the box (set width / height via Dom::with_css or Dom::with_css_property).

A null_image keeps a slot in the cache without uploading data. It's useful as a fallback when an asynchronous loader hasn't finished yet.

RawImage also exposes encoders (encode_png, encode_jpeg, encode_bmp, encode_gif, encode_pnm, encode_tga, encode_tiff) and two decoders. RawImage::decode_image_bytes_any is the synchronous, pure-compute decoder that works on every target. RawImage::decode_image_bytes is the request form: it resumes a callback with an ImageDecodeResult and, in the browser, hands the work to the hardware decoder - the faster choice for large images. Decoded pixels may differ slightly per backend (premultiplication, ICC handling).

SVG

Vector graphics go through tessellation: every closed path is converted to a triangle list, then rendered on the CPU or uploaded to the GPU. Build paths in memory with SvgPath::create and combine them via SvgMultiPolygon::create. Then call SvgMultiPolygon::tessellate_fill or tessellate_stroke to produce a TessellatedSvgNode.

See SVG for the full geometry model, stroking options, and how to combine multiple polygons into one draw call.

GPU textures and custom drawing

ImageRef::callback(...) defers image production until the layout pass knows the box dimensions. The callback receives a RenderImageCallbackInfo with the available GL context and the laid-out bounds, and returns an ImageRef (typically wrapping a fresh Texture):

rust,no_run
use azul::prelude::*;

extern "C" 
fn render(_data: RefAny, mut info: RenderImageCallbackInfo) -> ImageRef {
    let size = info.get_bounds().get_physical_size();
    // allocate a texture, draw into it, return ImageRef::gl_texture(tex)
    ImageRef::null_image(size.width as usize, size.height as usize,
                        RawImageFormat::RGBA8, U8VecRef::from(&[][..]))
}

fn build_dom(state: RefAny) -> Dom {
    Dom::create_image(ImageRef::callback(
        RenderImageCallback::create(render).to_core(),
        state,
    ))
}

An image callback is not GL-specific: it may return a raw CPU image just as well as a texture, and it is how a paint canvas, a chart or a video frame is drawn. See Image Callbacks for the general shape - repainting one node with update_image_callback, caching by revision, and animating - and Canvas and GL Textures for the texture allocation, drawing and FXAA flow used by the opengl example.

Sizing and aspect ratio

The renderer treats an ImageRef like an `` tag: it expands to fill its CSS box. To preserve aspect ratio:

  • width: 100px; height: 100px. Stretched to a square.
  • width: 100px (height auto). Scaled to keep the source aspect ratio.
  • flex-grow: 1 (square parent). Fills the parent.

Image content is stretched without filtering hints. For icon-quality output you usually want the source resolution to match the on-screen pixel size.

Updating an image

ImageRef is intern-keyed: passing a clone in two consecutive Doms reuses the same upload. To swap pixels you build a new ImageRef. Drop the old clone if you want the GPU memory freed.

From a callback, change_node_image(dom_id, node_id, image, update_type) swaps a node's image in place - UpdateImageType::Content for the image itself, UpdateImageType::Background for its CSS background - with no layout() pass. add_image_to_cache(name, image) puts an image under a name any later layout pass can reference, which is how a decoder thread hands its result to the UI.

For animation, use a render-image callback (ImageRef::callback) and a timer to repaint each frame. See Image Callbacks.

Image masks

ImageMask clips drawn content to an image-defined alpha mask. It carries image: ImageRef, rect: LogicalRect (element-local), and repeat: bool, which tiles the mask across the node. Coverage outside the mask is zero, so a mask smaller than its node hides the rest of it. Apply one with Dom::with_clip_mask, and change it from a callback with change_node_image_mask - masks are a paint-time effect, so changing one never reflows. A node carrying SVG path geometry is clipped to that path instead.

See Clip Masks for both kinds, including animating a mask and using an image callback as one.


Doc Guide En Layout (`doc/guide/en/layout.md`)


slug: layout
title: Layout
language: en
canonical_slug: layout
audience: external
maturity: mature
guide_order: 80
topic_only: false
short_desc: Overview of the layout solver
prerequisites: [styling]
tracked_files:

  • css/src/props/layout/mod.rs
  • css/src/props/layout/display.rs
  • css/src/props/layout/dimensions.rs
  • css/src/props/layout/spacing.rs
  • css/src/props/layout/wrapping.rs
  • css/src/props/layout/fragmentation.rs
  • css/src/props/layout/column.rs
  • css/src/props/layout/table.rs
  • css/src/props/layout/shape.rs
  • css/src/props/layout/flow.rs
  • layout/src/lib.rs
    last_generated_rev: 7ecd570e4c0c3584e5107e770058c16cb59fa6e7
    generated_at: 2026-05-02T05:49:28Z
    default-search-keys:
  • Dom
  • Css
  • CssProperty
  • StyledDom
  • LayoutCallback
  • LayoutCallbackInfo

Layout

Overview

Azul lays out content according to the W3C browser specifications, so this page will just give a main overview of the differences of Azul vs Google Chrome, which mainly revolves around APIs for measuring / retrieving layout. The layout modes (box, inline, flex, grid, table, ...) are exactly modeled after the W3C specification, any deviation from that can be reported as a bug.

If you already know how CSS values work, then you can skip all sub-pages referenced here and only read this page. The sub-pages only contain examples and descriptions of the various main layout modes, which are:

Please also see the way better documentation on HTML layout modes:

Azul does support (with tests) even advanced writing modes such as vertical text in various directions, as well as of course RTL text (which affects properties such as start and end). Currently, there is however no support of <ruby> text.

Adding stylesheets

In the previous documentation, and even in the hello-world example, you already came across
the single way stylesheets are added to the Dom: setting .with_css("..."). However, it's
very important to see what that does internally:

rust
impl Dom {

    pub fn with_css(mut self, style: &str) -> Self {
        self.set_css(style);
        self
    }
    
    pub fn set_css(&mut self, style: &str) {
        self.add_component_css(Css::parse_inline(style));
    }

    pub fn add_component_css(&mut self, css: Css) {
        self.css.push(css);
    }
}

Azul does not immediately cascade the CSS and applies it to the nodes, instead it
"stores" the CSS in an order and then applies / cascades it later in one go. Another
difference is that there is no <style> node like in HTML, as the CSS stylesheet can
only cascade within one subtree, not affect anything outside of it.

This is crucial for components, so you don't have to hack together anything like
BEM or generate unique IDs for your styling blocks,
so that they don't collide with other styling rules.

Another difference is that, as seen above, the "inline style" can take a full CSS
stylesheet verbatim, including @media rules, :hover, :focus and :active rules,
which browsers famously cannot do:

rust
let dom = Dom::create_div()
    .with_css("
        @media (max-width: 600px) {
          * {
            background-color: lightblue;
          }
        }
    ");

So, here this * applies only to this div and will style the background
color as lightblue if the width of the window. You can also use
@container to make the rule based on the size of the div instead.

A third difference is that you can "stack" entire stylesheets on top of each
other within a single subtree: the rule is that the last-applied stylesheet wins:

rust
// red background gets stacked on top, replaces blue background
let dom = Dom::create_div()
    .with_css("background:blue")
    .with_css("background:red");

Notably, this stacking happens before cascading happens, which allows designers to
override the default widget stylesheet configuration before the properties of the
widget component are applied to the subtree.

Measuring layout

A big issue when layouting items in a VirtualView callback (see Virtual Views)
is that you need to know or at least estimage the size of a DOM tree before actually rendering it in a
layout, and you need to "readjust" the scrolling position once you know the actual size more accurately.
For example, you might want to have a file browser view with 100.000 images (which all have different aspect ratios),
but only materialize 100 images. But to determine the scroll positions, you need to calculate the scroll position,
which is usually only know after the layout is finished.

To solve this problem, you can use the ``

Performance and relayouting

As a general rule of thumb, parsing CSS stylesheets is very fast as it does not use a lot of allocations.
You can


Doc Guide En Styling (`doc/guide/en/styling.md`)


slug: styling
title: Styling with CSS
language: en
canonical_slug: styling
audience: external
maturity: mature
guide_order: 70
topic_only: false
short_desc: Stylesheets, selectors, and the cascade
prerequisites: [dom]
tracked_files:

  • css/src/lib.rs
  • css/src/css.rs
  • css/src/props/basic/angle.rs
  • css/src/props/basic/animation.rs
  • css/src/props/basic/color.rs
  • css/src/props/basic/direction.rs
  • css/src/props/basic/error.rs
  • css/src/props/basic/font.rs
  • css/src/props/basic/geometry.rs
  • css/src/props/basic/length.rs
  • css/src/props/basic/mod.rs
  • css/src/props/basic/parse.rs
  • css/src/props/basic/pixel.rs
  • css/src/props/basic/time.rs
  • css/src/props/style/azul_exclusion.rs
  • css/src/props/style/background.rs
  • css/src/props/style/border.rs
  • css/src/props/style/border_radius.rs
  • css/src/props/style/box_shadow.rs
  • css/src/props/style/content.rs
  • css/src/props/style/effects.rs
  • css/src/props/style/filter.rs
  • css/src/props/style/lists.rs
  • css/src/props/style/mod.rs
  • css/src/props/style/scrollbar.rs
  • css/src/props/style/selection.rs
  • css/src/props/style/text.rs
  • css/src/props/style/transform.rs
  • css/src/system.rs
    last_generated_rev: 7ecd570e4c0c3584e5107e770058c16cb59fa6e7
    generated_at: 2026-05-02T12:00:00Z
    default-search-keys:
  • Css
  • CssProperty
  • CssPropertyValue
  • DynamicCssProperty
  • StyledDom
  • Dom
  • AttributeMatchOp
  • with_css
  • with_css_property
  • with_class
  • with_id

Styling with CSS

Overview

A Css is a parsed stylesheet. You build one from a string, attach it to a
Dom subtree with .with_component_css(css), and the cascade applies it on the next
layout pass. The dialect is a strict subset of standard CSS: tag, class, id,
and attribute selectors; descendant, child, sibling, and pseudo-class
combinators; the @media, @os, @theme, and @lang at-rules; and
shorthand properties for the common cases.

rust,no_run
use azul::prelude::*;

let css = Css::from_string("
    body { font-family: sans-serif; padding: 20px; }
    .panel { background: #f0f0f0; border: 1px solid #ccc; padding: 12px; }
    .panel:hover { background: #e8e8e8; }
".into());

let _ = Dom::create_body()
    .with_child(Dom::create_div().with_class("panel".into()))
    .with_component_css(css);
azul-render screenshot=styling-panel width=400 height=160 subtitle="a class-styled panel with hover-ready rules"
<html>
<head><style>
body { font-family: sans-serif; padding: 20px; background: white; }
.panel { background: #f0f0f0; border: 1px solid #ccc; padding: 12px; border-radius: 4px; }
</style></head>
<body><div class="panel">A panel with class-based styling</div></body>
</html>

Two ways to attach styles

There is one CSS model, and it works like the web's @scope:
every stylesheet is scoped to the subtree of the node it is attached to.

  • Dom::with_css(s) — the main entry point. The CSS string is parsed
    into a Css struct and pushed onto that node's .css vec. The
    cascade selector-matches it against the node's subtree during the
    single per-frame pass. A string with selectors (body { … } .btn { … })
    matches elements in the subtree; a bare-declaration string (color: red)
    parses to * { color: red }, so it applies to the whole subtree — and
    on a leaf node (subtree length 1) that is exactly an "inline style."
    There is no separate node-only path: inline is a one-node @scope.
  • Dom::with_css_property(p) — programmatic single-property override
    for when you already hold a typed CssProperty and don't want to
    round-trip through string parsing. Highest priority short of !important.
rust,no_run
use azul::prelude::*;

// 1. "Inline" — a bare-declaration string on a leaf node (subtree len 1)
let _ = Dom::create_div()
    .with_css("color: blue; padding: 4px; :hover { color: red; }");

// 2. A stylesheet scoped to a subtree — selectors match within it
let _ = Dom::create_body()
    .with_css(".btn { background: #1976d2; color: white; }")
    .with_child(
        Dom::create_button("Save", SmallAriaInfo::label("Save"))
            .with_class("btn".into())
    );

Note: with_css(&str) parses a string; with_component_css(Css) takes
a stylesheet you already parsed. Both are builder forms and both push onto
the subtree's .css vec, which the cascade reads; add_component_css is
the mutating twin for when you are not chaining.

Matching and inheritance happen once after layout() returns, in a single
cascade pass. The DOM page
walks through the timing.

For a static stylesheet shared across many nodes, build the Css once at
app startup and pass it through style(). Multiple style() calls stack.
A later one overrides earlier ones at equal specificity.

Selectors

The selector language matches W3C Selectors Level 3 minus a few rarely-used
pseudo-classes:

  • Universal: * matches every node.
  • Type: div, button, h1 match nodes whose tag matches.
  • Class: .panel matches nodes with with_class("panel").
  • Id: #sidebar matches nodes with with_id("sidebar").
  • Attribute: [lang], [lang="en"], [lang^="en"] test attribute
    presence and match. The operators are =, ~=, |=, ^=, $=, *=
    (see AttributeMatchOp).
  • Descendant: nav a matches an <a> anywhere under <nav>.
  • Child: nav > a matches a direct <a> child of <nav>.
  • Adjacent sibling: h2 + p matches a `` immediately after <h2>.
  • General sibling: h2 ~ p matches any `` after <h2> at the
    same level.
  • Pseudo-class: :hover, :focus, :nth-child(2n+1) are
    runtime-evaluated state.

Pseudo-classes

State pseudo-classes evaluate on every frame:

  • :hover: pointer is over the element.
  • :active: pointer is pressed and over the element.
  • :focus: element has keyboard focus.
  • :first, :last: first or last child of its parent.
  • :nth-child(n), :nth-child(2n+1), :nth-child(odd), :nth-child(even): positional.
  • :lang(en): system locale matches the BCP 47 prefix.
  • :backdrop: the containing window is unfocused. Use it for inactive-window styling.
  • :dragging, :drag-over: drag-and-drop states.

These run without re-parsing the stylesheet.

At-rules

Conditional rule blocks. The condition is evaluated per frame, so changing
the system theme or rotating a window adapts without re-cascading.

rust,no_run
use azul::prelude::*;

let _ = Dom::create_div().with_css("
    color: black;
    @theme dark { color: white; }
    @os linux { font-family: 'Cantarell'; }
    @os windows { font-family: 'Segoe UI'; }
    @os macos { font-family: '.SF NS'; }
    @media (max-width: 600px) { font-size: 14px; }
");
  • @os <name> / @os(<name>) matches the host platform. Names:
    windows, macos, linux, android, ios, apple (macOS+iOS),
    web, any.
  • @os(<family>:<de>) narrows to a Linux desktop environment. DEs:
    gnome, kde, xfce, unity, cinnamon, mate. Example:
    @os(linux:gnome) { ... }.
  • @os(<family> <op> <version>) narrows to an OS version. Operators:
    >=, <=, =. Examples: @os(windows >= win-11),
    @os(macos = sonoma).
  • @os(<family>:<de> <op> <version>) combines DE with a version.
    Example: @os(linux:gnome > 40).
  • @theme <variant> matches the system theme. Variants: dark, light,
    plus any custom string.
  • @media (orientation: ...) accepts portrait or landscape.
  • @media (min-width: Npx) and friends match numeric viewport ranges.
  • @media (prefers-reduced-motion) is the accessibility query for motion.
  • @media (prefers-contrast) is the accessibility query for contrast.
  • @container enables container queries by width, height, or name.
  • @lang() matches the system language by prefix.

Conditions nest. An @os linux block can contain a :hover block, and
both conditions have to hold for the rule to apply.

See System Themes for how the system populates these
values from OS settings.

The cascade and specificity

When more than one rule sets the same property, the cascade picks one. The
rules, in order:

  1. Higher specificity wins.
  2. Equal specificity. The later rule wins.
  3. style() calls stack. A later style() is "later" than an earlier one.
  4. with_css (inline) outranks any stylesheet for that node.

Specificity is the W3C tuple (ids, classes+pseudo+attrs, types, total).
Call Css::sort_by_specificity() once after parsing if you need
deterministic order. The parser doesn't sort by default. The framework
runs the sort during cascade.

Property values: the keyword set

Every typed property is wrapped in CssPropertyValue<T>:

rust,ignore
pub enum CssPropertyValue<T> {
    Auto,
    None,
    Initial,
    Inherit,
    Revert,
    Unset,
    Exact(T),
}

Most properties accept the CSS-wide keywords. inherit walks to the parent's
resolved value. initial resets to the property's spec default. unset
behaves as inherit for inheritable properties and initial otherwise.
revert returns to the user-agent default. The parser preserves the
keyword and the cascade picks an explicit value at the latest moment.

Inheritable properties

Some properties propagate from parent to child by default; others don't.
Inheritability is fixed by the property. The inheritable set follows CSS
conventions:

  • Text: color, font-family, font-size, font-weight, line-height,
    text-align, letter-spacing, word-spacing.
  • Cursor: cursor.
  • Visibility: visibility.
  • Custom: hyphenation-language.

Layout properties (width, padding, flex-grow, ...) and most visual
properties (background, border, ...) don't inherit. Write inherit
explicitly if you want one to.

Dynamic properties (var(...))

A dynamic declaration is a CSS value swappable from Rust per frame.
Syntax in CSS: var(--my_id, <default>). It compiles to
DynamicCssProperty:

rust,ignore
pub struct DynamicCssProperty {
    pub dynamic_id: AzString,
    pub default_value: CssProperty,
}

Use them when you want to change a single value (an accent color, a
spacing unit) without re-parsing the stylesheet. The override path lives
on Dom::with_css_property.

system: keywords

Anywhere a colour or font is expected, system:<name> resolves at cascade
time against the running OS and theme:

rust,no_run
use azul::prelude::*;

let _ = Dom::create_div().with_css("
    background: system:control;
    color: system:control-text;
    border: 1px solid system:separator;
    font-family: system:body;
    @theme dark { background: system:control; }
");

The lookup re-evaluates per frame, so a theme switch (light to dark)
updates without re-parsing the stylesheet. The available names
(system:control, system:accent, system:body, system:monospace,
...) are catalogued in System Themes. They compose
with @theme and @os the same way any other property would.

Parsing CSS

Css::from_string returns the parsed stylesheet:

rust,no_run
use azul::prelude::*;

let css = Css::from_string("
    color: rebeccapurple;
".into());

The parser is feature-gated behind parser (always enabled in the
default build).

Where styles meet the DOM

The cascade runs once per layout pass, after your LayoutCallback returns.
Inputs, in priority order (low to high):

  1. The user-agent stylesheet sets HTML defaults (h1 font sizes,
    `` padding, <a> color, ...).
  2. Each Css attached to a subtree via Dom::style(...), in style()
    push order.
  3. Inline with_css rules on each node.
  4. Programmatic with_css_property overrides (highest priority short of
    !important).

Internally the framework collects every CSS attachment from the recursive
Dom tree, merges the stylesheets in push order, and runs the cascade in
a single sweep. The output is a StyledDom: a flat, indexed view of the
cascaded properties. Subsequent frames only re-cascade the nodes whose
inputs actually changed.

The reason this matters even at the styling layer: every CSS string you
parse via with_css or Css::from_string is "free" in the sense that it
is one parse and one push onto a list. Selector matching and inheritance
happen once after you return.

See The DOM
for the per-frame walkthrough, and Layout for how the
cascaded properties feed the formatting algorithms.

Sub-pages cover the catalogue of properties, the platform integration,
and the icon and text-styling primitives:


Doc Guide En Widgets (`doc/guide/en/widgets.md`)


slug: widgets
title: Built-in Widgets
language: en
canonical_slug: widgets
audience: external
maturity: wip
guide_order: 110
topic_only: false
short_desc: Built-in widgets and how to write your own
prerequisites: [styling, events, events/text-input]
tracked_files:

  • layout/src/widgets/button.rs
  • layout/src/widgets/check_box.rs
  • layout/src/widgets/color_input.rs
  • layout/src/widgets/drop_down.rs
  • layout/src/widgets/file_input.rs
  • layout/src/widgets/frame.rs
  • layout/src/widgets/label.rs
  • layout/src/widgets/mod.rs
  • layout/src/widgets/progressbar.rs
  • layout/src/widgets/titlebar.rs
    last_generated_rev: 7ecd570e4c0c3584e5107e770058c16cb59fa6e7
    generated_at: 2026-05-02T05:54:43Z
    default-search-keys:
  • Dom
  • Button
  • TextInput
  • NumberInput
  • CheckBox
  • DropDown
  • FileInput
  • ColorInput
  • ProgressBar
  • Frame
  • TabHeader
  • TreeView
  • ListView
  • Ribbon

Built-in Widgets

Overview

WIP. Most leaf widgets (Button, CheckBox, ColorInput, DropDown, FileInput, ProgressBar, Frame, Titlebar) are usable today. Container widgets with richer state (see Structural Widgets) have known gaps that are called out per widget.

A widget is a value with a dom() method that returns a Dom subtree. Widgets
carry per-platform default styles, store callbacks in their own state, and
convert into a Dom exactly once. Because the output is a plain Dom, you can
mix widgets and hand-rolled markup freely.

rust,no_run
use azul::prelude::*;

let dom = Dom::create_div()
    .with_child(Button::create("OK".into()).dom());

This page covers the leaf widgets. For text and numeric input, and the larger
composite widgets, see:

Common shape

Every widget follows the same builder pattern.

  • Construct with Widget::create(...) (or new).
  • Set callbacks with with_on_*(data, fn) or set_on_*(...).
  • Override styles with with_*_style(css) or set_*_style(...).
  • Convert to a DOM with widget.dom().

Callbacks are extern "C" fn(RefAny, CallbackInfo, ...) -> Update. The RefAny
argument is the value you passed to with_on_* and is downcast back to your
application state inside the callback.

Button

Button renders as a button node with a label child. ButtonType selects one
of eight variants for color and contrast.

rust,ignore
pub enum ButtonType {
    Default, Primary, Secondary, Success,
    Danger,  Warning, Info,      Link,
}
rust,no_run
use azul::prelude::*;

extern "C" fn on_click(_data: RefAny, _info: CallbackInfo) -> Update {
    Update::DoNothing
}

let dom = Button::create("Save".into())
    .with_button_type(ButtonType::Primary)
    .with_on_click(RefAny::new(()), on_click)
    .dom();

The button is focusable. Set Button.image to put an icon next to the label.

CheckBox

CheckBox is a small box that toggles its filled state on click. The toggle
callback receives the post-click CheckBoxState.

rust,no_run
use azul::prelude::*;

extern "C" fn on_toggle(_d: RefAny, _i: CallbackInfo, _s: CheckBoxState) -> Update {
    Update::RefreshDom
}

let dom = CheckBox::create(true)
    .with_on_toggle(RefAny::new(()), on_toggle)
    .dom();

ColorInput

ColorInput is a colored swatch that fires a callback when clicked. Azul does
not ship a built-in color-picker dialog. Open one yourself in the callback (see
File Dialogs for ColorPickerDialog).

rust,no_run
use azul::prelude::*;

extern "C" fn on_change(_d: RefAny, _i: CallbackInfo, _s: ColorInputState) -> Update {
    Update::DoNothing
}

let dom = ColorInput::create(ColorU { r: 255, g: 128, b: 0, a: 255 })
    .with_on_value_change(RefAny::new(()), on_change)
    .dom();

FileInput

FileInput renders as a button labelled either the placeholder text or the
basename of the currently selected path. Like ColorInput, it does not show a
native file dialog itself. Your callback owns that.

rust,no_run
use azul::prelude::*;

extern "C" fn on_path(_d: RefAny, _i: CallbackInfo, _s: FileInputState) -> Update {
    Update::RefreshDom
}

let dom = FileInput::create(None.into())
    .with_default_text("Open project...".into())
    .with_on_path_change(RefAny::new(()), on_path)
    .dom();

DropDown shows the currently selected choice plus a dropdown indicator.
Receiving focus opens a native menu populated from choices. Selecting an item
invokes the callback with the chosen index.

rust,no_run
use azul::prelude::*;

extern "C" fn on_pick(_d: RefAny, _i: CallbackInfo, _idx: usize) -> Update {
    Update::RefreshDom
}

let choices = StringVec::from_vec(vec!["Red".into(), "Green".into(), "Blue".into()]);
let dom = DropDown::create(choices)
    .with_on_choice_change(RefAny::new(()), on_pick)
    .dom();

The trigger is focusable. Tabbing to it and pressing Space opens the menu just
like a click.

ProgressBar

ProgressBar shows a horizontal bar filled to a percentage. The completion is
clamped to [0, 100]. Supply your own bar_background and
container_background for custom palettes.

rust,no_run
use azul::prelude::*;

let dom = ProgressBar::create(42.5).dom();

set_height overrides the bar height. The rendered bar scales with its
container.

dom() mounts the bar as a VirtualView node carrying a private dataset,
which is what makes the live update API work: from any callback,
ProgressBar::update_progress(callback_info, node_id, percent) moves the bar
without a relayout - the framework re-invokes only this node's
VirtualView callback and repaints just the bar. Resolve node_id by
minting a UUID marker (Uuid::short()) during layout and stamping it on the
widget - markers are excluded from node equality, so the fresh string never
churns the DOM diff:

rust,no_run
use azul::prelude::*;

// In your layout(): mint, stamp, and keep the string in your state.
let marker = Uuid::short();
let bar = ProgressBar::create(0.0)
    .dom()
    .with_marker(Some(marker.clone()).into());

// In any callback (a timer tick, a network writeback, a pen-pressure event):
extern "C" fn on_tick(mut data: RefAny, mut info: CallbackInfo) -> Update {
    let marker = data.downcast_ref::<MyApp>().unwrap().meter_marker.clone();
    if let Some(node) = info.get_node_id_by_marker(marker).into_option() {
        ProgressBar::update_progress(info, node, 63.0);
    }
    Update::DoNothing // the queued VirtualView re-render repaints the bar
}

This is the reference implementation of the inter-widget fast path - the
pattern, when to use it, and when to prefer a plain RefreshDom are covered
in the architecture chapter.
The heavy path stays valid: store the percentage in your data model, return
Update::RefreshDom, and the rebuilt DOM carries the new value like any
other widget.

Frame

Frame is a titled border container, analogous to an HTML fieldset or a Win32
group box: a thin border with the title text inset into the top edge.

rust,no_run
use azul::prelude::*;

let dom = Frame::create(
    "Network".into(),
    Dom::create_div(),
).dom();

Titlebar

Titlebar renders a custom window chrome bar. Use Titlebar::dom for a
title-only bar (the OS still draws the close, minimize, and maximize buttons).
Use Titlebar::domWithButtons(...) for a windowless mode where the app draws
its own controls.

Pair the buttoned form with WindowDecorations::None (see
Windowing) to draw the entire title bar yourself.

Styling

Every widget exposes its inline default styles as a public field you can replace
before calling dom(). For one-off styling, prefer adding CSS to the produced
Dom via Dom::with_css_property or Dom::with_css.

rust,no_run
use azul::prelude::*;

let dom = Button::create("Save".into()).dom();

Doc Src Spec Exclude (`doc/src/spec/exclude.txt`)

Spec paragraphs excluded from claude-exec processing.

Format: feature:hash # CATEGORY: reason

Grouped prompts have multiple hashes; each hash is listed separately.

Categories:

FLEX_GRID - Flex/grid behavior handled by Taffy, not our solver

MISMATCHED - Paragraph matched to wrong feature via keyword overlap

NON_NORMATIVE - Notes, examples, editorial content with no impl requirement

SECURITY - Security/privacy advisories, not layout requirements

HISTORICAL - Legacy/quirks mode, CSS1 backward compat

To override: use --exclude-file with claude-exec

=== FLEX_GRID (34) ===

block-formatting-context:21ec9a # FLEX_GRID: flex container BFC behavior (Taffy)
block-formatting-context:440282 # FLEX_GRID: flex container BFC behavior (Taffy)
block-formatting-context:4bbd14 # FLEX_GRID: flex container BFC behavior (Taffy)
containing-block:bc85ca # FLEX_GRID: flex/grid containing block rules (Taffy)
containing-block:e78fdd # FLEX_GRID: flex/grid containing block rules (Taffy)
containing-block:f1e34a # FLEX_GRID: flex/grid containing block rules (Taffy)
containing-block:1bd3aa # FLEX_GRID: flex/grid containing block rules (Taffy)
display-property:1dcaab # FLEX_GRID: flex item display fixup (Taffy)
display-property:34c358 # FLEX_GRID: flex item display fixup (Taffy)
display-property:3dc2b2 # FLEX_GRID: flex item display fixup (Taffy)
display-property:400367 # FLEX_GRID: grid item display behavior (Taffy)
display-property:53ec28 # FLEX_GRID: flex item anonymous boxes (Taffy)
display-property:6d1a35 # FLEX_GRID: flex item display fixup (Taffy)
display-property:73e12c # FLEX_GRID: flex item display fixup (Taffy)
display-property:8b1c37 # FLEX_GRID: flex/grid item blockification (Taffy)
display-property:9f2f10 # FLEX_GRID: flex/grid item display fixup (Taffy)
display-property:acc3a8 # FLEX_GRID: flex/grid item display fixup (Taffy)
display-property:c3e8e3 # FLEX_GRID: flex/grid item display fixup (Taffy)
display-property:de75ba # FLEX_GRID: grid template area behavior (Taffy)
display-property:f24a3a # FLEX_GRID: flex container BFC behavior (Taffy)
floats:0ee40b # FLEX_GRID: floats in flex/grid containers (Taffy)
floats:e48b0f # FLEX_GRID: floats in flex/grid containers (Taffy)
height-calculation:34f03e # FLEX_GRID: flex/grid height calculation (Taffy)
height-calculation:fae9a1 # FLEX_GRID: flex/grid height calculation (Taffy)
intrinsic-sizing:47a5f2 # FLEX_GRID: flex/grid intrinsic sizing (Taffy)
intrinsic-sizing:c2f5fd # FLEX_GRID: flex/grid intrinsic sizing (Taffy)
margin-collapsing:78db70 # FLEX_GRID: margins in flex containers (Taffy)
min-max-sizing:09aca7 # FLEX_GRID: flex/grid min-max sizing (Taffy)
overflow:a25539 # FLEX_GRID: flex/grid overflow behavior (Taffy)
positioning:fd4b6f # FLEX_GRID: flex/grid static position (Taffy)
positioning:05212c # FLEX_GRID: flex/grid static position (Taffy)
table-layout:f66d5c # FLEX_GRID: grid/flex table interaction (Taffy)
width-calculation:49c825 # FLEX_GRID: flex/grid width calculation (Taffy)
width-calculation:bf2cb4 # FLEX_GRID: flex/grid width calculation (Taffy)

=== HISTORICAL (2) ===

box-model:dc65ce # HISTORICAL: CSS1 backward compat only
display-property:c8a95a # HISTORICAL: Legacy CSS2 quirks mode

=== METADATA (100) ===

block-formatting-context:22b37a # METADATA: Spec title/status/abstract
block-formatting-context:2d3d36 # METADATA: W3C conformance boilerplate
block-formatting-context:43dcca # METADATA: W3C copyright/legal
block-formatting-context:481b77 # METADATA: Editorial note
block-formatting-context:4dfe42 # METADATA: W3C copyright/legal
block-formatting-context:573a23 # METADATA: Spec changelog/revision
block-formatting-context:6a2b3a # METADATA: Spec metadata
block-formatting-context:77dd66 # METADATA: W3C conformance boilerplate
block-formatting-context:84ee16 # METADATA: Spec metadata
block-formatting-context:97bec1 # METADATA: Spec metadata
block-formatting-context:a817c8 # METADATA: Spec changelog/revision
block-formatting-context:a98713 # METADATA: Spec metadata
block-formatting-context:dc6656 # METADATA: W3C copyright/legal
block-formatting-context:e5c726 # METADATA: Spec title/status/abstract
block-formatting-context:b66a16 # METADATA: Spec title/status/abstract
block-formatting-context:afca25 # METADATA: Spec title/status/abstract
block-formatting-context:f7f612 # METADATA: Spec title/status/abstract
block-formatting-context:c8334a # METADATA: Spec title/status/abstract
block-formatting-context:d3744a # METADATA: Spec title/status/abstract
block-formatting-context:f92733 # METADATA: Spec title/status/abstract
box-model:206c07 # METADATA: Property index/JSON metadata
box-model:65e6c4 # METADATA: Spec metadata
box-model:b0e499 # METADATA: Editorial note
box-model:b8fd5b # METADATA: Spec metadata
box-model:4f423b # METADATA: Spec metadata
box-sizing:8a61b3 # METADATA: Spec metadata
containing-block:a7d527 # METADATA: Editorial note
containing-block:c44cd2 # METADATA: Spec changelog/revision
containing-block:75d635 # METADATA: Spec changelog/revision
containing-block:631e47 # METADATA: Spec changelog/revision
display-contents:6cc68c # METADATA: Spec title/status/abstract
display-contents:b6c26d # METADATA: Spec title/status/abstract
display-contents:cedb19 # METADATA: W3C copyright/legal
display-contents:d3be9a # METADATA: W3C conformance boilerplate
display-contents:ecd256 # METADATA: Spec metadata
display-property:297dde # METADATA: W3C copyright/legal
display-property:4cd7b8 # METADATA: Spec changelog/revision
display-property:d4ff4c # METADATA: Spec changelog/revision
display-property:232f73 # METADATA: Spec changelog/revision
display-property:522d08 # METADATA: Spec changelog/revision
display-property:c87e00 # METADATA: Spec changelog/revision
display-property:525355 # METADATA: W3C conformance boilerplate
display-property:590df0 # METADATA: Spec changelog/revision
display-property:29a047 # METADATA: Spec changelog/revision
display-property:98a6b8 # METADATA: Spec changelog/revision
display-property:8540ce # METADATA: Spec changelog/revision
display-property:a57217 # METADATA: Spec changelog/revision
display-property:757b3a # METADATA: Spec changelog/revision
display-property:ea5e83 # METADATA: Spec changelog/revision
display-property:b8272b # METADATA: Editorial note
display-property:c03a6b # METADATA: Spec metadata
display-property:c8598c # METADATA: W3C copyright/legal
display-property:d39fca # METADATA: W3C copyright/legal
display-property:d526c5 # METADATA: Spec title/status/abstract
display-property:da0df4 # METADATA: Editorial note
display-property:30a935 # METADATA: Editorial note
display-property:da3fc7 # METADATA: Editorial note
display-property:dc2132 # METADATA: Spec metadata
display-property:262f86 # METADATA: Spec metadata
display-property:e9a142 # METADATA: Spec title/status/abstract
display-property:f95610 # METADATA: Editorial note
display-property:fb134e # METADATA: Spec changelog/revision
display-property:9be88b # METADATA: Spec changelog/revision
display-property:ff9d60 # METADATA: W3C copyright/legal
display-property:ffec75 # METADATA: Spec changelog/revision
floats:213ea4 # METADATA: Spec changelog/revision
font-metrics:349ca3 # METADATA: Editorial note
font-metrics:790ce4 # METADATA: Editorial note
font-metrics:e530fe # METADATA: Editorial note
font-metrics:85ff7f # METADATA: Spec title/status/abstract
font-metrics:5ee4dc # METADATA: Spec title/status/abstract
font-metrics:bbe814 # METADATA: Editorial note
font-metrics:c55f9b # METADATA: JS tooling/boilerplate
font-metrics:dc0632 # METADATA: JS tooling/boilerplate
height-calculation:e7f6fb # METADATA: Spec changelog/revision
inline-formatting-context:101e51 # METADATA: Spec title/status/abstract
inline-formatting-context:17b92e # METADATA: Spec metadata
inline-formatting-context:b88638 # METADATA: Property index/JSON metadata
inline-formatting-context:c2e884 # METADATA: Spec metadata
inline-formatting-context:97af40 # METADATA: Spec metadata
inline-formatting-context:c8d8d9 # METADATA: Spec metadata
intrinsic-sizing:3ba351 # METADATA: Editorial note
line-breaking:155a96 # METADATA: JS tooling/boilerplate
line-breaking:c232b4 # METADATA: JS tooling/boilerplate
line-breaking:42df94 # METADATA: Spec title/status/abstract
line-breaking:5b0ee9 # METADATA: W3C copyright/legal
line-breaking:8980d0 # METADATA: W3C copyright/legal
line-breaking:565a68 # METADATA: W3C copyright/legal
line-breaking:de6065 # METADATA: Module scope/interaction
line-breaking:52ec28 # METADATA: Module scope/interaction
line-height:513681 # METADATA: Spec metadata
line-height:cc03df # METADATA: Spec metadata
line-height:e2253a # METADATA: Spec metadata
line-height:0078fa # METADATA: Spec metadata
line-height:09b54e # METADATA: Spec metadata
min-max-sizing:2f66a6 # METADATA: Spec title/status/abstract
min-max-sizing:567ee8 # METADATA: Spec title/status/abstract
overflow:1418bb # METADATA: Spec metadata
overflow:17d06b # METADATA: JS tooling/boilerplate
overflow:2376e4 # METADATA: W3C conformance boilerplate
overflow:2ffaa0 # METADATA: Editorial note
overflow:46027a # METADATA: Spec title/status/abstract
overflow:55ac3d # METADATA: Spec title/status/abstract
overflow:7e8036 # METADATA: Property index/JSON metadata
overflow:546eac # METADATA: Property index/JSON metadata
overflow:d1f602 # METADATA: Property index/JSON metadata
overflow:87489c # METADATA: Spec title/status/abstract
overflow:7ee3a3 # METADATA: Spec title/status/abstract
overflow:a76069 # METADATA: JS tooling/boilerplate
overflow:ab9999 # METADATA: Spec metadata
overflow:ba7484 # METADATA: Property index/JSON metadata
overflow:bd4b14 # METADATA: Spec feedback/filing
overflow:c1443f # METADATA: Spec feedback/filing
overflow:b5ac54 # METADATA: Spec feedback/filing
overflow:c8b3bd # METADATA: Spec title/status/abstract
overflow:da5dc5 # METADATA: Spec changelog/revision
overflow:c141d5 # METADATA: Spec changelog/revision
overflow:b731b1 # METADATA: Spec changelog/revision
overflow:ed23be # METADATA: Spec title/status/abstract
overflow:ee85d0 # METADATA: JS tooling/boilerplate
overflow:fd38ce # METADATA: JS tooling/boilerplate
overflow:da4bbc # METADATA: JS tooling/boilerplate
overflow:f41f10 # METADATA: Spec changelog/revision
overflow:fe1c1b # METADATA: Spec changelog/revision
overflow:a4c82a # METADATA: Spec changelog/revision
positioning:1e5c69 # METADATA: Spec changelog/revision
positioning:3ea25e # METADATA: Editorial note
positioning:460084 # METADATA: W3C conformance boilerplate
positioning:516137 # METADATA: Editorial note
positioning:523819 # METADATA: Spec metadata
positioning:b177ef # METADATA: W3C copyright/legal
positioning:b59128 # METADATA: Spec title/status/abstract
positioning:b9f3f2 # METADATA: W3C conformance boilerplate
positioning:e2d849 # METADATA: JS tooling/boilerplate
positioning:fbf11b # METADATA: Spec changelog/revision
positioning:63b3ff # METADATA: Spec changelog/revision
table-layout:277a05 # METADATA: W3C conformance boilerplate
table-layout:71e2a2 # METADATA: Spec title/status/abstract
text-alignment-spacing:27d8b5 # METADATA: Spec metadata
white-space-processing:c83dbd # METADATA: Property index/JSON metadata
white-space-processing:fef250 # METADATA: Property index/JSON metadata
white-space-processing:53e715 # METADATA: Property index/JSON metadata
width-calculation:313de5 # METADATA: Spec title/status/abstract
width-calculation:7b049f # METADATA: W3C copyright/legal
writing-modes:57b561 # METADATA: Module scope/interaction
writing-modes:5e1fcb # METADATA: Module scope/interaction
writing-modes:b1765b # METADATA: Module scope/interaction
writing-modes:c33b74 # METADATA: Module scope/interaction
writing-modes:607c36 # METADATA: Spec metadata
writing-modes:7db4b9 # METADATA: Spec title/status/abstract
writing-modes:cd3200 # METADATA: Module scope/interaction

=== MISMATCHED (56) ===

block-formatting-context:4b6625 # MISMATCHED: keyword matched wrong feature
block-formatting-context:655266 # MISMATCHED: keyword matched wrong feature
block-formatting-context:749dad # MISMATCHED: keyword matched wrong feature
block-formatting-context:7d7dda # MISMATCHED: keyword matched wrong feature
block-formatting-context:9d8db6 # MISMATCHED: keyword matched wrong feature
block-formatting-context:c17409 # MISMATCHED: SVG/MathML specific
block-formatting-context:f772ae # MISMATCHED: keyword matched wrong feature
box-model:7b9bf2 # MISMATCHED: text/font shaping, not layout
box-model:7fc84b # MISMATCHED: keyword matched wrong feature
box-model:b94cbd # MISMATCHED: text/font shaping, not layout
box-model:c6699f # MISMATCHED: text/font shaping, not layout
box-model:fd2b5e # MISMATCHED: text/font shaping, not layout
containing-block:03528c # MISMATCHED: keyword matched wrong feature
containing-block:0afddd # MISMATCHED: keyword matched wrong feature
containing-block:d63ca7 # MISMATCHED: keyword matched wrong feature
containing-block:dbef58 # MISMATCHED: keyword matched wrong feature
display-contents:20a28e # MISMATCHED: text/font shaping, not layout
display-contents:2f80e6 # MISMATCHED: SVG/MathML specific
display-contents:18e3f2 # MISMATCHED: SVG/MathML specific
display-contents:852a59 # MISMATCHED: SVG/MathML specific
display-contents:884e96 # MISMATCHED: keyword matched wrong feature
display-contents:bc936f # MISMATCHED: keyword matched wrong feature
display-contents:c03741 # MISMATCHED: SVG/MathML specific
display-contents:2b08f1 # MISMATCHED: SVG/MathML specific
display-contents:4a524e # MISMATCHED: SVG/MathML specific
display-contents:c8f6e5 # MISMATCHED: SVG/MathML specific
display-contents:19efc9 # MISMATCHED: SVG/MathML specific
display-contents:d1716b # MISMATCHED: keyword matched wrong feature
display-contents:e202d5 # MISMATCHED: SVG/MathML specific
display-contents:6bbdf4 # MISMATCHED: SVG/MathML specific
display-contents:514064 # MISMATCHED: SVG/MathML specific
display-contents:e539bb # MISMATCHED: SVG/MathML specific
display-property:2a8d62 # MISMATCHED: SVG/MathML specific
display-property:447f4e # MISMATCHED: SVG/MathML specific
display-property:a469a6 # MISMATCHED: SVG/MathML specific
display-property:37553c # MISMATCHED: text/font shaping, not layout
display-property:57a8ef # MISMATCHED: keyword matched wrong feature
display-property:626c86 # MISMATCHED: text/font shaping, not layout
display-property:15292f # MISMATCHED: text/font shaping, not layout
display-property:8e018d # MISMATCHED: text/font shaping, not layout
display-property:7a70a5 # MISMATCHED: keyword matched wrong feature
display-property:98fa8e # MISMATCHED: SVG/MathML specific
display-property:1f71ad # MISMATCHED: SVG/MathML specific
display-property:89dd7b # MISMATCHED: SVG/MathML specific
display-property:d67739 # MISMATCHED: keyword matched wrong feature
display-property:d88ebf # MISMATCHED: text/font shaping, not layout
font-metrics:f2cd75 # MISMATCHED: text/font shaping, not layout
font-metrics:e61649 # MISMATCHED: text/font shaping, not layout
font-metrics:95152b # MISMATCHED: text/font shaping, not layout
height-calculation:39155f # MISMATCHED: text/font shaping, not layout
height-calculation:ab6302 # MISMATCHED: text/font shaping, not layout
inline-formatting-context:0f6442 # MISMATCHED: SVG/MathML specific
inline-formatting-context:2b8c9c # MISMATCHED: keyword matched wrong feature
intrinsic-sizing:be7fe7 # MISMATCHED: keyword matched wrong feature
line-breaking:6684fe # MISMATCHED: text/font shaping, not layout
margin-collapsing:8a9215 # MISMATCHED: keyword matched wrong feature
margin-collapsing:a71cf1 # MISMATCHED: keyword matched wrong feature
min-max-sizing:5d4628 # MISMATCHED: keyword matched wrong feature
overflow:139949 # MISMATCHED: text/font shaping, not layout
overflow:342f47 # MISMATCHED: text/font shaping, not layout
overflow:38af76 # MISMATCHED: text/font shaping, not layout
overflow:fe839e # MISMATCHED: text/font shaping, not layout
overflow:3be607 # MISMATCHED: keyword matched wrong feature
overflow:a2907f # MISMATCHED: keyword matched wrong feature
positioning:128150 # MISMATCHED: keyword matched wrong feature
positioning:5c81da # MISMATCHED: keyword matched wrong feature
positioning:68112a # MISMATCHED: keyword matched wrong feature
positioning:6c7e53 # MISMATCHED: keyword matched wrong feature
positioning:6028da # MISMATCHED: keyword matched wrong feature
positioning:66782a # MISMATCHED: keyword matched wrong feature
text-alignment-spacing:19088c # MISMATCHED: text/font shaping, not layout
text-alignment-spacing:3ac3e8 # MISMATCHED: text/font shaping, not layout
white-space-processing:9d95c2 # MISMATCHED: keyword matched wrong feature
width-calculation:3ce756 # MISMATCHED: text/font shaping, not layout
width-calculation:cc5750 # MISMATCHED: keyword matched wrong feature
writing-modes:313575 # MISMATCHED: SVG/MathML specific

=== NON_NORMATIVE (81) ===

block-formatting-context:9f7b94 # NON_NORMATIVE: introductory prose only
block-formatting-context:ea9baf # NON_NORMATIVE: non-normative example
box-model:24e046 # NON_NORMATIVE: non-normative note
box-model:3aabdb # NON_NORMATIVE: security advisory for authors
box-model:4bcbe0 # NON_NORMATIVE: non-normative note
box-model:d44624 # NON_NORMATIVE: non-normative note
box-model:63e37a # NON_NORMATIVE: non-normative note
box-model:6b0fa0 # NON_NORMATIVE: non-normative note
box-model:82ba77 # NON_NORMATIVE: non-normative note
box-model:b8fd5b # NON_NORMATIVE: non-normative note
box-model:c0df26 # NON_NORMATIVE: introductory prose only
box-model:562dc0 # NON_NORMATIVE: introductory prose only
box-model:c44f5b # NON_NORMATIVE: non-normative example
box-model:dd2b04 # NON_NORMATIVE: non-normative note
containing-block:0649dc # NON_NORMATIVE: non-normative note
containing-block:12d549 # NON_NORMATIVE: non-normative note
containing-block:1d38ca # NON_NORMATIVE: non-normative example
containing-block:c583c4 # NON_NORMATIVE: non-normative note
containing-block:d2ba39 # NON_NORMATIVE: non-normative note
display-contents:3c7066 # NON_NORMATIVE: non-normative note
display-contents:3f4884 # NON_NORMATIVE: non-normative note
display-contents:4f9129 # NON_NORMATIVE: non-normative note
display-contents:61a7f1 # NON_NORMATIVE: non-normative note
display-contents:7ddfd5 # NON_NORMATIVE: introductory prose only
display-property:1f8179 # NON_NORMATIVE: non-normative note
display-property:593b40 # NON_NORMATIVE: non-normative note
display-property:98e0fb # NON_NORMATIVE: print-specific UA hint
display-property:a15ae9 # NON_NORMATIVE: browser interop note
display-property:b0f0c9 # NON_NORMATIVE: non-normative note
display-property:c147b0 # NON_NORMATIVE: non-normative note
display-property:cfe31d # NON_NORMATIVE: introductory prose only
display-property:e00f3c # NON_NORMATIVE: non-normative note
display-property:f23538 # NON_NORMATIVE: non-normative note
floats:03a43a # NON_NORMATIVE: non-normative note
floats:ca04c6 # NON_NORMATIVE: non-normative note
font-metrics:015c6b # NON_NORMATIVE: non-normative note
font-metrics:1d6ca9 # NON_NORMATIVE: non-normative note
font-metrics:992ff9 # NON_NORMATIVE: non-normative note
font-metrics:42624f # NON_NORMATIVE: non-normative note
font-metrics:34679d # NON_NORMATIVE: non-normative note
font-metrics:637595 # NON_NORMATIVE: non-normative note
font-metrics:bfd441 # NON_NORMATIVE: non-normative note
height-calculation:789703 # NON_NORMATIVE: non-normative note
height-calculation:ce2835 # NON_NORMATIVE: non-normative note
height-calculation:fe2f59 # NON_NORMATIVE: non-normative note
inline-block:3714ad # NON_NORMATIVE: non-normative note
inline-block:e4ccad # NON_NORMATIVE: non-normative example
inline-block:7aa0aa # NON_NORMATIVE: non-normative example
inline-formatting-context:0f6442 # NON_NORMATIVE: non-normative example
inline-formatting-context:670e50 # NON_NORMATIVE: non-normative example
inline-formatting-context:fe563c # NON_NORMATIVE: non-normative example
inline-formatting-context:32a9c3 # NON_NORMATIVE: print-specific UA hint
inline-formatting-context:7d8ab8 # NON_NORMATIVE: print-specific UA hint
inline-formatting-context:af591a # NON_NORMATIVE: print-specific UA hint
inline-formatting-context:ca71c0 # NON_NORMATIVE: non-normative note
line-breaking:341597 # NON_NORMATIVE: non-normative note
line-breaking:f428b0 # NON_NORMATIVE: non-normative note
margin-collapsing:4d5fa2 # NON_NORMATIVE: non-normative note
margin-collapsing:6eaf22 # NON_NORMATIVE: non-normative note
margin-collapsing:6706c1 # NON_NORMATIVE: non-normative note
overflow:1e301f # NON_NORMATIVE: print-specific UA hint
overflow:1f17dd # NON_NORMATIVE: non-normative note
overflow:1f388d # NON_NORMATIVE: non-normative example
overflow:25bb60 # NON_NORMATIVE: non-normative example
overflow:27d544 # NON_NORMATIVE: introductory prose only
overflow:49031c # NON_NORMATIVE: non-normative note
overflow:512709 # NON_NORMATIVE: non-normative example
overflow:629ba6 # NON_NORMATIVE: non-normative note
overflow:69b3d6 # NON_NORMATIVE: non-normative example
overflow:76f35a # NON_NORMATIVE: non-normative note
overflow:7d9ef0 # NON_NORMATIVE: print-specific UA hint
overflow:94d80c # NON_NORMATIVE: non-normative note
overflow:ba15dd # NON_NORMATIVE: non-normative note
overflow:bf3831 # NON_NORMATIVE: non-normative example
overflow:c1d791 # NON_NORMATIVE: non-normative note
overflow:c22866 # NON_NORMATIVE: non-normative example
overflow:cba678 # NON_NORMATIVE: non-normative example
overflow:e559ba # NON_NORMATIVE: print-specific UA hint
positioning:272bf3 # NON_NORMATIVE: non-normative note
positioning:448a3a # NON_NORMATIVE: non-normative example
positioning:55971e # NON_NORMATIVE: non-normative note
positioning:602a86 # NON_NORMATIVE: non-normative note
positioning:63dc56 # NON_NORMATIVE: non-normative note
positioning:930891 # NON_NORMATIVE: non-normative example
positioning:b780d3 # NON_NORMATIVE: non-normative example
positioning:9df3f9 # NON_NORMATIVE: non-normative example
positioning:9fae9b # NON_NORMATIVE: non-normative note
positioning:d39822 # NON_NORMATIVE: non-normative note
positioning:d58083 # NON_NORMATIVE: non-normative note
positioning:f4892f # NON_NORMATIVE: print-specific UA hint
positioning:f60387 # NON_NORMATIVE: non-normative note
positioning:ffb81c # NON_NORMATIVE: non-normative note
table-layout:6ccaf3 # NON_NORMATIVE: non-normative example
text-alignment-spacing:8924be # NON_NORMATIVE: non-normative example
text-alignment-spacing:cfd493 # NON_NORMATIVE: non-normative note
text-alignment-spacing:206406 # NON_NORMATIVE: non-normative note

=== SECURITY (11) ===

block-formatting-context:86d802 # SECURITY: security/privacy considerations
box-model:ef2d56 # SECURITY: security/privacy considerations
box-model:f08ad2 # SECURITY: security/privacy considerations
height-calculation:22ca67 # SECURITY: security/privacy considerations
overflow:729676 # SECURITY: security/privacy considerations
overflow:e2d58f # SECURITY: security/privacy considerations
overflow:f2f342 # SECURITY: security/privacy considerations
positioning:556aeb # SECURITY: security/privacy advisory
positioning:a7225d # SECURITY: security/privacy advisory
positioning:ba4d0b # SECURITY: security/privacy advisory
positioning:fb8117 # SECURITY: security/privacy advisory

=== AGENT RUN 2 — NOT APPLICABLE (22) ===

Identified by claude-exec agents as genuinely not applicable

block-formatting-context:26b535 # NON_NORMATIVE: vertical typographic baseline selection rules (text/inline concern)
block-formatting-context:3093e2 # NON_NORMATIVE: introductory prose about vertical writing, no normative requirements
block-formatting-context:3be1e0 # NON_NORMATIVE: author advice to use sideways-lr for certain scripts
block-formatting-context:410d6c # NON_NORMATIVE: notes about Ogham/Old Turkic script limitations
block-formatting-context:4ea192 # NON_NORMATIVE: informational example about text-transform + text-combine-upright
block-formatting-context:4f2433 # NON_NORMATIVE: introductory prose about vertical text concepts
block-formatting-context:9d3d05 # NON_NORMATIVE: editorial terminology clarification
block-formatting-context:f2cd5e # MISMATCHED: obsolete SVG glyph-orientation-vertical property aliasing
containing-block:ebcd87 # MISMATCHED: top layer is a browser UA concept (fullscreen/dialog)
display-property:511e16 # NON_NORMATIVE: author-facing guidance about unicode-bidi usage
display-property:f887e9 # FLEX_GRID: orthogonal flow root sizing for flex containers (Taffy) and table cells
intrinsic-sizing:99acd7 # MISMATCHED: UA may clause about form control minimum sizes
overflow:55ce1f # NON_NORMATIVE: informative UX details for text-overflow + scroll interaction
overflow:ea10a3 # MISMATCHED: viewport propagation of scrollbar-gutter (browser UA concept)
width-calculation:75a9f2 # MISMATCHED: CSS multi-column layout not implemented
width-calculation:bc5962 # FLEX_GRID: flex-basis handled by Taffy
writing-modes:3deddb # NON_NORMATIVE: authoring recommendation to use dir attribute instead of CSS direction
writing-modes:6e3844 # MISMATCHED: SVG1.1 presentational attributes and non-normative baseline intro
writing-modes:7a9e7d # MISMATCHED: Unicode bidi text reordering handled by external libraries
writing-modes:8e7281 # MISMATCHED: bidi control code insertion handled by external libraries
writing-modes:f3dd0e # MISMATCHED: SVG1.1 obsolete writing-mode value mapping
writing-modes:f488c9 # MISMATCHED: page progression direction (CSS Paged Media not implemented)


Webrender Core Doc CLIPPING AND POSITIONING (`webrender/core/doc/CLIPPING_AND_POSITIONING.md`)

Original Design

To understand the current design for clipping and positioning (transformations
and scrolling) in WebRender it can be useful to have a little background about
the original design for these features. The most important thing to remember is
that originally clipping, scrolling regions, and transformations were
properties of stacking contexts and they were completely hierarchical. This
goes a long way toward representing the majority of CSS content on the web, but
fails when dealing with important edges cases and features including:

  1. Support for sticky positioned content
  2. Scrolling areas that include content that is ordered both above and below
    intersecting content from outside the scroll area.
  3. Items in the same scrolling root, clipped by different clips one or more of
    which are defined outside the scrolling root itself.
  4. Completely non-hierarchical clipping situations, such as when items are
    clipped by some clips in the hierarchy, but not others.

Design changes have been a step by step path from the original design to one
that can handle all CSS content.

Current Design

All positioning and clipping is handled by the SpatialTree. The name is a
holdover from when this tree was a tree of Layers which handled both
positioning and clipping. Currently the SpatialTree holds:

  1. A hierarchical collection of SpatialNodes, with the final screen
    transformation of each node depending on the relative transformation of the
    node combined with the transformations of all of its ancestors. These nodes
    are responsible for positioning display list items and clips.
  2. A collection of ClipNodes which specify a rectangular clip and, optionally,
    a set of rounded rectangle clips and a masking image.
  3. A collection of ClipChains. Each ClipChain is a list of ClipNode
    elements. Every display list item has an assigned ClipChain which
    specifies what ClipNodes are applied to that item.

The SpatialNode of each clip applied to an item is completely independent of
the SpatialNode applied to the item itself.

One holdover from the previous design is that both ClipNode and SpatialNodes
have a parent node, which is either a SpatialNode or a ClipNode. From this
node WebRender can determine both a parent ClipNode and a parent SpatialNode
by finding the first ancestor of that type. This is handled by the
DisplayListFlattener.

`SpatialNode`

There are three types of SpatialNodes:

  1. Reference frames which are created when content needs to apply
    transformation or perspective properties to display list items. Reference
    frames establish a new coordinate system, so internally all coordinates on
    display list items are relative to the reference frame origin. Later
    any non-reference frame positioning nodes that display list items belong
    to can adjust this position relative to the reference frame origin.
  2. Scrolling nodes are used to define scrolling areas. These nodes have scroll
    offsets which are a 2D translation relative to ancestor nodes and, ultimately,
    the reference frame origin.
  3. Sticky frames are responsible for implementing position:sticky behavior.
    This is also an 2D translation.

SpatialNodes are defined as items in the display list. After scene building
each node is traversed hierarchically during the SpatialTree::update() step.
Once reference frame transforms and relative offsets are calculated, a to screen
space transformation can be calculated for each SpatialNode. This transformation
is added the TransformPalette and becomes directly available to WebRender shaders.

In addition to screen space transformation calculation, the SpatialNode tree
is divided up into compatible coordinate systems. These are coordinate systems
which differ only by 2D translations from their parent system. These compatible
coordinate systems may even cross reference frame boundaries. The goal here is
to allow the application clipping rectangles from different compatible
coordinate systems without generating mask images.

`ClipNode`

Each clip node holds a clip rectangle along with an optional collection of
rounded clip rectangles and a mask image. The fact that ClipNodes all have a
clip rectangle is important because it means that all content clipped by a
clip node has a bounding rectangle, which can be converted into a bounding
screen space rectangle. This rectangle is called the outer rectangle of the
clip. ClipNodes may also have an inner rectangle, which is an area within
the boundaries of the outer rectangle that is completely unclipped.

These rectangles are calculated during the SpatialTree::update() phase. In
addition, each ClipNode produces a template ClipChainNode used to build
the ClipChains which use that node.

`ClipChains`

There are two ways that ClipChains are defined in WebRender. The first is
through using the API for manually specifying ClipChains via a parent
ClipChain and a list of ClipNodes. The second is through the hierarchy of a
ClipNode established by its parent node. Every ClipNode has a chain of
ancestor SpatialNodes and ClipNodes. The creation of a ClipNode
automatically defines a ClipChain for this hierarchy. This behavior is a
compatibility feature with the old completely hierarchical clipping architecture
and is still how Gecko and Servo create most of their ClipChains. These
hierarchical ClipChains are constructed during the ClipNode::update() step.

During ClipChain construction, WebRender tries to eliminate clips that will
not affect rendering, by looking at the combined outer rectangle and inner
rectangle
of a ClipChain and the outer rectangle and inner rectangle of
any ClipNode appended to the chain. An example of the goal of this process is
to avoid having to render a mask for a large rounded rectangle when the rest of
the clip chain constrains the content to an area completely inside that
rectangle. Avoiding mask rasterization in this case and others has large
performance impacts on WebRender.

Clipping and Positioning in the Display List

Each non-structural WebRender display list item has

  • A SpatialId of a SpatialNode for positioning
  • A ClipId of a ClipNode or a ClipChain for clipping
  • An item-specific rectangular clip rectangle

The positioning node determines how that item is positioned. It's assumed that
the positioning node and the item are children of the same reference frame. The
clipping node determines how that item is clipped. This should be fully
independent of how the node is positioned and items can be clipped by any
ClipChain regardless of the reference frame of their member clips. Finally,
the item-specific clipping rectangle is applied directly to the item and should
never result in the creation of a clip mask itself.

Converting user-exposed `ClipId`/`SpatialId` to internal indices

WebRender must access ClipNodes and SpatialNodes quite a bit when building
scenes and frames, so it tries to convert ClipId/SpatialId, which are already
per-pipeline indices, to global scene-wide indices. Internally this is a
conversion from ClipId into ClipNodeIndex or ClipChainIndex, and from
SpatialId into SpatialNodeIndex. In order to make this conversion cheaper, the
DisplayListFlattner assigns offsets for each pipeline and node type in the
scene-wide SpatialTree.

Nodes are added to their respective arrays sequentially as the display list is
processed during scene building. When encountering an iframe, the
DisplayListFlattener must start processing the nodes for that iframe's
pipeline, meaning that nodes are now being added out of order to the node arrays
of the SpatialTree. In this case, the SpatialTree fills in the gaps in
the node arrays with placeholder nodes.

Hit Testing

Hit testing is the responsibility of the HitTester data structure. This
structure copies information necessary for hit testing from the
SpatialTree. This is done so that hit testing can still take place while a
new SpatialTree is under construction.

Ideas for the Future

  1. Expose the difference between ClipId and ClipChainId in the API.
  2. Prevent having to duplicate the SpatialTree for hit testing.
  3. Avoid having to create placeholder nodes in the SpatialTree while
    processing iframes.

Webrender Core Doc Blob (`webrender/core/doc/blob.md`)

Blob images

Blob image is fallback mechanism for webrender that Gecko uses to render primitives that aren't currently supported by webrender. The main idea is to provide webrender with a custom handler that can take arbitray drawing commands serialized as buffers of bytes (the blobs) and turn them into images that webrender internally will treat as regular images.

At the API level, blob images are treated as other images. They are resources created and associated with image keys, and are used in the display list with regular image display items.

Active area

In order to support scrolling very large content, blob images don't necessarily have a finite size. They can grow in any direction. At any time they do have an "active area", also called "visible area" which defines the portion that has to be rasterized. Typically this active area moves along large blob images depending on the scroll position.
The coordinate system of active area the should be the one of the blob's drawing commands (this is really up to the blob handler implementation to enforce that, Gecko does), and its scale should correspond to device pixels. The active area's coordinates can be negative.

As far as positioning goes, the active area maps to the image display item's bounds. In other words the content at the top-left corner of the active area will be rendered on screen at the position of the top-left corner of the display item's local rect.

In Gecko, the active area corresponds to the intersection of the fallback content's rect and the displayport.

The terms "visible area" and "visible rect" are used a lot in the blobs code, unfortunately they collide with frame building's visibility/culling terminology. They don't correspond to what is visible to the user, but rather what is in the displayport.

Tiling

Blob images can be either tiled or non-tiled. Non-tiled blob images support invalid rects while tiled blob images track only validty at the tile level. In gecko all blobs are tiled with a tile size of 256x256.

Just like regular tiled images, blob image tiles along the border of the image are shrinked to fit the remaining size. The only difference is that the tiling pattern always starts at the top-left corner for regular images (smaller boundary tiles only along the right and bottom edges), while it can be aribtrarily positioned for blob images (smaller boundary tiles potentially on all sides).

The tiling logic is in webrender/src/image.rs.

Async rasterization

Blobs are typically too slow to rasterize on the critical path. We try to avoid blocking frame building on blob image rasterization. In order to do that we rasterize blobs as part of scene building. Rather than rasterize tiles on demand from visibility informating, we rasterize the entire active area during scene building. This means we potentially process a lot more content than will be displayed if the user doesn't scroll through all of the visible area.

When the render backend receives a transaction, it looks for all new and update blob images, and generate blob rasterization requests for all tiles of the blob images that overlap their active area. The requests are bundled with an AsyncBlobImageRasterizer object in the transaction that is sent to the scene builder thread. The async rasterizer is created by the BlobImageHandler at each transaction. It is a snapshot of the state of the blobs as well as external information such as fonts, and does the actual rasterization.

While tiles are rasterized eagerly during scene building, their content is uploaded lazily to the texture cache depending on the result of the visibility pass during frame building.

Late rasterization

In some case we run into a missing blob image during frame building and have to rasterize it synchronously. This happens when a rasterized tile is uploaded to the texture cache (at which point the CPU side is discarded), the texture cache entry expires and after scrolling back into view the tile is needed again.
We should really keep the rasterized blobs around just like we keep regular images in the cache. Hopefully this section will become obsolete eventually and we'll be able to remove late blob rasterization.

The information needed for async rasterization corresponds to the state of blobs before scene building while late rasterization needs the state of blobs after the last complete scene build. This means we have to be careful about which version we manipulate in the resource cache.


Webrender Core Doc Swizzling (`webrender/core/doc/swizzling.md`)

It'd be great to have some (in-tree) docs describing the process you've worked through here, the overall motivation, how it works on different GPUs / platforms etc. Perhaps as a follow up?

Swizzling in WR

Problem statement

The goal is to avoid the CPU conversion of data done by the driver on texture data uploads. It's slow and always done synchronously, hurting our "Renderer" thread CPU utilization.

Gecko produces all of the image data in BGRA. Switching "imagelib" to RGBA is possible, but modifying Skia to follow is less trivial.
OpenGL support for BGRA8 as an internal texture format is a complex story: it's officially supported in Angle and a fair share of Android devices, but it's not available on the desktop GL (and until recently wasn't available in the Android emulator). Unofficially, when textures are initialized with glTexImage (as opposed to glTexStorage) with RGBA internal format, the desktop GL drivers often prefer to store the data in BGRA8 format, actually.

The only way to avoid the CPU conversion is to provide the data in exactly the same format that the driver is using internally for a texture. In this case, the driver does a straght memcpy into its CPU-visible memory, which is the best we can hope for with OpenGL API.

Solution: swizzling

https://phabricator.services.mozilla.com/D21965 is providing the solution to this problem. The main principles are:

  1. Use glTexStorage whenever it's available. Doing so gives us full control of the internal format, also allows to avoid allocating memory for mipmaps that we don't use.
  2. Make the shared texture cache format to be determined at the init time, based on the GL device capabilities. For Angle and OpenGL ES this is BGRA8, for desktop this is RGBA8 (since desktop GL doesn't support BGRA internal formats). WebRender is now able to tell Gecko, which color format it prefers the texture data to use.
  3. If the data comes in a format that is different from our best case, we pretend that the data is actually in our best case format, and associate the allocated cache entry with the Swizzle. That swizzle configuration changes the way shaders sample from a texture, adjusting for the fact the data was provided in a different format.
  4. The lifetime of a "swizzled" texture cache data is starting at the point the data is uploaded and ending at a point where any shader samples from this data. Any other operation on that data (copying or blitting) is not configurable by Swizzle and thus would produce incorrect results. To address this, the change enhances cs_copy shader to be used in place of blitting from the texture cache, where needed.
  5. Swizzling becomes a part of the batch key per texture. Mixing up draw calls with texture data that is differently swizzled then introduces the batch breaks. This is a downside for the swizzling approach in general, but it's not clear to what extent this would affect Gecko.

Code paths

Windows/Angle and Android:

  • we use glTexStorage with BGRA8 internal format, no swizzling is needed in general case.

Windows (non-Angle), Mac, Linux:

  • if glTexStorage is available, we use it with RGBA8 internal format, swizzling everything on texture sampling.
  • otherwise, we use RGBA unsized format with gTexImage and expect the data to come in BGRA format, no swizzling is involved.

Webrender Core Doc Text Rendering (`webrender/core/doc/text-rendering.md`)

Text Rendering

This document describes the details of how WebRender renders text, particularly the blending stage of text rendering.
We will go into grayscale text blending, subpixel text blending, and "subpixel text with background color" blending.

Prerequisites

The description below assumes you're familiar with regular rgba compositing, operator over,
and the concept of premultiplied alpha.

Not covered in this document

We are going to treat the origin of the text mask as a black box.
We're also going to assume we can blend text in the device color space and will not go into the gamma correction and linear pre-blending that happens in some of the backends that produce the text masks.

Grayscale Text Blending

Grayscale text blending is the simplest form of text blending. Our blending function has three inputs:

  • The text color, as a premultiplied rgba color.
  • The text mask, as a single-channel alpha texture.
  • The existing contents of the framebuffer that we're rendering to, the "destination". This is also a premultiplied rgba buffer.

Note: The word "grayscale" here does not mean that we can only draw gray text.
It means that the mask only has a single alpha value per pixel, so we can visualize
the mask in our minds as a grayscale image.

Deriving the math

We want to mask our text color using the single-channel mask, and composite that to the destination.
This compositing step uses operator "over", just like regular compositing of rgba images.

I'll be using GLSL syntax to describe the blend equations, but please consider most of the code below pseudocode.

We can express the blending described above as the following blend equation:

glsl
vec4 textblend(vec4 text_color, vec4 mask, vec4 dest) {
  return over(in(text_color, mask), dest);
}

with over being the blend function for (premultiplied) operator "over":

glsl
vec4 over(vec4 src, vec4 dest) {
  return src + (1.0 - src.a) * dest;
}

and in being the blend function for (premultiplied) operator "in", i.e. the masking operator:

glsl
vec4 in(vec4 src, vec4 mask) {
  return src * mask.a;
}

So the complete blending function is:

glsl
result.r = text_color.r * mask.a + (1.0 - text_color.a * mask.a) * dest.r;
result.g = text_color.g * mask.a + (1.0 - text_color.a * mask.a) * dest.g;
result.b = text_color.b * mask.a + (1.0 - text_color.a * mask.a) * dest.b;
result.a = text_color.a * mask.a + (1.0 - text_color.a * mask.a) * dest.a;

Rendering this with OpenGL

In general, a fragment shader does not have access to the destination.
So the full blend equation needs to be expressed in a way that the shader only computes values that are independent of the destination,
and the parts of the equation that use the destination values need to be applied by the OpenGL blend pipeline itself.
The OpenGL blend pipeline can be tweaked using the functions glBlendEquation and glBlendFunc.

In our example, the fragment shader can output just text_color * mask.a:

glsl
oFragColor = text_color * mask.a;

and the OpenGL blend pipeline can be configured like so:

rust
pub fn set_blend_mode_premultiplied_alpha(&self) {
        self.gl.blend_func(gl::ONE, gl::ONE_MINUS_SRC_ALPHA);
        self.gl.blend_equation(gl::FUNC_ADD);
    }

This results in an overall blend equation of

text
result.r = 1 * oFragColor.r + (1 - oFragColor.a) * dest.r;
           ^                ^  ^^^^^^^^^^^^^^^^^
           |                |         |
           +--gl::ONE       |         +-- gl::ONE_MINUS_SRC_ALPHA
                            |
                            +-- gl::FUNC_ADD

         = 1 * (text_color.r * mask.a) + (1 - (text_color.a * mask.a)) * dest.r
         = text_color.r * mask.a + (1 - text_color.a * mask.a) * dest.r

which is exactly what we wanted.

Differences to the actual WebRender code

There are two minor differences between the shader code above and the actual code in the text run shader in WebRender:

glsl
oFragColor = text_color * mask.a;    // (shown above)
// vs.
oFragColor = vColor * mask * alpha;  // (actual webrender code)

vColor is set to the text color. The differences are:

  • WebRender multiplies with all components of mask instead of just with mask.a.
    However, our font rasterization code fills the rgb values of mask with the value of mask.a,
    so this is completely equivalent.
  • WebRender applies another alpha to the text. This is coming from the clip.
    You can think of this alpha to be a pre-adjustment of the text color for that pixel, or as an
    additional mask that gets applied to the mask.

Subpixel Text Blending

Now that we have the blend equation for single-channel text blending, we can look at subpixel text blending.

The main difference between subpixel text blending and grayscale text blending is the fact that,
for subpixel text, the text mask contains a separate alpha value for each color component.

Component alpha

Regular painting uses four values per pixel: three color values, and one alpha value. The alpha value applies to all components of the pixel equally.

Imagine for a second a world in which you have three alpha values per pixel, one for each color component.

  • Old world: Each pixel has four values: color.r, color.g, color.b, and color.a.
  • New world: Each pixel has six values: color.r, color.a_r, color.g, color.a_g, color.b, and color.a_b.

In such a world we can define a component-alpha-aware operator "over":

glsl
vec6 over_comp(vec6 src, vec6 dest) {
  vec6 result;
  result.r = src.r + (1.0 - src.a_r) * dest.r;
  result.g = src.g + (1.0 - src.a_g) * dest.g;
  result.b = src.b + (1.0 - src.a_b) * dest.b;
  result.a_r = src.a_r + (1.0 - src.a_r) * dest.a_r;
  result.a_g = src.a_g + (1.0 - src.a_g) * dest.a_g;
  result.a_b = src.a_b + (1.0 - src.a_b) * dest.a_b;
  return result;
}

and a component-alpha-aware operator "in":

glsl
vec6 in_comp(vec6 src, vec6 mask) {
  vec6 result;
  result.r = src.r * mask.a_r;
  result.g = src.g * mask.a_g;
  result.b = src.b * mask.a_b;
  result.a_r = src.a_r * mask.a_r;
  result.a_g = src.a_g * mask.a_g;
  result.a_b = src.a_b * mask.a_b;
  return result;
}

and even a component-alpha-aware version of textblend:

glsl
vec6 textblend_comp(vec6 text_color, vec6 mask, vec6 dest) {
  return over_comp(in_comp(text_color, mask), dest);
}

This results in the following set of equations:

glsl
result.r = text_color.r * mask.a_r + (1.0 - text_color.a_r * mask.a_r) * dest.r;
result.g = text_color.g * mask.a_g + (1.0 - text_color.a_g * mask.a_g) * dest.g;
result.b = text_color.b * mask.a_b + (1.0 - text_color.a_b * mask.a_b) * dest.b;
result.a_r = text_color.a_r * mask.a_r + (1.0 - text_color.a_r * mask.a_r) * dest.a_r;
result.a_g = text_color.a_g * mask.a_g + (1.0 - text_color.a_g * mask.a_g) * dest.a_g;
result.a_b = text_color.a_b * mask.a_b + (1.0 - text_color.a_b * mask.a_b) * dest.a_b;

Back to the real world

If we want to transfer the component alpha blend equation into the real world, we need to make a few small changes:

  • Our text color only needs one alpha value.
    So we'll replace all instances of text_color.a_r/g/b with text_color.a.
  • We're currently not making use of the mask's r, g and b values, only of the a_r, a_g and a_b values.
    So in the real world, we can use the rgb channels of mask to store those component alphas and
    replace mask.a_r/g/b with mask.r/g/b.

These two changes give us:

glsl
result.r = text_color.r * mask.r + (1.0 - text_color.a * mask.r) * dest.r;
result.g = text_color.g * mask.g + (1.0 - text_color.a * mask.g) * dest.g;
result.b = text_color.b * mask.b + (1.0 - text_color.a * mask.b) * dest.b;
result.a_r = text_color.a * mask.r + (1.0 - text_color.a * mask.r) * dest.a_r;
result.a_g = text_color.a * mask.g + (1.0 - text_color.a * mask.g) * dest.a_g;
result.a_b = text_color.a * mask.b + (1.0 - text_color.a * mask.b) * dest.a_b;

There's a third change we need to make:

  • We're rendering to a destination surface that only has one alpha channel instead of three.
    So dest.a_r/g/b and result.a_r/g/b will need to become dest.a and result.a.

This creates a problem: We're currently assigning different values to result.a_r, result.a_g and result.a_b.
Which of them should we use to compute result.a?

This question does not have an answer. One alpha value per pixel is simply not sufficient
to express the same information as three alpha values.

However, see what happens if the destination is already opaque:

We have dest.a_r == 1, dest.a_g == 1, and dest.a_b == 1.

text
result.a_r = text_color.a * mask.r + (1 - text_color.a * mask.r) * dest.a_r
           = text_color.a * mask.r + (1 - text_color.a * mask.r) * 1
           = text_color.a * mask.r + 1 - text_color.a * mask.r
           = 1
same for result.a_g and result.a_b

In other words, for opaque destinations, it doesn't matter what which channel of the mask we use when computing result.a, the result will always be completely opaque anyways. In WebRender we just pick mask.g (or rather,
have font rasterization set mask.a to the value of mask.g) because it's as good as any.

The takeaway here is: Subpixel text blending is only supported for opaque destinations. Attempting to render subpixel
text into partially transparent destinations will result in bad alpha values. Or rather, it will result in alpha values which
are not anticipated by the r, g, and b values in the same pixel, so that subsequent blend operations, which will mix r and a values
from the same pixel, will produce incorrect colors.

Here's the final subpixel blend function:

glsl
vec4 subpixeltextblend(vec4 text_color, vec4 mask, vec4 dest) {
  vec4 result;
  result.r = text_color.r * mask.r + (1.0 - text_color.a * mask.r) * dest.r;
  result.g = text_color.g * mask.g + (1.0 - text_color.a * mask.g) * dest.g;
  result.b = text_color.b * mask.b + (1.0 - text_color.a * mask.b) * dest.b;
  result.a = text_color.a * mask.a + (1.0 - text_color.a * mask.a) * dest.a;
  return result;
}

or for short:

glsl
vec4 subpixeltextblend(vec4 text_color, vec4 mask, vec4 dest) {
  return text_color * mask + (1.0 - text_color.a * mask) * dest;
}

To recap, here's what we gained and lost by making the transition from the full-component-alpha world to the
regular rgba world: All colors and textures now only need four values to be represented, we still use a
component alpha mask, and the results are equivalent to the full-component-alpha result assuming that the
destination is opaque. We lost the ability to draw to partially transparent destinations.

Making this work in OpenGL

We have the complete subpixel blend function.
Now we need to cut it into pieces and mix it with the OpenGL blend pipeline in such a way that
the fragment shader does not need to know about the destination.

Compare the equation for the red channel and the alpha channel between the two ways of text blending:

text
single-channel alpha:
    result.r = text_color.r * mask.a + (1.0 - text_color.a * mask.a) * dest.r
    result.a = text_color.a * mask.a + (1.0 - text_color.a * mask.a) * dest.r

  component alpha:
    result.r = text_color.r * mask.r + (1.0 - text_color.a * mask.r) * dest.r
    result.a = text_color.a * mask.a + (1.0 - text_color.a * mask.a) * dest.r

Notably, in the single-channel alpha case, all three destination color channels are multiplied with the same thing:
(1.0 - text_color.a * mask.a). This factor also happens to be "one minus oFragColor.a".
So we were able to take advantage of OpenGL's ONE_MINUS_SRC_ALPHA blend func.

In the component alpha case, we're not so lucky: Each destination color channel
is multiplied with a different factor. We can use ONE_MINUS_SRC_COLOR instead,
and output text_color.a * mask from our fragment shader.
But then there's still the problem that the first summand of the computation for result.r uses
text_color.r * mask.r and the second summand uses text_color.a * mask.r.

There are multiple ways to deal with this. They are:

  1. Making use of glBlendColor and the GL_CONSTANT_COLOR blend func.
  2. Using a two-pass method.
  3. Using "dual source blending".

Let's look at them in order.

1. Subpixel text blending in OpenGL using `glBlendColor`

In this approach we return text_color.a * mask from the shader.
Then we set the blend color to text_color / text_color.a and use GL_CONSTANT_COLOR as the source blendfunc.
This results in the following blend equation:

text
result.r = (text_color.r / text_color.a) * oFragColor.r + (1 - oFragColor.r) * dest.r;
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^                ^  ^^^^^^^^^^^^^^^^^
                         |                              |      |
                         +--gl::CONSTANT_COLOR          |      +-- gl::ONE_MINUS_SRC_COLOR
                                                        |
                                                        +-- gl::FUNC_ADD

         = (text_color.r / text_color.a) * (text_color.a * mask.r) + (1 - (text_color.a * mask.r)) * dest.r
         = text_color.r * mask.r + (1 - text_color.a * mask.r) * dest.r

At the very beginning of this document, we defined text_color as the premultiplied text color.
So instead of actually doing the calculation text_color.r / text_color.a when specifying the blend color,
we really just want to use the unpremultiplied text color in that place.
That's usually the representation we start with anyway.

2. Two-pass subpixel blending in OpenGL

The glBlendColor method has the disadvantage that the text color is part of the OpenGL state.
So if we want to draw text with different colors, we have two use separate batches / draw calls
to draw the differently-colored parts of text.

Alternatively, we can use a two-pass method which avoids the need to use the GL_CONSTANT_COLOR blend func:

  • The first pass outputs text_color.a * mask from the fragment shader and
    uses gl::ZERO, gl::ONE_MINUS_SRC_COLOR as the glBlendFuncs. This achieves:
text
oFragColor = text_color.a * mask;

result_after_pass0.r = 0 * oFragColor.r + (1 - oFragColor.r) * dest.r
                     = (1 - text_color.a * mask.r) * dest.r

result_after_pass0.g = 0 * oFragColor.g + (1 - oFragColor.g) * dest.r
                     = (1 - text_color.a * mask.r) * dest.r

...
  • The second pass outputs text_color * mask from the fragment shader and uses
    gl::ONE, gl::ONE as the glBlendFuncs. This results in the correct overall blend equation.
text
oFragColor = text_color * mask;

result_after_pass1.r
 = 1 * oFragColor.r + 1 * result_after_pass0.r
 = text_color.r * mask.r + result_after_pass0.r
 = text_color.r * mask.r + (1 - text_color.a * mask.r) * dest.r

3. Dual source subpixel blending in OpenGL

The third approach is similar to the second approach, but makes use of the ARB_blend_func_extended extension
in order to fold the two passes into one:
Instead of outputting the two different colors in two separate passes, we output them from the same pass,
as two separate fragment shader outputs.
Those outputs can then be treated as two different sources in the blend equation.


Doc Guide En Animations Timers (`doc/guide/en/animations/timers.md`)


slug: animations/timers
title: Timers
language: en
canonical_slug: animations/timers
audience: external
maturity: wip
guide_order: 101
topic_only: false
short_desc: Timers, threads, and scheduled work
prerequisites: [hello-world, events]
tracked_files:

  • core/src/task.rs
  • layout/src/timer.rs
  • layout/src/callbacks.rs
    last_generated_rev: 7ecd570e4c0c3584e5107e770058c16cb59fa6e7
    generated_at: 2026-05-02T06:00:00Z
    default-search-keys:
  • Timer
  • TimerId
  • TimerCallback
  • TimerCallbackInfo
  • TimerCallbackReturn
  • TerminateTimer
  • Instant
  • Duration
  • RefAny
  • CallbackInfo
  • Update
  • add_timer
  • remove_timer

Timers

Introduction

WIP. The Timer API is functional today; field names may shift before 1.0.

A Timer is a function that runs on the main UI thread on its own schedule, independently of input events. You install it from inside an event callback and tear it down the same way. The framework wakes the event loop to fire it; the callback receives a normal CallbackInfo plus a TimerCallbackInfo wrapper.

rust,no_run
use azul::prelude::*;

extern "C" 
fn tick(_: RefAny, _: TimerCallbackInfo) -> TimerCallbackReturn {
    TimerCallbackReturn::continue_and_refresh_dom()
}

extern "C" 
fn on_click(mut data: RefAny, mut info: CallbackInfo) -> Update {

    let timer = Timer::create(
        data.clone(), tick, info.get_system_time_fn()
    ).with_interval(Duration::System(
        SystemTimeDiff::from_millis(16)
    ));
    
    info.add_timer(TimerId::unique(), timer);

    Update::DoNothing
}

When to reach for a timer

Use a timer when something must happen on a clock, not in response to input:

  • React to a click, hover, or key. Not a timer. Use an event filter (see events).
  • Re-paint every frame. Yes. Interval = 16 ms; return TimerCallbackReturn::continue_and_refresh_dom().
  • Run something once after a delay. Yes. with_delay(d); terminate from the callback.
  • Poll a long-running operation. Not a timer. Use a background thread (covered in background-tasks).
  • Animate a CSS property. Not yet. The animation runtime is a stub; see animations.

Timers run on the UI thread. Heavy work blocks input. For anything I/O-bound or CPU-heavy, spawn a thread.

The signature

A timer callback has the same C-ABI shape as an event callback, but the second argument and return type differ.

rust,ignore
pub type TimerCallbackType = extern "C" fn(
    RefAny,
    TimerCallbackInfo,
) -> TimerCallbackReturn;

The RefAny is whatever you handed to Timer::create. It's most often the same data you handed to your layout callback. TimerCallbackInfo is documented in the next section. The return value packs two enums:

rust,ignore
pub struct TimerCallbackReturn {
    pub should_update: Update,
    pub should_terminate: TerminateTimer,
}

Update is the same enum event callbacks return: DoNothing, RefreshDom, RefreshDomAllWindows. TerminateTimer is Continue or Terminate. Four convenience constructors match the common combinations:

rust,ignore
TimerCallbackReturn::continue_unchanged()        // keep ticking, no relayout
TimerCallbackReturn::continue_and_refresh_dom()  // keep ticking, re-run layout
TimerCallbackReturn::terminate_unchanged()       // stop, no relayout
TimerCallbackReturn::terminate_and_refresh_dom() // stop, re-run layout

TimerCallbackInfo

The wrapper type gives the callback two things the event API doesn't: which call this is and when the frame started.

  • call_count: usize. 0 on first invocation, monotonic from there. Useful for "after N ticks, do X".
  • frame_start: Instant. Monotonic timestamp captured before the callback runs. Use it for animation interpolation rather than calling Instant::now() again.
  • is_about_to_finish: bool. true only on the final invocation when a timeout is configured (see below).
  • node_id: OptionDomNodeId. The node the timer was attached to, if any.
  • callback_info: CallbackInfo. The full event-API surface.

TimerCallbackInfo re-exports the methods you'd reach for from CallbackInfo directly: add_timer, remove_timer, add_thread, remove_thread, modify_window_state, scroll_to, update_all_image_callbacks, trigger_virtual_view_rerender. Mutations are recorded and applied after the callback returns, exactly as in event callbacks.

Scheduling a timer

Timer uses a builder. The constructor takes the data, the callback, and a system-time function (passed in from the event loop):

rust,ignore
pub fn create<C: Into<TimerCallback>>(
    refany: RefAny,
    callback: C,
    get_system_time_fn: GetSystemTimeCallback,
) -> Self

The three modifier methods cover all common scheduling shapes:

rust,ignore
impl Timer {
    pub fn with_delay(self, delay: Duration) -> Self;       // wait before first fire
    pub fn with_interval(self, interval: Duration) -> Self; // gap between fires
    pub fn with_timeout(self, timeout: Duration) -> Self;   // stop after this much elapsed
}

A timer with no interval set defaults to a short tick. A timer with no timeout runs until the callback returns TerminateTimer::Terminate or you call remove_timer.

rust,no_run
use azul::prelude::*;

extern "C" 
fn tick(_: RefAny, _: TimerCallbackInfo) -> TimerCallbackReturn {
    TimerCallbackReturn::continue_and_refresh_dom()
}

extern "C" 
fn on_click(mut data: RefAny, mut info: CallbackInfo) -> Update {
    
    // Run timer after 500ms delay from now, then every 16ms, 
    // for at most 5 seconds.

    let initial_delay = SystemTimeDiff::from_millis(500);
    let interval = SystemTimeDiff::from_millis(16);
    let timeout = SystemTimeDiff::from_secs(5);
    
    let timer = Timer::create(
        data.clone(), 
        tick, 
        info.get_system_time_fn()
    )
    .with_delay(Duration::System(initial_delay))
    .with_interval(Duration::System(interval))
    .with_timeout(Duration::System(timeout));

    info.add_timer(TimerId::unique(), timer);
    
    Update::DoNothing
}

Stopping a timer

Three ways, in order of preference:

  1. Return TerminateTimer::Terminate from the callback when its work is done. The normal teardown path.
  2. Set with_timeout when the schedule is bounded. The runtime forces termination on the tick that crosses the deadline; the callback sees is_about_to_finish == true on its final call so it can flush state.
  3. Call info.remove_timer(timer_id) from any other callback when an external event (a window close, a cancel button) needs to kill the timer.

Returning Terminate doesn't drop the RefAny immediately. The framework releases its clone after the callback returns.

TimerId — the handle

TimerId is a wrapper used to look up and remove timers. The framework reserves the first few IDs for built-in timers (caret blink, scroll momentum, drag autoscroll, tooltip delay). Always create user IDs with TimerId::unique():

rust,ignore
let id = TimerId::unique();

Don't construct a literal TimerId { id: 0..=4 }. That collides with the framework's own timers.

Duration and Instant

Instant and Duration have two variants each: System (wraps std::time::Instant / Duration on platforms with std) and Tick (a counter for embedded targets). Mixing variants panics. Every Instant you'll see comes from the framework's own time function, so all values you compose match.

The constants you'll use most:

rust,ignore
use azul_core::task::{Duration, SystemTimeDiff};

Duration::System(SystemTimeDiff::from_millis(16))    // 60 fps tick
Duration::System(SystemTimeDiff::from_millis(500))   // 0.5 s
Duration::System(SystemTimeDiff::from_secs(5))       // 5 s
Duration::System(SystemTimeDiff::from_nanos(16_667_000)) // 60 fps, exact

Instant::linear_interpolate(start, end) -> f32 is the single most useful method on Instant. Given the current time and a (start, end) pair, it returns a clamped 0.0..=1.0 fraction. It's the building block for the animation runtime once it lands.

Timers that re-render images, not the DOM

When a timer animates pixels (a video frame, a GL texture, a canvas), re-running the layout pass for every tick is wasteful. Two narrower triggers exist:

  • info.update_all_image_callbacks() re-invokes every image callback in the tree without touching layout. Pair with TimerCallbackReturn::continue_unchanged().
  • info.trigger_virtual_view_rerender(dom_id, node_id) re-invokes a single virtual-view callback for lazy-rendered scroll regions.

Both are applied after the callback returns. Use them in preference to RefreshDom whenever the DOM structure isn't changing.

Common patterns

Run once after a delay

rust,no_run
use azul::prelude::*;

extern "C" 
fn run_once(_: RefAny, _: TimerCallbackInfo) -> TimerCallbackReturn {
    // ... do the deferred work ...
    TimerCallbackReturn::terminate_and_refresh_dom()
}

extern "C" fn on_click(mut data: RefAny, mut info: CallbackInfo) -> Update {
    
    let timer = Timer::create(
        data.clone(), 
        run_once, 
        info.get_system_time_fn()
    )
    .with_delay(Duration::System(SystemTimeDiff::from_millis(300)));
    
    info.add_timer(TimerId::unique(), timer);
    
    Update::DoNothing
}

Tick at 60 fps until a flag flips

rust,no_run
use azul::prelude::*;

struct State { running: bool }

extern "C" 
fn frame(data: RefAny, _: TimerCallbackInfo) -> TimerCallbackReturn {
    let state = data.downcast_ref::<State>().unwrap();
    if !state.running {
        TimerCallbackReturn::terminate_unchanged()
    } else {
        TimerCallbackReturn::continue_and_refresh_dom()
    }
}

extern "C" 
fn on_click(mut data: RefAny, mut info: CallbackInfo) -> Update {
    
    let timer = Timer::create(
        data.clone(), 
        frame, 
        info.get_system_time_fn()
    )
    .with_interval(Duration::System(SystemTimeDiff::from_millis(16)));
    
    info.add_timer(TimerId::unique(), timer);

    Update::DoNothing
}

Cancel from somewhere else

Stash the TimerId in your model when you create the timer; remove it from any callback that owns the model.

rust,no_run
use azul::prelude::*;

struct State { spinner_timer: Option<TimerId> }

extern "C" 
fn on_cancel(data: RefAny, mut info: CallbackInfo) -> Update {
    let mut state = data.downcast_mut::<State>().unwrap();
    if let Some(id) = state.spinner_timer.take() {
        info.remove_timer(id);
    }
    Update::RefreshDom
}

Limits

  • Timers fire on the UI thread. A callback that takes 50 ms blocks input for 50 ms.
  • Tick precision is bounded by the platform timer resolution and by competing input. Don't expect sub-millisecond accuracy.
  • A timer's RefAny is held alive by the framework until the timer terminates. A timer that holds the only reference to your model keeps the model alive for the lifetime of the timer.
  • The schedule is enforced per tick, not per millisecond. If a system stall bunches several ticks together, the callback fires once per tick. Compute deltas from frame_start, not from call_count.

Doc Guide En Animations Zombie Animations (`doc/guide/en/animations/zombie-animations.md`)


slug: animations/zombie-animations
title: Zombie Animations
language: en
canonical_slug: animations/zombie-animations
audience: external
maturity: wip
guide_order: 102
topic_only: false
short_desc: Exit animations, the retained subtree they run on, and native per-frame animation functions
prerequisites: [animations, dom/reconciliation]
tracked_files:

  • core/src/resources.rs
  • core/src/animation.rs
  • layout/src/window.rs
  • layout/src/callbacks.rs
  • css/src/props/basic/animation.rs
    last_generated_rev: e33ef81cc56d8a7dac357d6c0610ff5e26d02e26
    generated_at: 2026-08-20T00:00:00Z
    default-search-keys:
  • ZombieAnimCallback
  • ZombieAnimInfo
  • ZombieFrame
  • AnimationFunction
  • StyleAnimation
  • AnimationTiming
  • AnimationIterationCount
  • TimerCallbackInfo
  • RefAny

Zombie Animations

An exit animation animates a node that no longer exists. By the time the
layout callback has returned the DOM without it, reconciliation has already
unmounted it and layout has given its space to its neighbours — there is
nothing left to move. -azul-animation-out is what stops that: the node's
declaration makes the window retain the previous frame's subtree, and the
exit animates against that retained copy. The retained copy is the zombie.

css
.sidebar {
    -azul-animation-in:  slideInLeft 220ms spring;
    -azul-animation-out: slideOutLeft 180ms ease-in;
}
@keyframes slideOutLeft {
    from { transform: translateX(0); }
    to   { transform: translateX(-100%); opacity: 0; }
}

Retention is opt-in

A node without -azul-animation-out disappears the frame it unmounts — no
zombie, no retained tree, no engine-default slide. Departure animations are
something you or a widget library declare; nothing happens by default, and
the common unmount keeps its incremental layout entry untouched.

Retention also needs a resolvable name and a rect: a name that matches
neither a @keyframes block nor an attached animation function contributes
nothing, and a node that never laid out has nothing to animate from. Both
cases unmount instantly.

The zombie owns the frame it animates

When at least one exit resolves, the previous frame's layout result is
moved out of the window's live results into the zombie — not cloned, not
shared. Three consequences you can rely on:

  • Hit-testing skips it by construction. Input walks the live layout
    results, and the zombie is no longer among them. Clicks land on the new
    tree; your callbacks never see the departing node.
  • Scroll offsets freeze. The retained frame keeps the scroll positions it
    was showing, so a list that scrolls under a departing panel does not yank
    the panel's content with it.
  • It is freed exactly once, when the track finishes.

An infinite exit is clamped to a single run — an exit that never ends would
never reap its zombie. infinite on -azul-animation-in is not clamped, and
is how a spinner is expressed.

Clipping

By default an exit is clipped to the rect it was retained at, so a
translating zombie cannot paint over the neighbours that have already taken
its space. The no-clip keyword turns that off for a declaration:

css
-azul-animation-out: flyAway 400ms ease-out no-clip;

A native animation function can override it per frame through
ZombieFrame::clip_to_frozen_rect.

Remounting mid-exit

Toggle a panel off and on again before its exit finishes and the exit is
caught rather than restarted. The retained node is keyed by its
reconciliation identity, so the remounted node is recognised as the same
node: the zombie is dropped (no double image), and the live node travels home
from wherever the exit had carried it, with velocity preserved.

The direction it travels home along is -azul-animation-in if the new
cascade declares one. If it does not, the engine reverses the out-track over
the time the exit had already spent, so the return runs at the pace the
departure did.

The mirror case works too: a node that unmounts mid-enter starts its
out-track from wherever the in-track had carried it, instead of snapping to
its laid-out position first.

Native animation functions

@keyframes covers declarative motion. When a frame has to be computed —
physics, a value read off the live DOM, motion that depends on how far the
last gesture threw the panel — attach a function to the node and name it from
CSS:

rust,ignore
use azul::prelude::*;

extern "C" fn shrink_out(
    _data: &mut RefAny,
    _info: &mut TimerCallbackInfo,
    z: &ZombieAnimInfo,
) -> ZombieFrame {
    // `z.t` is RAW LINEAR progress; the engine does not pre-apply easing to
    // native functions. `evaluate` applies the curve the CSS asked for.
    let eased = z.timing.evaluate(z.t);
    ZombieFrame {
        translate_x: -z.rect.size.width * eased,
        translate_y: 0.0,
        opacity: 1.0 - eased,
        width: OptionF32::Some(z.rect.size.width * (1.0 - eased)),
        clip_to_frozen_rect: true,
    }
}

fn sidebar() -> Dom {
    Dom::create_div()
        .with_class("sidebar".into())
        .with_animation_callback(
            "shrinkOut".into(),
            ZombieAnimCallback { cb: shrink_out as usize },
            RefAny::new(()),
        )
}
css
.sidebar { -azul-animation-out: shrinkOut 300ms ease-in-out; }

The function lives on the node's own NodeData, not in app-global state: a
widget ships its fly-out next to its own DOM, and a library can hand you a
component whose motion travels with it.

Name resolution order: stylesheet @keyframes first, attached functions
second. The web mechanism stays the only default name source — a @keyframes shrinkOut block anywhere in the stylesheet shadows the attached
"shrinkOut". There are no engine builtins; an unresolvable name animates
nothing.

What the function receives

ZombieAnimInfo is the zombie-specific half:

Field What it is
styled_dom, node_id The tree the animated node lives in — retained for an exit, live for an enter — and its index in that tree. Borrowed for the call: walk it, don't store it.
rect, viewport, dpi_factor The retained rect for exits, the solved rect for enters; the viewport it was laid out in.
t Raw linear progress, 0..=1. Not eased.
timing The timing the CSS declared (ease, spring, cubic-bezier(…)). Apply it with timing.evaluate(t) or substitute your own curve.
velocity_x, velocity_y Speed entering this frame, logical px/s. Reversing with continuity means emitting frames that start at this speed.

The other half is a full TimerCallbackInfo — the same thing a timer
callback gets. The live DOM, the change queue, node measurement and the
momentum API are all reachable from inside an animation frame.

What it returns

ZombieFrame holds absolute values, not deltas:

  • translate_x / translate_y — logical px about the node's origin.
  • opacity — 0.0 skips the node entirely for that frame.
  • width — absolute painted width; left-anchored narrowing for exits. None
    keeps the full width. Ignored for enters, where the live layout owns width.
  • clip_to_frozen_rect — per-frame override of the CSS no-clip keyword.
    Ignored for enters.

Cost

A zombie is a whole retained frame — its own layout result, its own text and
layout caches. Tracks that change width re-solve that retained tree per
frame; LayoutWindow::zombie_relayouts counts how often that happened, which
is the number to watch if an exit animation costs more than it should. Tracks
that only translate or fade never re-solve anything.

Cross-references

  • Animations: the animation and -azul-animation-*
    properties, timing keywords, and @keyframes.
  • Reconciliation: the identity that lets a
    remount recognise its own zombie.
  • Timers: TimerCallbackInfo, which every animation function
    receives in full.

Doc Guide En Architecture Components (`doc/guide/en/architecture/components.md`)


slug: architecture/components
title: Components
language: en
canonical_slug: architecture/components
audience: external
maturity: mature
guide_order: 42
topic_only: false
short_desc: Reusable UI fragments - named functions of (args) -> Dom
prerequisites: [dom]
tracked_files:

  • core/src/dom.rs
  • core/src/xml.rs
  • core/src/resources.rs
    last_generated_rev: 7ecd570e4c0c3584e5107e770058c16cb59fa6e7
    generated_at: 2026-05-02T05:53:30Z
    default-search-keys:
  • Dom
  • ComponentLibrary
  • ComponentDef
  • ComponentMap
  • ComponentDataModel
  • ComponentFieldType
  • ComponentDefaultValue
  • CompileTarget
  • AppConfig
  • RefAny
  • StyledDom
  • CallbackInfo

Components

Introduction

A component is a Rust function that returns Dom.

There's no component trait. There's no derive macro. There's no special syntax. You compose components by calling functions. You reuse them through normal module visibility. The only value the framework actually inspects is the Dom you return.

rust,no_run
use azul::prelude::*;

fn card(title: &str, body: &str) -> Dom {
    Dom::create_div()
        .with_class("card".into())
        .with_child(Dom::create_h2_with_text(title))
        .with_child(Dom::create_p_with_text(body))
}

let _ = Dom::create_body()
    .with_child(card("First", "alpha"))
    .with_child(card("Second", "beta"));

That's the whole model. The rest of this page covers two layers built on top.

The first layer is state. How do you thread persistent data through a component, and how do nested components forward events back up to their caller? The second layer, covered later under Component Packs, is registration. When you want a component visible to the XML loader or the live debugger by name, you wrap it in a ComponentDef and put it in a ComponentLibrary.

Pure parameters

For purely visual components, pass values as parameters and return the constructed Dom. No RefAny is needed because no data has to persist across frames.

rust,no_run
use azul::prelude::*;

pub fn badge(text: &str, kind: BadgeKind) -> Dom {
    let class = match kind {
        BadgeKind::Info  => "badge badge-info",
        BadgeKind::Warn  => "badge badge-warn",
        BadgeKind::Error => "badge badge-error",
    };
    Dom::create_span_with_text(text).with_class(class.into())
}

pub enum BadgeKind { Info, Warn, Error }

The caller picks the variant. The component just produces nodes. Tests are easy: call the function, walk the returned Dom, assert on shape.

Owning state

When a component has internal state (an expansion flag, a counter value, a current selection), wrap it in a struct and pass it as a RefAny to the component's callbacks.

The state lives wherever the caller put it. The component's dom() method takes a clone of the RefAny and threads it through to each callback that needs it.

rust,ignore
use azul::prelude::*;

pub struct Counter { value: i64 }

impl Counter {
    pub fn dom(state: RefAny) -> Dom {
        let label = match state.downcast_ref::<Counter>() {
            Some(c) => format!("{}", c.value),
            None => return Dom::create_div(),
        };
        Dom::create_div()
            .with_child(Dom::create_span_with_text(label))
            .with_child(
                Dom::create_button_no_a11y("+1".into())
                    .with_callback(EventFilter::Hover(HoverEventFilter::MouseUp), state, increment)
            )
    }
}

extern "C" fn increment(mut data: RefAny, _info: CallbackInfo) -> Update {
    let mut c = match data.downcast_mut::<Counter>() { Some(c) => c, None => return Update::DoNothing };
    c.value += 1;
    Update::RefreshDom
}

The component doesn't own its state. The caller owns it. The component renders against it and wires callbacks back to it. That's what makes components composable. Anyone who can hand a Counter a RefAny<Counter> can build one.

For state that the node itself should carry (the typing buffer of a text input, a per-row checkbox, the scroll offset of a list), use with_dataset(OptionRefAny::Some(refany)) instead. The node-attached dataset is reachable inside the callback through info.get_dataset(info.get_hit_node()). That's the pattern used by the built-in widgets. See Built-in Widgets.

The backreference pattern

A component that wraps another component holds a RefAny to its own parent. When the inner component finishes its work, it follows that backreference to forward a higher-level event upward.

text
/* Detailed source-code truncated for AI context efficiency. */

The private callback validate only knows about NumberInput. It parses the text the user typed. It updates its own state. Then it follows the backreference to the application-level callback.

The application sees a clean signature: (parent: RefAny, info, value: i64). There's no string handling on the application side. There's no awareness of the inner widget's internals.

A worked example: AgeInput over NumberInput

The application wraps NumberInput once more, this time to enforce a domain rule.

rust,ignore
use azul::prelude::*;

pub struct AgeInput { age: i64 }

extern "C" fn layout(mut data: RefAny, _: LayoutCallbackInfo) -> Dom {
    let age = match data.downcast_ref::<AgeInput>() { Some(a) => a.age, None => return Dom::create_body() };
    let mut input = NumberInput::new(age);
    input.set_on_change(data.clone(), on_age_changed);
    Dom::create_body().with_child(input.dom())
}

extern "C" fn on_age_changed(mut data: RefAny, _info: CallbackInfo, new_age: i64) -> Update {
    let mut a = match data.downcast_mut::<AgeInput>() { Some(a) => a, None => return Update::DoNothing };
    if new_age < 0 || new_age > 150 { return Update::DoNothing; }
    a.age = new_age;
    Update::RefreshDom
}

The chain AgeInput -> NumberInput -> is a State Graph. Each layer holds one backreference, pointing at the layer above.

Events travel the chain in reverse. The `` loses focus. NumberInput::validate runs. AgeInput::on_age_changed runs. Nothing leaks across layers, and nothing has to be threaded through layout.

This is the same pattern walked through in Architecture, now in real Rust.

Returning multiple roots

A Dom has a single root. To return a sequence of siblings, wrap them in a neutral container or collect into a Dom.

rust,no_run
use azul::prelude::*;

pub fn breadcrumb(parts: &[&str]) -> Dom {
    parts.iter()
        .enumerate()
        .map(|(i, &p)| {
            if i == 0 {
                Dom::create_span_with_text(p)
            } else {
                Dom::create_div()
                    .with_class("crumb".into())
                    .with_child(Dom::create_span_with_text(" / "))
                    .with_child(Dom::create_span_with_text(p))
            }
        })
        .collect::<Dom>()
}

collect::<Dom> produces a `` whose children are the iterator's items. There's no fragment type and no portal type. If you don't want the wrapper to affect layout, give it display: contents.

Component-origin tracking

When a component's dom() returns, the framework can stamp the root nodes of its output with a component-origin record. That's the field the inspector populates with the qualified component id (like "shadcn:card") and the JSON-serialised data model.

The origin tag has three uses. The live debugger uses it to display a Component Tree alongside the DOM Tree. The code-generation roundtrip uses it to recover the source invocation. And clicking a node to jump to the component that produced it relies on it.

The origin is set automatically when a component is registered through the component system, whether from XML or from the builder. Plain functions don't need to opt in.

Component Packs

A component pack is a ComponentLibrary in the codebase. It's a named collection of ComponentDefs.

Packs are how the framework finds a component by name. <card title="..."/> in XML resolves through a pack. shadcn:avatar in the live debugger resolves through a pack. builtin:div resolves through a pack. They're also how a component invocation can roundtrip through the design-time tools and come back out as source code.

There's a second authoring surface in the same pipeline. A component can be declared in .azul markup with a typed argument list, registered into a ComponentLibrary, and called by name.

xml
<component name="card" args="title: String, body: String">
    <div class="card">
        <h2>{title}</h2>
        <p>{body}</p>
    </div>
</component>

<app>
    <card title="First" body="alpha"/>
    <card title="Second" body="beta"/>
</app>

The runtime path is Dom::create_from_parsed_xml, introduced in The DOM - Parsing from XHTML. It walks the parsed XML, resolves each tag against the registered component libraries, and produces the corresponding Dom.

Whether a component is hand-written Rust or XML-defined, the value is the same. It's a function from arguments and a RefAny to a Dom. The rest of this section is about registering those functions so the framework can look them up by name.

Why packs and not just functions

A plain Rust component (fn card(title: &str, body: &str) -> Dom) is visible to whoever can see the symbol. That's fine for compiled-in components: your own widgets, a third-party crate's widgets.

It isn't enough when you want more.

  • You want to preview a component in the live debugger without recompiling.
  • You want a typed data model the debugger can edit, populate, and pass back to the render function.
  • You want code generation. The design-time tool emits the equivalent Rust, C, or Python source for a tree the user laid out interactively.
  • You want to load components from XML by name. <card .../> has to resolve to some function.

A ComponentDef (in core/src/xml.rs) carries everything the runtime and the design-time tools need:

rust,ignore
pub struct ComponentDef {
    pub id: ComponentId,                 // collection + name, e.g. shadcn:card
    pub display_name: AzString,
    pub description: AzString,
    pub css: AzString,                   // ships with the component
    pub source: ComponentSource,         // where it came from (built-in, JSON, ...)
    pub data_model: ComponentDataModel,  // typed prop list - defaults double as preview values
    pub render_fn: ComponentRenderFn,    // (&def, &data, &registry) -> StyledDom
    pub compile_fn: ComponentCompileFn,  // (&def, &target_lang, &data, indent) -> source
    pub render_fn_source: OptionString,
    pub compile_fn_source: OptionString,
}

A ComponentLibrary groups defs under a name, version, and description. It also carries exportable and modifiable flags. The live editor uses those flags to decide whether the user can edit a component in place.

Registering a pack

AppConfig::create() always pre-registers the "builtin" library. That library has 112 HTML element components (plus three control-flow builtins: if, for, map), so builtin:div, builtin:p, builtin:button, and the rest are available out of the box. Anything else is registered on top.

There are two registration shapes.

rust,ignore
use azul::prelude::*;

let mut config = AppConfig::create();

// 1. Register a component library under a vendor name. The register fn is
//    called once and returns every component the library provides.
config.add_component_library(
    AzString::from("mylib"),
    my_register_library_fn,     // RegisterComponentLibraryFnType
);

// 2. Register an entire pre-built library.
config.add_component_library(
    AzString::from("shadcn"),
    register_shadcn,            // extern "C" fn() -> ComponentLibrary
);

Both work the same way. The registration function runs immediately at the call site. The returned ComponentDef or ComponentLibrary is moved into config.component_libraries. The library then becomes visible to the XML parser, to the layout callback (through CallbackInfo), and to the debug server.

Why the function-pointer indirection instead of a direct ComponentDef parameter? It's so the C and Python bindings can register libraries through their own callback shapes. The C example below shows it.

Built-in libraries use the same registration API

The 112 built-in HTML element components are themselves registered through add_component_library inside AppConfig::create(). The render functions are the same Dom::create_<tag>() constructors documented in DOM. Your own packs follow the same shape.

From C

The registration callback is a repr(C) function pointer, so a plain function pointer is enough on the C side.

c
/* `AzRegisterComponentLibraryFnType` is `AzComponentLibrary (*)(void)`. */
extern AzComponentLibrary register_shadcn(void);

int main(void) {
    AzAppConfig config = AzAppConfig_create();
    AzAppConfig_addComponentLibrary(
        &config,
        /* `AzString_fromCStr` takes `const int8_t*`, so a string literal
           needs the cast to compile without -Wpointer-sign. */
        AzString_fromCStr((const int8_t*)"shadcn"),
        register_shadcn
    );
    /* ... */
    return 0;
}

ComponentLibrary exports no constructor - it is a repr(C) struct, so the
registration function fills its fields in directly and returns it. The fields
are the ones listed above. examples/c/routing-components.c is a working
version of this, compiled and run by CI.

From Python

The Python binding wraps the function pointer in a trampoline. You pass a Python
callable; because RegisterComponentLibraryFnType takes no arguments, the binding
recovers the callable from the engine's invocation slot rather than from a data
parameter.

Registering a library from Python is not usable yet. The call side works:

python
config = AppConfig.create()
config.add_component_library("shadcn", register_shadcn)   # accepted

but the callable has to return a ComponentLibrary, and the Python class exposes
only name, version, description, exportable and modifiable. It has no
constructor, and its components field is a ComponentDefVec, which the binding
does not surface as a property (no Vec-typed field is). So there is no way to build
the value the callback must return. Use the C, or Rust, shape above until that gap
is closed.

Render: live preview

ComponentDef::render_fn has this signature:

rust,ignore
fn(&ComponentDef, &ComponentDataModel, &ComponentMap) -> ResultStyledDomRenderDomError

The render function takes a modified data_model. The design-time tool overrides default values on each field, then hands the model back. It also takes the full ComponentMap so the function can recursively instantiate sub-components. A <card> containing an <h2> containing a `` is one render call that recurses three levels through the same registry. The return is a fully cascaded StyledDom ready for layout.

That's what powers the live preview. The design-time tool reads the component's data model, lets the user edit each prop, calls the render function, and shows the result inline. No recompile. No trip through the disk.

Instantiation: from XML to DOM

When the XML parser encounters <card title="First" body="alpha"/>, it resolves card against the registered ComponentMap.

  1. Strip the namespace if present. <shadcn:card .../> becomes ("shadcn", "card"). A bare <card .../> falls back to "builtin" and is resolved like any built-in tag.
  2. Look up the corresponding ComponentDef.
  3. Take the def's data_model and populate each field's default value from the XML attributes. Coercion is typed.
  4. Call the def's render_fn with the populated data model and the component map.
  5. Stamp every root node of the returned StyledDom with a component-origin record. The qualified component id is "shadcn:card" and the JSON-serialised data model is the populated one. That's what lets the debugger reconstruct the invocation later.

The ComponentMap is what Dom::create_from_parsed_xml consults under the hood. The AppConfig's component_libraries field carries the registered libraries, which are folded into a ComponentMap at app-create time.

Compile: code generation roundtrip

ComponentDef::compile_fn is the inverse of render_fn. Given the same data model, it emits the source that would call this component as a function in the target language.

rust,ignore
fn(&ComponentDef, &CompileTarget, &ComponentDataModel, indent: usize)
    -> ResultStringCompileError

That's what closes the round-trip for the design-time tool.

text
┌──────────────────┐                           ┌────────────┐
   user edits │ ComponentDataModel│ ─── render_fn  ──────────▶│  StyledDom │
              └──────────────────┘                           └────────────┘
                       ▲                                            │
                       │                                            │ inspector
                       └────────────────── compile_fn ◀─────────────┘ (data → source)
                                            │
                                            ▼
                          fn card(title: &str, body: &str) -> Dom { … }

A node clicked in the inspector carries the component-origin record (the qualified component id and its data model JSON). The inspector calls compile_fn with that data model plus a CompileTarget (Rust, C, Cpp, or Python) and gets back source. From there the source can be pasted into the user's project, or handed to the codegen path that lives next to api.json. The round-trip is closed.

What the data model looks like

ComponentDataModel is a flat list of named fields. Each field has:

  • a name, like "title", "body", "on_click", or "children"
  • a ComponentFieldType (Bool, F32, Callback, EnumRef, OptionType, and so on)
  • a ComponentDefaultValue. That's the initial value the inspector shows, and it's the value render_fn reads when nothing has overridden it.

For non-trivial types like a struct of struct of enum, data_models and enum_models on the enclosing ComponentLibrary carry the type definitions. References between fields use the type's name. The inspector walks those references when it builds an editor for nested data.

That's what makes user-defined types editable in the inspector. A C callback like fn(RefAny, CallbackInfo) -> Update shows up as a Callback field. The inspector lets the user pick from a list of registered callbacks instead of asking them to write Rust into a text box.


Doc Guide En Architecture Routing (`doc/guide/en/architecture/routing.md`)


slug: architecture/routing
title: Routing
language: en
canonical_slug: architecture/routing
audience: external
maturity: mature
guide_order: 41
topic_only: false
short_desc: URL patterns, route params, and per-route layout callbacks
prerequisites: [dom, events/callbacks]
tracked_files:

  • core/src/resources.rs
  • layout/src/callbacks.rs
    default-search-keys:
  • Route
  • RouteVec
  • RouteMatch
  • AppConfig
  • LayoutCallbackInfo
  • CallbackInfo

Routing

Routes map a URL pattern to a layout callback. The same registry
drives a desktop app's view-switching and a web build's HTTP
endpoints, so a "settings page" written once shows up at the
/settings URL on the web and as a separate top-level layout on
desktop.

Registering routes

Register routes on the AppConfig before passing it to
App::create:

rust,no_run
use azul::prelude::*;

extern "C" fn layout_home(_: &mut RefAny, _: LayoutCallbackInfo) -> Dom { /* ... */ todo!() }
extern "C" fn layout_user(_: &mut RefAny, _: LayoutCallbackInfo) -> Dom { /* ... */ todo!() }
extern "C" fn layout_settings(_: &mut RefAny, _: LayoutCallbackInfo) -> Dom { /* ... */ todo!() }

fn main() {
    let mut config = AppConfig::create();
    config.add_route("/".into(), layout_home.into());
    config.add_route("/user/:id".into(), layout_user.into());
    config.add_route("/settings".into(), layout_settings.into());

    let app = App::create(initial_data, config);
    app.run(WindowCreateOptions::new(layout_home));
}

Adding a route that already exists (same pattern) replaces the
previous registration. The first registered route — or the explicit
"/" if present — is the initial layout.

Pattern syntax

Patterns are slash-separated segments. Each segment is either:

  • A literal — matches that exact path component.
  • A :name placeholder — matches any path component and captures
    it as a parameter named name.
Pattern Path Match
"/" "/" yes; no params
"/about" "/about" yes; no params
"/about" "/settings" no
"/user/:id" "/user/42" yes; id = "42"
"/user/:id" "/user/42/edit" no (segment count mismatch)
"/post/:slug" "/post/hello-world" yes; slug = "hello-world"

Patterns are matched in registration order; the first match wins.

Reading the active route

The LayoutCallbackInfo knows which route the callback is rendering:

rust,ignore
extern "C" fn layout_user(_: &mut RefAny, info: LayoutCallbackInfo) -> Dom {
    let id = info.get_route_param("id".into());
    Dom::create_h1_with_text(format!("User #{}", id.as_str()).into())
}

info.get_route_pattern() is the pattern the framework matched -
"/" when the app configured no routes at all, so a callback that
branches on it always has one string to branch on.
info.get_route_param(key) reads one extracted parameter, empty when
there is none.

Inside an event CallbackInfo, the same pair reads the same state,
via
info.get_route_pattern() (the active pattern) and
info.get_route_param(key) (one param). The set_route_param(key, value) helper modifies a param in place — useful for paginated
views that want to bump ?page=2 without a full route switch:

rust,ignore
extern "C" fn next_page(data: RefAny, mut info: CallbackInfo) -> Update {
    let cur: u32 = info.get_route_param("page".into()).as_str()
        .parse().unwrap_or(1);
    info.set_route_param("page".into(), (cur + 1).to_string().into());
    Update::DoNothing  // set_route_param already triggers a refresh
}

On web, set_route_param calls history.replaceState() so the URL
in the address bar stays in sync without adding a history entry.

Switching routes from a callback

CallbackInfo::switch_route is the imperative form — used when a
button or menu item should navigate elsewhere:

rust,ignore
extern "C" fn open_settings(_: RefAny, mut info: CallbackInfo) -> Update {
    info.switch_route("/settings".into(), StringPairVec::new());
    Update::RefreshDom
}

extern "C" fn open_user(data: RefAny, mut info: CallbackInfo) -> Update {
    let id = match data.downcast_ref::<u64>() { Some(i) => *i, None => return Update::DoNothing };
    let params = vec![StringPair { key: "id".into(), value: id.to_string().into() }].into();
    info.switch_route("/user/:id".into(), params);
    Update::RefreshDom
}

The framework swaps the active layout callback on the next frame,
fires RefreshDom, and reconciles the new tree against the
previous one — focus, scroll, and dataset state migrate across
matched nodes the same way a RefreshDom from an in-place mutation
does.

On web, switch_route calls history.pushState() so the back
button works as users expect.

A practical multi-route layout

A typical app keeps each top-level view in its own callback and
shares a model:

rust,no_run
use azul::prelude::*;

struct AppModel {
    users: Vec<User>,
    current_filter: String,
}

extern "C" fn layout_home(data: &mut RefAny, info: LayoutCallbackInfo) -> Dom {
    let model = data.downcast_ref::<AppModel>().unwrap();
    Dom::create_body()
        .with_child(navbar(info.get_route_pattern()))
        .with_child(home_content(&model))
}

extern "C" fn layout_user(data: &mut RefAny, info: LayoutCallbackInfo) -> Dom {
    let model = data.downcast_ref::<AppModel>().unwrap();
    let id = info.get_route_param("id".into());
    let user = model.users.iter().find(|u| u.id == id.as_str());

    let body = match user {
        Some(u) => user_detail(u),
        None => not_found_page(id.as_str()),
    };
    Dom::create_body()
        .with_child(navbar(info.get_route_pattern()))
        .with_child(body)
}

extern "C" fn layout_settings(data: &mut RefAny, info: LayoutCallbackInfo) -> Dom {
    let model = data.downcast_ref::<AppModel>().unwrap();
    Dom::create_body()
        .with_child(navbar(info.get_route_pattern()))
        .with_child(settings_panel(&model))
}

fn main() {
    let mut config = AppConfig::create();
    config.add_route("/".into(), layout_home.into());
    config.add_route("/user/:id".into(), layout_user.into());
    config.add_route("/settings".into(), layout_settings.into());

    let app = App::create(RefAny::new(initial_model()), config);
    app.run(WindowCreateOptions::new(layout_home));
}

Pull the navbar into its own component (with add_component_library
or a regular function) so the active-link styling — typically a
.is-active class on the link whose href matches the current
route — only lives in one place.

Web vs desktop

On a desktop build, the route is purely an in-memory selector for
which layout callback to run. There's no URL bar, no history
stack, no window.location — the route is application state.
switch_route updates that state and triggers a reconcile.

On a web build (compiled to WASM and served through azul's web
host), each registered route also maps to an HTTP endpoint on the
server side: a request to /user/42 runs layout_user with the
extracted params and returns the rendered HTML, so the page is
SEO-readable on first load. switch_route then calls
history.pushState() for the in-page client-side transition;
set_route_param calls history.replaceState(). The same callback
code drives both the server-rendered first-load HTML and the
client-side updates.

See Deploying to the web for the WASM-build pipeline,
the static asset layout, and how the web host serves routes.


Doc Guide En Data Background Tasks (`doc/guide/en/data/background-tasks.md`)


slug: data/background-tasks
title: Background Tasks
language: en
canonical_slug: data/background-tasks
audience: external
maturity: wip
guide_order: 212
topic_only: false
short_desc: Running long jobs off the layout thread
prerequisites: [animations/timers]
tracked_files:

  • core/src/task.rs
    last_generated_rev: 7ecd570e4c0c3584e5107e770058c16cb59fa6e7
    generated_at: 2026-05-02T12:00:00Z
    default-search-keys:
  • Thread
  • ThreadId
  • ThreadSendMsg
  • ThreadReceiveMsg
  • ThreadSender
  • ThreadReceiver
  • WriteBackCallback
  • Timer
  • RefAny
  • CallbackInfo
  • Update
  • Instant
  • Duration

Background Tasks

Introduction

Thread is azul's background-work primitive: an OS thread plus two typed channels back to the main thread, owned and ticked by the event loop. Use it for blocking I/O, long computations, and anything you can't finish inside a single frame budget. The framework polls each registered Thread every frame, drains messages, and runs your write-back callbacks on the main thread.

There's no embedded async runtime. The framework gives you threads and timers. If you want futures, you bring the runtime.

When to use a thread vs. a timer

  • Blocking I/O (file, network, DB) or long compute. Use Thread.
  • Periodic main-thread work (animation, polling app state). Use Timer.
  • Wait for a future without a runtime. Use Thread running a current-thread executor.

A Timer runs on the main thread and is bounded by your frame budget; see timers. A Thread runs in its own OS thread and reports back through a channel.

Spawning a thread

The worker function signature is fixed:

rust,ignore
pub type ThreadCallbackType =
    extern "C" fn(RefAny, ThreadSender, ThreadReceiver);

Build a Thread with Thread::create and hand it to the event loop via CallbackInfo::add_thread:

rust,no_run
use azul::prelude::*;

extern "C" fn worker(
    initial: RefAny,
    mut sender: ThreadSender,
    mut recv: ThreadReceiver,
) {
    // blocking work goes here
}

extern "C" fn on_click(mut data: RefAny, mut event: CallbackInfo) -> Update {
    let init_data      = RefAny::new(/* per-thread input */ ());
    let writeback_data = data.clone();
    let thread = Thread::create(init_data, writeback_data, worker);
    event.add_thread(ThreadId::unique(), thread);
    Update::DoNothing
}

Thread::create(thread_initialize_data, writeback_data, callback) takes:

  • thread_initialize_data. Moved into the worker. Available as the first RefAny argument of the worker function. Use this for inputs the thread needs but the main side doesn't.
  • writeback_data. Kept on the main side; passed back to every WriteBackCallback invocation. This is the handle the callback uses to mutate application state in response to the thread's output.
  • callback. The extern "C" fn(RefAny, ThreadSender, ThreadReceiver) that runs on the new thread.

event.add_thread(ThreadId::unique(), thread) hands the thread to the event loop. From this point the framework polls it every frame.

Sending data back: WriteBackCallback

The thread can't touch main-thread state directly. To update application data, send a ThreadReceiveMsg::WriteBack message. The payload is a RefAny plus a callback that runs on the main thread:

rust,ignore
pub type WriteBackCallbackType =
    extern "C" fn(RefAny, RefAny, CallbackInfo) -> Update;

Full example:

rust,no_run
use azul::prelude::*;

struct Loaded { rows: Vec<u32> }
struct MyModel { rows: Vec<u32> }

extern "C" fn apply_loaded(
    mut app: RefAny,
    mut payload: RefAny,
    _info: CallbackInfo,
) -> Update {
    let mut model = match app.downcast_mut::<MyModel>() {
        Some(m) => m, None => return Update::DoNothing,
    };
    let mut loaded = match payload.downcast_mut::<Loaded>() {
        Some(p) => p, None => return Update::DoNothing,
    };
    model.rows.append(&mut loaded.rows);
    Update::RefreshDom
}

extern "C" fn worker(
    _initial: RefAny,
    mut sender: ThreadSender,
    mut _recv: ThreadReceiver,
) {
    let rows = blocking_query();
    let msg  = ThreadReceiveMsg::WriteBack(ThreadWriteBackMsg {
        refany:   RefAny::new(Loaded { rows }),
        callback: WriteBackCallback {
            cb: apply_loaded,
            ctx: OptionRefAny::None,
        },
    });
    sender.send(msg);
}
# fn blocking_query() -> Vec<u32> { Vec::new() }

The first RefAny argument of apply_loaded is the writeback_data passed to Thread::create; the second is the payload the worker sent. Return Update::RefreshDom to trigger a re-layout. Update::DoNothing keeps the existing DOM.

sender.send returns bool. true if the message was queued, false if the channel is closed (the framework has already torn the thread down).

Returning Update directly

For the no-payload case (a thread that just wants to tell the UI to refresh) send ThreadReceiveMsg::Update:

rust,no_run
use azul::prelude::*;
fn _stub(mut sender: ThreadSender) {
  sender.send(ThreadReceiveMsg::Update(Update::RefreshDom));
}

The framework applies the Update value verbatim. No callback runs.

Receiving messages from the main thread

ThreadReceiver::recv() is non-blocking and returns OptionThreadSendMsg. The main thread sends three kinds of message:

  • ThreadSendMsg::Tick. One frame elapsed. An opportunity to check progress, send a chunk back.
  • ThreadSendMsg::TerminateThread. The framework is dropping the thread; finish quickly.
  • ThreadSendMsg::Custom(RefAny). App-defined message.

Tick arrives automatically on each frame. TerminateThread is sent when the thread is removed via CallbackInfo::remove_thread or the owning Thread handle is dropped. Custom is only delivered if your code arranges for it.

Cooperative termination

A long-running thread should poll for TerminateThread between work units:

rust,no_run
use azul::prelude::*;

fn _stub(mut recv: ThreadReceiver, items: Vec<u32>) {
  for item in items {
    if let OptionThreadSendMsg::Some(ThreadSendMsg::TerminateThread) = recv.recv() {
        return;
    }
    process(item);
  }
}

fn process(_: u32) { }

If the worker doesn't check, it runs to completion regardless. The framework's destructor sends TerminateThread and then joins. A non-cooperative thread blocks teardown until its callback returns.

Cancelling from the main thread

rust,no_run
use azul::prelude::*;
struct MyModel { 
    thread_id: Option<ThreadId> 
}

extern "C" 
fn on_cancel(mut data: RefAny, mut event: CallbackInfo) -> Update {
    let mut m = match data.downcast_mut::<MyModel>() {
        Some(m) => m, None => return Update::DoNothing,
    };
    if let Some(id) = m.thread_id.take() {
        event.remove_thread(id);
    }
    Update::RefreshDom
}

remove_thread schedules the same TerminateThread + drop sequence the destructor runs.

Waiting without blocking

The API has no blocking sleep. Blocking the UI thread is wrong on every
target, and the browser has no such primitive at all, so anything that has to
happen later is expressed as a timer:

rust,ignore
let timer = Timer::create(data, on_tick, get_system_time_fn)
    .with_delay(Duration::from_millis(500))      // first run after 500 ms
    .with_interval(Duration::from_millis(1000)); // then once a second
info.add_timer(TimerId::unique(), timer);

A worker thread created with Thread::create runs on its own OS thread and
may pace itself with the platform's own sleep (std::thread::sleep in Rust,
nanosleep / Sleep in C) - that never blocks the UI.

Instant and Duration

The thread API uses Instant and Duration from the framework rather than the std types directly so timing logic compiles on no_std targets. Both are two-variant enums:

rust,ignore
pub enum Instant  { System(InstantPtr),    Tick(SystemTick) }
pub enum Duration { System(SystemTimeDiff), Tick(SystemTickDiff) }

On std targets, Instant::System wraps std::time::Instant. On embedded or WASM targets that lack a real-time clock the variant is Instant::Tick(SystemTick), a frame counter you advance from your event loop. Mixing variants panics, so pick one per platform and stay consistent.

There's no Duration::from_millis shorthand. Build the SystemTimeDiff explicitly:

rust,ignore
Duration::System(SystemTimeDiff::from_millis(250))
Duration::System(SystemTimeDiff::from_secs(2))
Duration::System(SystemTimeDiff::from_nanos(1_000))

Instant exposes:

  • Instant::now(). Current time on the active variant.
  • duration_since(&earlier) -> Duration. Panics if earlier > self or if the variants don't match.
  • linear_interpolate(start, end) -> f32. Clamped 0.0–1.0, useful for animation progress.
  • add_duration(...) -> Self. Additive offsets.

Reserved thread IDs

ThreadId reserves the first few IDs for framework-internal use. ThreadId::unique() skips past them, so user code never collides. Don't construct a literal ThreadId { id: 0..=4 } in user code.

What you can't do today

  • Run async fn directly. The framework doesn't provide an executor. To use Tokio, futures-rs, or smol, spawn a Thread, build a Runtime inside it, and use WriteBackCallback to surface results.
  • Stream raw socket data through the framework. Networking has its own page; see networking. Until the runtime side lands, use a Thread plus std::net::TcpStream for the same shape as the example above.
  • Share &mut references between thread and main. Communication is RefAny payloads only. Lock-free shared state isn't part of the framework. Wrap a Mutex inside RefAny if you need it.

Doc Guide En Data Database (`doc/guide/en/data/database.md`)


slug: data/database
title: Local-first Database
language: en
canonical_slug: data/database
audience: external
maturity: beta
guide_order: 210
short_desc: A local-first key/value + index store that syncs to a backup endpoint
prerequisites: [hello-world, data/background-tasks]
tracked_files:

  • core/src/db.rs
  • dll/src/desktop/extra/sqlite/mod.rs
    last_generated_rev: 730697e830000000000000000000000000000000
    generated_at: 2026-09-07T00:00:00Z
    default-search-keys:
  • Db
  • DbConfig
  • DbValue
  • DbRows
  • DbKeyRange
  • DbScope
  • sync_now

Local-first Database

Introduction

Db is a local-first key/value + index store. The local store holds a
working set of your data (a file under the app's data directory on desktop,
IndexedDB in the browser); every write lands there immediately and is queued
in a row-level oplog; sync_now pushes the queued rows to an optional backup
endpoint and pulls the remote changes back. Being offline is a normal state
(Queued), never an error.

The surface is identical on every target and it is not SQL: desktop keeps a
pure-Rust SQLite engine as a hidden implementation detail and the browser
uses its own database, so no engine is ever shipped to the web. Reads are
requests that resume a callback (the browser's storage is asynchronous),
writes are fire-and-forget, and every result struct has a static
downcast(result) accessor. Like Pdf and AudioSink the handle is a
C-ABI value you keep in your own State; it is reference counted, so clones
share the store.

Opening + reading

text
/* Detailed source-code truncated for AI context efficiency. */

The request functions and the result struct each one resumes with:

request result struct
Db::open(config, data, on_open) DbOpenResult { result: ResultDbDbError }
db.get(store, key, data, on_result) DbValueResult { value: OptionDbValue, error }
db.iterate(store, range, limit, data, on_result) DbRowsResult { rows: DbRows, error }
db.query_index(store, index, range, limit, data, on_result) DbRowsResult
db.subscribe(scope, data, on_change) DbChangeResult { store, key, value }, repeatedly
db.sync_now(scope, data, on_result) DbSyncStatusResult { status: DbSyncStatus }

db.set(store, key, value) and db.remove(store, key) return bool and
never block; db.sync_status() is a synchronous snapshot.

Values, keys and ranges

[DbValue] is Null, Integer(i64), Real(f64), Text(AzString) or
Blob(U8Vec), with is_null / as_integer / as_real / as_text
accessors. Keys are any non-Null value and order the way SQLite orders
values: Null < numbers < text < blobs. A [DbKeyRange] bounds a scan:
DbKeyRange::all(), between(lower, upper), only(key), or the struct
itself with lower_open / upper_open for exclusive bounds.

Results come back as a [DbRows] grid with the two columns key and
value: num_rows(), num_columns() and get(row, col).

Schema and indexes

Stores are created on first write. To get secondary indexes and per-store
conflict policies, declare them up front:

rust,ignore
use azul::db::{DbConflictPolicy, DbIndexSchema, DbSchema, DbStoreSchema};

let schema = DbSchema {
    stores: vec![DbStoreSchema {
        name: "notes".into(),
        indexes: vec![DbIndexSchema {
            name: "by_tag".into(),
            key_path: "tag".into(),   // a top-level field of a JSON object value
            unique: false,
        }].into(),
        conflict_policy: DbConflictPolicy::LastWriteWins,
    }].into(),
};
let config = DbConfig::create("notes").with_schema(schema);

Values are indexed when they are JSON objects: key_path names the field
whose value becomes the index key. db.query_index("notes", "by_tag", DbKeyRange::only(DbValue::Text("work".into())), 0, data, on_rows) then
returns the matching rows in index-key order.

Sync, scope and budget

Sync is explicit: sync_now pushes every dirty row, then refreshes the
given (or the configured) scope from the endpoint. The [DbSyncStatus] it
resumes with reports state (Disconnected without an endpoint, Idle,
Pushing, Pulling, Queued while offline, Error), how many operations
still wait to be pushed, when the last sync completed, how many bytes the
local store uses against its budget, and how much of the scope the working
set covers. set_on_sync_status delivers the same struct after every sync.

Local stores are size-constrained (browser quotas), so the portable model is
working-set replication rather than full copies:

  • with_scope(DbScope { collections }) limits what is pulled to a set of
    [DbCollectionScope]s (a store, optionally a key range or an index
    predicate). subscribe(scope, ...) extends the working set and reports
    every change inside it, local or pulled.
  • with_local_budget_bytes(n) caps the local store. Under pressure clean
    rows are evicted oldest-first; dirty (unpushed) rows are never evicted
    before a successful push.
  • Conflicts (a key changed locally and remotely since the last sync) follow
    the store's [DbConflictPolicy]: LastWriteWins (default),
    ServerWins, ClientWins, or Merge with a merge function installed via
    set_on_conflict(store, data, on_merge). The merge function is a pure
    fn(RefAny, DbConflict) -> DbValue (return Null to delete) invoked
    while the sync is applied; it must not touch the UI.

The wire protocol is a row-level oplog over HTTPS (POST /push with
the queued operations as JSON, GET /pull?db=..&since=<cursor> for
the remote changes), so a thin server in front of any store can serve it;
the browser host speaks the same protocol through fetch().

Feature gating

The Db handle is always present, but the desktop engine is opt-in via the
db-sqlite Cargo feature (part of build-dll) and sync needs the http
feature. Without an engine Db::open resumes with
DbErrorKind::NoEngine; without http, sync_now reports Error naming
the missing feature. PlatformCapability::sql() and ::sync() answer these
questions at runtime.

See also


Doc Guide En Data Networking (`doc/guide/en/data/networking.md`)


slug: data/networking
title: Networking
language: en
canonical_slug: data/networking
audience: external
maturity: stub
guide_order: 211
topic_only: false
short_desc: HTTP from a callback
prerequisites: [data/background-tasks]
tracked_files:

  • core/src/task.rs
    last_generated_rev: 7ecd570e4c0c3584e5107e770058c16cb59fa6e7
    generated_at: 2026-05-02T12:00:00Z
    default-search-keys:
  • HttpRequestConfig
  • HttpClient
  • ThreadPool
  • HttpResponse
  • Thread
  • ThreadSendMsg
  • ThreadReceiveMsg
  • WriteBackCallback
  • RefAny
  • U8Vec
  • http_get
  • download_bytes
  • is_url_reachable
  • IrohEndpoint
  • IrohConfig
  • IrohEvent
  • IrohLoadBalancer

Networking

Introduction

Stub. The framework ships a small blocking HTTP helper (HttpRequestConfig, HttpResponse) you can call from inside a Thread. For raw sockets, async I/O, WebSockets, or anything else, do networking the same way you do any other blocking I/O: from a Thread.

What's available

  • HttpRequestConfig. A small blocking HTTP client. Configure timeouts, headers, max response size, and TLS verification, then call http_get, download_bytes, or is_url_reachable. The convenience constructors http_get_default and download_bytes_default skip configuration.

  • HttpResponse. The result. Carries status_code, body (U8Vec), headers, content_type, content_length. Use is_success, is_redirect, is_client_error, is_server_error, body_as_string to inspect it.

  • IrohEndpoint. Peer-to-peer QUIC connections between apps, for media frames and messages. See Peer-to-peer connections.

The framework is intentionally runtime-agnostic. There's no built-in raw-socket type and no async runtime integration. HTTP is request / resume shaped (below), so ordinary fetches need no worker thread; anything heavier still belongs in one.

Making a request

Every HttpRequestConfig call is a request: it returns a RequestId
immediately and resumes the callback you pass in with the answer. On desktop
the transfer runs synchronously inside the call and the callback runs right
after the requesting callback returns; in the browser it is a fetch() and
the callback runs on a later task. Either way the callback never runs
re-entrantly inside the callback that issued the request, and the same code
works on both.

rust,ignore
extern "C" fn on_fetch_clicked(data: RefAny, _info: CallbackInfo) -> Update {
    let cfg = HttpRequestConfig::create()
        .with_timeout(10)
        .with_user_agent("my-app/1.0");
    let _request = cfg.http_get("https://example.org/api".into(), data, on_response);
    Update::DoNothing
}

extern "C" fn on_response(mut data: RefAny, _info: CallbackInfo, result: RefAny) -> Update {
    let Some(answer) = HttpGetResult::downcast(result).into_option() else {
        return Update::DoNothing;
    };
    match answer.result {
        ResultHttpResponseHttpError::Ok(resp) => {
            // mutate the model through `data` the usual way
            let _ = (&mut data, resp);
            Update::RefreshDom
        }
        ResultHttpResponseHttpError::Err(e) => {
            eprintln!("request failed: {e:?}");
            Update::DoNothing
        }
    }
}

The result structs are HttpGetResult (for http_get, http_post and
http_request), HttpBytesResult (download_bytes) and
HttpReachableResult (is_url_reachable); each has a static
downcast(result) accessor. There is no default-config shortcut: build a
config with HttpRequestConfig::create() and call the method on it.

A request may be issued from a worker thread as well; its resume still runs
on the main thread. CORS applies in the browser and cannot be escaped: a
target that does not send Access-Control-Allow-Origin fails with
HttpError::Other naming CORS.

Reusing connections

A request opens a connection, uses it once and closes it. Many requests to the
same server each pay for a new connection and TLS handshake. To keep
connections open between requests, create an HttpClient once (in your app
state, not per request) and attach a clone to each config:

rust,ignore
// once, e.g. when building the app state
let client = HttpClient::create(
    HttpClientConfig::create().with_max_idle_connections_per_host(8),
);

// per request
let cfg = HttpRequestConfig::create().with_client(client.clone());

All clones share one pool, which closes its connections when the last clone is
dropped. With a client, TLS verification follows the client's
HttpClientConfig; the request's own timeout, headers and size limit still
apply. In the browser the client changes nothing: the browser pools
connections itself.

A pooled connection skips the TCP and TLS handshake, but not the DNS lookup:
the host is resolved before the pool is asked for a connection. When many
requests go to the same few hosts, let the client remember the answer:

rust,ignore
let client = HttpClient::create(HttpClientConfig::create().with_dns_cache_secs(300));

The default is 0, one lookup per request. A host that changes its address
is reached again once its cached answer expires.

The same idea applies to threads. ThreadPool::create(n) starts n workers,
and pool.create_thread(...) returns an ordinary Thread whose body runs on
one of them, so many short jobs don't each start an OS thread.

Modelling connection state

Use a plain enum on the application data side. A typical shape:

rust,ignore
enum ConnectionStatus {
    Idle,
    Connecting { thread_id: ThreadId, started: Instant },
    Done       { response:  HttpResponse },
    Failed     { reason:    String },
}

Cancel by calling event.remove_thread(thread_id) from a click handler. The thread destructor sends TerminateThread and joins. If your worker checks recv.recv() between operations, cancellation is prompt.

Using an async runtime

The framework doesn't host a runtime, but nothing prevents you from running one inside a Thread:

rust,ignore
extern "C" fn tokio_worker(
    _initial:   RefAny,
    mut sender: ThreadSender,
    mut _recv:  ThreadReceiver,
) {
    let rt = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .expect("runtime");

    rt.block_on(async {
        // futures-based code here; pump results through `sender`
    });
}

A current-thread runtime keeps everything on the worker. Use a multi-threaded runtime if you need a worker pool, but spawn it once and reuse. Runtimes are expensive to construct.

Peer-to-peer connections

IrohEndpoint (module iroh) connects two apps directly over QUIC, with hole punching and an optional relay, using iroh. An endpoint is identified by its public key. A ticket is the dialing string that carries the key and the endpoint's current addresses; hand it to the other side as a link or QR code.

rust,ignore
let endpoint = IrohEndpoint::bind(
    IrohConfig::create("my-app/1").with_relay_mode(IrohRelayMode::Disabled),
);

extern "C" fn pump(mut data: RefAny, _info: TimerCallbackInfo) -> TimerCallbackReturn {
    let endpoint = endpoint_of(&mut data);
    while let Some(event) = endpoint.recv().into_option() {
        match event.kind {
            IrohEventKind::Ready => show_invite(&mut data, event.text),
            IrohEventKind::PeerConnected => remember_peer(&mut data, event.peer),
            IrohEventKind::Frame => draw_frame(&mut data, event.track, event.data),
            IrohEventKind::Message => handle_message(&mut data, event.data),
            IrohEventKind::PeerDisconnected | IrohEventKind::Error => report(&mut data, event.text),
        }
    }
    TimerCallbackReturn::continue_unchanged()
}
  • connect(ticket) dials. The outcome arrives as a PeerConnected or Error event.
  • send_frame(peer, track, data) and broadcast_frame(track, data) send each frame on its own QUIC stream. A frame that has not left yet is replaced by the next frame of the same track, so a slow link lowers the frame rate instead of adding latency. JPEG frames from RawImage::encode_jpeg are the simplest video format.
  • send_message(peer, data) is reliable and ordered, for chat and control data.
  • peer_stats(peer) reports whether the path is direct or relayed, the RTT, the congestion window and frame counters.
  • Nothing arrives unless you poll recv, so drive it from a timer.
  • For rooms, IrohLoadBalancer picks the peers that forward media for everyone (backbone_size, select_backbone), and IrohTileRole::rendition_height picks the resolution a video tile should request.

The engine needs the dll's iroh feature, which build-dll enables; PlatformCapability::iroh() reports whether it is compiled in. In the browser the handle exists but does not bind yet. examples/azul-meet opens two windows that exchange camera and screen frames this way.

What this page doesn't cover

  • TLS configuration beyond disable_tls_cert_verification. For custom TLS stacks, use rustls, native-tls, or a third-party HTTP client inside the worker.
  • Mid-frame cancellation of in-flight DNS or TCP handshakes. std::net doesn't expose this. Use socket2 or a third-party client if you need it.
  • WebSockets, gRPC, HTTP/2. Any blocking client works inside a Thread.

Doc Guide En Debugging E2e Testing (`doc/guide/en/debugging/e2e-testing.md`)


slug: debugging/e2e-testing
title: End-to-End Testing
language: en
canonical_slug: debugging/e2e-testing
audience: external
maturity: wip
guide_order: 202
topic_only: false
short_desc: Driving an Azul app from a script for tests
prerequisites: [debugging]
tracked_files:

  • core/src/debug.rs
  • dll/src/desktop/logging.rs
  • dll/src/desktop/shell2/common/debug_server.rs
    last_generated_rev: 7ecd570e4c0c3584e5107e770058c16cb59fa6e7
    generated_at: 2026-05-02T00:00:00Z

End-to-End Testing

Introduction

WIP. Assertion vocabulary, JSON keys, and the in-browser test designer all work today; field names may shift as the assertion engine grows.

A test is a JSON object with a name, optional setup, and an ordered list of steps. A step is one debug command (click, text_input, resize, …) or one assertion (assert_text, assert_layout, …). The runner drives a real App instance and evaluates each assertion against the live DOM. Two ways to run:

  • AZ_E2E=<file> ./my_app boots your application headlessly and runs the JSON file as a single batch. Prints cargo-test-style output, exits 0 if every test passes, 1 otherwise.
  • POST /e2e/run from a script or the in-browser inspector queues the same payload against a running app.

A first test

json
{
  "name": "Button click increments counter",
  "setup": {
    "window_width": 800,
    "window_height": 600,
    "dpi": 96
  },
  "steps": [
    { "op": "wait_frame" },
    { "op": "click", "selector": "button" },
    { "op": "wait_frame" },
    { "op": "assert_text", "selector": "p", "expected": "6" },
    { "op": "take_screenshot" }
  ]
}

Run it:

bash
AZ_BACKEND=headless AZ_E2E=test.json ./my_app

Output mirrors cargo test:

text
running 1 test
test "Button click increments counter" ... ok

test result: ok. 1 passed; 0 failed; finished in 0.21s

A test file is either one test object or an array of them.

File and step shape

json
{
  "name": "string",
  "description": "string?",
  "config": {
    "continue_on_failure": false,
    "delay_between_steps_ms": 0
  },
  "setup": {
    "window_width":  800,
    "window_height": 600,
    "dpi":           96,
    "app_state":    { "any": "json" }
  },
  "steps": [
    { "op": "...", "screenshot": false, "<params>": "..." }
  ]
}

config.continue_on_failure keeps running steps after the first failure (still reports the test as failed). config.delay_between_steps_ms inserts a sleep, useful for visually inspecting a test that runs against a visible window. setup.app_state puts each test into a known state without restarting the process.

screenshot: true on any step captures the rendered window after the step and embeds the base64 PNG in that step's result.

Step operations

Every op accepted by POST / (covered in Debugging) is a valid step. Plus the assertion ops below.

  • assert_text (params: selector, expected). First-match text content equals expected.
  • assert_exists (params: selector). At least one node matches.
  • assert_not_exists (params: selector). No node matches.
  • assert_node_count (params: selector, expected int). Exactly expected nodes match.
  • assert_layout (params: selector, property, expected, tolerance?). property is x, y, width, or height. Default tolerance 0.5 px.
  • assert_css (params: selector, property, expected). Computed CSS value equals expected.
  • assert_app_state (params: path, expected). Dot-path against the JSON-serialised app data.
  • assert_scroll (params: selector, x?, y?, tolerance?). Scroll position of the first match.
  • assert_screenshot (params: reference, threshold?, max_diff_ratio?, save_actual?). Compares the current screenshot against a reference PNG. A missing reference PNG is a FAILURE. Run with AZ_E2E_RECORD=1 to record a provisional baseline — the step still fails, so an unreviewed baseline can never gate green.

Mounting a DOM from the test file

mount replaces the app's layout_callback output with an inline document, so a test does not depend on whatever DOM the host binary happens to build. Markup and CSS are given as an array of lines (a single string is also accepted):

json
{ "op": "mount",
  "html": ["<div class=\"a\">", "  <p>hi</p>", "</div>"],
  "css":  [".a { width: 100px; background: blue; }"] }

The lines are joined with \n, the CSS is injected as a <style> element and the document is parsed by azul's XML→StyledDom parser. unmount restores the app's own DOM.

Damage, frame-work and resource assertions

snapshot_frame (as), snapshot_resources (as), snapshot_managers (as) and reset_frame_counters are plain ops that record state for the assertions below. They read LayoutWindow::frame_report (paint/present damage + work counters).

Three more plain ops make the frame report directly observable:

  • tick_ms (params: ms). Advances the engine clock by ms without sleeping and renders a frame. Animations, scroll momentum, scrollbar fade and cursor blink all read Instant::now(), which honours this offset — so an animation can be driven to completion and then asserted to converge (assert_idle_stable).
  • get_frame_report. Returns the whole FrameReport as JSON: paint_damage_kind/paint_damage_rects/paint_damage_area_ratio, the same for present_*, relayout_iterations, dom_regenerations, hit_depth_cap, terminal_result, frame_index, test_clock_offset_ms.
  • capture_damage_png (params: path, which? = paint|present, crop?). Writes the partial screen update as a PNG: the frame masked to the damage region (everything else transparent), optionally cropped to the damage bounding box. No damage ⇒ a 1×1 transparent PNG.

The assert_damage* ops look at the damage accumulated since the last reset_frame_counters, not at the last frame — between the step that changed something and the assertion, the engine may render further idle frames whose damage is None, and those would otherwise clobber it. Pass "frame": "last" to look at the most recent frame only. assert_idle_stable always looks at the last frame.

  • assert_damage (params: which? = paint|present, kind? = none|rects|full, min_rects?, max_rects?, max_area_ratio?, frame?).
  • assert_changed (params: vs, min_damage_rects?, threshold?). LIVENESS: damage must be non-empty and pixels must actually differ from the snapshot.
  • assert_damage_covers_changes (params: vs, threshold?, slack_px?). SOUNDNESS: every changed pixel lies inside the damage rects (under-paint ⇒ stale screen).
  • assert_damage_incremental (params: max_area_ratio?, default 0.5). The repaint is a PATCH, not a full redraw.
  • assert_idle_stable (params: vs?). No input ⇒ damage drained to none (and pixels identical to vs). The infinite-redraw detector.
  • assert_work_bounded (params: max_relayouts?, max_dom_regens?, allow_depth_cap?). Measured since the last reset_frame_counters. Fails if MAX_EVENT_RECURSION_DEPTH was hit — an invalidation loop is now a red test rather than a log_warn.
  • assert_resource_counts (params: vs, then one mode per counter: "eq"|"le"|"ge" or an exact number). Counters: fonts, font_hash_map, font_id_map, font_families_map, images, image_key_map, parsed_fonts, font_hash_to_families, font_chain_cache.

Manager assertions: consistency, and non-interference

The engine keeps ~20 managers on LayoutWindow (scroll, focus, hover, gesture, text edit, undo, virtual view, GPU state, the capability managers …). Two different questions can be asked about them, and they need two different assertions.

Is any manager's state internally wrong? — assert_manager_invariants (params: managers?, cross?, min_checked?). It sweeps for dangling node keys (X10: no manager key may name a node that no longer exists) plus the pairwise invariants. Six of them (X2 X3 X5 X6 X9 X10) hold unconditionally and run by default. Four are opt-in because each is a statement about an interaction and hard-fails when the scenario never performed it:

  • X1 — after a scroll_into_view, the target is inside its container's visible rect according to ScrollManager's own offset.
  • X4 — the DragState the public drag API hands out describes the same drag the engine is running.
  • X7 — focus was cleared, so no cursor scroll may still be owed (cursor_needs_initialization / pending_contenteditable_focus).
  • X8 — during a text-selection drag, the container that autoscrolled is the selection focus node's own scroll container.

Naming a manager the assertion cannot check, or an invariant nobody implemented, is a hard failure with the reason attached — never a silent pass. min_checked lets a scenario require that the sweep actually inspected N things, so "nothing was wrong" cannot be confused with "there was no state to inspect".

Did this op move a manager it has no business touching? — snapshot_managers (as) + assert_only_managers_changed (params: vs, changed, min_populated?). Record every manager's state, perform one op, and require the set that moved to be exactly changed. Write "changed": [] for "this must not touch a single manager".

json
{ "op": "snapshot_managers", "as": "scrolled_and_settled" },
{ "op": "key_down", "key": "tab" },
{ "op": "key_up",   "key": "tab" },
{ "op": "wait_frame" },
{ "op": "assert_only_managers_changed",
  "vs": "scrolled_and_settled", "changed": ["focus"], "min_populated": 2 }

A scroll that quietly cleared focus, or a Tab that reset a scroll offset, renders identically to one that did not: it is invisible in the DOM, in the pixels and in the resource counters. This is the only assertion that sees it.

The check is two-sided. A manager that moved without being listed is a leak; a manager listed in changed that did not move is a scenario that has stopped exercising what it claims to. Without the second half, "changed": [every, manager] would pass forever while asserting nothing. min_populated is the third guard: two empty snapshots always compare equal, so it requires that at least N managers were actually holding state.

Settle the frames (wait_frame + wait, and tick_ms past any running fade or animation) before both the snapshot and the assertion. Transient repaint flags are real manager state and are recorded, so comparing a settled window against a mid-flight one correctly reports them as moved.

Selector resolution accepts CSS selectors (.btn, #counter, div > span), explicit node_id integers, or a text substring match. Pick whichever is least brittle. selector is preferred because the inspector can build them by clicking nodes in the DOM tree.

Mocking OS requests: dialogs, HTTP, files and devices

A request (FileDialog::open_file, HttpRequestConfig::http_get, FilePath::read_bytes, Db::sync_now, ...) asks the OS for something and resumes the app later through its resume callback. A scenario cannot click a native file picker or reach the network, so under AZ_E2E / AZ_BACKEND=headless every request first consults a mock store that the scenario fills with the mock op. A mocked answer resumes the app through the same pump and the same callback a real answer takes; the app code under test is unchanged.

  • mock (params: set object). Arms the store and queues answers. Keys of set:
    • reset: true - empties every queue and the records. Put it first in every scenario.
    • file_open: { "path": "..." } or null - the next FileDialog::open_file resumes with that path, or as cancelled. Queued: one answer per call, in order.
    • file_open_multi: { "paths": [...] } - the next FileDialog::open_multiple_files.
    • color_pick: { "r": 0, "g": 0, "b": 0 } or null - the next ColorPickerDialog::open.
    • save_file: { "path": "..." } or null - the next FileDialog::save_file.
    • save_bytes: { "accept": true } - what FileDialog::save_bytes reports (default true). The bytes are recorded either way, for assert_saved_file.
    • file_read: { "": { "text": "..." } } or { "b64": "..." } per path - canned content for FilePath::read_bytes / read_string. Paths without an entry read the real file.
    • http: { "": { "status": 200, "text": "...", "content_type": "..." } } or { "error": "..." } per pattern - canned answers for http_get, http_post, http_request, download_bytes, is_url_reachable and the Db sync endpoints. A pattern is an exact URL, a prefix ending in *, or *; b64 replaces text for binary bodies.
    • audio_devices: { "outputs": [...], "inputs": [...] } - AudioDeviceList::enumerate.
    • video_decode: { "none": true } - decode_mp4_h264 resumes without a video.

A request that runs while the store is armed but has no queued answer never opens a native dialog and never touches the network: it resumes as cancelled (dialogs), unreachable (HTTP) or empty (devices), and is recorded. That keeps a scenario that forgot to mock something from hanging on a dialog nobody can click; three assertions turn the record into a verdict:

  • assert_no_unmocked_requests. Every request since the last reset had a canned answer. End every scenario that mocks what it needs with it.
  • assert_unmocked_request (params: request, a substring of the record such as "FileDialog::open_file" or "http https://..."). The opposite: proves that a request ran without an answer, for scenarios that test the cancelled path.
  • assert_saved_file (params: name or name_ends_with, mime?, min_len?, contains?). An export recorded by FileDialog::save_bytes matches; the newest matching export wins.
json
{ "op": "mock", "set": { "reset": true,
    "file_open": { "path": "/tmp/report" },
    "file_read": { "/tmp/report": { "text": "a,b\n1,2\n" } } } },
{ "op": "click", "text": "Open file" },
{ "op": "wait_frame" },
{ "op": "assert_text", "selector": "#file", "expected": "file: /tmp/report (8 bytes)" },
{ "op": "assert_no_unmocked_requests" }

Mocked answers are delivered by the pump that also runs timers, and the pump keeps going while a resume callback issues further requests, so one wait_frame after the click settles a whole chain such as open -> read. The resume example (examples/rust/src/resume.rs) has one button per request kind, and the specs in tests/e2e/resume_*.json drive all of them, including the "forgot to mock" case.

Step results

Each step returns:

json
{
  "step_index": 1,
  "op": "click",
  "status": "pass",
  "duration_ms": 12,
  "logs": ["[Input] click at (200, 300)", "..."],
  "screenshot": "data:image/png;base64,...",
  "response": { "...command-specific data..." },
  "error": null
}

A test rolls steps up:

json
{
  "name": "Button click increments counter",
  "status": "pass",
  "duration_ms": 156,
  "step_count": 5,
  "steps_passed": 5,
  "steps_failed": 0,
  "steps": [ ... ],
  "final_screenshot": "data:image/png;base64,..."
}

final_screenshot is the screenshot from the last step that requested one, or null.

Driving with curl

The debug server's POST / accepts a run_e2e_tests op that bypasses the file-based runner:

bash
curl -s -X POST http://127.0.0.1:8765/ \
     -H 'Content-Type: application/json' \
     -d @tests.json | jq '.data.value.results'

Default request timeout is 30 s; tests that take longer must either pass "timeout_secs": 600 in the request body or use AZ_E2E= (which uses a 600 s timeout internally). The Hello World shell driver in Debugging shows the same wait-frame / click / read-back pattern that an E2E step performs internally. You can build a test scenario incrementally as a curl script, then crystallise it into a JSON file once it works.

Continuation across relayout

Some steps (resize, set_node_text, delete_node) require a relayout pass to complete before the next step can read the resulting state. The runner detects these and yields back to the timer; the test resumes on the next tick. From the test author's perspective this is invisible: write resize followed by assert_layout and the runner handles the suspension.

This is why AZ_E2E requires the application to reach the event loop. The test cannot make progress while the timer is not running. With AZ_BACKEND=headless (or AZUL_HEADLESS=1) the event loop runs without an OS window, which is the standard CI configuration.

CI integration

A typical workflow:

  1. Build the application once: cargo build --release.
  2. Run the test bundle headlessly: AZ_BACKEND=headless AZ_E2E=tests/smoke.json ./target/release/my_app.
  3. Process exit code is the test verdict. Use it as the CI step's exit code.
  4. For screenshot diffs, set assert_screenshot steps with a reference PNG path; commit the references to the repo and update them via a BLESS=1 workflow when intentional UI changes land.

For per-PR feedback, every shell script in tests/e2e/ is a curl-against-AZ_DEBUG driver that became a JSON test once it was stable.

The language-binding board (scripts/e2e_language_matrix.sh) runs the same counter scenario against every binding and prints a status table. On a --gate-shipped failure it now dumps the tail of each failed binding's build/run log and its AZ_RECORD trace, so CI logs explain the failure without a local repro.

Language toolchain prerequisites

Each binding drives the prebuilt library (built with --features build-dll,debug-server, which compiles in the AZ_E2E runner) and needs its own toolchain to build and run the hello-world. Missing toolchains report ⊘ SKIP (they do not gate); a present-but-broken toolchain reports ✗ FAILS. The shipped tier:

Binding Toolchain Notes / common gotchas
Rust cargo/rustc 1.91+ The example links the prebuilt DLL with --no-default-features --features link-dynamic (not link-static, which rebuilds Azul without the debug-server E2E runner). On Windows the import lib must be reachable as both azul.dll.lib and azul.lib.
C clang/gcc; MinGW on Windows (links azul.dll.lib) Include the generated azul.h.
C++ clang++/g++ with -std=c++20 (also 03–23) Include azul20.hpp. Wrapper types are move-only RAII; pass owning values with std::move — never copy a moved-from wrapper.
Python CPython 3.10+ Uses the azul extension module (a separate build), not FFI.
Node node + the koffi npm package macOS strips DYLD_* from the hardened node, so koffi must find the lib by an explicit path or the lib must sit next to the script.
C# .NET SDK (dotnet) P/Invoke; the native lib must be loadable next to the build output.
Java JDK 17+ and Maven (mvn) + JNA 5.14 The build targets release 11, so a JDK older than 11 fails with invalid target release: 11 — pin JDK 17 (e.g. actions/setup-java@v4 with Temurin 17). On macOS the JVM needs -XstartOnFirstThread.
Kotlin JDK 17+, kotlinc (or Gradle) + JNA 5.14 Same JDK requirement as Java; kotlinc compiles Azul.kt + HelloWorld.kt against the JNA jar.
Ruby ruby + the ffi gem require 'ffi' fails with LoadError unless the gem is installed: gem install ffi. It is not preinstalled on CI runners.
Lua LuaJIT (the stock PUC lua has no FFI); full E2E runs on arm64/macOS, not x86-64 LuaJIT's ffi cannot call a C function that passes an aggregate by value on some ABIs: on x86-64 (SysV), App.create(.., AppConfig) raises NYI: cannot call this C function (yet). This is a LuaJIT limitation, not a version issue (a freshly-built current LuaJIT 2.1 still NYIs) and not a binding bug — the same azul.lua runs on arm64/macOS, where the struct is passed differently. CI therefore reports lua as ⊘ SKIP on x86-64 (a toolchain limit, which never gates).
Go go toolchain + a C compiler (cgo) —

AZ_LOG is on by default (see Debugging), so a binding that builds but exits early will print the platform-layer trace on stderr, which the board captures.

Recording a test from the inspector

The in-browser inspector at http://localhost:/ includes an E2E designer. Click "Record", interact with the running window normally, and the inspector captures each click, text input, scroll, and resize as a step. Click "Stop", review the steps, and either run them in place or export to JSON for AZ_E2E.

The recorder uses the same selector resolver as assert_*, so the captured steps are robust to layout shifts as long as your DOM has stable IDs and classes.

Limitations

  • A test runs against the current application instance — there is no per-test sandbox. Use setup.app_state to put the app in a known state before each test.
  • take_native_screenshot returns the actual framebuffer of the running window. Pixel-identical comparison across platforms is unrealistic; use assert_screenshot with a max_diff_ratio tolerance, or pin the diff job to one platform in CI.
  • The runner does not multi-thread tests. Each test runs to completion before the next starts. If you need parallel runs, spawn N processes on N ports.

Doc Guide En Debugging Profiling (`doc/guide/en/debugging/profiling.md`)


slug: debugging/profiling
title: Profiling
language: en
canonical_slug: debugging/profiling
audience: external
maturity: wip
guide_order: 201
topic_only: false
short_desc: Tracking allocations and per-frame budgets
prerequisites: [debugging]
tracked_files:

  • core/src/debug.rs
  • dll/src/desktop/logging.rs
  • dll/src/desktop/shell2/common/debug_server.rs
    last_generated_rev: 7ecd570e4c0c3584e5107e770058c16cb59fa6e7
    generated_at: 2026-05-02T00:00:00Z
    default-search-keys:
  • App
  • StyledDom
  • Dom
  • RefAny

Profiling

Introduction

WIP. JSONL field names may shift; the env-var surface (AZ_PROFILE, AZ_PROFILE_OUT, AZ_E2E_TEST) is stable.

The profiler is gated by a build feature in the layout crate and driven entirely by environment variables. Without the feature it compiles to inline no-ops; with the feature on but no AZ_PROFILE set, every probe checks one atomic and returns. There is no static cost to leaving probes in production builds.

bash
AZ_PROFILE=cpu ./my_app                                            # per-phase CPU dump to stderr
AZ_PROFILE=memory ./my_app                                         # heap-breakdown dump to stderr
AZ_PROFILE=heap,jsonl AZ_PROFILE_OUT=/tmp/run.jsonl ./my_app       # phase boundaries to JSONL
AZ_PROFILE=heap,jsonl,detail AZ_PROFILE_OUT=/tmp/d.jsonl ./my_app  # add per-step probes

AZ_PROFILE tokens

AZ_PROFILE is a comma-separated list. Tokens are independent flags, not modes; combine freely. Whitespace is trimmed, matching is case-insensitive, unknown tokens are ignored.

  • memory (alias mem). Per-frame heap-breakdown dump (DOM caches, layout cache, text cache, RSS) to stderr.
  • cpu (alias perf). Per-phase wall-clock timings (layout, style, cascade, paint, callbacks, etc.).
  • cascade (alias css). Top-N CSS properties by cascade-walk count per frame.
  • heap. Phase-boundary heap probes inside the layout pass. Silent without jsonl.
  • jsonl. Formats heap probes as JSONL to AZ_PROFILE_OUT. Requires heap.
  • detail. Opts in to fine-grained per-step probes. Layered on top of heap.

AZ_PROFILE_OUT= names the JSONL destination. With jsonl set and AZ_PROFILE_OUT unset, writers silently skip — benchmarks stay clean.

What each token costs you

  • cpu wraps phase functions with a timestamp pair on entry/exit. Sub-microsecond per probe; safe to leave on in CI.
  • memory computes per-cache summaries every frame. Non-trivial — typical 20–100 µs depending on DOM size. Use during investigation, not in steady-state runs.
  • heap + jsonl queries the libc allocator at every named boundary. Roughly the cost of one syscall (~200 ns).

What each report contains

AZ_PROFILE=cpu prints one block per frame to stderr, one line per phase span name with mean, p99, and call count over the frame. AZ_PROFILE=memory prints one block per frame with field-level byte counts pulled from each cache:

text
StyledDom        node_count=4083  total=2.1 MiB
  node_hierarchy 4083 * 16  = 65 KiB
  node_data      4083 * 96  = 392 KiB
  styled_nodes   4083 * 24  = 98 KiB
  prop_cache     css_props=412 KiB  computed=78 KiB
  ...
LayoutCache      tree_hot=88 KiB  warm=412 KiB  cold=64 KiB
TextCache        logical=18 KiB  shaped_glyphs=240 KiB  shaped_clusters=87 KiB
RSS              78.2 MiB

Heap vs RSS

The profiler exposes both numbers because they tell you different things:

  • Heap — bytes the libc allocator currently holds. On macOS this is mstats().bytes_used. A growth here is a Rust-level retention: an Arc chain never dropped, a Vec never shrunk, a Box<T> forgotten. mmap'd regions (thread stacks, file-backed fonts, GL buffers) are not counted.
  • RSS — kernel resident-set size, what top and Activity Monitor show. Includes shared library text, mmap'd fonts, GPU driver pages.
  • phys_footprint (macOS only) — what Activity Monitor's "Physical footprint" shows; excludes shared text pages. More honest than RSS for short-lived processes.

A leak in your code shows up in both. A leak that shows up only in RSS but not in heap usually means an mmap'd resource (a font file, a texture) is being held longer than it needs to be.

Phase-boundary probes

AZ_PROFILE=heap,jsonl writes one line of JSONL per phase boundary inside the layout pass, with a monotonic call id grouping all labels from a single layout pass:

jsonl
{"ev":"phase","label":"start","heap":18874368,"call":42}
{"ev":"phase","label":"styled_dom_built","heap":19135488,"call":42}
{"ev":"phase","label":"layout_solved","heap":19921920,"call":42}
{"ev":"phase","label":"display_list_built","heap":19987456,"call":42}
{"ev":"phase","label":"end","heap":19135488,"call":42}

Heap delta between adjacent labels with the same call id is the bytes retained by that phase. If start and end differ, that frame leaked. Run a few hundred frames, group by label, and fit a linear trend per label — anything that climbs is a suspect.

Hunting a leak with AZ_E2E_TEST

AZ_E2E_TEST=<scenario.json> (gated by a build feature) takes over main() to run a deterministic resize/tick scenario against a headless backend. It probes RSS at a configurable cadence, compares against caps, and exits 0/1.

json
{
  "name": "calc-resize-leak",
  "warmup_ticks": 10,
  "steps": [
    { "action": "resize",      "width": 800, "height": 600 },
    { "action": "resize",      "width": 600, "height": 400 },
    { "action": "resize_full", "width": 800, "height": 600 },
    { "action": "tick" }
  ],
  "loop": { "iterations": 500 },
  "rss_probes": {
    "every_n_iterations": 50,
    "warmup_skip": 0,
    "assert_growth_mib_max": 5.0,
    "assert_absolute_mib_max": 80.0,
    "memory_breakdown": true
  },
  "output": {
    "jsonl_path":    "/tmp/calc.jsonl",
    "summary_path": "/tmp/calc.summary.jsonl"
  }
}

assert_growth_mib_max is the per-run delta between baseline (after warmup) and the final RSS sample; assert_absolute_mib_max caps total RSS at any point. Either breach exits the process with code 1. With memory_breakdown: true each probe also emits a flat mem event covering every measurable byte across the layout caches. Feed the JSONL to a regression analyzer and let it pick the field whose slope grew.

bash
cargo run --release --features e2e-test -- \
  AZ_E2E_TEST=calc.json
# → exit 0 (under cap) or 1 (growth/absolute breach)
# → JSONL events in /tmp/calc.jsonl

A workflow for "why is this growing?"

  1. Reproduce with AZ_PROFILE=memory. Watch the per-frame block. Identify which cache's bytes grow.
  2. If heap grows but no individual cache does, the leak is outside the layout pipeline. Check user code in callbacks that retain RefAny clones.
  3. Re-run with AZ_PROFILE=heap,jsonl,detail AZ_PROFILE_OUT=/tmp/probes.jsonl. Group by phase label. The phase whose heap delta grows over time is where the leak lives.
  4. Build a deterministic AZ_E2E_TEST scenario that reproduces the trigger (a resize loop, a route change, …). Set assert_growth_mib_max slightly under the observed growth so the regression becomes a binary signal.
  5. Commit the scenario JSON next to the test that runs it.

Allocator selection

The layout crate has feature flags for alternate allocators (allocator_mimalloc, allocator_jemalloc). They release freed pages to the OS before each RSS sample, otherwise the allocator holds onto pages and inflates RSS. Pick an allocator that matches your production deployment so the numbers match what users see — RSS curves change shape with the allocator.


Doc Guide En Deploying Mobile (`doc/guide/en/deploying/mobile.md`)


slug: deploying/mobile
title: Mobile (iOS & Android)
language: en
canonical_slug: deploying/mobile
audience: external
maturity: beta
guide_order: 231
topic_only: false
short_desc: Build, package, sign and install an Azul app as an .apk / .ipa — all Rust, from any OS, no Xcode or Android Studio
prerequisites: [hello-world]
tracked_files:

  • dll/build.rs
  • dll/Cargo.toml
  • dll/src/desktop/shell2/android/mod.rs
  • dll/src/desktop/shell2/run.rs
  • scripts/build-android.sh
  • scripts/build-ios.sh
  • scripts/mobile-check-all.sh
    last_generated_rev: ae991d834f665b88609d19ecf25dc88ea50e98a0
    generated_at: 2026-05-30T00:00:00Z
    default-search-keys:
  • App
  • WindowCreateOptions
  • link-static
  • aarch64-linux-android
  • aarch64-apple-ios

Mobile (iOS & Android)

Your Azul app is the mobile app. The same App::create(...).run(...) you
wrote for desktop ships as an .apk or .ipa — there is no Java/Kotlin or
Swift/Objective-C app layer to write, and you need neither Xcode nor Android
Studio
. An .apk and an .ipa/.app are just ZIP archives with a known
layout, so you cross-compile the native library with the Rust compiler and pack
it with a few command-line tools. You can build for both platforms from Linux;
only the final iOS code-signing touches an Apple-specific tool, and even that has
a cross-platform option (see iOS signing).

The two ready-made scripts —
scripts/build-android.sh
and scripts/build-ios.sh
— do the whole thing (cross-compile → bundle → sign → optionally deploy) with no
IDE; CI runs exactly these to produce the release artifacts. The sections below
explain what they do so you can reproduce or adapt them. Every example in the
repo (AzulMaps, azul-paint, azul-meet, …) is packaged this way.

Supported targets

Target Use
aarch64-apple-ios iOS device (arm64)
aarch64-apple-ios-sim iOS simulator on Apple silicon
x86_64-apple-ios iOS simulator on Intel
aarch64-linux-android Android device (arm64-v8a)
x86_64-linux-android Android emulator

rustup target add installs each. bash scripts/mobile-check-all.sh
runs cargo check across all five plus the two desktop cross targets
(x86_64-unknown-linux-gnu, x86_64-pc-windows-gnu) — seven in total, and it
needs no SDK for any of them (see Local testing).

How an Azul app maps onto each platform

Everything — layout, rendering (CPU), callbacks, the
realtime-media / sensor / gamepad / geolocation device APIs
— is identical to desktop. The one structural difference is the entry point:

  • iOS keeps a normal fn main(). Your main calls App::run(...), which on
    iOS hands control to UIApplicationMain and drives the UIKit run loop. So an
    iOS app is just your example compiled as a binary for an iOS target — the
    exact same source as desktop.
  • Android has no main(): the OS loads your .so and calls
    ANativeActivity_onCreate (provided by the bundled
    android-activity glue), which
    invokes android_main inside libazul. So an Android app is your example
    compiled as a cdylib with a tiny load-time shim. See
    Android entry point.

Two ways to ship libazul

Just like desktop, you don't rebuild the framework — you decide how your app
links against it:

  • Dynamic (drop-in prebuilt). Ship the prebuilt libazul in the bundle and
    link your small app against it. On Android, put libazul.so in the APK under
    lib/<abi>/ (e.g. lib/arm64-v8a/); on iOS, embed libazul.dylib in
    MyApp.app/Frameworks/ and set the app binary's rpath to
    @executable_path/Frameworks. CI publishes a libazul per mobile target on
    the release page for download. Your app can be C, Rust, or any
    binding — a C hello-world.c links libazul and calls AzApp_create /
    AzApp_run exactly as on desktop. On iOS the app binary and the embedded
    libazul.dylib must both be code-signed.
  • Static (single artifact). Build with the link-static feature so your app
    and azul compile into one .so (Android) or binary (iOS). This is what the
    build scripts and every repo example use, and what the rest of this page shows.

A mobile libazul is per-ABI/arch (aarch64 for devices, x86_64 for the
emulator/simulator) — you bundle the slice(s) you target, not a single file.

Minimal toolchain (only the stubs you need)

You do not need the full NDK or the iOS SDK. Both are mostly link stubs
(empty .so API stubs / .tbd text stubs) plus headers, and Rust already ships
its own linker (rust-lld). Azul renders on the CPU on mobile
(gl_context_ptr = None), so there are no OpenGL ES / Metal libraries to link
either. The entire system-library surface is:

Platform Links against Notes
Android libandroid (NativeActivity, ANativeWindow, ALooper), liblog (__android_log_print), and libc / libm / libdl Tiny NDK stub .sos — extract just those five, no full NDK needed at link time. (Set via cargo:rustc-link-lib=android,log in dll/build.rs.)
iOS Foundation, UIKit, CoreGraphics, libSystem — plus one framework per device API you use (AVFoundation, CoreMotion, CoreLocation, GameController) .tbd text stubs in the iOS SDK; copy only the ones you reference.

So a from-scratch minimal setup is:

  1. rustup target add — brings the Rust std for the target.
  2. A small stub sysroot: the five Android stub .sos (from the NDK's
    platforms/android-<api>/.../usr/lib/) or the iOS framework .tbd stubs
    (from the SDK's System/Library/Frameworks/). A few hundred KB, not the
    multi-GB toolchain.
  3. rust-lld as the linker — no external ld, clang, or xcrun.

For packaging you then need only small CLI tools — aapt2 / zipalign /
apksigner for Android (head-less sdkmanager install, no Studio), nothing for
iOS beyond zip and a signer. A pure-NativeActivity Android app needs no
Java
(so no JDK / d8) — the custom AzulActivity.java is only for the
optional gesture bridge.

The ready-made build-android.sh / build-ios.sh currently lean on a normal
NDK / iOS-SDK install for convenience; the table above is the irreducible set
if you want to assemble a minimal cross-toolchain (e.g. to build iOS apps on
Linux). Extracting a minimal stub sysroot is a one-time step.

Building the native library

Mobile builds use link-static with no default features (the desktop
windowing/renderer defaults pull in things mobile doesn't want):

sh
# iOS device — produces the binary (its main() runs UIApplicationMain via App::run)
cargo build --release --target aarch64-apple-ios -p my-app \
    --no-default-features --features "std,logging,link-static,a11y"

# Android arm64 — produces the cdylib the APK ships
cargo build --release --target aarch64-linux-android -p my-app \
    --no-default-features --features "std,logging,link-static,a11y,android-activity"

From Rust, depend on azul-dll directly with those features; from C, use the
generated azul.h (cargo run -r -p azul-doc -- codegen c) and the same
AzApp_create / AzApp_run entry points every binding uses.

Android

Android entry point

Because there is no main(), run your setup from a load-time constructor.
libazul already provides android_main (via the android-activity glue);
App::run on Android just stashes the window options for it to read — and it
must run before ANativeActivity_onCreate, which is exactly what a
ctor gives you. Factor the setup into one
function and wire both entry points:

rust
use azul::prelude::*;

pub fn start() {
    let data = RefAny::new(DataModel { counter: 0 });
    let app = App::create(data, AppConfig::create());
    // Android: run() stashes the window options + returns; desktop/iOS: blocks.
    app.run(WindowCreateOptions::create(my_layout));
}

// Desktop / iOS — main() runs and App::run drives UIApplicationMain on iOS.
#[cfg(not(target_os = "android"))]
fn main() { start(); }

// Android — fires at dlopen, before libazul's android_main reads the options.
#[cfg(target_os = "android")]
#[ctor::ctor]
fn azul_android_init() { start(); }

(azul-maps / azul-paint in the repo are set up exactly like this.) Your crate
must build as a cdylib for Android, and pulls the android-activity glue + ctor
only on Android:

toml
[lib]
crate-type = ["cdylib", "rlib"]

[target.'cfg(target_os = "android")'.dependencies]
azul = { package = "azul-dll", version = "0.2", default-features = false, features = ["link-static", "android-activity"] }
ctor = "0.2"

That is the entire difference from a desktop app.

Package the APK (no Android Studio)

You need NDK 27, build-tools 34 (aapt2, zipalign, apksigner) and a
JDK 17 — all installable head-less via sdkmanager, no IDE. An .apk is a
ZIP, assembled like this (what build-android.sh does):

sh
# 1. cross-compile the cdylib (cargo-ndk sets the NDK linker for you).
#    NB: the API level is --platform (cargo-ndk forwards a bare -p to cargo as
#    --package), and it goes BEFORE the `build` subcommand.
cargo ndk -t arm64-v8a --platform 24 -o ./jniLibs build --release \
    -p my-app --no-default-features --features "std,logging,link-static,a11y,android-activity"

# 2. lay out the APK tree, compile the manifest, add the lib + Java glue
aapt2 link --manifest AndroidManifest.xml -I "$ANDROID_HOME/platforms/android-34/android.jar" -o base.apk
zip -r base.apk lib/arm64-v8a/libmy_app.so classes.dex   # classes.dex = dexed scripts/android/*.java

# 3. align + sign with a (debug) keystore — apksigner is a CLI tool
zipalign -f 4 base.apk aligned.apk
apksigner sign --ks debug.keystore --ks-pass pass:android aligned.apk

Files to copy into your project (Java glue + manifest template) live in
scripts/android/:
AndroidManifest.xml (sets android.app.lib_name to your lib), AzulActivity.java
(the NativeActivity subclass), NativeGestureBridge.java, AzulFilePicker.java.
The manifest's lib_name must match your cdylib name. Simplest path:
bash scripts/build-android.sh aarch64-linux-android <AppName> <com.pkg>.

Declare permissions in AndroidManifest.xml (CAMERA, RECORD_AUDIO,
ACCESS_FINE_LOCATION, INTERNET, …) and request the dangerous ones at runtime.

Minimum Android 7.0 (API 24) — the camera backend links the NDK Camera2 stubs
(API 24); AAudio (API 26) is loaded at runtime, so on 7.0/7.1 the app runs and
audio just reports unavailable.

iOS

Build + bundle the .app/.ipa (no Xcode project)

A .app is a directory; an .ipa is Payload/<App>.app zipped. You do not
need an Xcode project — just the iOS SDK (for the linker sysroot) and the
cross-linker. build-ios.sh does:

sh
# 1. build your example as an iOS binary (main() runs UIApplicationMain via App::run)
cargo build --release --target aarch64-apple-ios -p my-app \
    --no-default-features --features "std,logging,link-static,a11y"

# 2. assemble the bundle: the executable + an Info.plist
mkdir -p MyApp.app
cp target/aarch64-apple-ios/release/my-app MyApp.app/MyApp
cp scripts/ios/Info.plist MyApp.app/Info.plist          # template to copy/edit

# 3. (device) sign, then zip into an .ipa
mkdir -p Payload && cp -r MyApp.app Payload/ && zip -r MyApp.ipa Payload

The Info.plist template + entitlements are standard iOS files.
Add the usage strings for the device APIs you use: NSCameraUsageDescription,
NSMicrophoneUsageDescription, NSLocationWhenInUseUsageDescription,
NSMotionUsageDescription.

Cross-compiling iOS from Linux: install the iOS SDK sysroot (extractable
from the Xcode toolchain, no GUI) and point Rust's linker at it. The simulator
needs no signing and runs unsigned .apps directly.

iOS code signing (no Xcode)

Code signing is the only Apple-specific step, and it does not require Xcode
or even a Mac:

  • rcodesign (the Rust
    apple-codesign crate) signs .app/.ipa bundles on Linux or Windows and
    can submit to Apple's notarization web service (rcodesign notary-submit),
    a REST API — no xcrun/notarytool needed.
  • You still need an Apple Developer ID certificate + provisioning profile (the
    paid program), but those are files, not tools.
  • Simulator builds and personal-team device installs over a debug bridge need no
    signing at all.

Installing & debugging the built app

You don't have to build anything to try the demos — every example is published
per-OS on the release page (Demos section): a .apk for Android, a
device .app and a Simulator .app for iOS. To install a build (yours or a
downloaded one):

Android (`.apk`)

The APKs are debug-signed, so they sideload directly.

sh
# Over USB (enable Settings → Developer options → USB debugging first):
adb install azul-maps-android.apk
# Replace an existing install: adb install -r …; uninstall: adb uninstall com.azul.azul_maps

Or copy the .apk to the phone and tap it (allow "install unknown apps" for the
browser/file manager).

Debug logs: azul's platform layer logs through the log facade to logcat
(via liblog):

sh
adb logcat -s azul:V '*:S'        # azul lines only
adb logcat | grep -E '\[camera\]|\[gamepad\]|\[sensors\]|\[cap\]'

A native crash prints a tombstone — adb logcat shows the backtrace: with the
faulting library; pull /data/tombstones/ for the full dump.

iOS Simulator (`.app`, no signing)

The Simulator slice (<demo>-ios-sim.app.zip) runs unsigned — easiest to try
on a Mac:

sh
unzip azul-maps-ios-sim.app.zip
open -a Simulator                              # boot a simulator
xcrun simctl install booted azul-maps.app
xcrun simctl launch --console booted <bundle-id>   # --console streams stdout/stderr

iOS device (`.app` → signed)

A physical iPhone needs the binary code-signed (a free Apple ID / personal
team works for a 7-day sideload). On a Mac:

sh
codesign --force --sign "Apple Development: [email protected] (TEAMID)" \
  --entitlements entitlements.plist azul-maps.app
xcrun devicectl device install app --device <udid> azul-maps.app

No Mac? rcodesign signs an .app/.ipa from Linux/Windows with a Developer ID
.p12. Device logs: xcrun devicectl device console --device , or
Console.app filtered by the app name, or idevicesyslog (libimobiledevice).

From Rust to a final .apk / .ipa — cross-platform

The whole pipeline is cargo + small CLI tools, no IDE, and the same on Linux or
macOS (only the final iOS signing prefers a Mac, and even that has the
rcodesign escape hatch):

Step Android iOS
1. Compile cargo ndk -t arm64-v8a --platform 24 build (the cdylib NativeActivity loads) cargo build --target aarch64-apple-ios[-sim] (the binary; main() runs UIApplicationMain)
2. Bundle aapt2 link + zip the .so + classes.dex → .apk lay out MyApp.app/ (binary + Info.plist); .ipa = Payload/MyApp.app zipped
3. Sign zipalign + apksigner (debug keystore is fine for sideloading) codesign / rcodesign (device only; Simulator needs none)
4. Install adb install xcrun simctl install (sim) / devicectl (device)

build-android.sh and build-ios.sh run steps 1–4 end to end.

Local testing

There are four rungs here, and each proves something the one below it cannot.
Start at the top — it is free and catches most of what actually breaks.

Rung Cost Proves
1. cargo check per target seconds, any OS, no SDK the platform code compiles
2. Synthetic-event harness seconds, no hardware the event pipeline dispatches
3. Emulator / simulator one-time SDK install the app boots and draws
4. Real device a cable timing, sensors, the actual digitizer

Everything below is also available as a command that probes before it acts:

sh
cargo run -p azul-doc -- mobile doctor            # what is installed, what is missing
cargo run -p azul-doc -- mobile install android   # plan, confirm, install
cargo run -p azul-doc -- mobile check             # rung 1, every target
cargo run -p azul-doc -- mobile emulator          # rung 3, headless
cargo run -p azul-doc -- mobile run android examples/azul-writer

mobile install prints a plan and waits for a y before running anything:
each step reports whether it is already satisfied, exactly what would run, or
why a human has to do it (--yes to skip the prompt in CI, --dry-run to only
ever print). It is idempotent, so a re-run after a failed download resumes
instead of starting over.

1. Cross-compiling is cheaper than it looks

cargo check does not link. That is the whole trick: it needs the Rust
std for the target and nothing else — no NDK, no iOS SDK, no cross C
toolchain, no JAVA_HOME. One rustup target add per triple and every mobile
backend compiles on whatever machine you happen to have:

sh
rustup target add aarch64-apple-ios aarch64-apple-ios-sim aarch64-linux-android
bash scripts/mobile-check-all.sh          # or, per target:
cargo check -p azul-dll --lib --target aarch64-linux-android

This matters more than it sounds. Platform shells are #[cfg]-gated, so
nothing in shell2/android/, shell2/ios/, shell2/windows/ or
shell2/linux/ is compiled by a normal cargo build on your machine
— an
editor will happily show you green squiggles over code the compiler has never
seen. A use super::foo that resolves on one platform and not another, a
struct literal missing a field added last week, a helper that exists only in
the desktop build: all of it is invisible until you check the target.

Run it before you commit platform code, not after.

2. Without any device at all

The synthetic-event harness drives the full input → dispatch → callback path
with no hardware, including pen, touch, gestures, scroll phases, IME
composition and gamepad state — see
e2e-testing. This is the right place to pin
behaviour; the rungs below are for proving the platform glue underneath it
is real.

3a. Android emulator — no Android Studio

Everything comes from Homebrew and the headless sdkmanager — no Android
Studio, no Gradle:

sh
cargo run -p azul-doc -- mobile install android
cargo run -p azul-doc -- mobile emulator

That is the whole thing. What it does, if you would rather type it:

text
/* Detailed source-code truncated for AI context efficiency. */

Note the activity: the manifest declares com.azul.app.AzulActivity, a
NativeActivity subclass that constructs the gesture and accessibility bridges
in onCreate. Launching android.app.NativeActivity instead fails with
"Activity class does not exist".

To build the APK you additionally need the NDK
(sdkmanager "ndk;27.0.12077973") — that is the one large download, and it is
required for linking, not for checking. mobile install android --no-ndk skips
it when you only want to run a prebuilt APK.

What an emulator cannot tell you. Azul's Android input bridges
(NativeTextBridge for soft-keyboard text/IME/insets, AzulGamepad for
controllers) are JNI entry points, and JNI is a callee. If the Java side
that calls them is not in your APK, the emulator boots, draws, and exercises
none of those paths — you will see a working window and conclude the input
layer works. Check the APK actually ships the Java glue before drawing that
conclusion.

3b. iOS Simulator

The simulator needs Xcode — not an Xcode project (you never need one of
those, see above), but the app
itself, because simctl and the iOS SDK ship inside it. Command Line Tools
alone are not enough: xcode-select -p pointing at
/Library/Developer/CommandLineTools gives you MacOSX.sdk and no
iphonesimulator sysroot, so iOS can be cargo checked but not linked.

sh
cargo run -p azul-doc -- mobile install ios   # checks the above, installs what it can
cargo run -p azul-doc -- mobile simulator     # boots one and prints its UDID

mobile install ios cannot install Xcode for you — it is an App Store download
tied to an Apple ID — so that step is reported as yours, with the exact commands
(including xcodes install --latest if you want a pinned version). Everything
around it, the Rust targets and the simulator runtime, it does install.

By hand:

sh
xcode-select -p                                   # must NOT be CommandLineTools
xcrun --sdk iphonesimulator --show-sdk-path       # must resolve

open -a Simulator
xcrun simctl install booted azul-maps.app
xcrun simctl launch --console booted <bundle-id>

Budget the disk: since Xcode 14 the simulator runtime is a separate
download
from Xcode itself, so it is roughly 10 GB + 7–10 GB, not one number.

3c. Headless simulator automation with baguette

baguette drives booted simulators
from a CLI — no Simulator.app window, no Xcode UI — which makes the iOS
simulator scriptable in CI the way adb already makes Android scriptable.

sh
brew install baguette        # needs Xcode 26 (⇒ macOS 15.6+), Apple silicon

It is not a simctl wrapper: it links SimulatorKit / CoreSimulator
directly, which is what buys real gesture dispatch rather than synthesized
taps. The commands that matter for testing an azul app:

sh
UDID=$(baguette list --json | jq -r '.[0].udid')
baguette boot --udid $UDID

# Input injection — the real thing, not a synthetic event
baguette tap    --udid $UDID --x 120 --y 300 --width 393 --height 852
baguette swipe  --udid $UDID --startX 40 --startY 400 --endX 350 --endY 400 \
                --width 393 --height 852
baguette pinch  --udid $UDID --cx 196 --cy 400 --startSpread 40 --endSpread 220 \
                --width 393 --height 852
baguette type   --udid $UDID --text "hello"
baguette key    --udid $UDID --code ArrowLeft --modifiers shift

# Assertions
baguette describe-ui --udid $UDID --output tree.json    # accessibility tree
baguette screenshot  --udid $UDID --output shot.png
baguette clipboard get --udid $UDID
baguette logs --udid $UDID --style ndjson --bundle-id <id>

Three of those are worth calling out because they map onto things azul
otherwise has no way to check on a device:

  • describe-ui dumps the accessibility tree as JSON. That is a structural
    assertion target
    : azul builds that tree itself (see
    accessibility), so a diff against a golden file
    catches a11y regressions that a screenshot cannot see.
  • clipboard get / sync reads the simulator's UIPasteboard, which is
    exactly what azul's iOS clipboard transport writes to. A copy in the app and
    a clipboard get outside it is a true end-to-end check of the typed-payload
    path — flavors and all.
  • tap / swipe / pinch / type enter through UIKit, so they exercise
    the real touchesBegan:, UIPencilInteraction, UIKeyInput and gesture
    recognizers — not azul's own synthetic-event injection. Rung 2 proves the
    dispatch logic; this proves the platform glue feeding it.

For a scripted loop, baguette serve --port 8421 exposes the same surface over
HTTP + WebSocket (POST /simulators/:udid/input,
GET /simulators/:udid/describe-ui.json, WS …/stream, WS …/logs), which is
usually easier to drive from a test harness than shelling out per gesture.

3d. One command: build, boot, install, launch, assert

mobile run is rungs 3a/3b end to end. It resolves the crate, builds it,
boots a device if one is not already up, installs, launches, waits for the
first frame, screenshots, and reports whether the engine actually started:

sh
cargo run -p azul-doc -- mobile run android examples/azul-writer
cargo run -p azul-doc -- mobile run ios AzWriter --windowed

It exits non-zero when nothing in the device log came from the Rust side, or
when the log contains a native crash — so it works as a CI gate, not just as a
convenience.

--e2e <scenario.json> additionally replays a scenario against the device,
through adb shell input on Android and through baguette on iOS:

sh
cargo run -p azul-doc -- mobile run android examples/azul-writer \
    --e2e e2e/op-scroll-to-position.json

This is deliberately a subset, and it tells you which subset. The e2e op
vocabulary is engine-internal: mount installs a DOM, assert_response
inspects the debug dispatcher's last reply, snapshot_frame reaches into the
frame cache. None of that is expressible from outside the process. What a host
driver can replay is the input half — taps, swipes, keys, text, screenshots —
plus structural assertions against the platform accessibility tree.

So every op is classified, and the ones the driver cannot honour are counted
and named:

text
==> host replay: bug_scroll_offsets_hit_test
  11 of 19 ops executed on the device
  7 op(s) a host driver cannot express:
    - assert_node_count x1
    - assert_response x2
    - mount x1
    …
  verdict: INCOMPLETE

A replay that dropped ops is never reported green. That matters more than it
looks: a passing test from a harness that silently did nothing is a false
statement about the device, and it is exactly how a platform-glue bug survives
a "green" suite. Run the engine-internal half with azul-doc e2e, which drives
the same dispatcher in-process; run this half to prove the touchesBegan: /
GestureDetector / InputConnection path that the in-process harness bypasses.

4. Real devices

An emulator will not tell you about digitizer sampling rate, pen tilt, Force
Touch stages, real sensor noise or thermal throttling. For anything timing- or
hardware-shaped, the device is the only answer — see the install steps
above.

See also


Doc Guide En Deploying Observability (`doc/guide/en/deploying/observability.md`)


slug: deploying/observability
title: Observability
language: en
canonical_slug: deploying/observability
audience: external
maturity: wip
guide_order: 233
topic_only: false
short_desc: Consent tiers, OTLP metrics and logs, crash reports, and the collector behind them
prerequisites: [hello-world, deploying/signing-updates]
tracked_files:

  • layout/src/telemetry/mod.rs
  • layout/src/telemetry/config.rs
  • layout/src/telemetry/metrics.rs
  • layout/src/telemetry/queue.rs
  • layout/src/telemetry/crash_mail.rs
  • layout/src/probe.rs
  • layout/src/dialogs/telemetry_consent.rs
  • dll/src/desktop/app.rs
    last_generated_rev: c7631154b571a2467f2fee67c4e67e72cb29be83
    generated_at: 2026-08-20T00:00:00Z
    default-search-keys:
  • CallbackInfo
  • SysDialogType
  • UpdateSettings
  • StringPairVec
  • AppConfig

Observability

A shipped desktop app answers "did 1.4.2 regress startup RAM?" from support
tickets, badly. Azul ships an OTLP client for the question: an in-process
metric registry, a structured log buffer, a disk queue that survives an
offline backend, and an uploader.

Nothing leaves the machine unless two keys turn:

  1. you compile the telemetry feature in and configure an endpoint, and
  2. the user selects a consent tier at or above the data being sent.

The default tier is off. Linking the feature in collects nothing.

Turning it on

toml
[dependencies]
azul = { version = "$VERSION", features = ["telemetry"] }

The app's identity comes from the UpdateSettings block that the updater
already reads — app_name becomes the OTLP service name and keys the queue
directory, current_version becomes the version label:

rust,ignore
let mut app_config = AppConfig::create();
app_config.updates.app_name = "myapp".into();
app_config.updates.current_version = env!("CARGO_PKG_VERSION").into();
// Where SysDialogType::ReportProblem and manual crash reports are mailed.
// Unset, the report dialog saves to disk instead.
app_config.report_problem = Some("[email protected]".into()).into();
let app = App::create(RefAny::new(data), app_config);

App::create then does the wiring itself: telemetry::init, the panic hook,
one app_sessions_started_total tick, and the background uploader thread.
Recording is lock-light and safe on the UI thread; uploads never happen on
it.

The destination is environment or config, not code — a packager can point a
build at their own collector without a rebuild:

bash
# Local debugging loop (the Grafana stack from layout/examples/telemetry-grafana):
AZ_OBSERVE=1 ./myapp

# Production / custom collector - the granular variables:
AZ_TELEMETRY_ENDPOINT=https://otlp.example.com \
AZ_TELEMETRY_TOKEN=<ingest token> \
AZ_TELEMETRY=metrics ./myapp

With no endpoint configured the client warns once and uploads nothing, rather
than buffering forever.

Tier Allows
off nothing. The default, always
crashes crash and panic reports
metrics the above plus anonymous metrics and logs
full the above plus serialized app state on a crash

Four layers set the tier, most specific first:

  1. AZ_TELEMETRY — CI, corporate lockdown, tests
  2. .azul/telemetryconfig.json beside the executable — the packager/admin
    pin. {"tier": "off"} here also suppresses the consent dialog
  3. {config_dir}/{app-id}/telemetry.json — this app's user choice
  4. {config_dir}/azul/telemetry.json — "remember for all azul apps"

They are human-readable JSON deliberately: the config is part of the
transparency story.

Asking the user

rust,ignore
info.invoke_system_dialog(SysDialogType::TelemetryConsent);

The dialog lists every instrument the app can record — each engine metric
with a plain-language sentence, plus any app-defined ones — with a checkbox
per metric above the four signal switches (crashes, logs, metrics, app state
on crash). Saving applies immediately at runtime and persists; the "remember
for all azul apps" box writes the machine-wide
{config_dir}/azul/config.json channel default instead of this app's
override.

A tier downgrade retires the client_id through a deletion-request ping, so
the backend learns which install asked to be forgotten.

What the engine records

Metrics carry exactly four labels — version, channel, os, arch —
enforced by the label set being a struct rather than a map. The random
client_id that makes "crash-free users" and adoption dedup possible rides
on log records only, never on a metric.

Metric Kind Answers
app_sessions_started_total counter the denominator of every release-health ratio
app_crashes_total, app_panics_total counter release health, per version
app_startup_seconds histogram "is startup slower since the update?"
app_startup_rss_bytes histogram the rollout gate: did RAM regress?
app_rss_bytes, app_heap_bytes gauge live memory
app_frame_seconds{scope} histogram frame cost by how much work it caused
app_timer_frame_seconds histogram the clock animations ride
app_frame_relayout_scope_total{scope} counter repaint vs relayout vs regenerate_dom mix
app_slow_frames_total{scope} counter frames past the slow threshold (32 ms default)
app_phase_seconds{phase} histogram per-phase timings from Probe::span
app_update_check_total{result}, app_update_apply_total{result} counter updater outcomes

Every slow frame also emits a WARN log record naming the scope, the
duration and the document size, and the session's first one carries a
one-shot system-info snapshot.

app_phase_seconds is armed automatically: telemetry::init calls
enable_probe_bridge() whenever the resolved consent tier ships metrics, so
a consenting run fills the phase histogram with no further switches. (It
used to require a separate enable_probe_bridge() call - the histogram was
silently empty otherwise, and an empty histogram is indistinguishable from a
fast one.) AZ_PROFILE is unrelated to telemetry: it arms the same recorder
for LOCAL consumers - agent/e2e debugging, AZ_PROFILE=cpu dumps,
cross-frame phase diffs - and unknown tokens now warn with the valid list
instead of parsing to nothing.

Your own metrics

From any callback, in any language binding:

rust,ignore
info.record_counter("documents_opened_total".into(), 1, StringPairVec::from_vec(vec![]));
info.record_histogram("export_seconds".into(), elapsed, labels);
info.record_gauge("open_tabs".into(), tabs as f64, labels);

Labels are sanitized and capped — six keys, 64-character values — and every
distinct combination counts against the global series ceiling. Names must be
fixed strings, never user-derived: a metric name built from a filename is an
unbounded series. All three are no-ops unless telemetry is compiled in and
consented to, so they are safe to leave in the code path.

Crashes

The panic hook counts the panic, buffers the message, and writes the queue
to disk. Release builds are panic = "abort": the process dies the moment
the hooks return, so nothing can be deferred to a background thread. With an
OTLP endpoint configured the hook therefore ships the queued crash record
synchronously (a 3-second budget; whatever fails stays queued for the next
launch). Without an endpoint it re-spawns the app as the crash-reporter dialog
(below). With telemetry off — below the crashes tier — the plain "unexpected
fatal error" message box is the only surface (AppConfig.enable_visual_panic_hook,
on by default; suppressed under AZ_E2E), and it yields whenever telemetry
owns the crash, so a user sees exactly one crash surface.

Each crash record carries crash.message, crash.location (file:line),
crash.scope (the live probe-span path, so you know what the app was doing),
crash.backtrace, the document size and the sys.* snapshot. Paths are
scrubbed: $HOME becomes ~, and rustc and registry paths collapse.

When a crashed process re-spawns itself with AZ_CRASH_DUMP=<dump.json>,
that invocation is the crash reporter: azul shows the dump in a CPU-rendered
dialog instead of starting the app. This happens on every crash-tier panic
without an endpoint — no crash contact is needed to see the report; only
its Send needs one. AppConfig.report_problem (the support mailbox
SysDialogType::ReportProblem mails to) arms that contact automatically when
the crash-mail transport is compiled in. A dump still queued on a later
launch — the reporter was closed, or crashed with the app — re-opens the
reporter alongside the app when it can send.

Reports the user starts

SysDialogType::ReportProblem is the path that does not wait for a crash: a
message box with an optional screenshot of the current window and an optional
system-info block. The screenshot is captured in-process at invoke time, so it
shows the situation the user is complaining about. Nothing leaves the machine
before they press Send, and the report goes to AppConfig.report_problem — or
to disk when that is unset.

Without a backend

For a deployment with no collector at all — tier crashes, metrics off, a
support mailbox — the crash-mail feature drains the dumps into one email
over plain SMTP, the dump as a JSON attachment and the user's message as the
body. It is manual by design: the app calls it from its own "the app crashed
last time, send a report?" dialog on the next launch.

Where the data goes

Payloads are OTLP/HTTP JSON to <endpoint>/v1/metrics and
<endpoint>/v1/logs with a bearer token. A flush reports what happened to
each ping:

  • uploaded — the collector took it.
  • retained — unreachable or 5xx. The ping stays on disk and goes out next
    flush. This is the offline path working.
  • dropped — a permanent 4xx, almost always a wrong token. Retrying a
    rejected payload forever is a worse failure than losing it.

The collector position is where authentication, per-tier policy and scrubbing
belong; Grafana belongs behind an identity proxy.

Plan for an aggregating ingest proxy. Four labels and no per-user label
means two clients on the same build write to the same series, each sending
its own cumulative totals — at the storage layer those interleave and look
like a counter that resets. rate() and increase() absorb resets, which is
why the queries below use them, but with a real fleet the arithmetic stops
being exact unless the proxy converts per-client cumulative payloads into
server-side aggregates first.

A stack you can run

layout/examples/telemetry-grafana/ is a complete loop — collector →
VictoriaMetrics + Loki → a provisioned Grafana dashboard — with a demo app
doing real work:

bash
cd layout/examples/telemetry-grafana && ./run-stack.sh up

Its README covers the dashboard panels, the offline and panic paths, and a
query cookbook. Nothing in it is simulated; every histogram observation is a
real duration.

Queries that gate a rollout

promql
# panics per session, by version — do not raise rollout_percent until the new
# version is at or below the old one
sum by (version) (increase(app_panics_total[1h]))
  / sum by (version) (increase(app_sessions_started_total[1h]))

# startup p95 by version
histogram_quantile(0.95,
  sum by (version, le) (increase(app_startup_seconds_bucket[$__range])))
logql
# every crash record, and one install's whole story
{service_name="myapp"} | event_kind="crash"
{service_name="myapp"} | client_id="…"

increase(…[$__range]) rather than a sliding rate(): startup is observed
once per run, so a five-minute window is empty whenever no process started in
it, and a quantile over an all-zero rate is NaN.

Environment variables

Variable Effect
AZ_OBSERVE ONE-VAR local loop: 1/local = metrics tier + http://127.0.0.1:4318 + the local stack's token + 5s flushes; `` targets another collector. Granular vars below override pieces.
AZ_TELEMETRY consent tier: off, crashes, metrics, full
AZ_TELEMETRY_ENDPOINT OTLP base URL; signal paths are appended
AZ_TELEMETRY_TOKEN bearer token for the ingest endpoint
AZ_TELEMETRY_FLUSH_SECS uploader flush interval
AZ_TELEMETRY_CHANNEL the channel label (default default)
AZ_TELEMETRY_CLIENT_ID pins the client id, for test determinism
AZ_CRASH_DUMP set on the re-spawn; makes this run the crash reporter
AZ_PROFILE also enables Probe recording, hence app_phase_seconds

An unknown AZ_TELEMETRY value is rejected rather than guessed, so a typo is
visible instead of silently collecting or silently not.

Cross-references

  • Signed Updates: the UpdateSettings block this page
    reads its identity from, and the release channels the metrics compare.
  • Profiling: the same Probe spans, read
    locally instead of shipped.
  • Debugging: overlays and structured logging on the
    developer's own machine.

Doc Guide En Deploying Signing Updates (`doc/guide/en/deploying/signing-updates.md`)


slug: deploying/signing-updates
title: Signed Updates
language: en
canonical_slug: deploying/signing-updates
audience: external
maturity: wip
guide_order: 232
topic_only: false
short_desc: The update manifest, the signing chain, and the release script
prerequisites: [hello-world]
tracked_files:

  • layout/src/updater.rs
  • layout/src/dialogs/update_version.rs
  • layout/examples/verify_update_manifest.rs
  • scripts/sign-release.sh
    last_generated_rev: 87d8e2882b7e7f84645a106a79d2fd5beacf256e
    generated_at: 2026-08-18T22:28:46Z
    default-search-keys:
  • AppConfig
  • UpdateSettings
  • UpdateMode
  • CallbackInfo
  • SysDialogType

Signed Updates

Azul apps can check for, download and install their own updates. This page
covers the release side: the manifest you publish, the signature chain that
makes it trustworthy, and the script that produces both.

If you only want the in-app side — CallbackInfo::check_for_updates, the
SysDialogType::UpdateVersion dialog, staged rollouts — those read
AppConfig.updates and need no release tooling beyond a manifest URL.

The shape of it

Your app is built with an UpdateSettings:

rust
config.updates.manifest_url =
    Some("https://downloads.example.com/updates.json".into()).into();
config.updates.current_version = env!("CARGO_PKG_VERSION").into();
config.updates.app_name = "myapp".into();
config.updates.root_public_key = "RWQ…".into();   // see below

and you publish a manifest at that URL:

json
{
  "latest": {
    "version": "2.0.0",
    "download_url": "https://downloads.example.com/myapp-2.0.0.bin",
    "changelog_md": "https://downloads.example.com/CHANGELOG.md",
    "digest": "sha256:49dba348…",
    "signature": "untrusted comment: …\nRUThI3d5…\ntrusted comment: …\n…\n",
    "signing_key_statement": "azul-signing-key-v1|pubkey=RWThI3d5…|expires=1818627834|generation=1",
    "signing_key_statement_sig": "untrusted comment: …\n…\n",
    "release_date": "2026-08-18T12:00:00Z",
    "slow": { "10": "…", "50": "…" }
  }
}

release_date and slow drive staged rollouts and are
optional. Everything else about delivery — where you host it, whether it is
S3 or a static file — is up to you; the client only needs to be able to GET
those two URLs.

You do not have to publish that manifest

manifest_url is one URL, but the updater does not insist on one format.
It fetches whatever is there and works out what it got, so the smallest
useful deployment is a text file and the largest is the manifest above.

What is at the URL What you get
The manifest above Everything: rollout, changelog, signature chain
A flat object {"version": "2.0.0", "url": "…"} Version + download; same optional fields, no latest wrapper
A GitHub release Version, asset, changelog, digest, signatures — see below
An OCI registry (oci://…) Version, layer blob, and a digest pin for free
A bare version number, e.g. a VERSION file Notification only: "there is a 2.0.0", with no download

The lenience is about SHAPE, never about verification. Whatever the source,
the digest and the signature chain are checked identically, and a body that
matches none of these shapes is an error — an HTML error page from a
misconfigured host must never look like "you are up to date".

GitHub releases

Point manifest_url at the repository. All of these mean the same thing:

text
github://owner/repo
https://github.com/owner/repo
https://github.com/owner/repo/releases/latest
https://api.github.com/repos/owner/repo/releases/latest

The updater then maps a release onto a ReleaseInfo:

  • tag_name is the version, with a leading v stripped.
  • The release body is the changelog, inline — no second request, and it
    still works if the release page is unreachable.
  • published_at seeds the rollout ladder, so staged
    rollout works with no extra fields at all.
  • The asset is chosen by matching this build's OS and architecture
    against the asset names (…-x86_64-unknown-linux-musl.tar.gz,
    …_windows_amd64.zip, …-aarch64-apple-darwin.tar.gz all work). Pin it
    explicitly with github://owner/repo?asset=myapp-{version}-linux.bin —
    * globs are allowed. If nothing matches this platform the updater
    reports the new version but refuses to nominate a download: handing
    someone an arbitrary binary is worse than telling them to fetch it.
  • The digest comes from the asset's own digest field where GitHub
    provides one, and otherwise from a sibling myapp.bin.sha256 or
    SHA256SUMS asset, matched by filename.
  • The signature chain rides as sibling assets:
    myapp.bin.minisig, signing-key-statement.txt and
    signing-key-statement.txt.minisig. Upload the files
    scripts/sign-release.sh produced alongside the artifact and the chain
    verifies exactly as it does from a manifest.

Draft and pre-release entries are skipped. An unauthenticated client makes
one or two API calls per check, well inside GitHub's rate limit for a
per-user application.

A GitHub release with no signature assets is unsigned: fine if your app
does not pin a root_public_key, and a hard refusal if it does. That is the
intended behaviour — an app that has been told to require signatures should
not quietly accept a release that has none.

OCI registries

If you already push artifacts to a container registry, that is an update
source:

text
oci://ghcr.io/owner/app:2.0.0
oci://registry.example.com:5000/team/app        # a port is not a tag
  • The registry's token dance is handled: an unauthenticated manifest
    request gets a 401 with a WWW-Authenticate challenge, the client
    fetches a (usually anonymous) pull token and retries. The same token is
    carried into the artifact download, since a blob request needs it too.
  • A multi-arch index is followed to this platform's manifest. OCI names
    platforms darwin/amd64 where Rust says macos/x86_64; that mapping is
    done for you. An index with nothing for your platform selects nothing
    rather than something arbitrary.
  • The version comes from the org.opencontainers.image.version annotation,
    or from the tag when the reference names one. A :latest with no version
    annotation is an error — there is no honest answer to "which version
    is this", and reporting "up to date" would be a lie.
    org.opencontainers.image.created seeds the rollout ladder.
  • Select the artifact layer with ?asset= matched against the layer's media
    type or its org.opencontainers.image.title annotation; the first layer
    is the default.

The nice property here is that the layer digest is the pin. A registry
already content-addresses its blobs, so an OCI release is digest-verified by
construction — there is no checksum file to publish, forget, or trust. The
minisign chain still applies on top if you pin a root key; put the .minisig
and the statement in the manifest's annotations or ship them as extra layers.

Why there are two keys

A single signing key is a bad trade: it has to live wherever your CI signs
builds, and if it leaks your only remedy is shipping a new binary to every
user, because the key they trust is compiled into the app they already have.

So the client trusts a root key that signs nothing but statements
about which signing key is currently valid:

text
root key ──signs──> signing-key statement ──names──> signing key ──signs──> artifact

The statement is a single line with no trailing newline:

text
azul-signing-key-v1|pubkey=<base64>|expires=<unix>|generation=<n>
  • The root secret key lives offline and signs a statement about once a
    year. Its public half is compiled into your app.
  • The signing key lives on the build machine and signs artifacts.
  • generation is a rotation counter. Clients remember the highest one they
    have accepted and refuse anything lower, so a retired key stays retired
    even if someone replays an old statement. Rotating is: mint a new signing
    key, publish a statement with generation one higher. No new binary.
  • expires bounds the damage of a leak you never noticed.

A manifest cannot name its own signing key — only a root-signed statement
can. That is the whole point: an attacker who controls your download server
and your manifest still cannot make a client install anything, because they
cannot produce a statement the root key signed.

Leaving root_public_key empty disables the chain and leaves only the
digest pin, which protects against corruption and a swapped file but not
against someone who can rewrite the manifest.

Signing a release

bash
# First run: mint the two key pairs, then STOP so you can put the root
# secret key somewhere safe before it has ever signed anything.
scripts/sign-release.sh --keys ./release-keys

# Then, per release:
scripts/sign-release.sh \
    --keys ./release-keys \
    --artifact ./target/release/myapp \
    --version 2.0.0 \
    --url https://downloads.example.com/myapp-2.0.0.bin \
    --changelog https://downloads.example.com/CHANGELOG.md \
    --out manifest.json

The script needs a minisign implementation. Either works:

bash
cargo install rsign2          # recommended
apt install minisign          # or: brew install minisign

Before printing anything the script verifies its own output with azul's
real client-side code
. Do not remove that step. A signature can be
perfectly valid to the tool that made it and still be refused by the app:

  • Prehashing. The client accepts only prehashed signatures (ED), not
    the legacy form (Ed). rsign2 always prehashes; some minisign builds
    need -H, which the script passes.
  • The statement is signed as exact bytes. echo appends a newline and
    the signature then covers a string that is not the one in the manifest.
    The script uses printf '%s'.

Neither mistake is visible by inspection, and both produce a release that
looks fine until every client rejects it.

Verifying before you publish

The same check runs standalone:

bash
cargo run -p azul-layout --features updater --example verify_update_manifest -- \
    manifest.json ./myapp-2.0.0.bin RWQ…rootpubkey

It prints the resolved signing key, the generation, when the statement
expires and whether the artifact matches — and exits non-zero if a client
would refuse the release.

--selftest mints a throwaway hierarchy and walks the whole chain, which is
a quick way to see the byte formats without touching real keys:

bash
cargo run -p azul-layout --features updater --example verify_update_manifest -- --selftest

Testing the whole path locally

Serve the manifest and the artifact from a directory and point a drill at
them. This exercises exactly what a client does — check, changelog fetch,
resumable download, digest, signature chain:

bash
cd release-dir && python3 -m http.server 8731 &

cargo run -p azul-layout --features updater,telemetry \
    --example telemetry_grafana -- \
    --update-manifest http://127.0.0.1:8731/manifest.json \
    --version 1.0.0 \
    --update-root-key RWQ…rootpubkey
text
update: install=UserWritable effective_mode=SelfUpdate
update: 1.0.0 -> 2.0.0 available (manual mode)
update: staged AND VERIFIED …/staging/myapp-2.0.0.bin (4096 bytes, cached=false)
        — signature chain OK, key generation now 1

The same drill takes a GitHub repository, which is the quickest way to see
source resolution working against something real:

bash
cargo run -p azul-layout --features updater,telemetry \
    --example telemetry_grafana -- \
    --update-manifest github://BurntSushi/ripgrep --version 0.1.0
text
update: 0.1.0 -> 15.2.0 available (manual mode)
update: downloaded …/ripgrep-15.2.0-x86_64-unknown-linux-musl.tar.gz (2265718 bytes)

Then break it on purpose, which is the half worth running:

  • Replace the artifact on the server after signing → refused, digest mismatch.
  • Replace it and recompute the digest in the manifest → refused, artifact signature invalid, and the staged file is deleted rather than left behind.

Install kinds and what the client will actually do

Verification is necessary but not sufficient: azul also refuses to
self-update where self-updating is wrong. InstallKind::detect() recognises
package-managed installs (dpkg/rpm, Homebrew, Flatpak/Snap, the Windows
Store, macOS /Applications bundles installed by a manager) and clamps the
mode to notify-only, so the app tells the user to update through the
mechanism that owns the files instead of overwriting them behind its back.

The machine-wide config can clamp further: updates.autoupdate: false in
{config_dir}/azul/config.json turns self-updating off for every azul app
on the machine, and maintenance_window (an RRULE subset) confines
unattended staging to a time window the machine's owner chose.

Staged rollout

release_date plus an optional slow map spread a release over days
instead of shipping it to everyone at once:

json
"release_date": "2026-08-18T12:00:00Z",
"slow": { "10": "2026-08-19T12:00:00Z", "50": "2026-08-20T12:00:00Z" }

Each client draws a persistent cohort bucket (0-99) once and keeps it
forever, so it stays in the same cohort for every release. Auto-updaters
open stage by stage; notify-only installs stay silent until the rollout
reaches 100 %. Clients still inside the gate report
app_update_check_total{result="staggered"}, so the reach of a rollout is a
dashboard query rather than a guess.

Without a slow map a default ladder applies (1 day → 10 %, 2 → 30 %,
3 → 50 %, 4 → 100 %). "slow": "off" ships to everyone immediately.

Key hygiene, briefly

  • The root secret key never touches CI. If it leaks, an attacker can appoint
    their own signing key and there is no in-band recovery — that is why it
    signs once a year and lives offline.
  • The signing key on the build machine is the one you expect to rotate.
    Practise the rotation before you need it: bump --generation, publish,
    confirm clients accept the new statement.
  • Keep the statement's expires shorter than the interval at which you are
    confident you would notice a compromise.

Doc Guide En Deploying Web (`doc/guide/en/deploying/web.md`)


slug: deploying/web
title: Web
language: en
canonical_slug: deploying/web
audience: external
maturity: wip
guide_order: 230
topic_only: false
short_desc: Run the same azul binary as a web app — how the WASM lift works and how to ship it
prerequisites: [hello-world, headless/rendering]
tracked_files:

  • dll/src/web/config.rs
  • dll/src/web/mod.rs
  • dll/src/web/html_render.rs
  • dll/src/web/transpiler_remill.rs
  • docker/Dockerfile
    default-search-keys:
  • AZ_BACKEND
  • web-prelift
  • run_web
  • Route

Web

The same binary that opens a desktop window can serve itself as a web app —
no separate WASM build, no JS rewrite, no second codebase. You set one
environment variable and azul replaces the platform window with an HTTP server:

sh
AZ_BACKEND=web://127.0.0.1:8080 ./my-azul-app
# open http://127.0.0.1:8080

Your layout callback runs on the server and is serialized to HTML + CSS; your
event callbacks (on_click, …) run in the browser as WebAssembly that azul
produces automatically from your compiled binary. This page explains how that
works
, then how to ship it.

What "lifting" is (and when each step runs)

azul does not ship a hand-written WASM build of itself. Instead it lifts the
native machine code of the library into WebAssembly with an embedded
remill-based lifter. That is what lets
one set of Rust callbacks run both server-side (desktop) and client-side
(browser). Lifting is slow — minutes for the whole framework — so it matters
when it happens. There are four distinct moments:

Stage When What happens
1. Docker build (optional, once per release) You build the base image The whole framework is lifted to WASM and written to an on-disk cache (AZ_LIFT_CACHE_DIR). Slow, but done once and baked into the image.
2. Server startup ./my-app starts with AZ_BACKEND=web://… Loads the warm cache. With a baked cache there is nothing left to lift for the framework; without one, this is where the multi-minute lift lands.
3. First HTTP request A browser hits / Only code not already cached is lifted — in practice just your own callbacks (seconds). Served at /az/cb/{name}.{hash}.wasm.
4. In the browser The page runs The client just fetches and runs that WASM. No lifting ever happens in the browser.

The cache key is a hash of each function's machine bytes, not the app — so
the same framework function always maps to the same entry regardless of which
app is running. That is what makes a shared, pre-warmed cache possible: lift
the framework once in CI, ship it, and every app reuses it.

Portability note. The lift output is architecture-neutral WASM, so the
framework can be lifted on one CPU and served on another. azul's CI lifts on
aarch64 (where lifting is validated today) and bakes the result into an
x86_64 image.

Quick start — run a demo

Once the base image is published (ghcr.io/fschutt/azul-web-base, built by
.github/workflows/dockery.yml on every website release):

sh
docker run --rm -p 8080:8080 ghcr.io/fschutt/azul-web-base:0.2.0
# open http://localhost:8080

(Use podman run … — the command is identical.)

Deploy your own app

Build your binary as usual, then base your container on the pre-lifted image so
your docker build only lifts your callbacks, not the framework:

dockerfile
FROM ghcr.io/fschutt/azul-web-base:0.2.0

# Your azul app binary.
COPY target/release/my-app /usr/local/bin/my-app

# Bind the web backend. allow_public=1 is REQUIRED to bind a non-loopback
# address — the default refuses 0.0.0.0 because the server ships no auth.
# Add ?auth_token=… (and a reverse proxy, below) before exposing it publicly.
ENV AZ_BACKEND="web://0.0.0.0:8080?allow_public=1"
EXPOSE 8080
CMD ["/usr/local/bin/my-app"]

Match your app's azul version to the base-image tag. The cache is keyed by
machine bytes, so a different libazul build hashes differently and the cache
misses — pin both to the same version.

Warming the cache yourself (`web-prelift`)

The base image is warmed by running an azul app once with the prelift
backend, which runs the exact server startup (Stages 1–2 above) and then exits
before it starts serving:

sh
AZ_BACKEND=web-prelift://127.0.0.1:0 ./my-app   # lifts, populates the cache, exits

web-prelift:// takes the same URL and query syntax as web://. Point it at
AZ_LIFT_CACHE_DIR and you get a warm cache you can copy into any image. This is
exactly what the base-image build does; you only need it if you build your own
variant.

`AZ_BACKEND` URL format

  • web://127.0.0.1:8080 — localhost only (the safe default).
  • web://0.0.0.0:3000?allow_public=1 — all interfaces (requires allow_public=1).
  • web://[::1]:8080 — IPv6 loopback.
  • web://0.0.0.0:8443?tls_cert=cert.pem&tls_key=key.pem — built-in TLS.
  • web-prelift://127.0.0.1:0 — warm the lift cache and exit (see above).

Query options: allow_public, tls_cert/tls_key, auth_token, max_body,
max_connections. The scheme is case-insensitive; a malformed value falls back
to the desktop shell.

What runs where

Runs on the server Runs in the browser
layout callback (builds the Dom) Browser layout + paint
Full CSS cascade (theme/viewport/container/lang all resolved) :hover / :focus / :active (plain CSS, no round-trip)
HTML serialization + image/font collection Event callbacks (on_click) as lifted WASM
Route matching Reads the hydration payload into shared WASM memory

The cascade is fully resolved server-side, so only interactive pseudo-states
ship as CSS. Event callbacks execute client-side against a hydrated RefAny in
shared WASM linear memory; mutations are read back by the loader and applied to
the DOM.

Maturity (WIP). on_click → counter works end-to-end for hello-world.
Broader event wiring (mousemove/keydown/…), full DOM diff/patch, and
arbitrary hydration payloads are in progress. Complex apps fall back to
server-side POST /az/exec/{node_id} execution.

Routes

Routes use :param segments, passed via AppConfig.routes; without any, the
root layout callback becomes /:

rust,ignore
let routes = vec![
    Route { pattern: "/users/:id".into(),     layout_callback: user_page() },
    Route { pattern: "/products/:sku".into(), layout_callback: product_page() },
];

Matching is exact → parameterized → / fallback. Assets are served under
/az/ with content-hashed, immutable URLs (/az/cb/…wasm, /az/img/…,
/az/font/…), so a rebuild invalidates browser caches automatically.

Production

The built-in server is a standalone HTTP listener — no auth by default, no
load balancing
. For anything public, front it with a reverse proxy and bind to
loopback:

text
[client] ──HTTPS──▶ [Caddy / nginx] ──HTTP──▶ [azul app on 127.0.0.1:8080]
text
my-app.example.com {
    reverse_proxy 127.0.0.1:8080
    encode gzip
}
  • Memory: the server holds all pre-rendered HTML, images, and fonts in RAM —
    a multi-route app with large images grows quickly.
  • Concurrency: one OS thread per connection — fine for tens of users; for
    more, run several processes behind the proxy with shared external state.
  • Browser support: plain HTML5 + ES5; the WASM path needs
    WebAssembly.instantiateStreaming + BigInt (Safari 14+, Chrome 85+,
    Firefox 78+ — evergreen browsers since 2020).

Doc Guide En Dom Datasets (`doc/guide/en/dom/datasets.md`)


slug: dom/datasets
title: Datasets
language: en
canonical_slug: dom/datasets
audience: external
maturity: mature
guide_order: 52
topic_only: false
short_desc: Attaching state to a node for navigation and per-instance state
prerequisites: [dom]
tracked_files:

  • core/src/dom.rs
  • core/src/callbacks.rs
    last_generated_rev: 7ecd570e4c0c3584e5107e770058c16cb59fa6e7
    generated_at: 2026-05-02T05:53:30Z
    default-search-keys:
  • Dom
  • NodeData
  • RefAny
  • DomNodeId
  • CallbackInfo
  • with_dataset
  • get_dataset
  • get_first_child
  • get_last_child
  • get_next_sibling
  • get_previous_sibling
  • get_hit_node

Datasets

Introduction

A dataset is a RefAny attached to a single node. You attach it with
Dom::with_dataset(OptionRefAny). The framework doesn't read the
contents. It stores the RefAny on the node and hands it back to your
callback through info.get_dataset(node_id).

That's the whole mechanism. Everything on this page follows from it.

The dataset is the slot for state that exists only because a specific
node exists. The cursor inside one text input. The expanded flag on one
tree row. A marker that tells a shared callback which button fired.
When layout() returns a fresh tree, the old node's dataset is dropped
along with the node. The dataset's lifetime is the node's lifetime.

There are two main use cases.

The first is marker structs: zero-field types whose only job is to
identify the node so a shared callback can dispatch on type. The second
is per-instance state: a struct of fields the callback reads and
mutates on every interaction.

For widgets that own resources expensive to recreate (a video decoder,
a GL texture, a websocket), the dataset is paired with a merge callback
so the framework can carry the RefAny across reconciliation instead
of dropping and rebuilding it. That mechanism has its own page. See
Merge Callbacks.

Attaching a dataset

The signature is Dom::with_dataset(OptionRefAny) -> Dom. Pass
OptionRefAny::Some(RefAny::new(my_value)) to attach. Pass
OptionRefAny::None to leave the slot empty.

rust,no_run
use azul::prelude::*;

struct EditorState { 
    text: String, 
    cursor: usize 
}

let state = RefAny::new(EditorState { 
    text: "hello".into(), 
    cursor: 0 
});

let _ = Dom::create_input_no_a11y("text".into(), "editor".into(), "hello".into())
    .with_dataset(OptionRefAny::Some(state));

NodeData::set_dataset is the underlying setter if you build
NodeData directly.

Reading a dataset in a callback

Inside a callback, info.get_hit_node() returns the DomNodeId of the
node the event landed on. info.get_dataset(node_id) returns the
RefAny attached to that node, or None.

rust,no_run
use azul::prelude::*;

struct EditorState { 
    text: String, 
    cursor: usize 
}

extern "C" fn on_keydown(_unused: RefAny, mut info: CallbackInfo) -> Update {
    let hit = info.get_hit_node();
    let mut ds = match info.get_dataset(hit) {
        Some(d) => d,
        None => return Update::DoNothing,
    };
    let mut state = match ds.downcast_mut::<EditorState>() {
        Some(s) => s,
        None => return Update::DoNothing,
    };
    state.text.push_str("...");
    Update::RefreshDom
}

Borrow rules follow RefAny. A downcast_ref blocks a downcast_mut.
Drop the guard before calling anything that may borrow the same
dataset.

Marker structs

A dataset doesn't have to carry data. A zero-field struct works as a
type-level tag. One callback handles many nodes. The dataset's type
tells the callback which node fired.

rust,no_run
use azul::prelude::*;

struct SaveButtonMarker;
struct CancelButtonMarker;

fn dialog_buttons() -> Dom {
    Dom::create_div()
        .with_child(
            Dom::create_button_no_a11y("Save".into())
                .with_dataset(OptionRefAny::Some(RefAny::new(SaveButtonMarker)))
                .with_callback(EventFilter::Hover(HoverEventFilter::MouseUp),
                               RefAny::new(()), on_dialog_click))
        .with_child(
            Dom::create_button_no_a11y("Cancel".into())
                .with_dataset(OptionRefAny::Some(RefAny::new(CancelButtonMarker)))
                .with_callback(EventFilter::Hover(HoverEventFilter::MouseUp),
                               RefAny::new(()), on_dialog_click))
}

extern "C" 
fn on_dialog_click(_unused: RefAny, mut info: CallbackInfo) -> Update {
    let hit = info.get_hit_node();
    let mut ds = match info.get_dataset(hit) {
        Some(d) => d,
        None => return Update::DoNothing,
    };
    if ds.downcast_ref::<SaveButtonMarker>().is_some() {
        // Save path.
    } else if ds.downcast_ref::<CancelButtonMarker>().is_some() {
        // Cancel path.
    }
    Update::RefreshDom
}

Both buttons share on_dialog_click. Dispatch happens on the dataset
type. There's no string match on a class name. There's no second
hit-test pass.

A marker can also carry fields. That's how a single callback handles a
whole table.

rust,no_run
use azul::prelude::*;

#[derive(Debug)]
struct RowMarker {
    row_id: u64,
    column: ColumnKind,
}

#[derive(Debug, Copy, Clone)]
enum ColumnKind { Name, Email, Avatar, DeleteButton }

fn row(row_id: u64, kind: ColumnKind, label: &str) -> Dom {
    Dom::create_td()
        .with_child(Dom::create_text_do_not_use_without_block_level_wrapper(label))
        .with_dataset(OptionRefAny::Some(RefAny::new(RowMarker { row_id, column: kind })))
        .with_callback(EventFilter::Hover(HoverEventFilter::MouseUp),
                       RefAny::new(()), on_cell_click)
}

extern "C" 
fn on_cell_click(_unused: RefAny, mut info: CallbackInfo) -> Update {
    let hit = info.get_hit_node();
    let mut ds = match info.get_dataset(hit) {
        Some(d) => d,
        None => return Update::DoNothing,
    };
    let marker = match ds.downcast_ref::<RowMarker>() {
        Some(m) => m,
        None => return Update::DoNothing,
    };
    let _ = marker.row_id;
    let _ = marker.column;
    Update::RefreshDom
}

marker.row_id and marker.column identify the cell directly. No tree
walk. No DOM query.

Walking the tree from a hit node

CallbackInfo exposes a small set of navigators. Each takes a
DomNodeId and returns Option<DomNodeId>. They read the styled-DOM
hierarchy.

rust,ignore
let hit = info.get_hit_node();

let parent = info.get_parent(hit);
let next   = info.get_next_sibling(hit);
let prev   = info.get_previous_sibling(hit);
let first  = info.get_first_child(hit);
let last   = info.get_last_child(hit);

info.get_dataset(other_node_id) works against any node, not just the
hit node. A click on a child cell can read the parent row's dataset
without any application-side mapping.

info.get_focused_node() returns the node that currently holds
keyboard focus, useful for keyboard-driven callbacks where there's no
mouse hit.

Datasets are rebuilt every frame

Each layout() call returns a fresh Dom. Each with_dataset call
inside that layout() builds a fresh RefAny. The previous tree's
nodes (and their datasets) get dropped.

This has consequences.

Anything a callback writes into a dataset is gone after the next
layout() unless the callback also writes it back into the
application-level RefAny. The dataset is short-term scratch. The
application data is the system of record.

Marker structs are cheap. A zero-field marker has no destructor body.
The allocation cost is the price of attaching it.

Heavy resources don't belong in a dataset that gets rebuilt. Use a
merge callback to carry them across reconciliation. See
Merge Callbacks.

There's also a related pattern called the double update. A text
input callback writes the new character to the application data and
also writes it directly into the node's dataset, so the input shows
the latest keystroke before the next layout() runs. That's covered
in Text Input and Selection.

What CallbackInfo exposes for dataset work

The handful of methods that matter on this page:

  • info.get_hit_node() returns the DomNodeId of the node the event
    landed on.
  • info.get_dataset(node_id) returns the dataset RefAny attached to
    any node, or None.
  • info.get_focused_node() returns the focused node id when there is
    one.
  • info.get_parent / get_next_sibling / get_previous_sibling /
    get_first_child / get_last_child walk the styled-DOM hierarchy.
  • info.get_string_contents(node_id) returns the node's resolved text
    content, useful for reading what was typed into an editable node.

Drop any active dataset borrow before calling another method that
might re-borrow the same RefAny.


Doc Guide En Dom Merge Callbacks (`doc/guide/en/dom/merge-callbacks.md`)


slug: dom/merge-callbacks
title: Merge Callbacks
language: en
canonical_slug: dom/merge-callbacks
audience: external
maturity: mature
guide_order: 53
topic_only: false
short_desc: How widgets keep heavy resources across a layout rebuild
prerequisites: [dom, dom/datasets, dom/reconciliation]
tracked_files:

  • core/src/dom.rs
  • core/src/diff.rs
    last_generated_rev: 7ecd570e4c0c3584e5107e770058c16cb59fa6e7
    generated_at: 2026-05-02T05:53:30Z
    default-search-keys:
  • Dom
  • RefAny
  • DatasetMergeCallback
  • DatasetMergeCallbackType
  • with_merge_callback
  • Update

Merge Callbacks

Introduction

A merge callback lets a widget keep heavy resources alive across a layout rebuild. The framework matches each new node against the previous frame's tree and, before dropping the old dataset, hands both the new and old RefAny to your merge function so you can move whatever you want to keep into the new one.

The problem

A dataset is rebuilt every time layout() runs. For a marker struct that's free. For an FFmpeg encoder it's a disaster.

Imagine a video pane whose dataset holds an open encoder, a GL texture, and a frame counter. The user toggles a checkbox somewhere far up the tree. layout() runs again. The pane appears in the new tree with a fresh dataset value. The old dataset drops. The encoder closes. The texture is freed. The next frame reopens everything.

That isn't acceptable for resources you can't recreate cheaply.

The fix

A merge callback is a function attached to a node that gets to claim resources from the previous frame's dataset before that dataset is dropped.

rust,ignore
pub type DatasetMergeCallbackType =
    extern "C" fn(new_data: RefAny, old_data: RefAny) -> RefAny;

You receive the new dataset (built fresh by layout() this frame) and the old dataset (the one from the previous frame). You move whatever you want to keep from old into new. You return the merged value. The framework installs it on the new node. Whatever you didn't take drops with the old RefAny.

This is a Reconcile() in the Kubernetes sense. The new value is the desired state. The old value is the actual state. The callback brings the world into alignment.

text
layout()  →  fresh Dom (desired state)
                                  │
                                  ▼
                         ┌─────────────────┐
                         │   diff pass     │
                         └─────────────────┘
                                  │
                  matched node?   │  no  → drop old, install new
                                  │ yes
                                  ▼
                      ┌────────────────────────┐
                      │ merge_fn(new, old) → m │
                      └────────────────────────┘
                                  │
                                  ▼
                         install m on new node

When it fires

The callback runs during reconciliation. The conditions are strict.

  • The new node has a merge callback registered via with_merge_callback(...).
  • Both the old node and the new node have a dataset attached.
  • The diff matched the two nodes (Stable or Moved). Created and Destroyed nodes don't qualify.

If any of those fails, nothing happens. The new dataset stays as layout() built it. The old one drops normally.

The matching rules belong to the diff. A node survives a reorder when it carries a stable identity - set an AttributeType::Id on it, e.g. with with_id(...). Without one the diff matches structurally, by logical position, which only holds while the sibling order does. See Reconciliation for how that works.

Worked example: a video encoder

Take a video pane backed by FFmpeg. The application data describes the desired pipeline. The widget owns the live encoder and a GL texture. The user can change the bitrate, toggle an overlay, or switch the source path.

Two structs:

rust,ignore
use azul::prelude::*;

#[derive(Clone, PartialEq)]
pub struct VideoConfig {
    pub source_path: String,
    pub width: u32, pub height: u32,
    pub codec: String,
    pub bitrate_kbps: u32,
    pub overlay_text: Option<String>,
}

pub struct VideoPaneState {
    pub config: VideoConfig,
    pub encoder: Option<FfmpegEncoder>,
    pub gl_texture: Option<GlTexture>,
    pub last_frame_idx: u64,
}
# pub struct FfmpegEncoder; pub struct GlTexture;
# impl FfmpegEncoder {
#     pub fn open(_: &VideoConfig) -> Self { Self }
#     pub fn reconfigure(&mut self, _: &VideoConfig) {}
# }

VideoConfig is what the application stores. VideoPaneState is widget-owned. The application never sees the encoder.

The merge function does three things. It takes the encoder out of the old state. It checks whether the new config is compatible. If yes, it reconfigures and keeps the encoder; if no, it lets the old encoder drop.

rust,ignore
extern "C" fn reconcile_video(new_data: RefAny, old_data: RefAny) -> RefAny {
    let new = new_data.clone();
    let mut new_state = match new.downcast_mut::<VideoPaneState>() {
        Some(s) => s,
        None => return new_data,
    };
    let old = old_data.clone();
    let mut old_state = match old.downcast_mut::<VideoPaneState>() {
        Some(s) => s,
        None => return new_data,
    };

    if let Some(mut enc) = old_state.encoder.take() {
        if encoder_compatible(&new_state.config, &old_state.config) {
            enc.reconfigure(&new_state.config);
            new_state.encoder = Some(enc);
        }
    }
    if let Some(tex) = old_state.gl_texture.take() {
        if texture_compatible(&new_state.config, &old_state.config) {
            new_state.gl_texture = Some(tex);
        }
    }
    new_state.last_frame_idx = old_state.last_frame_idx;

    drop(new_state);
    drop(old_state);
    new
}

fn encoder_compatible(a: &VideoConfig, b: &VideoConfig) -> bool {
    a.source_path == b.source_path && a.codec == b.codec
}
fn texture_compatible(a: &VideoConfig, b: &VideoConfig) -> bool {
    a.width == b.width && a.height == b.height
}

Building the widget is straightforward. Each frame layout() produces a VideoPaneState with the new config and empty resource slots. The merge callback fills the slots from the previous frame.

rust,ignore
pub fn video_pane(config: VideoConfig) -> Dom {
    let key = stable_key_for(&config.source_path);
    let state = RefAny::new(VideoPaneState {
        config,
        encoder: None,
        gl_texture: None,
        last_frame_idx: 0,
    });
    Dom::create_div()
        .with_class("video-pane".into())
        .with_dataset(OptionRefAny::Some(state))
        .with_merge_callback(reconcile_video)
        // The reconciler matches nodes by identity, and an explicit id is the
        // stable half of it - give the node one when its position can move.
        .with_id(key)
}
# fn stable_key_for(_: &str) -> u64 { 0 }

What happens on a RefreshDom:

  1. The parent re-renders.
  2. layout() builds a fresh VideoPaneState with encoder: None and gl_texture: None.
  3. The diff matches old and new by key.
  4. reconcile_video runs. The encoder moves over if the source and codec are unchanged. The texture moves over if the resolution is unchanged.
  5. The new node now owns the live encoder. The old VideoPaneState drops empty.

If the user navigates away and the pane disappears from the new tree, the diff classifies the old node as destroyed. No callback fires. The old VideoPaneState drops normally. Drop on FfmpegEncoder and GlTexture releases the resources.

Designing a merge function

A few rules.

Default to the new value. If you can't downcast, return new_data as-is. The new node ends up with whatever layout() built. That matches the behaviour of having no merge callback at all.

Use Option<T> for resources you might want to move. Option::take() is what transfers ownership. Whatever you don't take drops with the old RefAny.

Compare configs before reusing a resource. The new dataset has the desired config. The old dataset has the actual config plus the resource that was built for it. If the configs disagree, the resource is stale. Drop it and let the widget rebuild on the next render.

The clones the framework hands you are shallow. Heavy fields stay in place until you call take().

When to use this versus app-state RefAny

Putting expensive resources on the application's data model works if the resource is a singleton. One main viewer on the home screen is fine.

Merge callbacks are the right tool when the widget instance defines the lifetime. A video pane inside a search-result row is the canonical case. The application data has search results. It doesn't have "the encoder for the row that used to be at index 4". The widget's presence in the tree is what should keep the encoder alive.

Merge callbacks are also right when there are many instances created dynamically. Without them, you'd maintain a HashMap<WidgetId, VideoPaneState> on the application side that mirrors the DOM. The dataset slot is that hashmap, indexed by the diff key, freed when the node disappears.

A useful split:

  • Application data lives in a RefAny at app construction. layout() reads it to decide what to render.
  • Widget-instance state lives on the widget's root node as a dataset. Add a merge callback if the state owns expensive resources.

Doc Guide En Dom Reconciliation (`doc/guide/en/dom/reconciliation.md`)


slug: dom/reconciliation
title: Reconciliation
language: en
canonical_slug: dom/reconciliation
audience: external
maturity: mature
guide_order: 51
topic_only: false
short_desc: Diffing, restyle scope, and damage-rect repaint
prerequisites: [dom]
tracked_files:

  • core/src/diff.rs
  • core/src/styled_dom.rs
  • css/src/props/property.rs
  • layout/src/cpurender.rs
    last_generated_rev: 7ecd570e4c0c3584e5107e770058c16cb59fa6e7
    generated_at: 2026-05-02T05:53:30Z
    default-search-keys:
  • Dom
  • Update
  • AttributeType
  • with_merge_callback
  • DatasetMergeCallback
  • EventFilter

Reconciliation

Introduction

Azul rebuilds the Dom from scratch on every Update::RefreshDom. The
tree returned by layout() is frozen. The next state change runs
layout() again and produces a new tree. The diff is what makes that
affordable.

The framework matches each new node against the most plausible old node,
classifies what changed, and picks the cheapest path forward. A hover
highlight on one button repaints one rectangle. Tree size doesn't matter.

Matching new to old

Every node in the new tree is one of four things:

  • Stable. Matched against an old node at the same logical position.
    Old node id maps to new node id. Merge callbacks run. Focus and scroll
    state migrate.
  • Moved. Matched against an old node at a different parent or
    sibling position. Same migration treatment as Stable.
  • Created. No match. A Mount event fires.
  • Destroyed. The old tree had a node here, the new tree doesn't. An
    Unmount event fires. The dataset drops.

For trees with stable sibling order, structural matching works without
any help. The third <li> inside a `` matches its
counterpart in the old tree. Its text content can change and it'll
still match.

For trees whose order can change (sortable lists, drag-and-drop,
virtualised scrolling), structural matching loses cursor position,
focus, and dataset state when items reorder. Those cases need a stable
identity. Set an AttributeType::Id on the node so the diff can match
across the reorder.

What does and doesn't trigger work

Different changes cost different amounts of work. The framework
classifies each property change so it can pick the cheapest correct
path:

  • Repaint only. Color, background, opacity, transform, caret
    styling, object-fit, clip path. No layout pass runs. The display
    list updates and the affected pixels re-rasterise.
  • Inline reshape. Font-style flip, vertical-align change inside a
    paragraph, letter-spacing update. Only the paragraph's text reflows.
    Block-level siblings stay where they are.
  • Local resize. Border-width tweak, scrollbar gutter, padding
    inside a text container. The node's size recomputes. The parent may
    reposition subsequent siblings. No recursive relayout.
  • Full subtree relayout. Flips of display, position, float.
    Anything that changes the formatting context.

A :hover that only changes background-color lands in the repaint-
only bucket. A change to display: flex triggers a full subtree
relayout. The framework picks the right tier automatically.

Pseudo-class state changes (:hover, :focus, :active) go through
the same classification. A hover that flips just the background never
runs layout.

Lazy paint

Even after restyle decides "repaint only", azul doesn't paint the whole
window. It compares the new display list against the previous one and
returns the rectangles that need re-rasterising. Everything outside
those rectangles keeps the previous frame's pixels.

For a hover-state change on a button, this is constant-time work. The
renderer rasterises maybe 100 by 30 pixels. The rest of the frame is a
straight blit from the previous one.

A grow-only window resize feeds a specialised producer. Just the new
right strip and bottom strip get rasterised. The existing pixels stay.

Per-frame flow

  1. layout() returns a fresh Dom.
  2. The framework matches new nodes against old. Lifecycle events
    (Mount, Unmount, Update) fire.
  3. Restyle promotes the changes to the smallest sufficient scope. If
    nothing layout-affecting changed, no layout pass runs.
  4. The display list rebuilds for the parts that changed.
  5. The renderer compares display lists and produces damage rects.
  6. Only those rects get rasterised.

For a hover-state change on a button, steps 3 to 5 are constant-time.
Step 6 paints a single small rectangle.

Merge callbacks across the diff

For Stable and Moved nodes, the merge callback (if registered) runs
during the diff. It receives the old and new RefAny values. Heavy
resources (a video decoder, a GL texture, the cursor inside a focused
input) can move from the old dataset to the new one before the old
tree drops. See Merge Callbacks for the protocol.

For Created nodes, the Mount lifecycle event fires (no merge runs).
For Destroyed nodes, the dataset drops via Drop in the natural way
and the Unmount event fires.

Internals

The full algorithm covers matching tiers, the property-change bitmask,
the relayout-scope enum and damage-rect coalescing.


Doc Guide En Dom Virtual Views (`doc/guide/en/dom/virtual-views.md`)


slug: dom/virtual-views
title: Virtual Views
language: en
canonical_slug: dom/virtual-views
audience: external
maturity: mature
guide_order: 54
topic_only: false
short_desc: A node that materialises lazily, for infinite lists and embedded sub-DOMs
prerequisites: [dom]
tracked_files:

  • core/src/dom.rs
  • core/src/callbacks.rs
    last_generated_rev: 7ecd570e4c0c3584e5107e770058c16cb59fa6e7
    generated_at: 2026-05-02T05:53:30Z
    default-search-keys:
  • Dom
  • NodeType
  • VirtualViewCallback
  • VirtualViewCallbackInfo
  • VirtualViewCallbackReason
  • VirtualViewReturn
  • EdgeType
  • RefAny
  • LogicalPosition

Virtual Views

Introduction

A VirtualView is a single node. Its inner content comes from a separate
callback. That callback only runs when needed.

It's azul's iframe-equivalent. Use it when the inner content would be too
expensive to build every frame. Common cases:

  • An infinite list.
  • A 100,000-row table.
  • An editor with heavy decoration layers.
  • A panel that's logically independent from its surroundings.

The outer Dom containing the VirtualView is built inside layout() like
any other node. The inside isn't built until the framework calls the
callback.

rust,no_run
use azul::prelude::*;

struct ListData { 
    items: Vec<String> 
}

extern "C" 
fn render_list(mut data: RefAny, info: VirtualViewCallbackInfo) -> VirtualViewReturn {
    let d = match data.downcast_ref::<ListData>() { 
        Some(d) => d, 
        None => return VirtualViewReturn::default() 
    };
    
    let dom: Dom = d.items.iter()
        .map(|s| Dom::create_li_with_text(s.clone()))
        .collect();
    
    let row_h = 24.0_f32;
    let total = LogicalSize::create(
        info.bounds.logical_size.width, 
        row_h * d.items.len() as f32
    );
    
    VirtualViewReturn::with_dom(
        dom, total, 
        LogicalPosition::zero(), total, 
        LogicalPosition::zero()
    )
}

let list_state = RefAny::new(ListData { 
    items: vec!["A".into(), "B".into()] 
});
let cb = VirtualViewCallback { 
    cb: render_list, 
    ctx: OptionRefAny::None 
};
let _ = Dom::create_virtual_view(list_state, cb);

Two coordinate systems: rendered vs virtual

The key idea is that the rendered size and the virtual size are separate
numbers. That's what lets a scrollbar pretend to span 30,000 rows while
only 30 rows actually live in the DOM.

text
┌───────────────────────────────────┐
│         Virtual Scroll Frame      │  ← virtual_scroll_size
│                                   │    (infinite scroll: total logical height,
│  ┌─────────────────────────────┐  │     may be larger than actual content)
│  │       Scroll Frame          │  │  ← content_size (overflow_content_size)
│  │    (actual content area)    │  │    (total height of rendered children)
│  │                             │  │
│  │  ┌───────────────────────┐  │  │
│  │  │    Screen Bounds      │  │  │  ← scroll clip size (= container inner size)
│  │  │   (visible window)    │  │  │    This is what the user sees.
│  │  │                       │  │  │
│  │  │   ← scroll offset →   │  │  │    Scroll offset shifts which part
│  │  │                       │  │  │    of content_size is visible in
│  │  └───────────────────────┘  │  │    the clip rect.
│  │                             │  │
│  └─────────────────────────────┘  │
│                                   │
└───────────────────────────────────┘

VirtualViewReturn carries two pairs:

  • scroll_size and scroll_offset describe the actual rendered content.
    This is the box of DOM you're handing back. scroll_offset is where it
    sits in virtual coordinates.

  • virtual_scroll_size and virtual_scroll_offset describe what the view
    pretends to be. virtual_scroll_size is the size the scrollbar
    represents. virtual_scroll_offset is usually LogicalPosition::zero().

If every row is materialised, the rendered values match the virtual values.
The abstraction collapses to a plain scrollable subtree.

For a virtualised slice, say rows 10..30 of a million-row table:

  • scroll_size is (width, 20 * row_height). That's the 20 actual rows.
  • scroll_offset is (0, 10 * row_height). The rendered rows start at
    y = 10 × row_height in virtual coordinates.
  • virtual_scroll_size is (width, 1_000_000 * row_height). The
    scrollbar represents the whole table.
  • virtual_scroll_offset is (0, 0).

The framework paints the scrollbar from virtual_scroll_size. It clips the
rendered DOM to a viewport at scroll_offset of size scroll_size. If the
user scrolls outside the rendered window, it re-invokes the callback.

Why the callback was invoked

Each invocation carries a VirtualViewCallbackReason. The variants are
defined in core/src/callbacks.rs:

  • InitialRender. The first time the VirtualView appears. You always
    return content here.

  • DomRecreated. The parent tree was rebuilt from scratch. The cached
    subtree was thrown away. The callback has to rebuild.

  • BoundsExpanded. The window grew. The view's bounds now exceed its
    previous scroll_size. Time to enlarge the rendered content. This fires
    once per expansion, not on every resize tick.

  • EdgeScrolled(EdgeType). The user scrolled within approximately
    200 px of one of the four edges of the rendered content. Time to
    lazy-load more rows. EdgeType is one of Top, Bottom, Left,
    Right. Fires once per edge approach. The flag clears once the scroll
    moves away.

  • ScrollBeyondContent. A programmatic scroll (e.g. set_scroll_position)
    jumped the offset past the rendered scroll_size.

The callback does not fire on small resizes that stay inside the rendered
scroll_size. It does not fire when the window shrinks. It does not fire
on parent re-renders that don't recreate the parent DOM. That's the
optimisation.

Returning OptionDom::None

Sometimes the existing DOM is fine. The user scrolled, but stayed inside
the already-rendered area. Or the parent re-rendered without invalidating
the subtree.

Set dom: OptionDom::None in the return value. The previous DOM stays in
place. Only the scroll bounds are updated.

rust,ignore
fn render_table(data: &mut TableData, info: VirtualViewCallbackInfo) -> VirtualViewReturn {
    if data.already_rendered_area_covers(info.scroll_offset, info.bounds.logical_size) {
        return VirtualViewReturn {
            dom: OptionDom::None,                  // keep current DOM
            scroll_size:  data.current_scroll_size,
            scroll_offset: data.current_scroll_offset,
            virtual_scroll_size: data.virtual_size,
            virtual_scroll_offset: LogicalPosition::zero(),
        };
    }
    let new_dom = data.render_more_rows(info.scroll_offset, info.bounds.logical_size);
    /* ... */
    # VirtualViewReturn::default()
}

VirtualViewReturn::keep_current(...) is a shortcut for the same return
shape.

This is the cheapest re-invocation. The most expensive ones are
InitialRender and DomRecreated, since both rebuild from nothing.

A virtualised table, end to end

text
/* Detailed source-code truncated for AI context efficiency. */

The user sees a scrollbar that represents all million rows. The DOM holds
about 25 row nodes. The callback runs only when the user scrolls near an
edge, the parent rebuilds, or the window grows. It doesn't run every
frame.

Pairing with merge callbacks

The data parameter on a VirtualView is a RefAny. The framework keeps
it alive across re-renders of the parent DOM. The inner Dom returned by
the callback is rebuilt each time the callback fires.

If the inner content owns expensive resources (per-row decoders, GL
textures), pair each row's root with with_dataset(...)
and a merge callback. That way the resources
transfer when the table is re-rendered.

The outer RefAny is also a good place to keep live state: what was
rendered last time, the cached fetcher, in-flight requests. The callback
can look at that state before deciding whether to return OptionDom::None.


Doc Guide En Events Callbacks (`doc/guide/en/events/callbacks.md`)


slug: events/callbacks
title: Callbacks
language: en
canonical_slug: events/callbacks
audience: external
maturity: mature
guide_order: 61
topic_only: false
short_desc: What CallbackInfo exposes — state, DOM mutation, focus, async work
prerequisites: [dom]
tracked_files:

  • core/src/callbacks.rs
  • layout/src/callbacks.rs
    default-search-keys:
  • CallbackInfo
  • RefAny
  • Update
  • EventFilter
  • FocusTarget
  • KeyboardState
  • MouseState
  • DomNodeId

Callbacks

DOM showed how to attach a callback to a node. The other
side of the wire — what a callback receives, can read, can change,
and returns — is what this page documents.

The callback signature

Every callback the framework invokes has the same C-compatible
signature:

rust,ignore
extern "C" fn(data: RefAny, info: CallbackInfo) -> Update
  • data is the RefAny you passed to with_callback. Downcast it
    to your concrete type to read or mutate application state.
  • info is a borrowed view into the framework's frame state — the
    hit node, the current input state, the layout result, and a
    handful of dispatch helpers.
  • Update tells the framework what to do next: nothing, re-run
    layout() for this window, or re-run for every window.
rust,no_run
use azul::prelude::*;

struct Counter { value: i64 }

extern "C" fn on_click(mut data: RefAny, _info: CallbackInfo) -> Update {
    let mut c = match data.downcast_mut::<Counter>() {
        Some(c) => c,
        None => return Update::DoNothing,
    };
    c.value += 1;
    Update::RefreshDom
}

The extern "C" is mandatory. Callbacks are C function pointers,
which is what makes the FFI bindings (Python, JavaScript, C#, …) work
the same way.

The Update return

Update has three values:

Variant Meaning
DoNothing No re-layout, no re-render. Use this when the callback only mutates internal state that doesn't show up on screen yet.
RefreshDom Re-run layout() for the window the event came from. The framework reconciles old vs new tree.
RefreshDomAllWindows Re-run layout() for every open window. Use sparingly — pick this when the change touches global state that every window's layout reads.

If a single event fans out to multiple callbacks (e.g. propagation
from child to parent), the framework takes the strongest Update
across all of them.

Reading application state

RefAny::downcast_ref::<T>() and downcast_mut::<T>() recover the
typed payload. The downcast checks the type id, so passing the wrong
type returns None rather than reinterpreting memory.

rust,ignore
extern "C" fn save(data: RefAny, _info: CallbackInfo) -> Update {
    let model = match data.downcast_ref::<AppModel>() {
        Some(m) => m,
        None => return Update::DoNothing,  // wrong RefAny type
    };
    write_to_disk(&*model);
    Update::DoNothing
}

For a callback that needs to mutate, use downcast_mut. The mutable
borrow lasts the callback body.

Finding the right method

CallbackInfo has around 350 methods. They fall into groups, and most
of them are documented on a page of their own:

Group Page
Finding, walking and editing nodes; hit testing; geometry Node Tree & Hit Testing
Cursor position, seats, capture and lock, pen, touch, gestures Pointer, Pen & Touch
Drag sources, drop targets, drag data, OS file drops Drag & Drop
Scroll containers, offsets, programmatic scrolling Scrolling
Window state, monitors, transient windows, tooltips, screenshots, system capabilities Window & System
Audio and video playback, now-playing, media keys Media Playback
Locale-aware numbers, dates, plurals, collation Locale Formatting
Typing, IME, undo, document sync Text Input
Cursors, selection ranges, clipboard Text Selection
Route pattern and parameters Routing

What stays on this page is the part that is not a group: the signature,
the return value, reading state, propagation, focus, async work, and
the styling and cache queries.

Identifying the node that fired

rust,ignore
let hit: DomNodeId = info.get_hit_node();
let rect = info.get_node_rect(hit);

A DomNodeId is (DomId, NodeId), and for a callback attached to
several nodes the dataset is how you tell instances apart. Addressing,
walking and mutating nodes is the subject of
Node Tree & Hit Testing.

Reading input state

The framework hands you the input state as of the event, plus the
snapshot from the previous one, so transitions are a diff rather than
bookkeeping you keep yourself:

rust,ignore
info.get_current_keyboard_state();    // modifiers, pressed scancodes, reported chars
info.get_previous_keyboard_state();   // the same, one event ago
info.get_key_modifiers();             // just the modifier set
info.get_key_locks();                 // caps / num / scroll
info.is_key_repeat();                 // held, not newly pressed

get_physical_key() is the layout-independent key - the position on
the keyboard - which is what a shortcut like "W A S D" must use so it
stays a square on an AZERTY keyboard.

The pointer equivalents (get_current_mouse_state, the three cursor
position spaces, pen and touch) are on
Pointer, Pen & Touch.

Mutating the DOM without rebuilding

You don't have to return RefreshDom for small changes. The
framework exposes targeted mutations on info that go through a
faster path:

  • info.change_node_text(node_id, text) — replaces the text content of
    a node.
  • info.change_node_image(node_id, image_ref, ...) — swaps an image.
  • info.set_css_property(node_id, prop) / override_node_css_properties(...)
    — set or override a CSS declaration without re-running the cascade
    for the whole tree.
  • info.change_node_image_mask(node_id, mask) — update a clip mask.

These produce a CallbackChange queued on the info; the framework
applies them between the callback returning and the next paint. The
restyle and damage-rect machinery covered in
Reconciliation keeps the work proportional
to what changed.

For structural edits (insert a child, delete a node), use
insert_child_node and delete_node. Larger changes — anything
beyond a handful of nodes — are usually clearer expressed as a fresh
Dom from layout() plus Update::RefreshDom.

Focus

rust,ignore
info.set_focus(FocusTarget::Node(node_id));
info.set_focus_to_path(dom_id, css_path);     // by selector
info.has_focus(node_id);
info.clear_focus();

Focus moves on the next frame, and the reconciler migrates it across a
RefreshDom for nodes that still match - so a focused text field
survives a rebuild without you re-focusing it.

focus_next(), focus_previous(), focus_first() and focus_last()
walk the focus order, which is what a custom key handler calls instead
of computing the next focusable node itself.

Every one of these has a _for_seat twin - set_focus_for_seat(),
clear_focus_for_seat() - because a window can have several
independent cursors; see Pointer, Pen & Touch.
is_dom_focused(dom_id) asks the same question of a whole sub-DOM.

Accessibility rides on the same axis: set_accessibility_state() and
set_accessibility_value() update what assistive technology reports
for a node, and perform_accessibility_action() performs an action
the assistive layer requested.

Scrolling a node into view is scroll_node_into_view(node), and
set_cursor_visibility(false) hides the pointer - see
Scrolling and Pointer, Pen & Touch.

For text inputs and contenteditable surfaces, the cursor and
selection helpers (add_cursor, add_selection_range,
get_primary_selection, …) are documented separately in
Text Selection.

Stopping propagation

Events bubble from the hit node to the root by default. Two opt-outs:

  • info.stop_propagation() — finish the current node's callbacks,
    then stop. Other callbacks attached to this node still run.
  • info.stop_immediate_propagation() — stop right now. No further
    callbacks at this node, no parents.

info.prevent_default() is the third opt-out: it tells the framework
not to apply the built-in handling for the event (e.g. don't insert a
character on KeyDown after your callback handled it). Browsers use
the same name for the same idea.

Event filtering — EventFilter::Hover(...) vs Focus(...) vs
Window(...), propagation order, NotEvent — is in
Events and Input.

get_current_event_id() identifies the event being dispatched, which is
how two callbacks on different nodes tell "the same click" from "two
clicks", and get_last_input_sample() is the raw sample it came from.

Logging and metrics

A callback runs inside the framework's loop, so println! lands
wherever the host put stdout - which on a windowed app is often
nowhere. Log through the info instead, and it goes to the same sink as
the framework's own output:

rust,ignore
info.log(AppLogLevel::Info, "export finished".into());
info.warn("texture atlas is full".into());

Three metric recorders sit alongside them - record_counter(),
record_gauge() and record_histogram(), each taking a name, a value
and a label set. They feed the observability pipeline, so an app can be
instrumented without a metrics crate of its own.

get_ctx() returns the host-provided context RefAny, when one was
installed.

Async work: timers and threads

The callback runs on the UI thread. Any work it does blocks the next
frame. For anything slow, schedule it.

rust,ignore
let timer = Timer::new(/* interval */ 100.ms, refany.clone(), tick);
info.add_timer(TimerId::unique(), timer);

add_timer registers a recurring callback the framework drives on
the main loop. The timer callback returns a
TimerCallbackReturn { update, terminate } that controls both
whether to re-run layout and whether the timer fires again.

For background work, add_thread spawns a worker thread tied to a
RefAny. The thread sends messages back to a merge_callback on the
main thread — the framework already understands cross-thread message
delivery, so you don't need a manual mutex. See
Background Tasks.

Window control

rust,ignore
info.create_window(WindowCreateOptions::create(layout_fn));
info.close_window();
info.modify_window_state(new_state);
info.begin_interactive_move();       // hand the drag to the OS

begin_interactive_move() is the one worth singling out: it tells the
window manager to take over a drag, so a custom title bar moves the
window with the platform's own snapping and animations instead of a
per-frame reposition. Monitors, transient windows, tooltips and
screenshots are on Window & System.

Routing across pages is switch_route(pattern, params), with
get_route_pattern() and get_route_param() to read the active route;
see Routing.

Image and font caches

rust,ignore
info.add_image_to_cache("logo".into(), image_ref);
info.remove_image_from_cache("logo".into());
info.reload_system_fonts();

Cached images are addressable by name from any layout pass. Reloading
system fonts is the right thing to do after a font config change
(rare, but desktop environments do change font defaults at runtime).

Styling and layout queries

set_css_property() and override_css_property() change one
declaration on one node without re-running the cascade for the tree.
Reading back is the computed value, after the cascade:

rust,ignore
info.get_computed_width(node);
info.get_computed_height(node);
info.get_computed_css_property(node, CssPropertyType::Display);

has_pending_relayout_change() reports whether anything queued so far
this callback will force a relayout - worth checking before adding
more, in a handler that runs every frame.

get_animation_momentum() and set_animation_momentum() read and
inject the velocity a node is carrying, which is how a custom gesture
hands a fling over to the built-in animation.

get_gl_context() returns the GL context for a node that draws its own
content, and query_pagination() measures how a styled DOM would break
across pages. Node geometry - sizes, positions, hit-test bounds - is on
Node Tree & Hit Testing.

Working with sub-DOMs

info.trigger_virtual_view_rerender(dom_id, node_id) re-runs the
virtual-view callback for one specific sub-DOM. Use it when the
virtual view's source data changed but the parent layout hasn't.

info.update_image_callback(dom_id, node_id) triggers a re-render
of an ImageCallback-backed node — the GPU canvas pattern documented
in SVG and Canvas.

Finding another node: markers

A callback sometimes needs to act on a node it did not fire on — a
canvas callback moving a meter in the header, a timer updating a
preview tile. The address for that jump is a marker: a string you
mint at layout() time (use Uuid::short() — collision-free with no
coordination), stamp on the target with Dom::with_marker, and keep
wherever the driving callback can see it. Resolve it back to a node
with get_node_id_by_marker:

rust,no_run
use azul::prelude::*;

struct App { meter_marker: String } // filled with Uuid::short() in layout()

extern "C" fn on_pen_move(mut data: RefAny, mut info: CallbackInfo) -> Update {
    let marker = match data.downcast_ref::<App>() {
        Some(a) => a.meter_marker.clone(),
        None => return Update::DoNothing,
    };
    if let Some(node) = info.get_node_id_by_marker(marker).into_option() {
        // Drive the widget through ITS public API - its dataset stays private.
        ProgressBar::update_progress(info, node, 63.0);
    }
    Update::DoNothing // the widget repaints itself, no relayout needed
}

Markers are invisible to CSS matching and excluded from node
equality
, so minting a fresh UUID every layout() never makes the
DOM diff consider a node "changed". The full pattern — when to prefer
it over RefreshDom, and how widgets like ProgressBar expose update
functions for it — is the architecture guide's
"inter-widget fast path"
section.

A complete example

A "delete row" button: each row's button callback carries a dataset
naming its row plus a backreference to the app (the
backreference pattern), mutates the model, and
refreshes:

rust,no_run
use azul::prelude::*;

struct App { rows: Vec<String> }

/// Attached as the RefAny of each row's delete-button callback.
struct DeleteRow { index: usize, app: RefAny }

extern "C" fn on_delete(mut data: RefAny, _info: CallbackInfo) -> Update {
    let (index, mut app_ref) = match data.downcast_ref::<DeleteRow>() {
        Some(d) => (d.index, d.app.clone()),
        None => return Update::DoNothing,
    };
    let mut app = match app_ref.downcast_mut::<App>() {
        Some(a) => a,
        None => return Update::DoNothing,
    };

    if index < app.rows.len() {
        app.rows.remove(index);
    }

    Update::RefreshDom
}

The pattern — a per-row dataset naming the row, a backreference to the
model, mutate, return RefreshDom — works for nearly every "this
widget acts on its container" interaction.


Doc Guide En Events Drag And Drop (`doc/guide/en/events/drag-and-drop.md`)


slug: events/drag-and-drop
title: Drag & Drop
language: en
canonical_slug: events/drag-and-drop
audience: external
maturity: wip
guide_order: 67
topic_only: false
short_desc: Dragging nodes, MIME-keyed drag data, accepting drops, and files dragged in from the OS
prerequisites: [hello-world, events, events/callbacks]
tracked_files:

  • core/src/drag.rs
  • core/src/events.rs
  • layout/src/event_determination.rs
  • layout/src/callbacks.rs
    default-search-keys:
  • CallbackInfo
  • DragData
  • DragEffect
  • DropEffect
  • DragState
  • accept_drop
  • set_drag_data
  • get_drag_data
  • get_dropped_files

Drag & Drop

Drag and drop is two halves that never meet directly: a source that
says what is being dragged, and a target that says whether it will
take it. They communicate through a MIME-keyed payload, the same model
the W3C DataTransfer API uses.

Introduction

The drag system covers selection drags, scrollbar thumbs, window moves,
window resizes, OS file drops and DOM-node drags. The framework handles
the first four itself. This page is about the last two, which are the
ones you write callbacks for.

Dragging data out of an azul window into another application is not
implemented on any platform. Everything else on this page works.

Marking a node draggable

rust,no_run
use azul::prelude::*;
let card = Dom::create_div()
    .with_attribute(AttributeType::Draggable(true))
    .with_css("padding: 12px; background: #fef;");

The gesture manager sees the attribute during hit testing and starts a
drag when the user begins one. Nothing else is needed to make a node
pick up.

The event sequence

On the source node: DragStart, then Drag on every cursor move,
then DragEnd exactly once - on a successful drop, a rejected one, or a
cancel. DragEnd always fires, which makes it the only correct place to
undo optimistic UI.

On a target node: DragEnter, DragOver (throttled, repeated),
DragLeave, and Drop if the target accepted.

Each of these exists as HoverEventFilter, FocusEventFilter and
WindowEventFilter. Use the hover form for "this node is the drop
zone"; use the window form to observe drags anywhere in the window, for
example to highlight every valid target the moment a drag starts.

Drag data

Set it on the source at DragStart, read it on the target at Drop:

rust,ignore
// DragStart, on the source
info.set_drag_data("application/x-myapp-task".into(), payload_bytes);
info.set_drag_data("text/plain".into(), "Task 17".as_bytes().into());

// Drop, on the target
if let Some(bytes) = info.get_drag_data("application/x-myapp-task".into()).into_option() {
    // ...
}

Several MIME types per drag are allowed and the usual pattern is two:
your own structured type for drops inside the app, and text/plain so
the same drag means something to a foreign target.

get_drag_types() lists the types the current drag carries. It is
readable during DragOver, which is the point - a target decides
whether to accept before it is allowed to read the payload, exactly as
in the browser. get_drag_data() returns nothing until Drop.

Accepting a drop

Targets opt in. A node that never calls accept_drop() is not a drop
target: the cursor shows the no-drop indicator and Drop never fires.

rust,ignore
// DragOver, on the target
if info.get_drag_types().iter().any(|t| t.as_str() == "application/x-myapp-task") {
    info.accept_drop();
    info.set_drop_effect(DropEffect::Move);
}

accept_drop() is the equivalent of event.preventDefault() in a W3C
dragover handler, and the same trap applies: accepting only in
DragEnter is not enough, because a later DragOver that does not
accept revokes it.

Effects

DragEffect is what the source allows, set at DragStart:
Uninitialized, None, Copy, CopyLink, CopyMove, Link,
LinkMove, Move, All.

DropEffect is what the target chose: None, Copy, Link, Move.

The chosen effect must be in the set the source allows, or the drop is
rejected. The effect also drives the cursor, so a target that means
"copy" but says Move will lie to the user.

Styling during a drag

Two pseudo-classes, so drag feedback does not need callbacks:

css
.card:dragging  { opacity: 0.4; }
.dropzone:drag-over { outline: 2px solid #38f; }

Files dragged in from the OS

File drops arrive on the same pipeline, with their own events.
HoverEventFilter::DroppedFile fires when the drop lands, and the
hovered-file methods report what is over the window before it does:

rust,no_run
use azul::prelude::*;

extern "C" fn on_dropped_file(_: RefAny, info: CallbackInfo) -> Update {
    for path in info.get_dropped_files().iter() {
        // ... open each file ...
    }
    Update::RefreshDom
}

get_dropped_files() is the full list and get_dropped_file() the
first of them; get_hovered_files() and get_hovered_file() are the
same pair while the drag is still in flight, which is what a drop zone
uses to preview "3 files" before the user lets go.
is_file_drag_active() distinguishes an OS file drag from an in-app
node drag, which matters because both are "a drag" to everything else.

More methods

Querying the drag - is_drag_active, is_dragging,
is_node_drag_active, is_file_drag_active, get_drag_state,
get_dragged_node. get_drag_state() returns the whole picture at once

  • drag type, source node, current drop target, and the file path for a
    file drag - if you would rather read one struct than call four
    predicates.

Movement - get_drag_delta, get_drag_delta_screen,
get_drag_delta_screen_incremental. The first is relative to the drag's
start in window coordinates, the second the same in desktop
coordinates, and the incremental form is the delta since the previous
event
- which is the one a window-move or a resize handle wants, since
it never accumulates rounding error.

Data and acceptance - set_drag_data, get_drag_data,
get_drag_types, accept_drop, set_drop_effect.

Files - get_dragged_file, get_dropped_file, get_dropped_files,
get_hovered_file, get_hovered_files.

Cross-references

  • Pointer, Pen & Touch - pointer capture, which a
    hand-rolled drag needs and a draggable node does not.
  • Scrolling - drag auto-scroll runs on the scroll timer.

Doc Guide En Events Formatting (`doc/guide/en/events/formatting.md`)


slug: events/formatting
title: Locale Formatting
language: en
canonical_slug: events/formatting
audience: external
maturity: wip
guide_order: 70
topic_only: false
short_desc: Locale-aware numbers, dates, lists, plurals and collation from inside a callback
prerequisites: [hello-world, events, events/callbacks]
tracked_files:

  • layout/src/callbacks.rs
  • core/src/icu.rs
    default-search-keys:
  • CallbackInfo
  • IcuResult
  • PluralCategory
  • FormatLength
  • format_integer
  • format_date
  • pluralize
  • sort_strings

Locale Formatting

Numbers, dates, lists and plurals differ per locale in ways no format
string survives. These methods do it properly, from inside a callback,
without pulling in an i18n crate of your own.

Introduction

Every method takes the locale as its first argument - a BCP-47 tag such
as "en-US" or "de-AT" - rather than reading a global. That is
deliberate: an app that shows one user's document to another, or
formats a report for a locale that is not the UI's, needs both at once.

The date and time functions return IcuResult because a locale can be
unknown or a date invalid; the number functions return a String
directly.

Numbers

rust,ignore
let n = info.format_integer("de-DE".into(), 1_234_567);      // "1.234.567"
let x = info.format_decimal("de-DE".into(), 1234, 2);        // "12,34"

format_decimal() takes the value as an integer plus a decimal-place
count rather than an f64, which keeps money exact: 12.34 is
(1234, 2) and never becomes 12.339999999999999.

Dates and times

rust,ignore
let d = info.format_date("fr-FR".into(), date, FormatLength::Long);
let t = info.format_time("fr-FR".into(), time, /* include_seconds */ false);
let dt = info.format_datetime("fr-FR".into(), datetime, FormatLength::Short);

FormatLength picks how much the locale spells out, from numeric-short
to fully written. Let it decide field order - month-day versus day-month
is a locale property, not a formatting choice.

get_current_time() returns the current Instant through the same
clock the framework uses, which is the one to read if you want a
timestamp that agrees with the animation and timer subsystems.

Plurals

English has two plural forms. Arabic has six, Polish three with
different rules, and "if n == 1" is wrong in most of the world:

rust,ignore
let s = info.pluralize(
    locale, count,
    "no files".into(), "one file".into(), "two files".into(),
    "{} files".into(), "{} files".into(), "{} files".into(),
);

You supply every CLDR category - zero, one, two, few, many, other - and
the locale selects. For English only one and other are ever chosen,
so filling the rest with the same string costs nothing and makes the
call correct in Polish too. get_plural_category() returns the chosen
category if you would rather branch yourself.

Lists

rust,ignore
let s = info.format_list(locale, items, ListType::And);

"A, B and C" in English, "A, B et C" in French, and the serial comma
handled per locale. ListType also covers or and unit lists.

Sorting and comparing

Alphabetical order is not code-point order: ä sorts with a in
German and after z in Swedish.

rust,ignore
let sorted = info.sort_strings(locale, names);
let ord = info.compare_strings(locale, a, b);   // -1, 0, 1
let same = info.strings_equal(locale, a, b);

strings_equal() is locale-aware equality, not byte equality, so it
matches strings that differ only in how they are composed - which is
the difference between a search box that finds café typed two ways
and one that does not.

More methods

Numbers - format_integer, format_decimal.

Dates and times - format_date, format_time, format_datetime,
get_current_time.

Plurals and lists - pluralize, get_plural_category,
format_list.

Collation - sort_strings, compare_strings, strings_equal.

Other - get_icu_localizer returns the underlying localizer handle
for code that wants to hold one rather than pass a locale each time;
get_string_contents(node_id) reads a node's localized string content.


Doc Guide En Events Media (`doc/guide/en/events/media.md`)


slug: events/media
title: Media Playback
language: en
canonical_slug: events/media
audience: external
maturity: wip
guide_order: 69
topic_only: false
short_desc: Driving audio and video nodes, now-playing metadata, OS media keys and system audio
prerequisites: [hello-world, events, events/callbacks]
tracked_files:

  • layout/src/callbacks.rs
  • core/src/audio.rs
  • core/src/dom.rs
    default-search-keys:
  • CallbackInfo
  • PlaybackState
  • NowPlayingInfo
  • MediaControlRequest
  • media_play
  • media_seek
  • set_now_playing
  • get_media_position

Media Playback

Dom::create_audio() and Dom::create_video() make a media node. This
page is the control surface: a callback drives the node, reads its
state, and tells the OS what is playing.

Introduction

A media node holds its own playback state. Every method here addresses
one by DomNodeId, so a page with three videos is three independent
players and no global "current track" exists unless you make one.
create_audio() and create_video() take accessibility info, because
a player nobody can announce is a player a screen reader user cannot
operate.

Transport

rust,ignore
info.media_play(node);
info.media_pause(node);
info.media_toggle(node);                 // whichever is the opposite
info.media_seek(node, 42.0);             // seconds, absolute

Reading back:

rust,ignore
let playing  = info.is_media_playing(node);
let at       = info.get_media_position(node);   // seconds
let total    = info.get_media_duration(node);
let state    = info.get_media_state(node);      // OptionPlaybackState

is_media_playing() is the boolean shortcut; get_media_state() is
the full state - stopped, playing, paused, buffering, ended - which is
what a progress bar needs to distinguish "stalled" from "paused".

Volume

rust,ignore
info.media_set_volume(node, 0.5);   // 0.0 ..= 1.0
info.media_set_muted(node, true);

Mute is not volume zero, and the pair is kept separate deliberately:
unmuting restores the volume the user had set, so a mute button must
use media_set_muted() rather than saving and restoring a level
itself. get_media_volume() and is_media_muted() read both back.

Driving a custom source

If you decode media yourself and push frames into the node, three
methods let you own the clock:

rust,ignore
info.media_set_duration(node, total_seconds);  // once, when known
info.media_advance(node, dt);                  // per frame
info.media_report_error(node);