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
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/releaseazul-doc is a multitool that generates all the code necessary for having a stable public API that works across multiple languages.
# 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 --releaseDoc README (`doc/README.md`)
azul-doc
Build tool, FFI code generator, and W3C spec verification pipeline for the
Azul CSS layout engine.
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:
- Modify Rust source code (add/change/remove public types or functions)
- Run
autofixto synchronizeapi.jsonwith the workspace - Run
codegen allto regenerate all language bindings - 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.
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
azul-doc normalize # Canonicalize types + remove cross-module duplicatesPerforms 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/.
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 attarget/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 thepython_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.
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.
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.
# 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
Readtool) - 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).
# 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-6The 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:
FcFontRegistryfrom 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/:
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.
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:
[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 exitProgress 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:
Create a bot. Open Telegram, message @BotFather,
send/newbot, follow the prompts. Pick any name and any handle ending
inbot. BotFather replies with a token like1234567890:ABCdef-....Pair. Run the wizard:
bashazul-doc telegram-setupPaste the token when asked. The wizard verifies the token, prints your
bot's@handle, and waits for you to message/startto it from your
phone. As soon as it sees your message, it stores the chat-id and sends
a confirmation.Done. Future runs of
apply-midlevelautomatically mirror prompts
to your bot. Each prompt comes with a tap-keyboard:Stage Buttons Free-form text Pre-apply applyrefinediffskiprejectquitbecomes analyzer feedback Post-apply accepteditrevertquitbecomes "edit" instructions The legacy single-letter shortcuts (
y/p/s/r/d/qandy/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.patchfiles. - 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 withdisable_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] difffrom the local terminal still does the editor checkout
(detached HEAD on the commit, restored on Enter). From Telegram it
ships a.patchdocument 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 todoc/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.
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 PNGPipeline stages
autoreview ──► autodoc ──► autodoc-screenshots ──► deploy
(per-file) (per-group) (PNG renders) (website)autoreviewproduces per-file reports underdoc/target/autoreview/reports/.autodocreadsautodoc-groups.toml, builds one prompt per group with
the group's tracked source, the per-groupdesign_docs(intent, not
truth), and the global writing-style + max-effort thinking rules from[meta].- Agents write pages to their declared paths under
doc/guide/en/. autodoc-screenshotsrenders anyazul-renderfences viaHeadlessWindow
intodoc/guide/en/screenshots//<name>.png.deploywalksdoc/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 againsttracked_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_fileshad commits sincelast_generated_revin the page's frontmatter (git logquery). - Translation-stale pages — translated page's
source_hashno longer
matches the SHA-256 of its canonical English body. CI fails on either.
Translations
Translations live in language-prefixed sibling directories underdoc/guide/. The English tree is canonical; every other language mirrors
it page-for-page with localized slugs.
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/flexA translated page's frontmatter carries five extra fields beyond the
canonical set:
---
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
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.
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:
// +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
┌─ 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.
# 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 progressStage 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.
# 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).
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:
- Refactoring — implement groundwork relevant to this group
- LLM-apply — apply patch semantic intent to current code (similar to
cherry-pick, but with semantics) - Compile —
cargo check -p azul-dll --features build-dll - Review — verify again against the original W3C spec, fix issues and compile again
- Commit — use
git add -pto 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
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 commentsFinally, you can always get help a spec command with:
azul-doc spec <command> --help # Detailed help for any subcommandAdding 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.
Register the spec in
doc/src/spec/downloader.rs— add the URL toSPECS. The downloader stores HTML indoc/target/w3c_specs/.Add a feature node in
doc/src/spec/skill_tree.rs— define keywords
used to match spec paragraphs to the feature.Run agents:
azul-doc spec claude-exec
(automatically runsdownload+build-allif prompts are missing)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
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 packagingCI 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)
- Lint & Static Checks — runs
autofix, applies patches, runsnormalize, verifies no uncommitted changes remain. Then runscodegen allandcargo teston the DLL. - Build DLL —
codegen all+cargo build -p azul-dll --features build-dll
on Linux, macOS, and Windows. - Reftests —
codegen all+reftestwith Chrome screenshot caching. - C/C++ Examples — copies
azul.hand C++ headers fromtarget/codegen/, compiles all example files with clang/clang++. - Website Build —
codegen all+nfpm+deploy(production). - OS Packaging — uses nfpm config to build
.deband.rpmpackages.
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
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 registryKey layout source files reviewed by spec agents:
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 heightDoc 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 in621e7fb8c, 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 inlayout/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: insidefrom_system_style (:1116-1117, AZ_THEME > SystemStyle.theme) and again inLayoutWindow::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_pinruns at the end of all three platformdiscover()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=lightproduced light-theme TEXT on the
desktop's DARK background, because the cascade followed the pin and the background followedSystemStyle. 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
- Shell probe → window theme. macOS: KVO
effective_appearance_changed
(macos/mod.rs:985-1003) or the 2 s poll (:1426-1439, throttlesystem_style.rs:851) →adopt_probed_theme(system_style.rs:909-936): early-out whenws.themealready equals the
probe (:919), elseupdate_unsynced_state(|ws| ws.theme = theme)(:928) and a
re-discover()edSystemStyle(: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. adopt_system_style(dll/src/desktop/shell2/common/event.rs:9552-9624). Bails when
the wholeSystemStyleis unchanged (:9556-9559).needs_full(:9563-9565) askssystem_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 byget_themecallbacks.rs:1446, harvested atlayout.rs:496-497). Full →request_regeneration(ThemeChange)(:9597-9601); otherwisereset_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 theThemeChangedwindow event:grep ThemeChangedinevent.rsfinds
only the app path (:4848) and the twoThemeChangerequests above. So aws.themewrite
whose rediscovered style equals the held one triggers nothing (a dark desktop at startup:discover()is already dark,ws.themeisLightMode, the poll writesDarkMode, step 2
returnsfalse, and the only follow-up isrequest_redrawatmacos/mod.rs:1435-1439).regenerate_layout(layout.rs:236). The callback seescurrent_window_state.theme
(:449). The pre-cascade fingerprint skip (:543-553) excludesThemeChange(: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.StyledDomcreation cascade (core/src/styled_dom.rs:1322-1365), withdynamic_context = None(prop_cache.rs:2367):restylegates@theme/@mediablocks on
the context (prop_cache.rs:1531-1538— no context, no conditional rule),apply_ua_css
(:1343; themed lookup atprop_cache.rs:4911-4915but the context isNone, so the light
table; the property list:4859-4891contains noTextColor),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 contextcompact.rs:955-957—None→ dropped; UA defaults go in through the unthemedget_ua_propertycompact.rs:1194-1196; children copy the parent's text tier:768), thenprune_compact_normal_props(:1365,prop_cache.rs:1253-1257drops Normal compact-encoded
entries fromcascaded_props).- 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_retainedis a no-op for an empty sheet:1760-1770); otherwiserecompute_inheritance_and_compact_cache(:2389-2404) which,
despite its name, rebuilds the compact cache only (:1659-1684) —apply_ua_cssandcompute_inherited_valuesare not re-run. - 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_valuesfor 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_defaultseeds black
(:2497-2507). Normal-state fast paths read the compact cache instead: text colourgetters.rs:3134-3157, border colours:2262-2280. - 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). - Display list re-resolves it.
live_color(display_list.rs:7570-7639): ancestor
user-override walk (:7587-7605), thencache.get_text_coloron the slow path (:7617-7619),
thenevaluate_ua_root_text_colorwith 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. - DL cache. Key tuple
cache.rs:567-574; inputsmod.rs:770-779; hit condition:802-808;dl_input_fingerprint(:470-500) now foldspaint_defaults_fingerprint
(:488-490;dynamic_selector.rs:1185-1190hashes the theme only). A second gate,cascade_ctx_unchanged(mod.rs:1520-1566againstcache.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 asevent.rs:5302,6236,6664) never installs a context — it reads whatever the last layout pass left. - Layout hash. Colour is excluded from relayout by design (
property.rs:1700-1715,:1807-1808);NodeDataFingerprinthashes inline declarations, never resolved values
(diff.rs:2085-2160, inline at:2109-2111); it becomes the subtree hash atcache.rs:1577-1584, 2196-2201, 3612-3617. Two cascades of one DOM under two themes hash
identically — the documented consequence is atcache.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) andComputedScrollbarStyle::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 noset_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 atdisplay_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 andcomputed_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 leftSystemStyle.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 inbutton_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 untilfaa2f6c6a; 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 aStyledDom; the DL
fallback (display_list.rs:7626),get_scrollbar_style(getters.rs:5609) and the shells'
contexts readcache.dynamic_contextor go away. Enforce: makefrom_system_style/default
non-puboutsidecss+ a grep lint inscripts/check.sh. - I2 — The context exists before the first cascade, and a theme flip is a full restyle.
debug_assert!(dynamic_context.is_some())inapply_ua_cssand the compact builders;set_dynamic_selector_contextontheme_changedruns the same sequence asrestyle()
(styled_dom.rs:1772-1830). Test: create under Light, install Dark, assertcascaded_props
andcomputed_valuescarry the dark UA values. - I3 — UA defaults are cascaded like any other property, from one themed table. One
&[CssPropertyWithConditions]per node type (the shapeUA_SCROLLBAR_CSSalready has,ua_css.rs:1342), withTextColoron the root, consumed byapply_ua_cssandapply_ua_css_to_compactthrough one resolver. Thenget_ua_property_themed's twin match,UA_ROOT_TEXT_COLOR_CSS,display_list.rs:7620-7635andgetters.rs:3164are unreachable.
Test:ua_css_test.rs:1590rewritten to assert through aStyledDomunder both themes. - I4 — The two readers agree. For every node in the normal state, compact == slow for every
compact-encoded property. Enforce: anAZ_VALIDATEcross-check at the end ofbuild_compact_cache_with_inheritance; makecompute_inherited_valuesStep 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 byrestyle,set_dynamic_selector_context, and user
overrides; put it in the DL key (mod.rs:770-779,window.rs:17992) andStyleCache; retirepaint_defaults_fingerprintand thelast_dynamic_contextequality. Test: the uncommitteda_theme_switch_recolours_the_retained_domplus a variant throughregenerate_display_list_for_dom. - I6 — The theme is paint-only. Node rects are identical under both themes and across a
switch: the uncommittedthe_theme_does_not_change_layoutanda_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.themewrite decides regeneration vs restyle on the window theme
delta, not onSystemStyleequality (event.rs:9556-9559); creation seedsws.themefrom the
probe. Test: headlessset_system_theme(headless/mod.rs:1978) on a window whoseSystemStylealready matches must still restyle;dll/tests/headless_lifecycle.rs:476is the
place. - I8 — Ordering by construction. Dark twins are emitted with their light value
(hover_bg_bothshape,flat.rs:2160) and states after resting values; the widget lint
manifest (layout/tests/widget_lint_manifest_is_exhaustive.rs) rejects adark_theme(X)that
precedes an unconditionalXof the same property type in any*_stylevector. - 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 darkSystemStyleand default window state. Fixtures exist inwindow.rs's test module.
4. Recommended restructuring (ranked by leverage vs risk)
- Theme flip = restyle (high leverage, low risk, ~1 day). In
set_dynamic_selector_context,
whentheme_changed(or the DOM carries any conditional declaration), resetcascaded_props, re-runapply_ua_css→compute_inherited_values→ compact rebuild (the
tail ofrestyle()), and have the funnel install the context beforeStyledDom::create's
cascade (or pass it in). Completes fix 5; removes R2. Fix 5 as written is the patch this replaces. - Inherit conditional inline declarations on the slow path (high, low, ~1 day).
prop_cache.rs:5056-5060evaluatescondsagainstself.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. - One themed UA table consumed by both cascades (high, medium, ~2-3 days). Replaces fix 2's
twin match andUA_ROOT_TEXT_COLOR_CSS, deletes the DL fallback and the BLACK/DEFAULT_TEXT_COLOR
seeds (keep them asdebug_assert!s). After this, fix 4's theme hash is redundant. cascade_epochin the DL and style-cache keys (high, low, ~½ day). Replaces fix 4 and thelast_dynamic_contextgate with one key that also covers@media, restyles and user overrides.- Single theme source and trigger (medium, low, ~½ day). Seed
ws.themefrom the probe at
creation in all shells (or make itOption,None= follow system); keyadopt_system_style
on the window-theme delta; delete the shells' dead contexts andWindowCreateOptions.theme;
route scrollbars through the stored context; remove the[THEME-DBG]prints. - Button surface classification (medium, low, ~½ day).
ButtonType::surface()→Neutral | OwnColour | NoSurface, used byflat::button,flora::buttonandbutton_states
for face, text and border twins alike. Fixes symptom (b) without touching a light value. - 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, fromget_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 getssimple(BackgroundContent(bootstrap_primary)) (button.rs:307), simple(TextColor(WHITE))
(:262), and blue borders (:312-313, border_color = bg_normal). flat::button then appendsdark_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 vsflat.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) afterprune_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 viatext3/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 uncommittedthe_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 theRestyle 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:
Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.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.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.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.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:
Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.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.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.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.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:
Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.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.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.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.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:
Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.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.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.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.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
<name> <duration> [<delay>] [<timing>] [infinite | <count>] [no-clip]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: addinganimation in the same rebuild that changes a value does not retro-animate
that change.
.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.
.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`
@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 readstransform (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:
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 readsstate.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.
---
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---
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 dataThe "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.
# 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("")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:2pxIn 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").
# 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)
])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:2pxHowever, 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.
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:
- Model: The entire state of the application is held in a single, immutable data structure.
- View: A pure function that takes the
Modeland returns a description of the UI. - 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.
# 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)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:2pxIMGUI 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":
- 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()). - 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). - 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/useEffectin 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:
<div class="parent">
<div class="child"></div>
<div class="child"></div>
<div class="child"></div>
</div>with:
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:
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 droppedEffectively this is similar to an Observable, however, since RefAnys are usually connected to aCallback, 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:
// 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“.
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) viaon_number_input:
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:
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.
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:2pxWhen the user now finishes editing and the input loses focus, the event flows
through the backreferences:
_on_focus_lost(RefAny<TextInput>, text_string)_validate_text_input_as_number(RefAny<NumberInput>, text_string)_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 wrapsTextInput 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 theNodeGraph, when a user clicks an input port on a node, how does the widget tell the top-levelNodeGraph 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“):
- The
Domfor the input port has a callback holding aPortWidget's data. - This
PortWidgetcontains a backreference to its logical parent, theNodeWidget. - The
NodeWidgetin turn holds a backreference to the top-levelNodeGraphWidget, 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.
# 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:
# 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:
Event->PortWidget.handle_click_event()PortWidget.handle_click_event()->NodeWidget.on_port_clicked()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.
/* 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.
- Store the pressure in the app's data model
- Return
Update::RefreshDom 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.
// 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:
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.
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 dataThis 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()andRefreshDom - 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).
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 atApp::createso 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/falsesilences it entirely;error,warn,info,debug(the default),trace(everything, including per-frame). It honorsNO_COLORand only colorizes a TTY. If your host already installs a logger (Python'spyo3-log, Android'sandroid_logger, your ownenv_logger), Azul's logger steps aside and does not override it.AZ_DEBUG=. Binds the HTTP debug server on127.0.0.1:. A bind failure exits the process.AZ_BACKEND=<mode>. One ofauto,gpu,cpu, orheadless. Resolves the rendering backend.headlessskips the OS window and is required by the E2E runner. Defaultauto.AZUL_HEADLESS=1. Legacy alias forAZ_BACKEND=headless.AZ_RECORD=. Appends every internal log message to `` as plain text.AZ_E2E=. Reads JSON tests from ``, runs them, exits0(all pass) or1(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 withAZ_PROFILE=heap,jsonl.RUST_LOG=<filter>. Standardlogcrate 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:
{
"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:
curl -s -X POST http://localhost:8765/ \
-H 'Content-Type: application/json' \
-d '{"op":"click","selector":"button"}' | jqThe 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(programmaticscroll_toon 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:
#!/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.pngThis 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:
- 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. - Both arrays are flat
Vecs in DOM tree order. Parent before
children. So a slicedata[self..self.last_child + 1]is the
subtree rooted atself. No pointer-chasing to walk a subtree. - The DOM is frozen after
layout()returns. There is noinsertChild, nosetAttribute, no mutation observers. To change
the tree, the nextlayout()call returns a newDom. The
framework diffs old against new and migrates state across. - CSS is stored in a compact, layout-hot cache. Common enum
properties (display,position,float,overflow) are
bit-packed into a singleu64per 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:
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:
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, classesFor 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:
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 atself_idx. No pointer-chasing, no recursion needed to copy a subtree.
NodeData carries everything that defines a single node:
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 theextra 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:
- A callback returns
Update::RefreshDom. - The framework re-invokes the layout function.
- A fresh
Domis built from the application data. - 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:
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 areconst fn and don't allocate until a child is added:
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:
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_indexordatasetyou 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 thecreate_*_with_textfamily (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 withAZ_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.
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
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 asAttributeType::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:
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 underlyingVec 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 andset_children call. The framework reads it to pre-size the flat
arena during conversion. If children is mutated directly, callfixup_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.
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 inNodeData::style: Css.
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):
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 aLayoutCallbackInfo describing the window. Returning a Dom
finishes the pass; the framework reconciles, lays out, and renders.
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, orInitial — 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:
/* 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 theAppConfig — the framework picks the right one for the active
route and re-runs it on switch_route:
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:
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 anXml value, it returns a Dom ready to return from layout():
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 CSSclip-path: (see Defining a clipping path
above).
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 withAZ_BACKEND=headless and the framework rasterises into an in-memory
framebuffer instead of opening a window. Combine withAZ_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
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 withwith_merge_callback. The framework calls it with the old and newRefAny 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.
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:
{
"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.
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.
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:
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 nodeFor "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:
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:
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::VirtualKeyUpFocusEventFilter::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:
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 windowUse 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:
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 changedReconciliation 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:
ApplicationEventFilter::DeviceConnected
ApplicationEventFilter::DeviceDisconnected
ApplicationEventFilter::MonitorConnected
ApplicationEventFilter::MonitorDisconnectedEvent propagation
For each event the framework computes the path from root to target and calls handlers in three phases (W3C DOM Level 2):
- Capture: root to target. Rare; only nodes with capture-phase handlers are visited.
- Target: handlers on the target node itself.
- 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:
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/Spaceon focused button: synthetic click on the button.Escape: clear focus or close modal.Ctrl+Ain text input: select all.- Arrow keys in scroll container: scroll by line.
To suppress the default action from a callback:
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:
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:
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:
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):
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:
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. ReturnUpdate::RefreshDomafter mutating the model. - Default action still happens after
prevent_default. Verify the call is onCallbackInfo, 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.
<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:
- Installation or linking the Azul library.
- Defining a data model.
- Writing a
layoutcallback that returns aDom. - Attaching a click callback that mutates the model.
- Building, running, notes for common pitfalls.
Supported languages
Pick the "Hello World" / Setup guide for your language:
- C (99+)
- C++ (03 - 23)
- C#
- Crystal
- D
- Fortran
- Go
- Haskell
- Java
- Kotlin
- Lua
- Node.js
- OCaml
- Pascal
- Python (3.10+)
- Ruby
- Rust
- Scala
- Swift
- Zig
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
SvgMultiPolygonplus tessellation. Icons, charts, diagrams. - GPU texture. Backed by a
Textureproduced 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)(alsonew_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.
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):
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:
- Block - default, full-width stacked boxes. See
Blocks, Sizing, and Positioning. - Inline - text and inline-block runs inside a block. See Inline Layout.
- Flex - one-axis containers (rows or columns). See Flexbox.
- Grid - two-axis containers (columns and rows). See Grid.
Please also see the way better documentation on HTML layout modes:
- MDN - Box Model: extensive documentation, however Azul does not support all properties, only common ones
- W3CSchools - Box Model: simpler, but more approachable documentation
- Every Layout - explains the box model more visually
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:
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:
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:
// 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 aDom 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.
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);<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 aCssstruct and pushed onto that node's.cssvec. 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 typedCssPropertyand don't want to
round-trip through string parsing. Highest priority short of!important.
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.cssvec, which the cascade reads;add_component_cssis
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,h1match nodes whose tag matches. - Class:
.panelmatches nodes withwith_class("panel"). - Id:
#sidebarmatches nodes withwith_id("sidebar"). - Attribute:
[lang],[lang="en"],[lang^="en"]test attribute
presence and match. The operators are=,~=,|=,^=,$=,*=
(seeAttributeMatchOp). - Descendant:
nav amatches an<a>anywhere under<nav>. - Child:
nav > amatches a direct<a>child of<nav>. - Adjacent sibling:
h2 + pmatches a `` immediately after<h2>. - General sibling:
h2 ~ pmatches 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.
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: ...)acceptsportraitorlandscape.@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.@containerenables 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:
- Higher specificity wins.
- Equal specificity. The later rule wins.
style()calls stack. A laterstyle()is "later" than an earlier one.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>:
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 toDynamicCssProperty:
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:
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:
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):
- The user-agent stylesheet sets HTML defaults (
h1font sizes,
`` padding,<a>color, ...). - Each
Cssattached to a subtree viaDom::style(...), instyle()
push order. - Inline
with_cssrules on each node. - Programmatic
with_css_propertyoverrides (highest priority short of!important).
Internally the framework collects every CSS attachment from the recursiveDom 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:
- CSS Properties Cheatsheet. Every property and
the values it accepts. - System Themes.
system:*colors and fonts,@theme,@os, and accessibility queries. - Text and Fonts.
font-family, weight,
style, alignment, plus thesystem:font keywords. - Icon Packs. Registering image and font icons
under named packs.
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.
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:
- Input Widgets:
TextInput,NumberInput. - Structural Widgets:
TabHeader,TreeView,ListView,Ribbon.
Common shape
Every widget follows the same builder pattern.
- Construct with
Widget::create(...)(ornew). - Set callbacks with
with_on_*(data, fn)orset_on_*(...). - Override styles with
with_*_style(css)orset_*_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.
pub enum ButtonType {
Default, Primary, Secondary, Success,
Danger, Warning, Info, Link,
}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.
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).
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.
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
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.
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 andcontainer_background for custom palettes.
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'sVirtualView 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:
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, returnUpdate::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.
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 producedDom via Dom::with_css_property or Dom::with_css.
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:
METADATA - W3C boilerplate, copyright, JS tooling, changelogs, abstracts
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:
- Support for sticky positioned content
- Scrolling areas that include content that is ordered both above and below
intersecting content from outside the scroll area. - Items in the same scrolling root, clipped by different clips one or more of
which are defined outside the scrolling root itself. - 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:
- 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. - A collection of
ClipNodeswhich specify a rectangular clip and, optionally,
a set of rounded rectangle clips and a masking image. - A collection of
ClipChains. EachClipChainis a list ofClipNode
elements. Every display list item has an assignedClipChainwhich
specifies whatClipNodesare 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 theDisplayListFlattener.
`SpatialNode`
There are three types of SpatialNodes:
- 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. - 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. - 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 parentClipChain and a list of ClipNodes. The second is through the hierarchy of aClipNode 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
SpatialIdof aSpatialNodefor positioning - A
ClipIdof aClipNodeor aClipChainfor 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 anyClipChain 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 fromSpatialId into SpatialNodeIndex. In order to make this conversion cheaper, theDisplayListFlattner 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, theDisplayListFlattener 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 theSpatialTree. This is done so that hit testing can still take place while a
new SpatialTree is under construction.
Ideas for the Future
- Expose the difference between
ClipIdandClipChainIdin the API. - Prevent having to duplicate the
SpatialTreefor hit testing. - Avoid having to create placeholder nodes in the
SpatialTreewhile
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:
- Use
glTexStoragewhenever 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. - 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.
- 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. - 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
Swizzleand thus would produce incorrect results. To address this, the change enhancescs_copyshader to be used in place of blitting from the texture cache, where needed. - 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
glTexStoragewith BGRA8 internal format, no swizzling is needed in general case.
Windows (non-Angle), Mac, Linux:
- if
glTexStorageis available, we use it with RGBA8 internal format, swizzling everything on texture sampling. - otherwise, we use RGBA unsized format with
gTexImageand 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:
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":
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:
vec4 in(vec4 src, vec4 mask) {
return src * mask.a;
}So the complete blending function is:
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:
oFragColor = text_color * mask.a;and the OpenGL blend pipeline can be configured like so:
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
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.rwhich 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:
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
maskinstead of just withmask.a.
However, our font rasterization code fills the rgb values ofmaskwith the value ofmask.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, andcolor.a. - New world: Each pixel has six values:
color.r,color.a_r,color.g,color.a_g,color.b, andcolor.a_b.
In such a world we can define a component-alpha-aware operator "over":
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":
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:
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:
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 oftext_color.a_r/g/bwithtext_color.a. - We're currently not making use of the mask's
r,gandbvalues, only of thea_r,a_ganda_bvalues.
So in the real world, we can use the rgb channels ofmaskto store those component alphas and
replacemask.a_r/g/bwithmask.r/g/b.
These two changes give us:
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.
Sodest.a_r/g/bandresult.a_r/g/bwill need to becomedest.aandresult.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.
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_bIn 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:
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:
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:
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.rNotably, 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 usestext_color.r * mask.r and the second summand uses text_color.a * mask.r.
There are multiple ways to deal with this. They are:
- Making use of
glBlendColorand theGL_CONSTANT_COLORblend func. - Using a two-pass method.
- 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:
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.rAt 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 * maskfrom the fragment shader and
usesgl::ZERO, gl::ONE_MINUS_SRC_COLORas the glBlendFuncs. This achieves:
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 * maskfrom the fragment shader and usesgl::ONE, gl::ONEas the glBlendFuncs. This results in the correct overall blend equation.
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.r3. 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.
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.
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:
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:
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 layoutTimerCallbackInfo
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 callingInstant::now()again.is_about_to_finish: bool.trueonly 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):
pub fn create<C: Into<TimerCallback>>(
refany: RefAny,
callback: C,
get_system_time_fn: GetSystemTimeCallback,
) -> SelfThe three modifier methods cover all common scheduling shapes:
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.
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:
- Return
TerminateTimer::Terminatefrom the callback when its work is done. The normal teardown path. - Set
with_timeoutwhen the schedule is bounded. The runtime forces termination on the tick that crosses the deadline; the callback seesis_about_to_finish == trueon its final call so it can flush state. - 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():
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:
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, exactInstant::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 withTimerCallbackReturn::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
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
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.
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
RefAnyis 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 fromcall_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.
.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:
-azul-animation-out: flyAway 400ms ease-out no-clip;A native animation function can override it per frame throughZombieFrame::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:
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(()),
)
}.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.0skips 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 CSSno-clipkeyword.
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
animationand-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.
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.
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.
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.
/* 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.
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.
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.
<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:
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, ®istry) -> 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.
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.
/* `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:
config = AppConfig.create()
config.add_component_library("shadcn", register_shadcn) # acceptedbut 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:
fn(&ComponentDef, &ComponentDataModel, &ComponentMap) -> ResultStyledDomRenderDomErrorThe 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.
- 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. - Look up the corresponding
ComponentDef. - Take the def's
data_modeland populate each field's default value from the XML attributes. Coercion is typed. - Call the def's
render_fnwith the populated data model and the component map. - Stamp every root node of the returned
StyledDomwith 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.
fn(&ComponentDef, &CompileTarget, &ComponentDataModel, indent: usize)
-> ResultStringCompileErrorThat's what closes the round-trip for the design-time tool.
┌──────────────────┐ ┌────────────┐
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 valuerender_fnreads 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 toApp::create:
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
:nameplaceholder — matches any path component and captures
it as a parameter namedname.
| 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:
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,
viainfo.get_route_pattern() (the active pattern) andinfo.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:
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:
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:
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 callshistory.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
Threadrunning 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:
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:
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 firstRefAnyargument 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 everyWriteBackCallbackinvocation. This is the handle the callback uses to mutate application state in response to the thread's output.callback. Theextern "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:
pub type WriteBackCallbackType =
extern "C" fn(RefAny, RefAny, CallbackInfo) -> Update;Full example:
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:
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:
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
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:
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:
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:
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 ifearlier > selfor 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 fndirectly. The framework doesn't provide an executor. To use Tokio, futures-rs, or smol, spawn aThread, build aRuntimeinside it, and useWriteBackCallbackto surface results. - Stream raw socket data through the framework. Networking has its own page; see networking. Until the runtime side lands, use a
Threadplusstd::net::TcpStreamfor the same shape as the example above. - Share
&mutreferences between thread and main. Communication isRefAnypayloads only. Lock-free shared state isn't part of the framework. Wrap aMutexinsideRefAnyif 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 staticdowncast(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
/* 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) orBlob(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 andvalue: 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:
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, orMergewith a merge function installed viaset_on_conflict(store, data, on_merge). The merge function is a purefn(RefAny, DbConflict) -> DbValue(returnNullto 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 thedb-sqlite Cargo feature (part of build-dll) and sync needs the http
feature. Without an engine Db::open resumes withDbErrorKind::NoEngine; without http, sync_now reports Error naming
the missing feature. PlatformCapability::sql() and ::sync() answer these
questions at runtime.
See also
- background-tasks - timers, for driving
sync_now
periodically on desktop. - architecture - keeping the handle in your
State.
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 callhttp_get,download_bytes, oris_url_reachable. The convenience constructorshttp_get_defaultanddownload_bytes_defaultskip configuration.HttpResponse. The result. Carriesstatus_code,body(U8Vec),headers,content_type,content_length. Useis_success,is_redirect,is_client_error,is_server_error,body_as_stringto 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.
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 andhttp_request), HttpBytesResult (download_bytes) andHttpReachableResult (is_url_reachable); each has a staticdowncast(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 withHttpError::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:
// 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'sHttpClientConfig; 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:
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:
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:
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.
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 aPeerConnectedorErrorevent.send_frame(peer, track, data)andbroadcast_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 fromRawImage::encode_jpegare 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,
IrohLoadBalancerpicks the peers that forward media for everyone (backbone_size,select_backbone), andIrohTileRole::rendition_heightpicks 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, userustls,native-tls, or a third-party HTTP client inside the worker. - Mid-frame cancellation of in-flight DNS or TCP handshakes.
std::netdoesn't expose this. Usesocket2or 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_appboots your application headlessly and runs the JSON file as a single batch. Prints cargo-test-style output, exits0if every test passes,1otherwise.POST /e2e/runfrom a script or the in-browser inspector queues the same payload against a running app.
A first test
{
"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:
AZ_BACKEND=headless AZ_E2E=test.json ./my_appOutput mirrors cargo test:
running 1 test
test "Button click increments counter" ... ok
test result: ok. 1 passed; 0 failed; finished in 0.21sA test file is either one test object or an array of them.
File and step shape
{
"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 equalsexpected.assert_exists(params:selector). At least one node matches.assert_not_exists(params:selector). No node matches.assert_node_count(params:selector,expectedint). Exactlyexpectednodes match.assert_layout(params:selector,property,expected,tolerance?).propertyisx,y,width, orheight. Default tolerance0.5px.assert_css(params:selector,property,expected). Computed CSS value equalsexpected.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 withAZ_E2E_RECORD=1to 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):
{ "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 bymswithout sleeping and renders a frame. Animations, scroll momentum, scrollbar fade and cursor blink all readInstant::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 wholeFrameReportas JSON:paint_damage_kind/paint_damage_rects/paint_damage_area_ratio, the same forpresent_*,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?, default0.5). The repaint is a PATCH, not a full redraw.assert_idle_stable(params:vs?). No input ⇒ damage drained tonone(and pixels identical tovs). The infinite-redraw detector.assert_work_bounded(params:max_relayouts?,max_dom_regens?,allow_depth_cap?). Measured since the lastreset_frame_counters. Fails ifMAX_EVENT_RECURSION_DEPTHwas hit — an invalidation loop is now a red test rather than alog_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 ascroll_into_view, the target is inside its container's visible rect according toScrollManager's own offset.X4— theDragStatethe 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".
{ "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:setobject). Arms the store and queues answers. Keys ofset:reset: true- empties every queue and the records. Put it first in every scenario.file_open: { "path": "..." }ornull- the nextFileDialog::open_fileresumes with that path, or as cancelled. Queued: one answer per call, in order.file_open_multi: { "paths": [...] }- the nextFileDialog::open_multiple_files.color_pick: { "r": 0, "g": 0, "b": 0 }ornull- the nextColorPickerDialog::open.save_file: { "path": "..." }ornull- the nextFileDialog::save_file.save_bytes: { "accept": true }- whatFileDialog::save_bytesreports (defaulttrue). The bytes are recorded either way, forassert_saved_file.file_read: { "": { "text": "..." } }or{ "b64": "..." }per path - canned content forFilePath::read_bytes/read_string. Paths without an entry read the real file.http: { "": { "status": 200, "text": "...", "content_type": "..." } }or{ "error": "..." }per pattern - canned answers forhttp_get,http_post,http_request,download_bytes,is_url_reachableand the Db sync endpoints. A pattern is an exact URL, a prefix ending in*, or*;b64replacestextfor binary bodies.audio_devices: { "outputs": [...], "inputs": [...] }-AudioDeviceList::enumerate.video_decode: { "none": true }-decode_mp4_h264resumes 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 lastresethad 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:nameorname_ends_with,mime?,min_len?,contains?). An export recorded byFileDialog::save_bytesmatches; the newest matching export wins.
{ "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:
{
"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:
{
"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:
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:
- Build the application once:
cargo build --release. - Run the test bundle headlessly:
AZ_BACKEND=headless AZ_E2E=tests/smoke.json ./target/release/my_app. - Process exit code is the test verdict. Use it as the CI step's exit code.
- For screenshot diffs, set
assert_screenshotsteps with areferencePNG path; commit the references to the repo and update them via aBLESS=1workflow 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_stateto put the app in a known state before each test. take_native_screenshotreturns the actual framebuffer of the running window. Pixel-identical comparison across platforms is unrealistic; useassert_screenshotwith amax_diff_ratiotolerance, 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.
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 probesAZ_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(aliasmem). Per-frame heap-breakdown dump (DOM caches, layout cache, text cache, RSS) to stderr.cpu(aliasperf). Per-phase wall-clock timings (layout, style, cascade, paint, callbacks, etc.).cascade(aliascss). Top-N CSS properties by cascade-walk count per frame.heap. Phase-boundary heap probes inside the layout pass. Silent withoutjsonl.jsonl. Formatsheapprobes as JSONL toAZ_PROFILE_OUT. Requiresheap.detail. Opts in to fine-grained per-step probes. Layered on top ofheap.
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
cpuwraps phase functions with a timestamp pair on entry/exit. Sub-microsecond per probe; safe to leave on in CI.memorycomputes per-cache summaries every frame. Non-trivial — typical 20–100 µs depending on DOM size. Use during investigation, not in steady-state runs.heap+jsonlqueries 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:
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 MiBHeap 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: anArcchain never dropped, aVecnever shrunk, aBox<T>forgotten. mmap'd regions (thread stacks, file-backed fonts, GL buffers) are not counted. - RSS — kernel resident-set size, what
topand 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:
{"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.
{
"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.
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.jsonlA workflow for "why is this growing?"
- Reproduce with
AZ_PROFILE=memory. Watch the per-frame block. Identify which cache's bytes grow. - If heap grows but no individual cache does, the leak is outside the layout pipeline. Check user code in callbacks that retain
RefAnyclones. - 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. - Build a deterministic
AZ_E2E_TESTscenario that reproduces the trigger (a resize loop, a route change, …). Setassert_growth_mib_maxslightly under the observed growth so the regression becomes a binary signal. - 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(). YourmaincallsApp::run(...), which on
iOS hands control toUIApplicationMainand 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.soand callsANativeActivity_onCreate(provided by the bundledandroid-activityglue), which
invokesandroid_maininsidelibazul. So an Android app is your example
compiled as acdylibwith 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
libazulin the bundle and
link your small app against it. On Android, putlibazul.soin the APK underlib/<abi>/(e.g.lib/arm64-v8a/); on iOS, embedlibazul.dylibinMyApp.app/Frameworks/and set the app binary's rpath to@executable_path/Frameworks. CI publishes alibazulper mobile target on
the release page for download. Your app can be C, Rust, or any
binding — a Chello-world.clinkslibazuland callsAzApp_create/AzApp_runexactly as on desktop. On iOS the app binary and the embeddedlibazul.dylibmust both be code-signed. - Static (single artifact). Build with the
link-staticfeature 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:
rustup target add— brings the Ruststdfor the target.- A small stub sysroot: the five Android stub
.sos (from the NDK'splatforms/android-<api>/.../usr/lib/) or the iOS framework.tbdstubs
(from the SDK'sSystem/Library/Frameworks/). A few hundred KB, not the
multi-GB toolchain. rust-lldas the linker — no externalld,clang, orxcrun.
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.shcurrently 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):
# 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 sameAzApp_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 actor gives you. Factor the setup into one
function and wire both entry points:
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:
[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):
# 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.apkFiles to copy into your project (Java glue + manifest template) live inscripts/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:
# 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 PayloadThe 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 Rustapple-codesigncrate) signs.app/.ipabundles on Linux or Windows and
can submit to Apple's notarization web service (rcodesign notary-submit),
a REST API — noxcrun/notarytoolneeded.- 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.
# 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_mapsOr 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):
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:
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/stderriOS 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:
codesign --force --sign "Apple Development: [email protected] (TEAMID)" \
--entitlements entitlements.plist azul-maps.app
xcrun devicectl device install app --device <udid> azul-maps.appNo 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 thercodesign 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:
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-writermobile 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 Ruststd 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:
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-androidThis matters more than it sounds. Platform shells are #[cfg]-gated, so
nothing in shell2/android/, shell2/ios/, shell2/windows/ orshell2/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:
cargo run -p azul-doc -- mobile install android
cargo run -p azul-doc -- mobile emulatorThat is the whole thing. What it does, if you would rather type it:
/* Detailed source-code truncated for AI context efficiency. */Note the activity: the manifest declares com.azul.app.AzulActivity, aNativeActivity 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
(NativeTextBridgefor soft-keyboard text/IME/insets,AzulGamepadfor
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 noiphonesimulator sysroot, so iOS can be cargo checked but not linked.
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 UDIDmobile 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:
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.
brew install baguette # needs Xcode 26 (⇒ macOS 15.6+), Apple siliconIt 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:
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-uidumps 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/syncreads the simulator'sUIPasteboard, which is
exactly what azul's iOS clipboard transport writes to. A copy in the app and
aclipboard getoutside it is a true end-to-end check of the typed-payload
path — flavors and all.tap/swipe/pinch/typeenter through UIKit, so they exercise
the realtouchesBegan:,UIPencilInteraction,UIKeyInputand 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:
cargo run -p azul-doc -- mobile run android examples/azul-writer
cargo run -p azul-doc -- mobile run ios AzWriter --windowedIt 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:
cargo run -p azul-doc -- mobile run android examples/azul-writer \
--e2e e2e/op-scroll-to-position.jsonThis 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:
==> 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: INCOMPLETEA 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
- Realtime Media and Devices — camera/mic/sensors/gamepad/
geolocation on mobile, and the permissions you declare. - Deploying to the web — the WASM target, by comparison.
- hello-world — the app you're shipping.
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:
- you compile the
telemetryfeature in and configure an endpoint, and - 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
[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:
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:
# 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 ./myappWith no endpoint configured the client warns once and uploads nothing, rather
than buffering forever.
Consent tiers
| 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:
AZ_TELEMETRY— CI, corporate lockdown, tests.azul/telemetryconfig.jsonbeside the executable — the packager/admin
pin.{"tier": "off"}here also suppresses the consent dialog{config_dir}/{app-id}/telemetry.json— this app's user choice{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
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 randomclient_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 callsenable_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:
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 mailboxSysDialogType::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:
cd layout/examples/telemetry-grafana && ./run-stack.sh upIts 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
# 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])))# 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
UpdateSettingsblock this page
reads its identity from, and the release channels the metrics compare. - Profiling: the same
Probespans, 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, theSysDialogType::UpdateVersion dialog, staged rollouts — those readAppConfig.updates and need no release tooling beyond a manifest URL.
The shape of it
Your app is built with an UpdateSettings:
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 belowand you publish a manifest at that URL:
{
"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:
github://owner/repo
https://github.com/owner/repo
https://github.com/owner/repo/releases/latest
https://api.github.com/repos/owner/repo/releases/latestThe updater then maps a release onto a ReleaseInfo:
tag_nameis the version, with a leadingvstripped.- The release body is the changelog, inline — no second request, and it
still works if the release page is unreachable. published_atseeds 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.gzall work). Pin it
explicitly withgithub://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
digestfield where GitHub
provides one, and otherwise from a siblingmyapp.bin.sha256orSHA256SUMSasset, matched by filename. - The signature chain rides as sibling assets:
myapp.bin.minisig,signing-key-statement.txtandsigning-key-statement.txt.minisig. Upload the filesscripts/sign-release.shproduced 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:
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 a401with aWWW-Authenticatechallenge, 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
platformsdarwin/amd64where Rust saysmacos/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.versionannotation,
or from the tag when the reference names one. A:latestwith 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.createdseeds the rollout ladder. - Select the artifact layer with
?asset=matched against the layer's media
type or itsorg.opencontainers.image.titleannotation; 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:
root key ──signs──> signing-key statement ──names──> signing key ──signs──> artifactThe statement is a single line with no trailing newline:
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.
generationis 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 withgenerationone higher. No new binary.expiresbounds 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 thedigest pin, which protects against corruption and a swapped file but not
against someone who can rewrite the manifest.
Signing a release
# 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.jsonThe script needs a minisign implementation. Either works:
cargo install rsign2 # recommended
apt install minisign # or: brew install minisignBefore 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).rsign2always prehashes; someminisignbuilds
need-H, which the script passes. - The statement is signed as exact bytes.
echoappends a newline and
the signature then covers a string that is not the one in the manifest.
The script usesprintf '%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:
cargo run -p azul-layout --features updater --example verify_update_manifest -- \
manifest.json ./myapp-2.0.0.bin RWQ…rootpubkeyIt 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:
cargo run -p azul-layout --features updater --example verify_update_manifest -- --selftestTesting 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:
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…rootpubkeyupdate: 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 1The same drill takes a GitHub repository, which is the quickest way to see
source resolution working against something real:
cargo run -p azul-layout --features updater,telemetry \
--example telemetry_grafana -- \
--update-manifest github://BurntSushi/ripgrep --version 0.1.0update: 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:
"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 reportapp_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
expiresshorter 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:
AZ_BACKEND=web://127.0.0.1:8080 ./my-azul-app
# open http://127.0.0.1:8080Your 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):
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:
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:
AZ_BACKEND=web-prelift://127.0.0.1:0 ./my-app # lifts, populates the cache, exitsweb-prelift:// takes the same URL and query syntax as web://. Point it atAZ_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 (requiresallow_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 forhello-world.
Broader event wiring (mousemove/keydown/…), full DOM diff/patch, and
arbitrary hydration payloads are in progress. Complex apps fall back to
server-sidePOST /az/exec/{node_id}execution.
Routes
Routes use :param segments, passed via AppConfig.routes; without any, the
root layout callback becomes /:
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:
[client] ──HTTPS──▶ [Caddy / nginx] ──HTTP──▶ [azul app on 127.0.0.1:8080]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).
Related
- Headless rendering — the render pipeline without a window
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 withDom::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. PassOptionRefAny::Some(RefAny::new(my_value)) to attach. PassOptionRefAny::None to leave the slot empty.
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 buildNodeData 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 theRefAny attached to that node, or None.
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.
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.
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 aDomNodeId and returns Option<DomNodeId>. They read the styled-DOM
hierarchy.
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 nextlayout() 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 theDomNodeIdof the node the event
landed on.info.get_dataset(node_id)returns the datasetRefAnyattached to
any node, orNone.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_childwalk 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.
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.
layout() → fresh Dom (desired state)
│
▼
┌─────────────────┐
│ diff pass │
└─────────────────┘
│
matched node? │ no → drop old, install new
│ yes
▼
┌────────────────────────┐
│ merge_fn(new, old) → m │
└────────────────────────┘
│
▼
install m on new nodeWhen 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:
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.
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.
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:
- The parent re-renders.
layout()builds a freshVideoPaneStatewithencoder: Noneandgl_texture: None.- The diff matches old and new by key.
reconcile_videoruns. The encoder moves over if the source and codec are unchanged. The texture moves over if the resolution is unchanged.- The new node now owns the live encoder. The old
VideoPaneStatedrops 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
RefAnyat 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 runslayout() 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
layout()returns a freshDom.- The framework matches new nodes against old. Lifecycle events
(Mount, Unmount, Update) fire. - Restyle promotes the changes to the smallest sufficient scope. If
nothing layout-affecting changed, no layout pass runs. - The display list rebuilds for the parts that changed.
- The renderer compares display lists and produces damage rects.
- 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.
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.
┌───────────────────────────────────┐
│ 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_sizeandscroll_offsetdescribe the actual rendered content.
This is the box of DOM you're handing back.scroll_offsetis where it
sits in virtual coordinates.virtual_scroll_sizeandvirtual_scroll_offsetdescribe what the view
pretends to be.virtual_scroll_sizeis the size the scrollbar
represents.virtual_scroll_offsetis usuallyLogicalPosition::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_sizeis(width, 20 * row_height). That's the 20 actual rows.scroll_offsetis(0, 10 * row_height). The rendered rows start at
y = 10 × row_height in virtual coordinates.virtual_scroll_sizeis(width, 1_000_000 * row_height). The
scrollbar represents the whole table.virtual_scroll_offsetis(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 theVirtualViewappears. 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
previousscroll_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.EdgeTypeis one ofTop,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 renderedscroll_size.
The callback does not fire on small resizes that stay inside the renderedscroll_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.
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 areInitialRender and DomRecreated, since both rebuild from nothing.
A virtualised table, end to end
/* 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:
extern "C" fn(data: RefAny, info: CallbackInfo) -> Updatedatais theRefAnyyou passed towith_callback. Downcast it
to your concrete type to read or mutate application state.infois 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.Updatetells the framework what to do next: nothing, re-runlayout()for this window, or re-run for every window.
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.
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
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:
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 pressedget_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), useinsert_child_node and delete_node. Larger changes — anything
beyond a handful of nodes — are usually clearer expressed as a freshDom from layout() plus Update::RefreshDom.
Focus
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 aRefreshDom 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() andset_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), andset_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(...) vsWindow(...), 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:
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.
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 aTimerCallbackReturn { 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 aRefAny. 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
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 OSbegin_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), withget_route_pattern() and get_route_param() to read the active route;
see Routing.
Image and font caches
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:
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:
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:
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
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 andWindowEventFilter. 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:
// 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.
// 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 W3Cdragover handler, and the same trap applies: accepting only inDragEnter 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:
.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:
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 adraggablenode 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
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
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:
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
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.
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
info.media_play(node);
info.media_pause(node);
info.media_toggle(node); // whichever is the opposite
info.media_seek(node, 42.0); // seconds, absoluteReading back:
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); // OptionPlaybackStateis_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
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:
info.media_set_duration(node, total_seconds); // once, when known
info.media_advance(node, dt); // per frame
info.media_report_error(node);