Kael 0.4 · Rust application framework
One codebase.
Every serious surface.
Kael is a retained, GPU-accelerated framework for building ambitious applications in Rust. Design the interface once, keep the product logic once, and run it as a native desktop app or a WebAssembly application in the browser.
macOSWindowsLinuxBrowser
Your application
Views · State · Product logic One typed Rust architectureThe same retained scene, adapted at the platform boundary.
Why I created Kael
Powerful software should not need a stack of runtimes.
I wanted a foundation for the kind of applications I care about building: documents, sheets, presentations, whiteboards, creative tools, engines, and workspaces that stay fast as they become more capable.
I also wanted the web build to be the same product, not a second interface maintained beside the first. Kael keeps rendering, state, input, accessibility, documents, platform services, testing, and release engineering inside one Rust system.
Augustus Otu
Creator of Kael
What Kael is today
A complete application foundation, from first pixel to signed release.
Proof, not promises
Scale is part of the release contract.
Kael’s maintained workloads exercise the actual retained rendering path on native and browser targets. The limits are checked in release tooling, not estimated from isolated widgets.
Desktop and web
Shared by default. Explicit at the boundary.
Application state, layout, components, painting, virtualization, animation, document bytes, workers, and most product services compile from the same source. Native-only abilities remain visible through typed capability reports, so fallbacks are intentional.
Shared Views, state, scenes, components, input, files, documents, realtime networking
Adapted Windows, GPU presentation, storage, printing, capture, audio, WebViews
Explicit Subprocesses, arbitrary native paths, system keychains, detached OS windows
Start with a real app
From an empty directory to two targets.
Kael’s CLI generates a project whose entry point is already arranged for native and browser builds.
$ cargo install kael-cli
$ kael new my_app
$ cd my_app
# Native desktop
$ cargo run
# Browser
$ kael web serve
Choose your path
Build the product, not the plumbing.
Why Kael exists
Kael started from a personal frustration: ambitious desktop software too often has to choose between native capability, web reach, performance, and a coherent development model.
I wanted to build products with the depth of a document suite or creative engine: large sheets, long documents, presentations, whiteboards, media, collaboration, and rich input. I did not want an application that constantly redraws, carries several runtimes, or needs a second frontend to reach the web.
That is the idea behind Kael: one Rust application architecture, a retained GPU scene, explicit resource bounds, and platform services that tell the truth about where they can run.
The product I wanted to build with
Kael is designed for software that grows. A useful framework has to remain clear when the application has many screens, many windows, background work, large data, documents, plugins, native services, and years of product decisions.
That led to a few non-negotiable principles:
- Do useful work. Invalidation, frame skipping, localized damage, virtualization, recycling, bounded caches, and GPU budgets should prevent work that cannot improve the current frame.
- Keep one architecture. Views, state, async work, files, networking, documents, diagnostics, packaging, and updates should share Rust types and failure handling.
- Let products own their identity. Use
kael_ui, reshape its tokens and components, mix it with custom work, or build an entire design system directly onkaelprimitives. - Treat platforms honestly. A capability report is better than an API that exists everywhere but quietly does something weaker on one target.
- Prove scale in releases. Large tables, documents, slide decks, whiteboards, browser engines, and native renderers belong in maintained gates, not only in roadmap language.
Why desktop and web share a foundation
The browser should not require a rewrite of the product. Kael sends the same retained scene to native GPU backends or a dedicated WebGL2 renderer. State, layout, components, painting, virtualization, animations, document bytes, and workers stay in Rust.
The platform boundary remains real. A browser cannot create a detached OS window, expose arbitrary native paths, launch subprocesses, or provide a system keychain. Kael represents those differences through capabilities and portable byte-oriented workflows instead of hiding them.
Read One codebase, desktop and web for the exact contract.
Why retained rendering
Immediate-mode UI is excellent for many tools, but Kael is aimed at long-lived product surfaces where identity, focus, accessibility, text, window state, and large collections benefit from stable retained structure.
Reactive Entity<T> state invalidates affected views. Scene fingerprints can
skip unchanged frames. Damage can remain local. Virtual lists mount only the
visible range. Hidden and idle windows stop requesting frames. These mechanisms
do not make every application fast automatically, but they give a product direct
control over where time and memory go.
Why the framework is layered
kael provides rendering, state, elements, layout, text, input, accessibility,
windows, async work, and platform primitives. kael_ui provides a broad,
brandable component system. Focused kael_* crates add storage, networking,
secrets, documents, diagnostics, notifications, sharing, media, release
services, and application engines.
The dependency direction is deliberate: kael_ui depends on kael; kael
never depends on kael_ui. A custom visual system is an architecture choice,
not an escape hatch.
Where Kael fits
Kael is a strong fit for editors, IDEs, agent workspaces, communication apps, dashboards, database clients, document suites, media tools, design software, simulations, and game or creative engines where responsiveness, native services, and product architecture all matter.
Choose another stack when a product depends primarily on DOM-only packages, the npm ecosystem is the main advantage, an immediate-mode UI is the better mental model, or a required platform capability is not yet implemented in Kael.
Kael 0.4 is pre-1.0. Pin a compatible minor release, validate the capabilities your product needs, and expect refinement before 1.0.
Foundation and independence
Kael began as a fork of GPUI, created by Zed Industries, and was previously distributed as the adabraka GPUI fork. It retains the required Apache-2.0 attribution for that foundational work.
Kael is now an independent project with its own application model, browser renderer, component system, product crates, performance workloads, platform bridges, and release process. It is not affiliated with or endorsed by Zed Industries.
Augustus Otu, creator of Kael
What Kael is today
Kael 0.4 is a pre-1.0 application framework for substantial Rust products. It is larger than a widget toolkit and more focused than a collection of unrelated libraries: the renderer, application runtime, product services, and release tooling are designed to share types, bounds, errors, and platform truth.
This page is the current map. Follow the linked guides when you need the API contract or platform details.
The application foundation
kael owns the retained application model:
- GPU scenes on Metal, Direct3D 11, Vulkan through Blade, and browser WebGL2;
- windows, elements, flex and grid layout, text shaping, images, SVG, canvas, paths, effects, clipping, transforms, and high-DPI presentation;
- reactive
Entity<T>state, contexts, observation, async executors, actions, keybindings, focus, drag and drop, and multi-window lifecycle; - mouse, keyboard, wheel, gesture, game, touch, and pen input through shared contracts;
- AccessKit semantics on desktop and a bounded retained ARIA mirror in browsers;
- invalidation, frame skipping, localized damage, bounded atlases, renderer batching, and display-aware scheduling.
Start with Core concepts, Layout and styling, and Canvas and graphics.
The product interface layer
kael_ui is optional and brandable. It adds controls and compositions without
making the primitive crate depend on a visual identity:
- buttons, text inputs, text areas, selection controls, date and color inputs;
- menus, popovers, dialogs, tooltips, tabs, splitters, scrollbars, and toasts;
- virtual lists, recycling lists, data tables, charts, editors, navigation, and responsive layout helpers;
- theme tokens, runtime theme switching, accessibility behavior, and reduced motion policies;
- opt-in media, Markdown/HTML rendering, syntax editing, game input, and screen capture feature sets.
Use the Component library as the index. Building an
entire design system directly on kael remains a first-class choice.
Scale and graphics
Large logical workloads do not need large retained trees. Kael provides uniform and variable-height virtualization, adaptive recycling pools, compressed table selection, spatial indexing, tile damage, bounded caches, and GPU memory budget queries.
PortableScene2d records a bounded high-throughput 2D scene shared by native
and browser renderers. It supports up to 100,000 retained quads, sprites, filled
paths, or triangle objects with transforms, clips, opacity, and transactional
rollback. Unsupported custom shaders, compute, blend modes, or 3D work return
typed results instead of pretending to be portable.
For simulations and engines, FixedFrameClock provides bounded fixed-timestep
catch-up, interpolation, pause and resume, and dropped-time telemetry. Read
Lists and large data, Animations, and
Game input.
Product services
Focused crates keep capabilities optional. Applications compile only the batteries they select.
| Area | Crates and capabilities |
|---|---|
| Data | kael_storage, kael_cache, kael_secrets: SQLite, IndexedDB, JSON, bounded caches, OS credentials |
| Documents | kael_document, kael_office, kael_pdf, kael_markdown: recovery, versions, byte import and export, OOXML, PDF, structured Markdown |
| Network | kael_http_client, kael_net: HTTP, bounded WebSockets, sync primitives, host policy |
| Media | kael_audio, kael-media, kael_media_engines: mixing, capture, playback, timelines, compositing, export foundations |
| Operations | kael_diagnostics, kael_notifications, kael_share, kael_release: logs, metrics, crashes, notifications, sharing, signed updates |
| Rendering | kael_render_graph, kael_gpu_budget: pass scheduling, invalidation, GPU memory budgets |
| Product foundations | kael_engines, kael_i18n, kael_icons: bounded editor and workload state, localization, typed icons |
The Platform APIs, Office and PDF, and Realtime networking guides cover the shared contracts and their platform boundaries.
Desktop and browser delivery
New CLI projects use one main.rs for native and browser builds. The same view,
state, layout, components, retained scenes, virtualization, animation, document
bytes, and worker requests compile to both targets. The host adapts GPU
presentation, windows, files, storage, printing, capture, audio, notifications,
sharing, and WebView composition.
The browser backend is not a DOM rewrite or an application inside a desktop WebView. It is a dedicated WebGL2 renderer with its own text atlas, IME and clipboard bridge, retained accessibility mirror, file-byte workflows, Web Workers, IndexedDB storage, AudioWorklets, WebSockets, capture, printing, and sandboxed iframe WebView islands.
Read One codebase, desktop and web before selecting a platform-sensitive workflow.
Testing and release engineering
Kael treats release readiness as code. The repository checks extracted crates.io packages, platform compilation, real native renderer windows, browser engines, WebView hosts, optimized Wasm, accessibility bounds, large workloads, installer contents, and signed update metadata.
Application tooling covers DMG, MSI, and AppImage packaging; macOS signing and notarization; Windows signing; checksums; update manifests; and atomic signed installation. These tools package Kael applications. The application owner still defines release policy and credentials.
Use Testing, Benchmarking evidence, and the Release process when preparing a product.
Current boundaries
Kael is broad, but it does not erase the operating system:
- browser secondary windows are retained surfaces inside the page, not detached operating-system windows;
- browser builds cannot expose arbitrary native paths, subprocesses, or a system keychain;
- full Office layout, spreadsheet calculation, and slide layout engines remain product layers above Kael's bounded OOXML byte foundation;
- custom shaders, compute, custom blending, and 3D are native or
application-specific extensions rather than part of
PortableScene2d; - some native touch, pen, media, sharing, and desktop-environment services vary by backend and must be checked at runtime;
- the public API is pre-1.0 and can change between minor releases.
CapabilityReport::current() and WebViewCapabilityReport make those
differences inspectable. Kael prefers an actionable Unsupported result over an
API that silently behaves like a different feature.
Object guide
Use this page when you know what you want to build but not which Kael object to reach for.
Application objects
| Object | Purpose | Use it for |
|---|---|---|
Application | Starts and owns the runtime | Process startup and the main event loop |
App | Accesses application services | Windows, tasks, files, clipboard, and global state |
Window | Handles one rendered surface | Focus, input, drawing, and window operations |
WindowOptions | Describes a new window | Size, title, appearance, and placement |
CapabilityReport | Reports platform support | Choosing a native path or a portable fallback |
State and rendering
| Object | Purpose | Use it for |
|---|---|---|
Entity<T> | Owns reactive application state | Models that outlive one render call |
Context<T> | Reads and updates an entity | Listeners, notifications, tasks, and child entities |
Render | Turns state into elements | Views with retained state |
RenderOnce | Turns a value into elements once | Small value components and builders |
IntoElement | Converts a value into a UI element | Return types from render methods |
Subscription | Keeps an event listener alive | Observing entities, windows, and application events |
FocusHandle | Identifies a focus target | Keyboard input and focus movement |
The normal update path is:
input → entity.update(...) → cx.notify() → Render → retained scene → platform renderer
Layout and components
| Object or function | Purpose | Use it for |
|---|---|---|
div() | Creates the base layout element | Flex, grid, spacing, color, borders, and children |
Styled | Adds style methods | Size, alignment, typography, and visual state |
kael_ui::init | Registers the component system | Any app that uses kael_ui controls |
Theme | Holds component design tokens | Product colors, type, radius, and density |
Button, Input, Select | Ready made controls | Common interactive UI |
AppShell | Creates an application frame | Sidebars, toolbars, and main content |
Large data and graphics
| Object or function | Purpose | Use it for |
|---|---|---|
uniform_list | Virtualizes equal height rows | Logs, feeds, and simple tables |
list | Builds a measured list | Rows with variable height |
VirtualList | Adds higher level virtual list behavior | Product lists using kael_ui |
VirtualSheetGrid | Virtualizes rows and columns | Spreadsheet surfaces |
canvas | Runs custom retained drawing | Charts, whiteboards, and editors |
Scene | Stores platform render primitives | Low level retained output |
PortableScene2d | Describes portable 2D scene data | Game and simulation foundations |
Async, files, and web content
| Object | Purpose | Use it for |
|---|---|---|
Task<T> | Represents scheduled async work | Fetching, parsing, saving, and delayed updates |
BackgroundExecutor | Runs work away from UI rendering | CPU work that should not block a frame |
ExternalFile | Carries a file name, type, and bytes | Portable open and drop workflows |
PrintJob | Describes printable content | Native print dialogs and browser printing |
WebView | Hosts a web owned surface | Compatibility islands and existing web products |
Start with Application, one root Entity, and a Render view. Add services
only when the feature needs them. See Core concepts for the
working code and One codebase for platform boundaries.
Getting Started
Prerequisites
- Rust 1.97.1 or newer with Rust 2024 support. The repository pins the
supported toolchain in
rust-toolchain.toml. - macOS: Xcode Command Line Tools. Release builds using precompiled Metal
shaders require the full Xcode application; development builds can use the
kael/runtime_shadersfeature. - Windows: Visual Studio Build Tools with the Desktop development with C++ workload.
- Linux: Vulkan, Wayland/X11, font, keyboard, D-Bus, and udev development
packages. WebView, audio, capture, and media features add GTK/WebKitGTK, ALSA,
PipeWire, and FFmpeg packages. The repository's
install-linux-deps.shis the canonical Ubuntu/Debian list. - Browser: the
wasm32-unknown-unknownRust target andwasm-bindgen-cli0.2.122. Optimized release builds also use Binaryen 132. Projects created bykael newrequest the Rust target automatically through their toolchain file.
With only the macOS Command Line Tools installed, enable runtime shader compilation during development:
cargo run --features kael/runtime_shaders
Create an application
The CLI creates a small application using the core framework and optional UI component layer:
cargo install kael-cli
kael new my_app
cd my_app
cargo run
The generated project uses that same source for the browser target. Install the packager once, then build and open it locally:
cargo install wasm-bindgen-cli --version 0.2.122 --locked
npm install --global binaryen@132.0.0
kael web serve
Use kael web build for optimized dist/web deployment files. See
Browser (WebAssembly) for target-specific dependencies and the
initial browser capability boundary.
To configure a project manually, choose the layer you need:
[dependencies]
kael = "0.4"
kael_ui = "0.4" # remove this line when building a custom component system
kael_ui depends on kael; the core framework never depends on kael_ui.
Your first window
//! Compiled source for the Getting started and One codebase guides.
use kael_ui::prelude::*;
struct Counter {
count: i32,
}
impl Render for Counter {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let counter = cx.entity();
div()
.size_full()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_4()
.child(div().text_3xl().child(format!("Count: {}", self.count)))
.child(
Button::new("increment", "Increase").on_click(move |_, _, cx| {
counter.update(cx, |state, cx| {
state.count += 1;
cx.notify();
});
}),
)
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
Application::try_new()?.run(|cx| {
kael_ui::init(cx);
install_theme(cx, Theme::dark());
if let Err(error) = cx.open_window(WindowOptions::default(), |_, cx| {
cx.new(|_| Counter { count: 0 })
}) {
eprintln!("failed to open the application window: {error}");
cx.quit();
}
});
Ok(())
}
What just happened
Application::try_new()initializes the selected native or browser platform and returns startup failures instead of panicking.kael_ui::init(cx)registers the component systems used by the optional UI layer.cx.open_window(...)creates a GPU-rendered native window or the browser's#bladecanvas window.cx.new(...)stores the view in a reactiveEntity<Counter>.entity.update(...)mutates the model, andcx.notify()invalidates the affected view so Kael can render the next state.
Core patterns
Compose elements in Rust
Elements use a typed builder API for layout and appearance:
div()
.flex()
.flex_col()
.gap_4()
.p_4()
.rounded_lg()
.bg(rgb(0x1e1e1e))
.text_color(rgb(0xffffff))
.child("Hello")
Keep state in entities
entity.update(cx, |state, cx| {
state.count += 1;
cx.notify();
});
Treat platform support as data
use kael::{CapabilityReport, PlatformFeature};
let capabilities = CapabilityReport::current();
if capabilities.is_supported(PlatformFeature::GlobalHotkeys) {
// Enable the native workflow.
} else {
// Keep a deliberate fallback or explain the platform requirement.
}
Add only the batteries the product needs
WebView, media, storage, documents, diagnostics, icons, PDF, notifications, sharing, and other integrations are feature-gated or provided by focused support crates. Start from the smallest dependency set and add capabilities when the product requires them.
Next steps
- Core Concepts — entities, contexts, rendering, and ownership
- API Documentation — crate/module map and docs.rs links
- Component Library — brandable ready-made UI
- Platform APIs — native services and capability checks
- Testing — headless and platform-aware verification
- Examples Gallery — Astryx and the application templates
One codebase, desktop and web
Yes: the aim is one product codebase. A Kael application keeps its views, state, layout, components, retained drawing, virtualization, animations, and product logic in Rust, then selects a native or browser host at build time.
That does not mean every operating-system ability exists inside a browser sandbox. Kael keeps the shared application surface large and makes the remaining differences explicit, so portable code is the default and platform branches stay small and intentional.
Start from one entry point
Projects created by kael new arrange target dependencies while keeping one
main.rs:
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
kael = { version = "0.4", features = ["runtime_shaders"] }
kael_ui = "0.4"
[target.'cfg(target_arch = "wasm32")'.dependencies]
kael = { version = "0.4", default-features = false, features = ["browser"] }
kael_ui = { version = "0.4", default-features = false, features = ["browser"] }
The application entry point remains normal Kael code:
//! Compiled source for the Getting started and One codebase guides.
use kael_ui::prelude::*;
struct Counter {
count: i32,
}
impl Render for Counter {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let counter = cx.entity();
div()
.size_full()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_4()
.child(div().text_3xl().child(format!("Count: {}", self.count)))
.child(
Button::new("increment", "Increase").on_click(move |_, _, cx| {
counter.update(cx, |state, cx| {
state.count += 1;
cx.notify();
});
}),
)
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
Application::try_new()?.run(|cx| {
kael_ui::init(cx);
install_theme(cx, Theme::dark());
if let Err(error) = cx.open_window(WindowOptions::default(), |_, cx| {
cx.new(|_| Counter { count: 0 })
}) {
eprintln!("failed to open the application window: {error}");
cx.quit();
}
});
Ok(())
}
Build either target:
# Native desktop
cargo run
# Local browser build
kael web serve
# Optimized static deployment in dist/web
kael web build
What remains the same
| Product layer | Shared contract |
|---|---|
| Interface | Elements, components, layout, text, images, SVG, canvas, effects |
| Application state | Entities, observation, actions, keybindings, async tasks |
| Scale | Virtual lists and grids, recycling, bounded caches, spatial culling |
| Interaction | Pointer, keyboard, wheel, focus, IME, clipboard, accessibility actions |
| Documents | Byte import/export, recovery snapshots, OOXML, PDF, Markdown |
| Network and work | HTTP, WebSockets, typed background worker requests |
| Visual behavior | Retained scenes, animation policy, reduced motion, high-DPI layout |
The browser sends the same retained Scene to WebGL2. It does not rebuild the
interface as HTML and it does not run the application inside a WebView.
What the host adapts
- Rendering: Metal on macOS, Direct3D 11 on Windows, Vulkan through Blade on Linux, and WebGL2 in the browser.
- Windows: native OS windows on desktop; independent retained surfaces inside the browser page on the web.
- Files: native paths and Save As on desktop; byte-backed pickers, drops, and Blob downloads in the browser.
- Storage: native SQLite/JSON and OS keychains where selected; IndexedDB and bounded browser key/value storage on the web.
- System services: printing, capture, audio, notifications, sharing, and WebViews use the host implementation and permission model.
Keep product code on the shared APIs. Use native paths, subprocesses, raw window handles, or system credentials only behind a capability decision.
Branch on capability, not platform names
use kael::{CapabilityReport, PlatformFeature};
let report = CapabilityReport::current();
if report.is_supported(PlatformFeature::GlobalHotkeys) {
enable_global_shortcut_workflow();
} else {
enable_in_app_shortcut_workflow();
}
This survives more environments than if cfg!(target_os = ...): Linux desktop
services vary, browser permissions can be denied, and optional features may not
be compiled into a particular build.
Keep platform-owned surfaces deliberate
WebViews are compatibility islands for OAuth, payments, maps, hosted documents, or vendor widgets. They are not the default way to build Kael screens. The retained application continues to own navigation, editors, data surfaces, commands, and long-lived product state.
Use WebViewCapabilityReport before depending on history, cookies, downloads,
custom headers, profiles, permissions, or custom protocols. Browser iframe,
WKWebView, WebView2, and WebKitGTK security models are not identical.
Test both deliverables
Source parity is not release parity. Before shipping:
- Exercise native and optimized Wasm builds.
- Test your supported browsers and desktop backends.
- Verify high-DPI text, keyboard navigation, IME, screen readers, and reduced motion on each target.
- Measure representative data sizes rather than a blank window.
- Record the capability report for workflows that depend on the host.
Kael's own browser gates run retained pixels, million-row virtualization, suite-scale workloads, IME, clipboard, context loss, accessibility bounds, workers, audio, WebSockets, capture, and lifecycle behavior. Product tests still need to cover the features and devices the application promises.
Continue with Browser and WebAssembly for the detailed host contract or Suite-scale applications for a maintained one-source workload.
What remains
Kael is broad, but it is not finished. This page separates current gaps from deliberate product boundaries.
CapabilityReport::current() is the live source of truth at runtime. A type or
request builder does not prove that every backend implements the operation.
Framework work
These areas need more implementation or wider platform coverage:
| Area | Current gap |
|---|---|
| Spellcheck | No native or bundled spelling backend |
| Realtime network | Server sent event descriptors exist; live transport is not complete |
| File drag | No outbound promised file drag backend |
| Sharing | No share receiver; outgoing support varies by platform |
| Location and devices | Geolocation, USB, HID, serial, and Bluetooth backends are absent |
| Browser install features | Push, notification actions, and share targets need product service worker code |
| Input depth | Some native pen, touch, and gesture paths need broader coverage |
| Media | Portable spatial audio is stereo and distance based, not a full room or HRTF engine |
| Web packaging | The CLI packages source HTML and assets, but has no content hashing or service worker generator |
| Platform quality | Linux services and hardware coverage still need continued hardening |
The public API is pre 1.0. Stabilization, compatibility policy, and wider hardware testing remain release work even where a feature is already usable.
Deliberate boundaries
Some work belongs to applications or focused engines rather than Kael core:
- Exact Microsoft Office layout, spreadsheet calculation, and slide playback
- Full PDF authoring and layout parity with specialist products
- A complete 3D engine with custom shaders, compute, physics, and asset pipelines
- Browser access to native paths, subprocesses, keychains, global hotkeys, or detached OS windows
- Product specific collaboration protocols and document semantics
Kael supplies the application runtime, rendering, controls, data foundations, portable files, document bytes, and extension points. A suite or game engine can build its product model above those parts without hiding the remaining work.
How to plan a portable feature
- Build the shared view and state first.
- Query
CapabilityReportat the platform boundary. - Define a useful browser fallback before calling a native service.
- Test desktop and browser as separate release targets.
- Record unsupported behavior in the product, not only in build scripts.
See One codebase for the structure and Platform APIs for the capability model.
Core Concepts
Application lifecycle
Every Kael app follows this flow:
Application::try_new()? → run() → cx.open_window() → cx.new(|_| View) → render loop
fn main() -> Result<(), Box<dyn std::error::Error>> {
Application::try_new()?.run(|cx: &mut App| {
if let Err(error) = cx.open_window(WindowOptions::default(), |_, cx| {
cx.new(|_| MyView {})
}) {
eprintln!("failed to open the application window: {error}");
cx.quit();
}
});
Ok(())
}
Entity<T> — reactive state containers
An Entity<T> is a handle to a value stored in the framework's arena. When the value changes and you call cx.notify(), any view rendering that entity re-renders automatically.
#![allow(unused)] fn main() { struct AppState { user: String, count: i32, } let state: Entity<AppState> = cx.new(|_cx| AppState { user: "Alice".into(), count: 0, }); let name = state.read(cx).user.clone(); state.update(cx, |this, cx| { this.count += 1; cx.notify(); }); }
Entity vs. direct state
If your view struct holds state directly (like struct Counter { count: i32 }), the view IS the entity — cx.new() wraps it in Entity<Counter> automatically. Use separate entities when you need shared state across views:
#![allow(unused)] fn main() { struct Sidebar { shared: Entity<AppState>, } struct Editor { shared: Entity<AppState>, } }
The Render trait
Any type that implements Render can be displayed in a window:
#![allow(unused)] fn main() { impl Render for MyView { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { div().child("Hello") } } }
Parameters:
&mut self— mutable access to your statewindow: &mut Window— the window being rendered into (for window-level APIs)cx: &mut Context<Self>— entity-scoped context for creating entities, subscribing to events, and notifying changes
Return: Anything implementing IntoElement — a Div, a Button, or any widget.
Context types
| Context | Where you get it | What it does |
|---|---|---|
App | Application::try_new()?.run(|cx| { ... }) | Root context — open windows, set globals |
Context<T> | impl Render and cx.new() closures | Entity-scoped — notify, observe, subscribe |
Window | impl Render render method | Window-level — bounds, focus, painting |
AsyncApp, AsyncWindowContext | Convert a live context before spawning async work | Fallible access that can safely outlive a window or entity callback |
TestAppContext | Headless framework tests | Deterministic entity, input, and rendering test access |
Getting an entity handle inside render
#![allow(unused)] fn main() { impl Render for MyView { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { let entity = cx.entity(); button("click-me") .label("Click") .on_click(move |_, _, cx| { entity.update(cx, |this, cx| { this.handle_click(); cx.notify(); }); }) } } }
Global state
For app-wide values (theme, user session, config), use the Global trait:
#![allow(unused)] fn main() { struct AppConfig { dark_mode: bool, font_size: f32, } impl Global for AppConfig {} cx.set_global(AppConfig { dark_mode: true, font_size: 14.0 }); cx.read_global::<AppConfig, _>(|config, _| { config.dark_mode }); cx.update_global::<AppConfig, _>(|config, cx| { config.dark_mode = false; }); }
Element composition
Views compose by nesting elements with .child():
#![allow(unused)] fn main() { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { div() .flex() .flex_col() .child(self.render_header()) .child(self.render_content()) .child(self.render_footer()) } fn render_header(&self) -> impl IntoElement { div().h(px(48.0)).bg(rgb(0x2563eb)).child("Header") } }
Conditional rendering
Use .when() for conditional styling or .map() for conditional children:
#![allow(unused)] fn main() { div() .when(self.is_active, |div| div.bg(rgb(0x2563eb))) .when(!self.is_active, |div| div.bg(rgb(0x64748b))) .child(if self.show_label { "Active" } else { "Inactive" }) }
Iterating children
Use .children() with an iterator:
#![allow(unused)] fn main() { div() .flex() .flex_col() .children(self.items.iter().map(|item| { div().px_2().py_1().child(item.name.clone()) })) }
Event handling
All events pass (event_data, &mut Window, &mut App):
#![allow(unused)] fn main() { div() .id("my-element") .on_click(|event, window, cx| { }) .on_mouse_down(MouseButton::Left, |event, window, cx| { }) .on_key_down(|event, window, cx| { }) }
Widget events use the same pattern:
#![allow(unused)] fn main() { text_input("name", self.name.clone()) .on_change(|new_value, window, cx| { }) .on_submit(|value, window, cx| { }) }
Subscriptions and observations
Watch for changes on other entities:
#![allow(unused)] fn main() { cx.observe(&other_entity, |this, other, cx| { cx.notify(); }); cx.subscribe(&other_entity, |this, _other, event: &MyEvent, cx| { }); }
State Management
Kael's state model is built on entities: reference-counted, observable pieces of
state owned by the application. This chapter covers the full state toolkit —
entities, derived state with Computed, globals, and the patterns that keep a
large app consistent.
Entities
An Entity<T> is a handle to a value owned by the app. Create one with
cx.new, read it with read, and mutate it with update:
#![allow(unused)] fn main() { struct Counter { count: usize, } let counter = cx.new(|_| Counter { count: 0 }); let value = counter.read(cx).count; counter.update(cx, |counter, cx| { counter.count += 1; cx.notify(); }); }
cx.notify() is what tells Kael the entity changed: every view observing the
entity re-renders, and every computed value depending on it invalidates.
Forgetting cx.notify() is the most common cause of a stale UI — if you
mutated state and the screen didn't change, check for a missing notify first.
In event handlers inside a view, prefer cx.listener — it hands you &mut Self
and the right context without manual entity cloning:
#![allow(unused)] fn main() { .on_click(cx.listener(|this, _event, _window, cx| { this.count += 1; cx.notify(); })) }
Observing and subscribing
Views and entities react to each other with observe (any notify) and
subscribe (typed events from an EventEmitter):
#![allow(unused)] fn main() { cx.observe(&other_entity, |this, _other, cx| { this.recompute(); cx.notify(); }) .detach(); cx.subscribe(&input, |this, _input, event: &InputEvent, cx| { if matches!(event, InputEvent::Change) { this.refilter(cx); } }) .detach(); }
Both return a Subscription that unsubscribes when dropped — hold it in your
struct to scope it to the view's lifetime, or .detach() to keep it for the
emitter's lifetime.
Derived state with Computed
Computed<T> is Kael's equivalent of a memo: a cached value derived from
entities, recomputed only when a dependency actually changes. Reads go through
the Tracker argument so dependencies are recorded automatically:
#![allow(unused)] fn main() { use kael::computed::Computed; let filtered = Computed::new(cx, |tracker| { let orders = tracker.read(&orders_entity); let query = tracker.read(&search_entity).text.to_lowercase(); orders .items .iter() .filter(|order| order.customer.to_lowercase().contains(&query)) .cloned() .collect::<Vec<_>>() }); let rows = filtered.read(cx); }
The closure runs once; the result is cached until any tracked entity notifies. Dependencies re-track on every recompute, so conditional reads work — a branch that stops reading an entity stops depending on it.
A Computed is itself observable: cx.observe(&filtered.entity(), ...) lets a
view re-render when the derived value invalidates. Use filtered.get(cx) for a
cloned value.
Use Computed whenever you find yourself recomputing derived data inside
render — filtering, sorting, aggregating — so the work runs on change, not on
every frame.
Globals
App-wide singletons implement the Global marker trait and live on the app:
#![allow(unused)] fn main() { struct Settings { telemetry: bool, } impl Global for Settings {} cx.set_global(Settings { telemetry: false }); let settings = cx.global::<Settings>(); cx.update_global::<Settings, _>(|settings, _| settings.telemetry = true); }
React to changes with cx.observe_global::<Settings>(...). The kael_ui theme is
a global: read it with Theme::of(cx) (see Theming).
Persistent Settings
Use SettingsStore for typed JSON preferences, workspace defaults, account
state, and feature flags that need to survive app restarts:
#![allow(unused)] fn main() { use kael::app_runtime::SettingsStore; use serde::{Deserialize, Serialize}; #[derive(Default, Serialize, Deserialize)] struct AppSettings { theme: String, telemetry: bool, } let settings_path = app_data_dir.join("settings.json"); let mut settings = SettingsStore::<AppSettings>::builder(&settings_path) .load_checked()?; settings.update(|data| { data.theme = "dark".into(); })?; }
Prefer SettingsStore::new_checked(path) and
SettingsStore::builder(path).migration(...).load_checked() for generated app
preferences. The checked path rejects empty paths, control-character paths,
directory targets, invalid parents, zero-version migrations, and duplicate
migration target versions before the app starts reading or atomically writing
settings. Raw new(...), load(...), and builder .load() remain available
when an app owns filesystem validation.
Undo & Redo
Use UndoRedoManager for editor, canvas, form-builder, and design-tool history:
#![allow(unused)] fn main() { use kael::app_runtime::UndoRedoManager; let mut history = UndoRedoManager::new(100); history.begin_transaction_checked("move selected layers")?; history.push(move_layer_change); history.push(update_bounds_change); history.end_transaction_checked()?; }
Prefer begin_transaction_checked(...) and end_transaction_checked() for
generated workflows. Checked transactions reject nested begins, missing ends,
empty/padded/control-character/overly long descriptions, and expose
has_open_transaction() for cleanup and diagnostics. Raw begin_transaction(...)
and end_transaction() remain available for hand-written code that wants assert
semantics.
Structuring a larger app
- One entity per unit of independent change. A chat app wants
Entity<ChannelList>,Entity<Thread>,Entity<ComposerState>— not one giant struct, which makes every keystroke re-render everything. - Derive, don't duplicate. If a value can be computed from other state, use
Computedrather than storing a second copy you must keep in sync. - Events for actions, observation for state. Emit typed events for things that happen (message sent); observe entities for things that are (current draft).
- Wire async through weak handles. See Async & Data Fetching
for the spawn/weak-update pattern and the
Loadable/QueryStatehelpers.
A worked example lives in the dashboard template
(templates/dashboard): the search input subscribes to InputEvent::Change
and refilters the orders table through DataTable::set_data.
Async & Data Fetching
Kael apps stay responsive by doing work off the render path: futures run on the
foreground or background executor, and results land back in entities through
weak handles. kael_ui layers Loadable and QueryState on top so the common
fetch-render lifecycle needs no boilerplate.
Tasks and executors
cx.spawn runs a future on the main thread with access to an async context;
cx.background_spawn runs CPU-bound or blocking work on the thread pool.
#![allow(unused)] fn main() { cx.spawn(async move |this, cx| { let data = cx .background_executor() .spawn(async move { expensive_parse(bytes) }) .await; this.update(cx, |this, cx| { this.data = Some(data); cx.notify(); }) .ok(); }) .detach(); }
The closure receives a WeakEntity<Self> — the entity may be dropped while the
future runs, which is why every update through it returns a Result. Dropping
a Task cancels it; call .detach() to let it run to completion, or store it
in your struct so navigating away cancels in-flight work.
Background Jobs
Use JobScheduler when work needs durable status, progress, retry metadata,
dependencies, cancellation, or a worker-pool handoff:
#![allow(unused)] fn main() { use kael::background_jobs::{JobDescriptor, JobPriority, JobScheduler, RetryPolicy}; let scheduler = JobScheduler::new().with_max_concurrent(2); let descriptor = JobDescriptor::new("export/video") .with_priority(JobPriority::High) .with_retry_policy(RetryPolicy { max_retries: 2, delay_ms: 500, backoff_multiplier: 2.0, }); let job_id = scheduler.schedule_with_descriptor_checked(export_job, descriptor)?; }
Prefer schedule_checked(...) or schedule_with_descriptor_checked(...) for
generated background work. Checked scheduling rejects empty, padded,
control-character, overly long, or non-portable job IDs, descriptor/job ID
mismatches, self-dependencies, duplicate or invalid dependency IDs, and invalid
retry policies before the queue state is mutated. Raw schedule(...) and
schedule_with_descriptor(...) remain available when an app owns validation.
Loadable: the four states of remote data
kael_ui::query::Loadable<T> models the lifecycle every fetched value goes
through:
#![allow(unused)] fn main() { use kael_ui::prelude::Loadable; match &self.orders { Loadable::Idle => div().child("Press fetch"), Loadable::Loading => Skeleton::new("orders-skeleton").into_any_element(), Loadable::Loaded(orders) => render_orders(orders), Loadable::Error(message) => Banner::error(message.clone()), } }
QueryState: fetch lifecycle without the footguns
QueryState<T> owns a Loadable<T> and manages the transitions: it sets
Loading, spawns the fetch, writes the result back through a weak handle, and
drops stale responses when a newer fetch started (a generation counter —
no flash of old results when the user types fast). It supports debounce and
refetch.
#![allow(unused)] fn main() { use kael_ui::query::QueryState; struct OrdersView { orders: QueryState<Vec<Order>>, } self.orders.run(cx, |cx| async move { fetch_orders(cx).await.map_err(|error| error.to_string().into()) }); }
For request dedupe across views, QueryCache keys results by string with a TTL.
The Astryx showcase composes query loading, data, refetch, and error states in one application.
Rules of thumb
- Never block the main thread: decode, parse, and diff on the background executor, then apply to entities on the foreground.
- Treat
WeakEntity::updatefailures as cancellation, not errors — the view is gone;.ok()is the idiomatic acknowledgment. - Hold the
Taskwhen navigation should cancel the request; detach when the result matters regardless. - Set state to
Loadingbefore awaiting so the UI reflects the fetch immediately;QueryState::rundoes this for you.
Layout & Styling
Kael uses GPU-accelerated flexbox (powered by Taffy) with a Tailwind-inspired API. Every style is a method call on a Div.
Flexbox layout
#![allow(unused)] fn main() { div().flex().flex_row().gap_2() .child(div().child("Left")) .child(div().child("Right")) div().flex().flex_col().gap_4() .child(div().child("Top")) .child(div().child("Bottom")) }
Alignment
#![allow(unused)] fn main() { div().flex() .items_center() .justify_center() .justify_between() .items_start() .items_end() }
Flex sizing
#![allow(unused)] fn main() { div().flex_1() div().flex_grow() div().flex_shrink_0() div().flex_none() }
Grid layout
Switch a container to Kael's native grid layout with .grid(), define tracks with .grid_cols(n) / .grid_rows(n), and place children with .col_span(n) / .row_span(n) (or .col_span_full() / .row_span_full()). .gap_*() sets the gutters:
#![allow(unused)] fn main() { div() .grid() .grid_cols(5) .grid_rows(5) .gap_1() .child(div().row_span(1).col_span_full().child("Header")) .child(div().col_span(1).row_span(3).child("Sidebar")) .child(div().col_span(3).row_span(3).child("Content")) .child(div().col_span(1).row_span(3).child("Aside")) .child(div().row_span(1).col_span_full().child("Footer")) }
See the Astryx showcase's layout section for a complete application shell.
Sizing
#![allow(unused)] fn main() { div().w(px(200.0)).h(px(100.0)) div().w_full() div().h_full() div().size_full() div().size_8() div().w_12() div().h_6() div().min_w(px(200.0)).max_w(px(600.0)) }
Spacing
#![allow(unused)] fn main() { div().p_4() div().px_3() div().py_2() div().pt_1() div().pl(px(20.0)) div().m_4() div().mx_auto() div().mt_2() div().flex().gap_2() div().flex().gap_4() }
Colors
#![allow(unused)] fn main() { div().bg(rgb(0x1E1E1E)) div().text_color(rgb(0xFFFFFF)) div().border_color(rgb(0x3C3C3C)) div().bg(rgba(0x00000080)) div().bg(kael::red()) div().bg(kael::blue()) div().bg(kael::white()) div().bg(kael::black()) use kael::hsla; div().bg(hsla(210.0 / 360.0, 1.0, 0.5, 1.0)) }
Borders
#![allow(unused)] fn main() { div().border_1() div().border_2() div().border_t_1() div().border_b_1() div().border_l_1() div().border_r_1() div().border_color(rgb(0x3C3C3C)) div().border_dashed() }
Corners
#![allow(unused)] fn main() { div().rounded_sm() div().rounded_md() div().rounded_lg() div().rounded_full() div().rounded(px(8.0)) }
By default, rounded corners use continuous (squircle) rounding to match SwiftUI's
RoundedRectangle shape on macOS. Use .circular_corners() to opt into the
legacy pure quarter-circle look:
#![allow(unused)] fn main() { div().rounded(px(8.0)).circular_corners() }
Shadows
#![allow(unused)] fn main() { div().shadow_sm() div().shadow_md() div().shadow_lg() div().shadow_xl() }
Typography
#![allow(unused)] fn main() { div() .text_xs() .text_sm() .text_base() .text_lg() .text_xl() .text_2xl() .text_3xl() div().font_weight(FontWeight::BOLD) div().font_family(".SystemUIFont") }
Overflow and scrolling
Control how content behaves when it exceeds the element bounds:
#![allow(unused)] fn main() { div().overflow_hidden() div().overflow_x_scroll() div().overflow_y_scroll() div().overflow_y_auto() .id("scroll-container") }
When using overflow_y_scroll() or overflow_y_auto() with a ScrollHandle,
Kael automatically renders a macOS-style scrollbar thumb when content overflows.
No extra widget is needed:
#![allow(unused)] fn main() { let scroll_handle = ScrollHandle::new(); div() .id("my-scrollable") .overflow_y_scroll() .track_scroll(&scroll_handle) .child(long_content) }
The auto-scrollbar appears only when content exceeds the viewport and tracks the scroll position automatically. To keep scrollbars visible at all times (instead of auto-hiding after idle):
#![allow(unused)] fn main() { let scroll_handle = ScrollHandle::new().always_show_scrollbars(); }
For custom scrollbar styling, use the explicit scroll_bar() widget instead
(see Lists & Data).
Positioning
#![allow(unused)] fn main() { div().relative() .child( div().absolute() .top(px(10.0)) .right(px(10.0)) .child("Badge") ) }
Opacity
#![allow(unused)] fn main() { div().opacity(0.5) }
Cursor
#![allow(unused)] fn main() { div().cursor_pointer() div().cursor_default() }
Conditional styling with .when()
#![allow(unused)] fn main() { div() .when(self.is_selected, |this| { this.bg(rgb(0x2563eb)).text_color(rgb(0xffffff)) }) .when(!self.is_selected, |this| { this.bg(rgb(0xffffff)).text_color(rgb(0x000000)) }) }
Authoring Components
Most of Kael's UI is built by composing div() and the widgets in kael_ui. When a piece of UI repeats, or earns a name, you promote it to a component. This guide walks the ladder from the simplest approach to the most powerful — stop climbing the moment your needs are met.
Rung 1: compose in Render
The plainest reuse is a helper method that returns impl IntoElement. No new types, no traits — just split a large render into named pieces:
#![allow(unused)] fn main() { impl Render for Dashboard { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { div() .flex() .flex_col() .child(self.header()) .child(self.body(cx)) } } impl Dashboard { fn header(&self) -> impl IntoElement { div().h(px(48.0)).child(self.title.clone()) } } }
Reach for a real component only when the markup needs to live outside this view — used by more than one screen, or configured through its own builder.
Rung 2: a stateless component with RenderOnce
A RenderOnce component is a recipe: build it, configure it through chained methods, render it once. Derive IntoElement so it drops into any .child() like a built-in widget.
Here is a complete Badge — a small colored pill — modeled on the shape every simple kael_ui component shares:
#![allow(unused)] fn main() { use crate::theme::Theme; use kael::{prelude::FluentBuilder as _, *}; #[derive(IntoElement)] pub struct Badge { label: SharedString, subtle: bool, style: StyleRefinement, } impl Badge { pub fn new(label: impl Into<SharedString>) -> Self { Self { label: label.into(), subtle: false, style: StyleRefinement::default(), } } pub fn subtle(mut self, subtle: bool) -> Self { self.subtle = subtle; self } } impl Styled for Badge { fn style(&mut self) -> &mut StyleRefinement { &mut self.style } } impl RenderOnce for Badge { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let tokens = &Theme::of(cx).tokens; let user_style = self.style; let (bg, fg) = if self.subtle { (tokens.muted, tokens.muted_foreground) } else { (tokens.primary, tokens.primary_foreground) }; div() .px(px(8.0)) .py(px(2.0)) .rounded(tokens.radius_sm) .bg(bg) .text_color(fg) .text_xs() .font_family(tokens.font_family.clone()) .child(self.label) .map(|this| { let mut div = this; div.style().refine(&user_style); div }) } } }
Use it anywhere:
#![allow(unused)] fn main() { div().child(Badge::new("New")).child(Badge::new("Beta").subtle(true)) }
Four conventions are doing the work here, and every kael_ui component follows them:
- Builder methods take
mut selfand returnSelfso calls chain. #[derive(IntoElement)]+impl RenderOnceturns the struct into something.child()accepts.- Read the theme with
Theme::of(cx)— never hardcode colors. Pulltokensonce, then style fromtokens.primary,tokens.radius_sm, and friends so the component tracks the active theme and any live theme switch. - Carry a
StyleRefinementand apply it last viaimpl Styledplus the closing.map(...), so callers can override your defaults with.bg(...),.w(...), and the rest.
RenderOnce receives cx: &mut App (not Context<Self>): a recipe has no persistent identity, so it cannot notify itself. Wire interactivity through callbacks (on_click, on_change) that update an entity the caller owns — see how kael_ui's controls thread an Entity through their handlers.
Rung 3: a stateful component with Render
When a component owns state that changes over its lifetime — an open/closed flag, a scroll position, a cached value — make it an Entity with impl Render. Now render takes &mut self and Context<Self>, so it can mutate itself and call cx.notify():
#![allow(unused)] fn main() { struct Disclosure { open: bool, title: SharedString, } impl Render for Disclosure { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { let entity = cx.entity(); div() .id("disclosure") .transition(Theme::of(cx).tokens.transition_fast) .child(self.title.clone()) .on_click(move |_, _, cx| { entity.update(cx, |this, cx| { this.open = !this.open; cx.notify(); }); }) .when(self.open, |this| this.child("…details…")) } } }
The caller creates it with cx.new(|_| Disclosure { open: false, title: "Advanced".into() }) and holds the Entity<Disclosure>. See State Management for the full story on entities, observation, and shared state.
Transition conventions
Whenever a component restyles on hover, active, or a state change, ease it. Add .transition(duration) to the element — it must have a stable .id(...), since unkeyed elements snap. Match kael_ui's convention:
tokens.transition_fastfor color-only changes — background, text, border tint.tokens.transition_basefor changes that move or resize — shadow steps, a hover lift, scale.
#![allow(unused)] fn main() { div() .id("row") .transition(Theme::of(cx).tokens.transition_fast) .hover(|style| style.bg(Theme::of(cx).tokens.accent)) }
Animations covers the full set of interpolated properties, explicit with_animation timelines, and springs.
Rung 4: custom drawing with canvas
For visuals that styled divs cannot express — waveforms, charts, custom paint — drop to the canvas. canvas(prepaint, paint) and canvas_with_prepaint(prepaint, paint) hand you the element's Bounds and the Window, and you paint quads, paths, and shadows directly. The prepaint closure runs first and can return data the paint closure reuses, keeping per-frame work cheap.
For high-volume immediate-mode drawing, canvas(size, draw) exposes
DrawContext::reserve_commands, fill_rects, and fill_circles. Reserve known
work before mixed command streams, batch repeated rectangles, and prefer the
rounded-quad circle path for particles or graph nodes that do not require vector
tessellation.
The Waveform component in crates/kael_ui/src/components/waveform.rs is the pattern to copy: it builds a paint_data struct in prepaint, then a free paint_waveform(bounds, &data, window) function does the drawing. Wrap the canvas in a positioned div() so layout still owns sizing:
#![allow(unused)] fn main() { div() .relative() .h(px(48.0)) .child( canvas_with_prepaint( move |_bounds, _window, _cx| paint_data, move |bounds, data, window, _cx| paint_waveform(bounds, &data, window), ) .absolute() .inset_0() .size_full(), ) }
You rarely need the raw Element trait
Beneath everything is the Element trait with its request_layout / prepaint / paint lifecycle. RenderOnce, Render, and canvas are all built on top of it, and they cover the overwhelming majority of components. Implement Element by hand only when you need to control layout participation itself — a custom layout container, or an element that measures children and positions them manually. If you are reaching for it to draw or to hold state, one of the rungs above is the better fit.
Component Library (kael_ui)
Kael ships a complete, shadcn-inspired component library: kael_ui. It provides 100+ polished, accessible components so you can build rich desktop applications with Kael alone — no external component library required.
kael_ui is the continuation of adabraka-ui, now developed inside the Kael repository at crates/kael_ui.
Installation
[dependencies]
kael = "0.4"
kael_ui = "0.4"
Setup
One import gives you everything — the components plus the Kael essentials
(div, px, Render, Application, …). You do not need a separate
use kael::*;, and mixing the two globs is discouraged because the names
collide:
use kael_ui::prelude::*;
fn main() -> Result<(), Box<dyn std::error::Error>> {
Application::try_new()?.run(|cx: &mut App| {
kael_ui::init(cx);
install_theme(cx, Theme::dark());
if let Err(error) = cx.open_window(WindowOptions::default(), |_, cx| {
cx.new(|_| MyApp)
}) {
eprintln!("failed to open the application window: {error}");
cx.quit();
}
});
Ok(())
}
kael_ui::init(cx) registers the bundled Inter and JetBrains Mono fonts, sets up keybindings for interactive components (inputs, selects, the editor, sidebars, popovers, sheets, dialogs), and initializes the HTTP client used for remote image loading.
Using the theme
install_theme stores the active Theme in the app's global state, so the
recommended way to read it is Theme::get(cx) (or the alias Theme::of(cx)),
which borrows the theme out of cx with no per-render clone:
impl Render for MyApp {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::of(cx);
div()
.bg(theme.tokens.background)
.text_color(theme.tokens.foreground)
.child(
Button::new("save", "Save")
.variant(ButtonVariant::Default)
.on_click(|_, _, _| println!("Saved!")),
)
}
}
use_theme() is still available and returns an owned Theme; it is the legacy
path (it clones the whole theme on every call and does not take a cx). Prefer
Theme::get(cx) / Theme::of(cx) in new code.
Tokens follow shadcn/ui naming: background/foreground, primary, secondary, muted, accent, destructive, border, card, and so on, each with light and dark variants.
Custom themes and live switching
Eighteen presets ship in-tree (Theme::dark(), Theme::light(),
Theme::tokyo_night(), Theme::catppuccin_mocha(), Theme::nord(), …), and
you can brand your app with Theme::custom: start from any preset's tokens and
override only what you need with struct-update syntax.
let brand = Theme::custom(ThemeTokens {
primary: hsla(262.0 / 360.0, 0.83, 0.58, 1.0),
primary_foreground: hsla(0.0, 0.0, 1.0, 1.0),
radius_md: px(10.0),
..ThemeTokens::dark()
});
install_theme(cx, brand);
install_theme can be called again at any time — it refreshes every open
window, so components re-read the new tokens immediately. Wiring a theme picker
is just a button:
Button::new("theme-light", "Light").on_click(cx.listener(|_, _, _, cx| {
install_theme(cx, Theme::light());
cx.notify();
}))
Customizing individual components
Every component implements Kael's Styled trait, so the entire Tailwind-like
styling API works directly on it — this is the className of kael_ui. User
styles are applied last and override the component's defaults:
Button::new("cta", "Get started")
.rounded(px(999.0)) // pill shape
.px(px(28.0)) // wider padding
.bg(rgb(0x8b5cf6)) // one-off brand color
.shadow_lg()
Card::new()
.content(body("Hello"))
.w(px(360.0))
.border_2()
.border_color(rgb(0x10b981))
Use the theme for app-wide identity and Styled overrides for one-off
adjustments. The repository-only Astryx showcase composes both approaches in
one application.
What's included
| Module | Components |
|---|---|
components | Button, IconButton, Input, Textarea, SearchInput, NumberInput, OtpInput, TagInput, MentionInput, HotkeyInput, Checkbox, Radio, Toggle, Switch, Slider, RangeSlider, Select, Combobox, Dropdown, DatePicker, TimePicker, Calendar, ColorPicker, Rating, FileUpload, Avatar, AvatarGroup, Progress, Spinner, Skeleton, Stepper, Pagination, Carousel, Timeline, QrCode, CopyButton, InlineEdit, code Editor with tree-sitter syntax highlighting, audio/video players, and many more |
display | Table, DataTable, DataGrid, Card, Badge, Accordion, RichText, Markdown and HTML rendering (feature-gated) |
navigation | Sidebar, Menu, AppMenu, Tabs, Breadcrumbs, Toolbar, StatusBar, Tree, FileTree, VirtualList |
overlays | Dialog, AlertDialog, ConfirmDialog, Sheet, BottomSheet, Popover, PopoverMenu, HoverCard, ContextMenu, Toast, Tooltip, CommandPalette |
charts | LineChart, AreaChart, BarChart, PieChart, DonutChart, RadarChart, Gauge, Heatmap, Treemap, Sparkline |
layout | VStack, HStack, Grid, ScrollContainer, responsive breakpoint helpers |
animations | Easing presets, springs, transitions, animated presence/state, shimmer, confetti, and other motion effects |
For native desktop code editors, markdown editors, log viewers, SQL consoles,
and prompt builders, prefer the native Editor before embedding Monaco,
CodeMirror, or a WebView textarea. Position::to_text(),
Selection::to_text(), FoldRange::to_text(),
EditorDiagnostic::to_text(), EditorState::to_text(), and
Editor::to_text() expose language, line/content byte counts, cursor and
selection geometry, modified/file-path presence, undo/redo depth, syntax and
highlight readiness, search counts/options, fold state, readonly mode,
diagnostic counts, and visual override coverage without logging document text,
file paths, selected text, search terms, diagnostic messages, or style callback
internals.
For native desktop dashboards, admin tools, file managers, and data-heavy
workspaces, prefer native Table, DataTable, and DataGrid components before
reaching for browser tables. ColumnDef::to_text(),
DataTableState::to_text(), DataTable::to_text(), RowAction::to_text(),
GridColumnDef::to_text(), DataGridState::to_text(), and
DataGrid::to_text() expose column/row counts, virtual backing, cached rows,
sort and selection state, editable columns, active edit buffers, search
presence, edit/row-action handlers, and load-more/fetch-page wiring without
logging headers, row values, ids, labels, queries, edit text, dimensions, or
callback internals.
For native desktop media players, timelines, podcast tools, video review
surfaces, and lightweight editors, prefer native VideoPlayer, AudioPlayer,
and Waveform before embedding a browser player. VideoPlayer::to_text(),
VideoPlayerState::to_text(), VideoCaptionStyle::to_text(),
AudioPlayer::to_text(), AudioPlayerState::to_text(), and
Waveform::to_text() expose source kind, route, player size,
controls/captions/poster/source/title presence, progress and volume buckets,
handler counts, and waveform sample shape without logging media URLs, file
paths, titles, caption text, exact seek times, volume or rate values, waveform
amplitudes, or colors.
For native desktop dialogs, sheets, custom menus, context menus, command
palettes, and omniboxes, prefer the native Dialog, Sheet, BottomSheet,
Menu, ContextMenu, MenuBar, and CommandPalette stacks instead of hosted
browser overlays. Their to_text() helpers expose size/purpose/dismissal
policy, header/content/footer presence, item/result counts, nesting, disabled
state, shortcut coverage, query presence/length, selection state, and handler
coverage without logging ids, labels, titles, descriptions, categories,
shortcut strings, user queries, coordinates, dimensions, child contents, or
callback internals.
Icons
Components render Lucide icons by name. kael_ui
bundles the compact set used by its built-in components, so published-crate
consumers do not need to copy framework assets into their application. An
application asset source is checked first, preserving branded overrides.
The complete 1,600+ SVG catalog remains repository-only under
crates/kael_ui/assets/icons for discovery and the Astryx showcase. Point the
resolver at your own icon directory to replace the bundled set:
kael_ui::set_icon_base_path("assets/icons");
Feature flags
| Feature | Default | Enables |
|---|---|---|
http | yes | Remote image loading (Avatar, image components) |
markdown | no | display::markdown rendering |
html-render | no | display::html rendering |
audio | no | AudioPlayer playback via rodio |
image-avif, image-exr | no | Opt-in AVIF (libdav1d) and OpenEXR decoding |
editor-languages | no | Tree-sitter grammars for 20+ languages in the editor |
Showcase
The repository keeps one comprehensive, sectioned showcase instead of a large collection of small examples:
cargo run -p kael_ui --example astryx_showcase \
--features "markdown html-render audio media editor-languages"
The showcase is not part of the kael_ui crate package.
Template apps
Three complete starter applications live in templates/ — copy one as the skeleton of your own app:
cargo run -p dashboard-app # analytics: sidebar, stat cards, charts, data table
cargo run -p messaging-app # chat: conversation list, message bubbles, composer
cargo run -p workspace-app # IDE shell: file tree, syntax-highlighted editor, status bar
Form Controls
Every form control follows the same pattern:
- Create with
widget_name(id, value, ...) - Chain builder methods for configuration
- Add
.on_change()for state updates - Optionally add
.render_with()for custom visuals
All controls support keyboard navigation and accessibility out of the box.
Button
A focusable, clickable element with label support.
#![allow(unused)] fn main() { use kael::button; button("save-btn") .label("Save File") .on_click({ let entity = entity.clone(); move |_event, _window, cx| { entity.update(cx, |this, cx| { this.save(); cx.notify(); }); } }) }
Builder methods:
| Method | Description |
|---|---|
.label(text) | Display text |
.disabled() | Disable interaction |
.on_click(handler) | Click handler (|event, window, cx| { ... }) |
.render_with(renderer) | Custom rendering with ButtonRenderState |
ButtonRenderState fields: label: Option<SharedString>, focused: bool, disabled: bool
Use state.to_text(), has_label(), and label_len_bytes() in custom
renderers when logging or testing generated button chrome. The summary exposes
focus, disabled state, label presence, and label byte length without logging the
button text.
TextInput
Full-featured text field with selection, clipboard, undo/redo, and password masking.
#![allow(unused)] fn main() { use kael::text_input; text_input("project_name", self.name.clone()) .placeholder("Enter project name") .on_change({ let entity = entity.clone(); move |value, _window, cx| { entity.update(cx, |this, cx| { this.name = value; cx.notify(); }); } }) }
Builder methods:
| Method | Description |
|---|---|
.placeholder(text) | Placeholder text when empty |
.multi_line() | Enable multiline editing |
.max_lines(n) | Limit visible height |
.password() | Mask input characters |
.mask(impl InputMask) | Custom input normalization |
.on_change(handler) | Text change handler (|value: SharedString, window, cx|) |
.on_submit(handler) | Enter key handler (|value: SharedString, window, cx|) |
.render_with(renderer) | Custom rendering with TextInputRenderState |
TextInputRenderState fields: value, display_text, placeholder, showing_placeholder, focused, hovered, multi_line, outer_bounds, field_bounds, text_bounds, line_height, lines, selection_bounds, cursor_bounds
Custom rendering helpers on state: state.paint_selection(color, window), state.paint_text(window, cx), state.paint_cursor(color, window)
For custom renderers, use state.to_text(), value_len_bytes(),
display_text_len_bytes(), placeholder_len_bytes(), has_placeholder(),
is_empty(), is_masked_display(), line_count(),
selection_rect_count(), has_selection(), and has_cursor() for
content-safe diagnostics. These summaries describe focus, placeholder,
multiline, selection, caret, masking, and line shape without logging the field
value, placeholder text, displayed password mask contents, selected text, or
geometry coordinates.
RichText
Native formatted text for previews, feeds, mentions, links, inline chips, and read-only editor surfaces:
#![allow(unused)] fn main() { use kael::{rich_text, HighlightStyle}; let body = rich_text() .selectable() .text("Welcome ") .styled("builder", HighlightStyle::default()) .link("docs", "https://example.com/docs", |_, _| {}) .mention("@sam", "user-42", |_, _| {}) .code("cargo run") .build(); tracing::info!(summary = body.to_text(), "rich text"); }
Builder methods:
| Method | Description |
|---|---|
.text(text) | Plain text segment |
.styled(text, HighlightStyle) | Highlighted text segment |
.link(text, target, handler) | Clickable link entity |
.mention(text, payload, handler) | Clickable mention entity |
.hashtag(text, payload, handler) | Clickable hashtag entity |
.code(text) | Inline code-styled segment |
.inline_element(element) | Inline child element |
.inline_element_with_baseline(element, px) | Inline child with explicit baseline |
.selectable() | Enable native text selection |
.track_layout(layout) | Track selection and geometry through RichTextLayout |
.selection_color(color) | Override selection highlight color |
Use to_text(), segment_count(), text_segment_count(),
text_len_bytes(), inline_element_count(), inline_baseline_count(),
highlighted_segment_count(), code_segment_count(), entity_count(),
link_count(), mention_count(), hashtag_count(), click_handler_count(),
is_selectable(), has_selection_color(), and has_element_id() for
content-safe agent summaries before render. These summaries do not log text,
URLs, mentions, hashtags, code contents, or entity payloads.
Checkbox
Three-state checkbox (checked, unchecked, indeterminate) with undo/redo.
#![allow(unused)] fn main() { use kael::checkbox; checkbox("notifications", self.enabled) .label("Enable notifications") .on_change({ let entity = entity.clone(); move |checked, _window, cx| { entity.update(cx, |this, cx| { this.enabled = *checked; cx.notify(); }); } }) }
Builder methods:
| Method | Description |
|---|---|
.label(text) | Label text |
.indeterminate(bool) | Show indeterminate state |
.disabled() | Disable interaction |
.on_change(handler) | State change (|&bool, window, cx|) |
.render_with(renderer) | Custom rendering with CheckboxRenderState |
CheckboxRenderState fields: checked, indeterminate, label, focused, disabled
Use state.to_text(), has_label(), and label_len_bytes() in custom
renderers. The summary reports checked, indeterminate, focus, disabled, and
label-shape state without logging the label text.
Toggle
Boolean on/off switch with undo/redo.
#![allow(unused)] fn main() { use kael::toggle; toggle("dark_mode", self.dark_mode) .label("Dark mode") .on_change({ let entity = entity.clone(); move |on, _window, cx| { entity.update(cx, |this, cx| { this.dark_mode = *on; cx.notify(); }); } }) }
Builder methods:
| Method | Description |
|---|---|
.label(text) | Label text |
.disabled() | Disable interaction |
.on_change(handler) | State change (|&bool, window, cx|) |
.render_with(renderer) | Custom rendering with ToggleRenderState |
ToggleRenderState fields: on, label, focused, disabled
Use state.to_text(), has_label(), and label_len_bytes() in custom
renderers. The summary reports on/off, focus, disabled, and label-shape state
without logging the label text.
RadioGroup
Mutually exclusive option selection with generic value types.
#![allow(unused)] fn main() { use kael::radio_group; #[derive(Clone, Copy, PartialEq, Eq)] enum Theme { Light, Dark, System } radio_group("theme", self.theme, [ (Theme::Light, "Light"), (Theme::Dark, "Dark"), (Theme::System, "System"), ]) .on_change({ let entity = entity.clone(); move |value, _window, cx| { entity.update(cx, |this, cx| { this.theme = *value; cx.notify(); }); } }) }
Builder methods:
| Method | Description |
|---|---|
.on_change(handler) | Selection change (|&T, window, cx|) |
.render_with(renderer) | Custom rendering per option with RadioItemRenderState<T> |
RadioItemRenderState fields: value, label, index, option_count, selected, focused, disabled
Use state.to_text(), label_len_bytes(), is_first(), and is_last() in
custom renderers. The summary reports option position, count, selection, focus,
disabled, and label byte length without logging option values or label text.
Slider
Continuous or discrete value control with drag support.
#![allow(unused)] fn main() { use kael::slider; slider("volume", self.volume) .min(0.0) .max(100.0) .step(5.0) .on_change({ let entity = entity.clone(); move |value, _window, cx| { entity.update(cx, |this, cx| { this.volume = *value; cx.notify(); }); } }) }
Builder methods:
| Method | Description |
|---|---|
.min(f64) | Minimum value (default: 0.0) |
.max(f64) | Maximum value (default: 100.0) |
.step(f64) | Keyboard increment (default: 1.0) |
.discrete() | Snap to step values |
.vertical() | Vertical orientation |
.disabled() | Disable interaction |
.on_change(handler) | Value change (|&f64, window, cx|) |
.render_with(renderer) | Custom rendering with SliderRenderState |
SliderRenderState fields: value, min, max, percentage, dragging, focused, disabled
Use state.to_text(), position_class(), is_at_min(), and is_at_max() in
custom renderers. The summary reports coarse position, edge state, dragging,
focus, and disabled state without logging exact values, bounds, or fractions.
Progress
Determinate or indeterminate progress indicator with custom paint support.
#![allow(unused)] fn main() { use kael::progress; progress("download", self.downloaded_bytes as f64) .max(self.total_bytes as f64) }
Builder methods:
| Method | Description |
|---|---|
.max(f64) | Maximum value (default: 1.0) |
.indeterminate() | Show busy progress without a numeric value |
.render_with(renderer) | Custom painting with ProgressRenderState |
ProgressRenderState fields: value, max, percentage, indeterminate
Use state.to_text(), is_determinate(), and completion_class() in custom
renderers or generated task UIs. The summary reports determinate/indeterminate
state and a coarse completion class without logging exact values, maximums, or
fractions.
Tabs
Controlled tab list with caller-owned panels and customizable tab triggers.
#![allow(unused)] fn main() { use kael::{TabItem, tabs}; tabs("settings", self.section, [ TabItem::new(Section::General, "General", general_panel()), TabItem::new(Section::Billing, "Billing", billing_panel()), ]) .on_change({ let entity = entity.clone(); move |section, _window, cx| { entity.update(cx, |this, cx| { this.section = *section; cx.notify(); }); } }) }
Builder methods:
| Method | Description |
|---|---|
.on_change(handler) | Selection change (|&T, window, cx|) |
.render_tabs_with(renderer) | Custom tab trigger rendering with TabRenderState<T> |
TabRenderState fields: value, label, index, tab_count, selected, focused
Use state.to_text(), label_len_bytes(), is_first(), and is_last() in
custom tab renderers. The summary reports tab position, count, selection, focus,
and label byte length without logging tab values or label text.
Disclosure
Controlled expandable section with caller-owned trigger visuals and panel content.
#![allow(unused)] fn main() { use kael::disclosure; disclosure("advanced", self.advanced_open) .label("Advanced") .panel(advanced_panel()) .on_change({ let entity = entity.clone(); move |open, _window, cx| { entity.update(cx, |this, cx| { this.advanced_open = *open; cx.notify(); }); } }) }
Builder methods:
| Method | Description |
|---|---|
.label(text) | Trigger label text |
.panel(element) | Content shown while open |
.on_change(handler) | Open-state change (|&bool, window, cx|) |
.render_with(renderer) | Custom trigger rendering with DisclosureRenderState |
DisclosureRenderState fields: open, label, focused
Use state.to_text(), has_label(), and label_len_bytes() in custom trigger
renderers. The summary reports open, focus, label presence, and label byte
length without logging trigger label text.
Modal
Controlled dialog overlay with caller-owned content, backdrop, and dismissal policy.
#![allow(unused)] fn main() { use kael::modal; modal("confirm-delete", self.confirming_delete) .label("Confirm delete") .dismiss_on_escape(true) .dismiss_on_click_outside(true) .render_with(|state, _window, _cx| { tracing::info!(summary = state.to_text(), "modal"); confirm_delete_panel().into_any_element() }) .on_change({ let entity = entity.clone(); move |open, _window, cx| { entity.update(cx, |this, cx| { this.confirming_delete = *open; cx.notify(); }); } }) }
Builder methods:
| Method | Description |
|---|---|
.label(text) | Dialog accessibility label |
.backdrop(color) | Backdrop color behind the dialog |
.dismiss_on_click_outside(bool) | Request dismissal on outside click |
.dismiss_on_escape(bool) | Request dismissal on Escape |
.on_change(handler) | Open-state change (|&bool, window, cx|) |
.render_with(renderer) | Custom dialog rendering with ModalRenderState |
ModalRenderState fields: open, label, focused, dismiss_on_click_outside, dismiss_on_escape
Use state.to_text(), has_label(), label_len_bytes(), and
dismissal_mode() in custom dialog renderers. The summary reports open, focus,
label shape, and dismissal policy without logging dialog label text.
Popover
Controlled anchored overlay for menus, pickers, help panels, and compact inspectors.
#![allow(unused)] fn main() { use kael::popover; popover("help", self.help_open) .render_anchor_with(|state, _window, _cx| { tracing::info!(summary = state.to_text(), "popover anchor"); help_button(state.open).into_any_element() }) .render_popup_with(|state, _window, _cx| { tracing::info!(summary = state.to_text(), "popover popup"); help_panel(state.width).into_any_element() }) .on_open_change({ let entity = entity.clone(); move |open, _window, cx| { entity.update(cx, |this, cx| { this.help_open = *open; cx.notify(); }); } }) }
Builder methods:
| Method | Description |
|---|---|
.on_open_change(handler) | Open-state change (|&bool, window, cx|) |
.render_anchor_with(renderer) | Custom anchor rendering with PopoverAnchorRenderState |
.render_popup_with(renderer) | Custom popup rendering with PopoverPopupRenderState |
.dismiss_on_click_outside(bool) | Request dismissal on outside click |
.dismiss_on_escape(bool) | Request dismissal on Escape |
.offset(point) | Offset popup relative to anchor |
PopoverAnchorRenderState fields: open, dismiss_on_click_outside, dismiss_on_escape
PopoverPopupRenderState fields: open, width, anchor_bounds, focused, dismiss_on_click_outside, dismiss_on_escape
Use anchor to_text() plus popup to_text(), has_anchor_bounds(),
width_class(), and dismissal_mode() in custom renderers. The summaries
report open/focus state, geometry availability, coarse width, and dismissal
policy without logging exact widths, coordinates, or bounds.
MenuButton
Anchored popup menu button with keyboard navigation and customizable trigger and item rows.
#![allow(unused)] fn main() { use kael::{MenuButtonItem, menu_button}; menu_button("file-actions", [ MenuButtonItem::new(Action::Rename, "Rename"), MenuButtonItem::new(Action::Delete, "Delete"), ]) .label("Actions") .render_trigger_with(|state, _window, _cx| { tracing::info!(summary = state.to_text(), "menu trigger"); menu_trigger(state.open).into_any_element() }) .render_items_with(|state, _window, _cx| { tracing::info!(summary = state.to_text(), "menu item"); menu_item_row(state.label, state.highlighted, state.disabled).into_any_element() }) .on_select({ let entity = entity.clone(); move |action, _window, cx| { entity.update(cx, |this, cx| { this.perform(*action); cx.notify(); }); } }) }
Builder methods:
| Method | Description |
|---|---|
.label(text) | Trigger label text |
.on_select(handler) | Item selection (|&T, window, cx|) |
.render_trigger_with(renderer) | Custom trigger rendering with MenuButtonTriggerRenderState |
.render_items_with(renderer) | Custom item row rendering with MenuButtonItemRenderState<T> |
MenuButtonTriggerRenderState fields: open, label, focused
MenuButtonItemRenderState fields: value, label, index, highlighted, disabled
Use trigger and item to_text() helpers plus has_label(),
label_len_bytes(), and item label_len_bytes() in custom renderers. The
summaries report open/focus state, item index, highlight/disabled state, and
label byte lengths without logging trigger labels, item labels, or item values.
Toast
Transient in-window notification displayed by a ToastStack.
#![allow(unused)] fn main() { use kael::{Toast, ToastPosition, ToastStack}; use std::time::Duration; let toast = Toast::new("Saved") .body("Project settings updated") .duration(Duration::from_secs(4)) .position(ToastPosition::BottomRight); tracing::info!(summary = toast.to_text(), "toast"); toast_stack.update(cx, |stack, cx| stack.push(toast, window, cx)); }
Builder methods:
| Method | Description |
|---|---|
.body(text) | Optional secondary text |
.duration(duration) | Auto-dismiss duration |
.position(position) | Screen position |
Use toast.to_text(), has_body(), title_len_bytes(),
body_len_bytes(), duration_class(), and position_key() before pushing
generated notifications. The summary reports text lengths, body presence,
coarse duration, and position without logging title/body text or exact seconds.
ToastPosition::to_text() returns stable keys for tests and traces.
Splitter
Controlled splitter handle for resizable panes with keyboard, drag, and undo/redo support.
#![allow(unused)] fn main() { use kael::{px, splitter}; splitter("sidebar-width", self.sidebar_width) .min(px(180.0)) .max(px(420.0)) .step(px(8.0)) .on_change({ let entity = entity.clone(); move |width, _window, cx| { entity.update(cx, |this, cx| { this.sidebar_width = *width; cx.notify(); }); } }) }
Builder methods:
| Method | Description |
|---|---|
.min(px) | Minimum splitter position |
.max(px) | Maximum splitter position |
.step(px) | Keyboard and drag snap increment |
.discrete() | Snap drag updates down to step values |
.horizontal() | Horizontal rule that moves vertically |
.on_change(handler) | Position change (|&Pixels, window, cx|) |
.render_with(renderer) | Custom rendering with SplitterRenderState |
SplitterRenderState fields: value, min, max, vertical, percentage, dragging, focused
Use state.to_text(), orientation(), position_class(), is_at_min(), and
is_at_max() in custom renderers. The summary reports orientation, coarse
position, edge state, dragging, and focus without logging exact pixel values or
fractions.
Label
Text label that can forward focus to another control.
#![allow(unused)] fn main() { use kael::label; let label = label("project-name-label") .text("Project name") .for_focus_handle(name_focus.clone()); tracing::info!(summary = label.to_text(), "label"); }
Builder methods:
| Method | Description |
|---|---|
.text(text) | Visible and accessible label text |
.for_focus_handle(handle) | Focus target control when clicked |
Use label.to_text(), has_text(), text_len_bytes(),
has_target_focus(), and child_count() before rendering generated form rows.
The summary reports text presence, text length, focus-target presence, and
custom child count without logging label text or child contents.
ScrollBar
Focusable scroll bar bound to a scrollable container through a ScrollHandle.
#![allow(unused)] fn main() { use kael::{scroll_bar, px}; scroll_bar("results-scroll", self.scroll_handle.clone()) .step(px(48.0)) .render_with(|state, bounds, window, _cx| { tracing::info!(summary = state.to_text(), "scroll bar"); paint_custom_scrollbar(state, bounds, window); }) }
Builder methods:
| Method | Description |
|---|---|
.horizontal() | Render a horizontal scroll bar |
.step(px) | Keyboard scroll increment |
.render_with(renderer) | Custom rendering with ScrollBarRenderState |
ScrollBarRenderState fields: vertical, logical_offset, max_offset, viewport_size, content_size, percentage, thumb_ratio, dragging, focused, opacity
Use state.to_text(), orientation(), has_overflow(),
position_class(), thumb_size_class(), opacity_class(), is_at_start(),
and is_at_end() in custom renderers. The summary reports orientation,
overflow, coarse scroll position, thumb class, drag/focus state, and visibility
class without logging exact offsets, sizes, ratios, coordinates, or opacity.
Lists
Virtualized native lists for large collections, file explorers, queues, and reorderable settings.
#![allow(unused)] fn main() { use kael::{ListAlignment, ListState, list, px}; let list_state = ListState::new(items.len(), ListAlignment::Top, px(240.0)); tracing::info!(summary = list_state.to_text(), "list state"); list_state.set_scroll_handler(|event, _window, _cx| { tracing::info!(summary = event.to_text(), "list scroll"); }); list(list_state.clone(), |index, _window, _cx| { render_row(index).into_any_element() }) }
List helpers:
| Helper | Description |
|---|---|
ListState::to_text() | Content-safe item count, alignment, viewport, visible-count, scroll, overflow, and position summary |
ListScrollEvent::to_text() | Content-safe visible range, visible count, item count, and scrolled state |
UniformList::to_text() | Content-safe item count, measurement index, decoration count, scroll tracking, and sizing summary |
UniformListScrollHandle::to_text() | Content-safe pending scroll-to-item intent, strategy, offset-in-items, scrollability, and flip summary |
RecyclingList::to_text() | Content-safe delegate item count, sizing, alignment, and coarse overdraw summary |
ListAlignment::to_text() | Stable top / bottom key |
ListSizingBehavior::to_text() | Stable infer / auto key |
ListHorizontalSizingBehavior::to_text() | Stable horizontal sizing key |
ScrollStrategy::to_text() | Stable top / center / bottom key |
Use item_count(), alignment(), has_viewport(), visible_range(),
visible_item_count(), has_scroll_offset(), has_overflow(), and
scroll_position_class() when generated list UIs, diagnostics, or agents need
stable state. Summaries report counts, item indexes/ranges, and coarse scroll
classes without logging row contents, measured heights, viewport pixels, or
scroll offsets.
Use UniformList::to_text() before rendering large fixed-row collections and
UniformListScrollHandle::to_text() before generated scroll-to-item commands.
Use RecyclingList::to_text() for heterogeneous feeds or inspectors that rely
on delegate-provided estimated heights. These wrapper summaries report list
configuration and scroll intent without rendering rows or logging row contents,
measured heights, exact overdraw pixels, viewport geometry, or scroll offsets.
For sortable lists, call sortable_reorder_plan(source, insertion, count) and
log plan.to_text() before applying generated reorder mutations. Inspect
has_move(), is_noop(), is_out_of_range(), and target() to separate
valid moves from no-op or invalid drops. Use sortable_auto_scroll_class(...)
for drag-edge diagnostics without logging pointer coordinates.
Semantic Primitives and Navigation
Native app structure for menus, links, trees, panes, dialogs, alerts, and route stacks without embedding DOM history or browser accessibility nodes.
#![allow(unused)] fn main() { use kael::{link, menu_item, tree_item, Navigator, Route, Transition}; let docs = link("docs") .label("Docs") .url("https://example.com/docs"); tracing::info!(summary = docs.to_text(), "semantic link"); let item = tree_item("src").label("src").selected(true).expanded(true); tracing::info!(summary = item.to_text(), "tree item"); let nav = Navigator::new(Route::new("home", cx.new(|_| HomeView))); tracing::info!(summary = nav.to_text(), "navigator"); }
Semantic helpers:
| Helper | Description |
|---|---|
MenuEntry::to_text() | Label presence/byte length, disabled state, callback wiring, and child count |
Link::to_text() | Label/URL presence and byte lengths, disabled state, activation mode, and child count |
TreeItem::to_text() | Label byte length, selected state, expansion state, disabled state, activation mode, and child count |
Route::to_text() | Route-id byte length and memento presence |
RouteChangeEvent::to_text() | Previous/current route presence, route-id byte lengths, and stack depth |
Transition::to_text() | Stable transition key |
Navigator::to_text() | Stack depth, current-route presence, current route-id byte length, and active transition key |
Use these summaries when generated app chrome, sidebars, command menus, route stacks, or tree views need native structure that would otherwise be modeled with DOM nodes and browser history. The summaries do not log menu labels, link URLs, tree labels, route IDs, mementos, child contents, or callback internals.
Select
Dropdown with popup menu, optional search, and generic value types.
#![allow(unused)] fn main() { use kael::select; select("accent", self.accent, [ (AccentColor::Blue, "Atlantic"), (AccentColor::Green, "Forest"), (AccentColor::Orange, "Ember"), ]) .placeholder("Choose an accent") .searchable() .on_change({ let entity = entity.clone(); move |value, _window, cx| { entity.update(cx, |this, cx| { this.accent = *value; cx.notify(); }); } }) }
Builder methods:
| Method | Description |
|---|---|
.placeholder(text) | Placeholder when nothing selected |
.searchable() | Enable type-to-filter in popup |
.on_change(handler) | Selection change (|&T, window, cx|) |
.render_with(renderer) | Custom trigger rendering with SelectRenderState |
.render_options_with(renderer) | Custom option row rendering with SelectOptionRenderState<T> |
.render_popup_with(renderer) | Custom popup shell with SelectPopupRenderState |
.render_search_with(renderer) | Custom search field with SelectSearchRenderState |
SelectRenderState fields: open, display_text, selected_label, placeholder, showing_placeholder, focused
For custom renderers, use SelectRenderState::to_text(),
SelectOptionRenderState::to_text(), SelectPopupRenderState::to_text(), and
SelectSearchRenderState::to_text() for content-safe diagnostics. The helpers
report open/focus state, placeholder and selected-label presence, option index,
selected/highlighted state, filtered counts, highlighted/selected index
presence, search activity, and string byte lengths without logging display
text, option labels, placeholder text, search queries, option values, popup
widths, or coordinates.
DatePicker
Calendar-based date selection with month/year navigation.
#![allow(unused)] fn main() { use kael::date_picker; use time::Date; date_picker("delivery", self.delivery_date) .on_change({ let entity = entity.clone(); move |date, _window, cx| { entity.update(cx, |this, cx| { this.delivery_date = *date; cx.notify(); }); } }) }
Builder methods:
| Method | Description |
|---|---|
.on_change(handler) | Date selection (|&Date, window, cx|) |
.render_with(renderer) | Custom trigger rendering with DatePickerRenderState |
.render_days_with(renderer) | Custom day cell rendering with DatePickerDayRenderState |
.render_popup_with(renderer) | Custom popup shell with DatePickerPopupRenderState |
.render_header_with(renderer) | Custom month header with DatePickerHeaderRenderState |
.render_nav_buttons_with(renderer) | Custom month navigation buttons |
.render_weekdays_with(renderer) | Custom weekday labels |
DatePickerDayRenderState fields: date, day, selected, highlighted, disabled
For custom renderers, use DatePickerRenderState::to_text(),
DatePickerDayRenderState::to_text(), DatePickerPopupRenderState::to_text(),
DatePickerHeaderRenderState::to_text(),
DatePickerNavButtonRenderState::to_text(), and
DatePickerWeekdayRenderState::to_text() for content-safe diagnostics. The
helpers report open/focus state, label byte lengths, selectable/selected/
highlighted day state, selected-highlighted relation, navigation availability,
button direction/enabled state, and weekday index without logging exact dates,
month names, weekday labels, button labels, popup widths, or coordinates.
Display & Feedback
Elements for showing information and providing feedback to users.
Text
Basic text rendering. Strings passed to .child() automatically become text elements:
#![allow(unused)] fn main() { div().child("Hello, world!") div().text_xl().text_color(rgb(0x2563eb)).child("Title") }
For styled inline text, use SharedString:
#![allow(unused)] fn main() { use kael::SharedString; let label: SharedString = "Click me".into(); div().child(label) }
Label
Accessible label that forwards focus to a target control:
#![allow(unused)] fn main() { use kael::label; label("Email address", "email-input") // Clicking the label focuses the text_input with id "email-input" }
Icon
Render named icons from the icon set:
#![allow(unused)] fn main() { use kael::icon; icon("folder") icon("file").size(px(16.0)) }
Image
Display raster images with caching:
#![allow(unused)] fn main() { use kael::{img, ImageSource}; img(ImageSource::from_path("photo.png")) .w(px(200.0)) .h(px(150.0)) .rounded_md() }
SVG
Render SVG content:
#![allow(unused)] fn main() { use kael::svg; svg() .path("icons/logo.svg") .w(px(24.0)) .h(px(24.0)) .text_color(rgb(0x2563eb)) // fills SVG with color }
RichText
Compose text from inline styled spans, clickable entities (links, mentions, hashtags), inline code, and embedded elements:
#![allow(unused)] fn main() { use kael::{rich_text, FontWeight, HighlightStyle, rgb}; rich_text() .text("The ") .styled("quick brown fox", HighlightStyle { color: Some(rgb(0xb45309).into()), font_weight: Some(FontWeight::BOLD), ..Default::default() }) .text(" jumps. See the ") .link("docs", "https://augani.github.io/kael/", |_window, _app| {}) .text(" or ping ") .mention("@team", "team-id", |_window, _app| {}) .text(". Run ") .code("cargo run") .selectable(true) }
Builder methods: .text(), .styled(text, HighlightStyle), .link(text, target, on_click), .mention(text, payload, on_click), .hashtag(text, payload, on_click), .code(text), .inline_element(element), .selectable(bool), .selection_color(color). Entity click handlers have the signature Fn(&mut Window, &mut App).
Progress
Determinate or indeterminate progress indicator:
#![allow(unused)] fn main() { use kael::progress; // Determinate (0.0 to 1.0) progress("export", 0.65) // Indeterminate progress("loading", 0.0).indeterminate() // Custom rendering progress("download", self.progress) .render_with(|state, bounds, window, _cx| { // state.percentage: Option<f64> // state.indeterminate: bool // Paint track and fill bar using window.paint_quad() window.paint_quad(fill(bounds, rgb(0xe2e8f0)).corner_radii(px(4.0))); if let Some(pct) = state.percentage { let width = bounds.size.width * pct as f32; window.paint_quad(fill( Bounds::new(bounds.origin, size(width, bounds.size.height)), rgb(0x2563eb), ).corner_radii(px(4.0))); } }) }
ProgressRenderState fields: value, max, percentage, indeterminate
Toast
Auto-dismissing notification overlay:
#![allow(unused)] fn main() { use kael::{Toast, ToastStack}; // In your view, create a ToastStack entity struct MyApp { toasts: Entity<ToastStack>, } // Create it let toasts = cx.new(|_| ToastStack::new()); // Push a toast from anywhere with the entity handle toasts.update(cx, |stack, cx| { stack.push( Toast::new("File saved") .body("changes written to disk") .duration(Duration::from_secs(3)), window, cx, ); }); // Render the stack in your view impl Render for MyApp { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { div() .size_full() .child(/* your content */) .child(self.toasts.clone()) // ToastStack renders as overlay } } }
Toast positions: ToastPosition::TopRight, TopCenter, BottomRight
Canvas
GPU-accelerated custom drawing surface:
#![allow(unused)] fn main() { use kael::{Bounds, canvas, point, px, rgb, size}; canvas(size(px(400.0), px(300.0)), |draw, _window, _cx| { draw.reserve_commands(3); draw.fill_rects([ ( Bounds::new(point(px(16.0), px(40.0)), size(px(72.0), px(180.0))), rgb(0x2563eb).into(), ), ( Bounds::new(point(px(104.0), px(80.0)), size(px(72.0), px(140.0))), rgb(0x60a5fa).into(), ), ]); draw.fill_circles([( point(px(280.0), px(110.0)), px(36.0), rgb(0xf59e0b).into(), )]); }) }
fill_rects batches axis-aligned quads. fill_circles uses the rounded-quad
fast path rather than path tessellation, and reserve_commands avoids command
buffer growth during predictable real-time frames.
Containers & Overlays
Components for organizing content, managing layers, and showing floating UI.
Modal
Controlled dialog overlay with backdrop, escape-to-dismiss, and click-outside handling:
#![allow(unused)] fn main() { use kael::modal; modal("confirm-dialog", self.is_open) .label("Confirm action") .backdrop(hsla(0.0, 0.0, 0.0, 0.5)) .dismiss_on_escape(true) .dismiss_on_click_outside(true) .render_with({ let entity = entity.clone(); move |state, _window, _cx| { div() .w(px(400.0)) .p_6() .bg(rgb(0xffffff)) .rounded(px(12.0)) .shadow_xl() .flex().flex_col().gap_4() .child(div().text_lg().child("Are you sure?")) .child(div().child("This action cannot be undone.")) .child( div().flex().justify_end().gap_2() .child(button("cancel").label("Cancel") .on_click({ let entity = entity.clone(); move |_, _, cx| { entity.update(cx, |this, cx| { this.is_open = false; cx.notify(); }); } })) .child(button("confirm").label("Confirm") .on_click({ let entity = entity.clone(); move |_, _, cx| { entity.update(cx, |this, cx| { this.do_action(); this.is_open = false; cx.notify(); }); } })) ) .into_any_element() } }) .on_change({ let entity = entity.clone(); move |open, _window, cx| { entity.update(cx, |this, cx| { this.is_open = *open; cx.notify(); }); } }) }
ModalRenderState fields: open, label, focused
Popover
Anchored floating panel with positioning:
#![allow(unused)] fn main() { use kael::popover; popover("color-picker") .anchor(|_window, _cx| { button("show-colors").label("Colors").into_any_element() }) .popup(|_window, _cx| { div() .w(px(200.0)) .p_3() .bg(rgb(0xffffff)) .shadow_lg() .rounded(px(8.0)) .child("Color picker content") .into_any_element() }) .dismiss_on_escape(true) .dismiss_on_click_outside(true) }
Tabs
Tabbed content switcher with keyboard navigation:
#![allow(unused)] fn main() { use kael::tabs; #[derive(Clone, Copy, PartialEq, Eq)] enum EditorTab { Code, Preview, Settings } tabs("editor-tabs", self.active_tab, [ TabItem::new(EditorTab::Code, "Code", |_w, _cx| { div().child("Code editor here").into_any_element() }), TabItem::new(EditorTab::Preview, "Preview", |_w, _cx| { div().child("Live preview").into_any_element() }), TabItem::new(EditorTab::Settings, "Settings", |_w, _cx| { div().child("Editor settings").into_any_element() }), ]) .on_change({ let entity = entity.clone(); move |tab, _window, cx| { entity.update(cx, |this, cx| { this.active_tab = *tab; cx.notify(); }); } }) }
TabRenderState fields: value, label, index, tab_count, selected, focused
Disclosure
Collapsible section (accordion):
#![allow(unused)] fn main() { use kael::disclosure; disclosure("advanced-settings", self.expanded) .trigger(|_w, _cx| { div().child("Advanced Settings ▾").into_any_element() }) .panel(|_w, _cx| { div().p_3().child("Hidden content here").into_any_element() }) .on_change({ let entity = entity.clone(); move |open, _window, cx| { entity.update(cx, |this, cx| { this.expanded = *open; cx.notify(); }); } }) }
Splitter
Draggable pane divider for resizable layouts:
#![allow(unused)] fn main() { use kael::splitter; splitter("main-split", self.split_ratio) .on_change({ let entity = entity.clone(); move |ratio, _window, cx| { entity.update(cx, |this, cx| { this.split_ratio = *ratio; cx.notify(); }); } }) }
Use the ratio value to size adjacent panes:
#![allow(unused)] fn main() { let left_width = self.split_ratio * total_width; div().flex().flex_row() .child(div().w(px(left_width)).child("Left pane")) .child(splitter("split", self.split_ratio).on_change(/* ... */)) .child(div().flex_1().child("Right pane")) }
Context Menu
Right-click menus via .context_menu() on any Div:
#![allow(unused)] fn main() { div() .id("file-item") .child("document.txt") .context_menu(|menu| { menu.item("Open", |_w, cx| { /* handle open */ }) .item("Rename", |_w, cx| { /* handle rename */ }) .separator() .item("Delete", |_w, cx| { /* handle delete */ }) }) }
Tooltip
Hover information via .tooltip() on any Div:
#![allow(unused)] fn main() { div() .id("save-icon") .child(icon("save")) .tooltip("Save file (Cmd+S)") // Custom tooltip content div() .id("status") .child("●") .tooltip_element(|| { div() .p_2() .bg(rgb(0x1E1E1E)) .text_color(rgb(0xffffff)) .rounded(px(4.0)) .child("Connected to server") }) }
Layer
Managed layer system for in-window modals and popovers:
#![allow(unused)] fn main() { use kael::layer; layer("notification-layer") .placement(LayerPlacement::Centered) .child(/* floating content */) }
Navigation
Multi-screen apps push and pop views on a navigation stack. The canonical API is
kael::Navigator in the core crate.
Navigator
#![allow(unused)] fn main() { use kael::{Navigator, Route, Transition, navigator}; let nav = navigator(Route::new("inbox", inbox_view)); nav.push( Route::new("thread", thread_view).with_memento(ThreadScroll { offset }), Transition::SlideLeft, window, cx, ); nav.pop(Transition::SlideRight, window, cx); }
- Routes pair a stable id with an
AnyView. - Mementos carry restorable per-route state (scroll positions, selections):
attach with
with_memento, read back withroute.memento::<T>()when the route resurfaces. - Events:
NavigatoremitsRouteChangeEvent, so views cancx.subscribeto react to navigation (analytics, focus restoration). - Transitions: pushes and pops animate with
Transition(slide, fade, or custom).
Navigator is a screen stack, not a URL router — there is no path matching or
deep linking yet; those are tracked for a future release.
Navigator also offers replace, replace_stack, and pop_to_root for
rewriting the stack, each taking a Transition to animate the change.
Theming
Kael has one theming pipeline with two cooperating types, each with a clear role:
kael::Theme— the serializable, file-facing theme. It is what a JSON/TOML theme file deserializes into (colors,typography,spacing,radii,shadows), and it is what the hot-reload file watcher reloads. Think of it as the theme on disk.kael_ui::Theme(avariantplus a rich [ThemeTokens]) — the runtime token system that components actually render from. Every kael_ui component readsTheme::of(cx).tokens.*. Think of it as the theme in memory.
A bridge connects them so that editing a theme file restyles live components.
The pipeline
theme.toml / theme.json (you edit this)
│ file watcher (App::observe_theme_file)
▼
kael::Theme (parsed, stored as a Global)
│ App::observe_theme_files subscriber
▼
kael_ui::ThemeTokens (core fields mapped onto current tokens)
│ install_theme → set_global + refresh_windows
▼
components (re-render with Theme::of(cx).tokens.*)
Each stage is one observable hop: a file edit walks all the way down to a visible restyle, with no restart.
Reading the theme in components
Components read tokens through the zero-clone borrow:
#![allow(unused)] fn main() { impl Render for MyView { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { let theme = Theme::of(cx); div() .bg(theme.tokens.background) .text_color(theme.tokens.foreground) .border_color(theme.tokens.border) .child( div() .bg(theme.tokens.primary) .text_color(theme.tokens.primary_foreground) .px(px(16.0)) .py(px(8.0)) .rounded(theme.tokens.radius_md) .child("Primary button"), ) } } }
Theme::of(cx) (alias Theme::get(cx)) borrows the theme from the app's global
state without cloning. use_theme() is a legacy clone-per-call shim retained
for call sites where the borrow checker cannot take a &Theme cleanly; prefer
Theme::of(cx) everywhere else.
Presets, custom themes, and live switching
kael_ui ships 18 presets and lets you brand your app from any of them:
#![allow(unused)] fn main() { kael_ui::init(cx); install_theme(cx, Theme::dark()); // Brand it: start from a preset's tokens and override what you need. let brand = Theme::custom(ThemeTokens { primary: hsla(262.0 / 360.0, 0.83, 0.58, 1.0), radius_md: px(10.0), ..ThemeTokens::dark() }); install_theme(cx, brand); }
install_theme stores the active Theme as a Global and refreshes every open
window, so re-installing at runtime switches themes live. See
Component Library.
Theme files
A theme file deserializes into a kael::Theme. Fields are grouped; omit any
section to keep its defaults.
[colors]
background = "#0b1020"
surface = "#161c2e"
primary = "#6366f1"
accent = "#22d3ee"
muted = "#3b4252"
foreground = "#e5e7eb"
border = "#2a3350"
error = "#ef4444"
[radii]
sm = 4.0
md = 8.0
lg = 12.0
xl = 16.0
[typography]
ui_font_family = "Inter"
code_font_family = "JetBrains Mono"
The same shape works as JSON. Load one directly with:
#![allow(unused)] fn main() { let theme = kael::Theme::from_path("themes/active.toml")?; cx.set_global(theme); }
Hot-reload end-to-end
Wire the file watcher and the bridge once during startup. After that, every save to the watched file restyles the live UI:
#![allow(unused)] fn main() { Application::new().run(move |cx| { kael_ui::init(cx); install_theme(cx, Theme::dark()); // Register the bridge: maps reloaded kael::Theme -> ThemeTokens, then // install_theme (refreshing all windows). install_theme_file_bridge(cx); // Watch the file; on_change updates the core kael::Theme global, which // fires the bridge subscriber registered above. cx.observe_theme_file("themes/active.toml", |theme, cx| cx.set_global(theme)) .expect("failed to watch theme file"); // ... open your window; components read Theme::of(cx).tokens.* }); }
Order matters only in that the bridge must be registered before (or alongside)
the watcher; observe_theme_file applies the initial file once, and every
later save flows through the same path. The Astryx showcase demonstrates live
theme switching alongside branded components.
Core → token mapping
When a theme file reloads, the bridge maps the loaded kael::Theme onto the
currently installed ThemeTokens. Token fields without a core source are
preserved, so a partial file changes only what it names.
core kael::Theme field | ThemeTokens field(s) |
|---|---|
colors.background | background |
colors.foreground | foreground |
colors.surface | card, popover |
colors.primary | primary, ring |
colors.accent | accent |
colors.muted | muted |
colors.border | border, input |
colors.error | destructive |
radii.sm / md / lg / xl | radius_sm/md/lg/xl |
shadows.sm / md / lg | shadow_sm/md/lg |
typography.ui_font_family | font_family |
typography.code_font_family | font_mono |
Fields with no core source keep their existing token values: the *_foreground
colors, secondary, muted_foreground, accent_foreground, shadow_xs,
shadow_xl, ring_offset, and the spacing / duration / z-index scales. Core
fields with no token target (separator, selected_text, warning,
success, radii.pill, and the typographic sizes/weights) are intentionally
not mapped.
If you need a different mapping, call tokens_from_core_theme(core, base)
yourself inside a custom cx.observe_theme_files subscriber.
Accessibility
Kael's built-in widgets are accessible by default — every form control reports its role, state, and value to screen readers and supports full keyboard navigation.
Built-in accessibility
All form controls automatically provide:
- Roles: Button reports as button, checkbox as checkbox, etc.
- States: Focused, disabled, checked, selected, expanded
- Values: Slider reports its numeric value, progress reports percentage
- Labels: Set via
.label()builder method - Keyboard navigation: Tab between controls, Space/Enter to activate
You get this for free when using the built-in widgets.
Adding accessibility to custom elements
For custom div-based interactive elements, add accessibility attributes:
#![allow(unused)] fn main() { let accessibility = AccessibilityAttributes::switch("Enable dark mode", self.is_on); accessibility.validate()?; div() .id("custom-toggle") .accessibility(accessibility) .on_click(|_, _, cx| { /* toggle */ }) }
Semantic recipes are available for common custom controls:
#![allow(unused)] fn main() { AccessibilityAttributes::button("Save") AccessibilityAttributes::link("Open documentation") AccessibilityAttributes::checkbox("Email notifications", enabled) AccessibilityAttributes::switch("Enable sync", enabled) AccessibilityAttributes::radio_button("Compact", selected) AccessibilityAttributes::slider("Volume", value, 0.0, 100.0, Some(1.0)) AccessibilityAttributes::progress_bar("Upload progress", progress, 0.0, 100.0) AccessibilityAttributes::text_input("Search", query.clone()) }
Use .validate()? in tests or builder code to catch unlabeled interactive
controls, missing actions, and invalid ranges before the UI renders.
Keyboard navigation
Focus management
#![allow(unused)] fn main() { // Create a focus handle let focus = cx.focus_handle(); div() .id("panel") .track_focus(&focus) .on_key_down(|event, window, cx| { match event.keystroke.key.as_str() { "enter" => { /* activate */ }, "escape" => { /* cancel */ }, _ => {} } }) }
Tab stops
Controls with IDs are automatically tab-focusable. Custom tab order:
#![allow(unused)] fn main() { div() .id("first-field") .tab_index(1) div() .id("second-field") .tab_index(2) }
Focus traps
For custom modals, popovers, command palettes, and inspector panels, use the
headless FocusTrapController so Tab, Shift-Tab, and Escape behave like
users expect without tying the behavior to one visual component:
#![allow(unused)] fn main() { use kael_ui::prelude::{FocusTrapAction, FocusTrapController}; let trap = FocusTrapController::modal(); let root_focus = cx.focus_handle(); if trap.should_autofocus() { window.focus(&root_focus); } div() .id("settings-dialog") .track_focus(&root_focus) .tab_index(0) .on_key_down(move |event, window, cx| { match trap.action_for_keystroke(&event.keystroke) { Some(FocusTrapAction::FocusNext) => { window.focus_next_in_group(); cx.stop_propagation(); window.prevent_default(); } Some(FocusTrapAction::FocusPrevious) => { window.focus_prev_in_group(); cx.stop_propagation(); window.prevent_default(); } Some(FocusTrapAction::Dismiss) => { close_settings(window, cx); } Some(FocusTrapAction::FocusFirst) | None => {} } }) .child(/* first focusable child */) .child(/* second focusable child */) }
Use FocusTrapController::persistent() for surfaces where Escape should not
dismiss, or .dismiss_on_escape(false) / .autofocus(false) to tune a trap.
Assistive-technology actions
Advertised actions tell screen readers and other assistive technologies what a custom element can do:
#![allow(unused)] fn main() { let attrs = AccessibilityAttributes::switch("Enable sync", enabled) .action(AccessibilityAction::Toggle); }
When a platform adapter or custom integration receives a native action request, Kael normalizes it into its action vocabulary. Apps can register handlers on the window:
#![allow(unused)] fn main() { window.on_accessibility_action( accessibility_id, AccessibilityAction::Toggle, |request| { assert_eq!(request.action, AccessibilityAction::Toggle); toggle_sync(); }, ); let requests = window.drain_accessibility_actions(); }
Value-setting actions carry payload data:
#![allow(unused)] fn main() { window.on_accessibility_action( slider_id, AccessibilityAction::SetValue, |request| { if let Some(AccessibilityActionPayload::NumericValue(value)) = request.payload { set_volume(value); } }, ); window.on_accessibility_action( search_id, AccessibilityAction::SetValue, |request| { if let Some(AccessibilityActionPayload::Value(value)) = request.payload { set_search_query(value); } }, ); }
For custom test harnesses or non-window integrations, route normalized requests
through AccessibilityActionRouter:
#![allow(unused)] fn main() { let node = attrs.to_node(accessibility_id); let mut router = AccessibilityActionRouter::new(); router.on_action(accessibility_id, AccessibilityAction::Toggle, |request| { assert_eq!(request.action, AccessibilityAction::Toggle); toggle_sync(); }); router.dispatch_accesskit(accessibility_id, &node, accesskit::Action::Click); }
AccessibilityActionRequest::from_accesskit_for_node(...) uses the node's
advertised actions to recover Kael-specific meaning when a platform action is
coarser than Kael's vocabulary. For example, AccessKit Click can normalize to
Toggle for a switch or ShowMenu for a combobox when that is what the node
declared.
Label association
Use the label element to associate labels with controls:
#![allow(unused)] fn main() { label("Email address", "email-input") // Clicking the label focuses the associated input text_input("email-input", self.email.clone()) }
Screen reader announcements
#![allow(unused)] fn main() { // Announce to screen readers window.announce("File saved successfully"); }
Accessibility roles
| Role | Used by |
|---|---|
Button | button() |
Checkbox | checkbox() |
Radio | radio_group() options |
Slider | slider() |
TextInput | text_input() |
Switch | toggle() |
Dialog | modal() |
Tab | tabs() |
TabPanel | tabs() panel content |
ProgressBar | progress() |
Menu | context menus |
MenuItem | menu items |
Tree | tree views |
TreeItem | tree items |
Platform support
Kael builds one cross-platform accessibility tree per window each frame and
hands it to the native platform layer. There is nothing to opt into: any
window that renders accessible widgets (or custom elements with
AccessibilityRole/aria_* attributes) is exposed automatically.
| Platform | Backend | Status |
|---|---|---|
| macOS | accesskit_macos SubclassingAdapter over the window's NSView | Adapter-backed; serves a full NSAccessibility tree to VoiceOver |
| Linux | accesskit_unix AT-SPI2 adapter (one per window, x11 and wayland) | Adapter-backed; exposes the tree on the AT-SPI2 D-Bus bus to Orca |
| Windows | Hand-rolled UI Automation provider (IRawElementProviderSimple) | Native UIA, served via WM_GETOBJECT |
On macOS and Linux the tree is built once with AccessKit and the official adapters translate it to the platform protocol; Windows keeps its dedicated UIA provider. All three are driven from the same per-frame tree, so widget roles, labels, values, and focus stay consistent across platforms.
Notes:
- macOS requires no special entitlement; VoiceOver reads the served tree
directly. The adapter dynamically subclasses the
NSView, so it coexists with the rest of the AppKit window. - Linux uses
accesskit_unix's defaultasync-ioexecutor, which owns its own background thread for the zbus/AT-SPI2 connection — kael's executors are not involved. AT-SPI2 needs no special permission. - Assistive-technology action requests can be normalized with
AccessibilityActionRequestand routed withAccessibilityActionRouter. macOS and Linux adapter drains now normalize pending AccessKit requests against the current tree so Kael-specific actions such asToggle,ShowMenu, andDismisssurvive platform delivery. Windows feeds standard UIA focus, invoke, toggle, expand/collapse, and range-value pattern calls into the same window route.
Testing with a screen reader
macOS (VoiceOver)
Turn VoiceOver on with Cmd-F5, then focus your window and navigate with
Ctrl-Option-Arrow. Each control should be announced with its role and value
(for example, "Enable notifications, checkbox, checked").
To inspect the served tree without VoiceOver, use Xcode's Accessibility
Inspector (Xcode → Open Developer Tool → Accessibility Inspector) and point
its target picker at your running app, or query the Accessibility API directly
(AXUIElementCreateApplication(pid) walking kAXChildrenAttribute). A window
that previously exposed only a single root group will now report the full
control hierarchy.
Linux (Orca)
Start Orca (orca &) with your app running. Because Kael registers an
accesskit_unix adapter per window, the controls appear on the AT-SPI2 bus and
Orca announces them as you Tab through. The accerciser tool can also be used
to browse the live AT-SPI2 tree.
Windows (Narrator)
Start Narrator with Ctrl-Win-Enter. The UI Automation provider answers
WM_GETOBJECT, so controls are announced by role and name. The Accessibility
Insights for Windows tool can inspect the UIA tree.
Lists & Data
High-performance list components with virtualization for rendering thousands of items.
UniformList
Highest-performance list for items of equal, positive height. It measures one row and only renders the visible range, so large logs and tables do not create an element for every record:
#![allow(unused)] fn main() { use kael::{uniform_list, UniformListScrollHandle}; let scroll_handle = UniformListScrollHandle::new(); uniform_list( "log-entries", self.entries.len(), { let entries = self.entries.clone(); move |range, _window, _cx| { entries[range.clone()] .iter() .map(|entry| { div() .px_3() .py_1() .text_sm() .child(entry.message.clone()) .into_any_element() }) .collect() } }, ) .track_scroll(scroll_handle.clone()) }
When to use: Log viewers, file lists, data tables — any list where every row has the same height.
The measured row must resolve to a finite height greater than zero. A zero, negative, NaN, or
infinite height is treated as an empty viewport for that frame instead of attempting an invalid
visible-range calculation. Use with_width_from_item(Some(index)) when a representative row is a
better width sample than row zero.
List
Flexible list with alignment and overflow handling:
#![allow(unused)] fn main() { use kael::list; // Basic list list() .child(div().child("Item 1")) .child(div().child("Item 2")) .child(div().child("Item 3")) }
RecyclingList
Virtualized list for items with different heights. Supply a delegate with stable estimated heights for rows that have not been measured yet:
#![allow(unused)] fn main() { use kael::{ AnyElement, App, FontWeight, IntoElement, ListDelegate, Pixels, Window, div, px, recycling_list, }; use std::sync::Arc; #[derive(Clone)] struct MessageDelegate { messages: Arc<[Message]>, // Increment when messages are inserted, removed, reordered, or their // estimated heights change. height_revision: u64, } impl ListDelegate for MessageDelegate { fn item_count(&self) -> usize { self.messages.len() } fn estimated_item_height(&self, index: usize) -> Pixels { let body_lines = self.messages[index].body.lines().count().max(1); px(44.0 + body_lines as f32 * 18.0) } fn estimated_heights_revision(&self) -> Option<u64> { Some(self.height_revision) } fn render_item(&self, index: usize, _window: &mut Window, _cx: &mut App) -> AnyElement { let msg = &self.messages[index]; div() .p_3() .child(div().font_weight(FontWeight::BOLD).child(msg.sender.clone())) .child(div().text_sm().child(msg.body.clone())) .into_any_element() } } recycling_list( "messages", MessageDelegate { messages: self.messages.clone(), height_revision: self.message_height_revision, }, ) }
When to use: Chat messages, feed items — lists where rows vary in height.
Returning Some(revision) from estimated_heights_revision is the fast path: unchanged frames
reuse the existing height sum-tree without an O(total items) estimation pass. Increment the
revision whenever count, order, or estimates change. The default return value is None; that is
safe for fully dynamic delegates because estimates are refreshed every frame, but it intentionally
trades away the steady-state optimization.
Element pooling is opt-in through recycle_key and render_recycled_item. Kael sizes each keyed
pool from the observed visible-and-overdraw high-water mark, so large viewports are not constrained
by a small fixed pool. Only return elements whose supports_reuse() implementation is true, and
fully update recycled content before returning it for a new index.
SortableList
Drag-to-reorder list with auto-scroll and insertion indicator:
#![allow(unused)] fn main() { use kael::sortable_list; sortable_list( "layers", self.layers.len(), { let layers = self.layers.clone(); move |index, _window, _cx| { div() .px_3() .py_2() .child(layers[index].name.clone()) .into_any_element() } }, ) .on_reorder({ let entity = entity.clone(); move |from, to, _window, cx| { entity.update(cx, |this, cx| { let item = this.layers.remove(from); this.layers.insert(to, item); cx.notify(); }); } }) }
When to use: Layer panels, playlist editors, kanban columns — anywhere users reorder items by dragging.
ScrollBar
Kael provides automatic scrollbars for any element with overflow_y_scroll()
or overflow_y_auto() and a tracked ScrollHandle. The scrollbar appears
as a native-style dark rounded thumb when content overflows — no extra code
needed (see Layout & Styling).
For custom scroll bar rendering, use the explicit scroll_bar() widget:
#![allow(unused)] fn main() { use kael::scroll_bar; scroll_bar(scroll_handle.clone()) .render_with(|state, bounds, window, _cx| { // Custom scroll bar rendering // state.thumb_bounds, state.dragging }) }
Patterns
Virtual DataTable selection and paging
DataTable::new_virtual keeps only a bounded LRU of pages and virtualizes both
rows and variable-width columns. A million-row select-all is represented as
"all except these deselected rows", so it stores zero row indices until a user
starts excluding rows:
DataTable::new_virtual(1_000_000, columns, 128, cx)
.max_cached_pages(8)
.show_selection(true)
.on_fetch_page_request(|request, _window, cx| {
// Fetch or compute only request.page_start()..page_start + page_size.
// Commit with set_page_data_for(request, rows, cx); stale generations
// are rejected automatically.
})
.on_selection_change_snapshot(|selection, _window, _cx| {
println!(
"{} selected; {} stored indices ({})",
selection.selected_count(),
selection.stored_index_count(),
selection.representation_key(),
);
})
Use on_selection_change_snapshot for bulk actions. Its
DataTableSelectionSnapshot::AllExcept { total_rows, deselected } variant is
exact without expansion. The compatibility on_selection_change(&[usize], ...)
callback is invoked only when the exact selected indices can be materialized
within 16,384 items; it is intentionally skipped for a million-row all-except
selection rather than reporting an empty or partial slice. Likewise,
selected_rows() returns None for all-except state; this does not mean the
selection is empty.
Virtual-table search and sort are query inputs for the backing source. Kael invalidates the current generation and cache; the application or a Kael Web Worker performs the large search/sort and returns the requested page. Kael does not scan or allocate the million-row logical range on the UI thread.
Data table with uniform_list
#![allow(unused)] fn main() { struct DataTable { rows: Vec<Row>, columns: Vec<Column>, scroll: UniformListScrollHandle, } impl Render for DataTable { fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement { let columns = self.columns.clone(); let rows = self.rows.clone(); div().flex().flex_col().size_full() .child(self.render_header()) .child( uniform_list("table-body", rows.len(), move |range, _w, _cx| { rows[range.clone()].iter().map(|row| { div().flex().flex_row() .children(columns.iter().map(|col| { div().w(px(col.width)).px_2().py_1() .child(row.get(&col.key).clone()) })) .into_any_element() }).collect() }) .track_scroll(self.scroll.clone()) ) } } }
Suite-scale Applications
Kael maintains one source-level release workload for the four surfaces common to an office suite. It compiles as a native desktop application and as WebAssembly without changing view code:
# Desktop
cargo run -p kael_ui --example suite_scale_smoke
# Browser package
bash scripts/build-browser-suite-smoke.sh
python3 -m http.server 8133 --directory target/browser-suite-smoke
The reference app is not a DOCX renderer, spreadsheet formula engine, or slide layout engine. It is the framework-scale proof beneath those product layers: it exercises retained views, virtual mounting, bounded caches, editing state, search/undo, immediate canvas batches, spatial culling, damage, rich pointer input, and fixed-step animation using the same Rust source on both targets.
Maintained workload contract
| Surface | Logical workload | Per-view retained work |
|---|---|---|
| Sheets | 1,000,000 rows × 16,384 columns | at most 2,048 mounted cells; 8-tile LRU in the live grid |
| Docs | 250,000 blocks (5,209 pages) | at most 6 pages / 288 blocks; sparse edits and 64 undo transactions |
| Slides | 10,000 slides | at most 16 thumbnails and one retained slide surface |
| Whiteboard | 100,000 shapes | at most 2,048 visible shapes / 4,096 spatial candidates; 512 KiB tile payload cache |
The live example uses VirtualSheetGrid over the full 16,384-column address
space. Rows and columns are both mounted from their current viewports, and the
model responds to generation-scoped, row-major tile requests. No row vector,
column-definition vector, or cell matrix scales with the logical sheet size.
The CI probe also enforces generous regression ceilings: building the 100,000 shape spatial index must finish within 30 seconds and a viewport query within 2 seconds. Typical optimized runs are much faster, but the ceilings tolerate shared CI machines while still catching an accidental linear full-scene render.
Architecture for a product suite
Keep the document model logical and make the view a window into it:
- A sheet stores sparse edits and bounded row-major tiles, not 16,384 resident
strings per row.
VirtualSheetGridde-duplicates generation-safe requests, caps its LRU and pending set, and mounts only intersecting rows and columns. Use a Kael worker for formula recalculation, indexing, or remote queries. - A document stores immutable base blocks plus sparse edits. Mount pages with a virtual list, keep undo transactions bounded, and execute full-document search as bounded chunks through the existing worker bridge.
- A deck mounts only its thumbnail viewport and reuses one retained slide surface as selection changes.
- A whiteboard indexes retained shape bounds once, queries the viewport through
SpatialIndex, invalidates moved bounds withTileDamageTracker, caches only visible tiles, and feedsPointerInputEvent::stroke_samples()into a bounded stroke pipeline. Drive simulations withFixedFrameClock, then request the next host animation frame.
This separation is what makes one codebase portable: native and browser hosts provide windows, input, timing, GPU presentation, storage, workers, and file boundaries while the application owns the same models and retained views.
Exact current boundaries
- Browser full-document search and spreadsheet recalculation should run through Kael's typed worker bridge; the synchronous reference search API is chunked and capped, but it does not silently create a worker.
- Browser export cannot include cross-origin WebViews, protected surfaces, or other live hosted content. Export the retained scene before mounting such a surface, or export the hosted document through its own API.
- Browser pointer events include mouse, simultaneous touch, pen pressure/tilt, capture, cancellation, and bounded coalesced samples. Desktop compatibility mouse input uses the same event type; raw native touch/pen streams still depend on their platform backends. Browser pointer lock is exposed through the portable game-input API; synthesized pinch coverage remains partial.
- Browser secondary windows are independent retained canvases hosted inside the page, not operating-system windows. The smoke proves focus, presentation, and close cleanup for that browser model.
- Retained GPU presentation and device-pixel scene export have release coverage for WebGL 2 in browsers, Metal on macOS, Direct3D 11 on Windows, and the Blade/Vulkan X11 surface path on Linux. Linux hosted CI selects lavapipe, so that gate proves software-renderer correctness and liveness rather than hardware throughput or native-Wayland compositor integration.
- The browser owns IME candidate UI, native print settings, and security policy around cross-origin/network/file access. Kael exposes typed capability reports for these boundaries rather than claiming unavailable desktop behavior.
Run bash scripts/verify-browser-suite-smoke.sh before release to execute the
workload in real Chrome at both 1280 × 720 and 760 × 720, including
compressed select-all, bounded page cache, virtual page/thumbnail mounting,
retained whiteboard drawing, responsive offscreen mounting, and a synthetic pen
sequence through the browser pointer bridge. The same gate also proves:
- the primary and a focused secondary Kael window present independent, non-uniform retained pixels; closing the secondary surface restores focus to the primary and removes its browser host;
on_open_urlsreceives the current URL across realhashchange,history.back(), andhistory.forward()transitions without a document reload;- the presented WebGL frame exports as a nontrivial PNG before any hosted live surface is mounted; and
- the million-row, 16,384-column live grid mounts no more than 64 rows, 16 columns, or 1,024 cells, and keeps the whole primary accessibility tree at or below 768 mounted semantic nodes. These are counts from the rendered browser accessibility tree, not only a model-side prediction.
The smoke registers on_reopen, but CI does not pretend to force a browser
back-forward-cache restore. Browser pagehide/suspension is not synthesized as
a native quit. Test BFCache restoration separately in products that depend on
that lifecycle detail.
Animations
Kael drives animations from its render-on-demand loop: an animating element requests frames only while it is in flight, then the window returns to idle (0% CPU). There are three layers — implicit transitions that ease style changes automatically, explicit time-driven animations you attach to any element, and the framework's built-in motion such as elastic scrolling.
Implicit transitions
The web's "soft" feel comes from transition: all 150ms ease; Kael's equivalent is .transition(duration) on any element with a stable id. Whenever the element's computed style changes — hover, active, focus, or a state-driven restyle — the change is interpolated instead of snapping:
#![allow(unused)] fn main() { use std::time::Duration; div() .id("cta") .bg(theme.tokens.primary) .rounded(px(10.)) .transition(Duration::from_millis(150)) .hover(|style| style.bg(theme.tokens.accent).rounded(px(16.))) .active(|style| style.scale(0.97)) }
Animated properties: background (including gradients with matching stop counts), border color, text color, opacity, corner radii, box shadows, rotation, and scale. transition_with(duration, easing) takes an explicit easing curve; transitions interrupt cleanly, retargeting from the current visual state. kael_ui's controls ship with this wired to the transition_fast theme token.
Layout (FLIP) animation
.animate_layout(duration) makes a keyed element glide to its new position when layout moves it — list reorders, grid changes, sidebar toggles:
#![allow(unused)] fn main() { div().id(item.id).animate_layout(Duration::from_millis(350)) }
Avoid it on children of containers that scroll mid-animation; scrolling moves the element and restarts the glide. The Astryx showcase's layout and motion sections demonstrate this API in a complete application.
Springs and gestures
For physics-driven motion, kael_ui provides SpringValue/SpringPoint (real
spring integration with velocity, presets from SpringPreset) and
DraggableSpring, a container you can drag and throw: on release, the pan
gesture's velocity hands off to the spring, which settles to the nearest snap
point. The Astryx showcase includes the production-facing motion examples.
Animating an element
Bring the AnimationExt trait into scope and call with_animation on any element. You give it a stable id, an Animation describing the timeline, and an animator closure that receives the element and the eased progress delta in 0.0..=1.0:
#![allow(unused)] fn main() { use std::time::Duration; use kael::{Animation, AnimationExt as _, Transformation, bounce, ease_in_out, percentage, svg}; svg() .size_20() .path(ARROW_CIRCLE_SVG) .with_animation( "spinner", Animation::new(Duration::from_secs(2)) .repeat_forever() .with_easing(bounce(ease_in_out)), |svg, delta| svg.with_transformation(Transformation::rotate(percentage(delta))), ) }
Kael coalesces duplicate animation-frame requests from the same entity in a frame. Reduced-motion, low-power, and cancelled explicit animations jump to their completed state instead of keeping a hidden frame loop alive.
Custom display-frame effects
Use kael_ui::animations::DisplayFrameClock for custom particles, canvas
effects, or springs whose state changes every presented frame. Call restart
when the effect starts, pair try_arm with Window::on_next_frame, and pass the
returned generation to sample inside the callback. sample returns monotonic,
refresh-rate-independent delta time, so the same motion runs correctly at 60,
90, 120, or 144 Hz. It also rejects callbacks left behind by a restart and
clamps the first frame after a suspended browser tab.
Schedule one callback from the effect's render path and notify its state from
that callback. The next render arms the following frame. This pattern naturally
stops when the effect leaves the retained tree and coalesces repeated renders
before a presentation. Confetti, ParticleEmitter, and DraggableSpring use
this path on both desktop and WebAssembly; none of them polls a fixed 16 ms
timer.
Fixed-timestep games and simulations
UI animation should follow display time, but gameplay, physics, editors, and
deterministic simulations usually need fixed updates. kael_engines provides a
browser-compatible clock with bounded catch-up work and render interpolation:
#![allow(unused)] fn main() { use std::time::Duration; use kael_engines::game_loop::{FixedFrameClock, FixedFrameClockConfig}; let config = FixedFrameClockConfig::from_updates_per_second(60)? .with_max_frame_delta(Duration::from_millis(250)) .with_max_catch_up_steps(8); let mut clock = FixedFrameClock::new(config)?; let frame = clock.advance_by(Duration::from_millis(17)); for _ in frame.updates() { // update simulation by frame.fixed_timestep() } let render_alpha = frame.interpolation_alpha(); let _ = render_alpha; Ok::<(), Box<dyn std::error::Error>>(()) }
Use tick() in a live render loop and advance_by for replay/tests. Request the
next Kael animation frame only while the simulation is active. The clock clamps
long display gaps, caps update steps, and exposes dropped-time telemetry so a
stall cannot become an unbounded spiral of work. Pair it with canvas bulk
submission (reserve_commands, fill_rects, and fill_circles) for particles,
sprites, maps, and dense game/editor surfaces.
The Animation timeline
#![allow(unused)] fn main() { use kael::{Animation, Easing, Repeat}; Animation::new(Duration::from_millis(400)) .delay(Duration::from_millis(100)) // wait before starting .easing(Easing::EaseInOut) // pick a curve (see below) .repeat(Repeat::Count(3)); // Once | Count(n) | Forever }
repeat_forever() is shorthand for repeat(Repeat::Forever), and with_easing(f) accepts any Fn(f32) -> f32 (including the helpers ease_in_out, ease_out_quint(), and bounce(inner)).
Generated motion can be inspected before it is attached to UI:
#![allow(unused)] fn main() { let animation = Animation::new(Duration::from_millis(400)) .delay(Duration::from_millis(100)) .easing(Easing::EaseInOut) .repeat(Repeat::Count(3)); tracing::info!(summary = animation.to_text(), "animation"); }
Use Animation::to_text(), Repeat::to_text(), and Easing::to_text() for stable timeline, repeat, and curve summaries. The summaries name curve classes such as ease-in-out, cubic-bezier, steps, or custom without logging custom callbacks or cubic-bezier control points.
Easing curves
kael::Easing is the single, canonical easing vocabulary for the workspace. It
covers the full standard curve set in named variants:
| Variant | Curve |
|---|---|
Easing::Linear | constant rate |
Easing::EaseIn / EaseOut / EaseInOut | quadratic |
Easing::EaseInCubic / EaseOutCubic / EaseInOutCubic | cubic |
Easing::EaseInQuart / EaseOutQuart / EaseInOutQuart | quartic |
Easing::EaseInQuint / EaseOutQuint / EaseInOutQuint | quintic |
Easing::EaseInExpo / EaseOutExpo / EaseInOutExpo | exponential |
Easing::EaseInCirc / EaseOutCirc / EaseInOutCirc | circular |
Easing::EaseInBack(overshoot) / EaseOutBack(overshoot) / EaseInOutBack(overshoot) | backing overshoot |
Easing::EaseInElastic / EaseOutElastic / Elastic | elastic |
Easing::Steps(n) | n discrete steps |
Easing::CubicBezier(x1, y1, x2, y2) | CSS-style cubic Bézier |
Easing::Custom(Rc<dyn Fn(f32) -> f32>) | your own |
#![allow(unused)] fn main() { Animation::new(Duration::from_millis(600)) .easing(Easing::EaseOutBack(1.70158)); }
For a smooth physical spring, kael_ui provides SpringValue/SpringPoint
(see Springs and gestures).
kael_ui compatibility shims
kael_ui::animations::easings exposes the same curves as free fn(f32) -> f32
functions (ease_out_cubic, ease_in_back, steps(n), …). These are
compatibility shims that delegate to the Easing variants above; prefer the
Easing variants directly in new code. The spring, smooth_spring, and
cubic_bezier helpers have no Easing variant and remain defined in that
module.
Keyframes and sequences
For multi-stop transitions across common styled properties, build a Keyframes set and attach it with with_keyframes:
#![allow(unused)] fn main() { use kael::{Animation, AnimationExt as _, Keyframes}; div().with_keyframes( "pulse", Keyframes::new() .at(0.0, |k| k.opacity(0.4)) .at(0.5, |k| k.opacity(1.0)) .at(1.0, |k| k.opacity(0.4)), Animation::new(Duration::from_secs(1)).repeat_forever(), ) }
Chain whole animations with AnimationSequence::new().then(...).then_for(duration).with_overlap(...) and drive them with with_animation_sequence. For animations you may need to interrupt, with_cancellable_animation returns an (element, AnimationHandle); call handle.cancel() to jump to the final state.
AnimationSequence::to_text() reports animation count, finite scheduled duration, whether any step repeats forever, and empty state. Keyframes::to_text(), StyledKeyframe::to_text(), MediaKeyframe::to_text(), and KeyframeTrack::to_text() report frame/property/interpolation counts and finite-value checks without logging opacity values, transform distances, media automation values, or keyframe times.
Lottie animated assets
Enable the optional native renderer first:
[dependencies]
kael = { version = "0.4", features = ["lottie"] }
Use lottie(src) for native vector animation assets instead of routing every animated visual through a WebView. Sources can be embedded resources, paths, URLs, byte buffers, or pre-decoded LottieAnimation values, and the element supports autoplay, once/loop/ping-pong playback, object-fit placement, loading content, failure fallback content, and frame prefetching:
#![allow(unused)] fn main() { use kael::{ObjectFit, lottie}; let loader = lottie("animations/spinner.json") .autoplay() .loop_forever() .object_fit(ObjectFit::Contain) .prefetch_frames(8); tracing::info!(summary = loader.to_text(), "lottie element"); }
Inspect generated animated UI with LottieSource::to_text(), LottieAnimation::to_text(), LottiePlayer::to_text(), and lottie(...).to_text(). These summaries report source class, byte presence, decoded metadata, playback state, loop mode, object-fit mode, prefetch counts, and loading/fallback configuration without logging paths, URLs, embedded resource names, raw bytes, or replacement text.
Elastic scrolling
Scrollable regions — overflow_*_scroll() containers, uniform_list, and list (via ListState) — get native rubber-band overscroll automatically on macOS: content stretches past its bounds on a trackpad pull and springs back on release. Use a ScrollHandle to read or set the offset programmatically:
#![allow(unused)] fn main() { use kael::{ScrollHandle, point, px}; let scroll = ScrollHandle::new(); scroll.set_offset(point(px(-360.0), px(0.0))); let current = scroll.offset(); // Point<Pixels> let max = scroll.max_offset(); // Size<Pixels> }
See the Astryx showcase for runnable motion and scrolling compositions.
Canvas & Graphics
Beyond the element tree, Kael gives you direct GPU drawing: an immediate-mode canvas, a vector path builder, gradients, backdrop blur, SVG, and optional Lottie playback. Everything renders through the same per-platform pipeline (Metal / DirectX 11 / Vulkan / browser WebGL2) with device-pixel snapping for crisp output at any DPI.
Visual escape-hatch ladder
When designing a graphics-heavy workflow or giving an AI agent a rendering task, choose the lowest rung that solves the problem:
| Need | Use today | Notes |
|---|---|---|
| Product UI, dashboards, tool chrome | styled div() / kael_ui | Best memory and startup profile |
| Charts, timelines, waveform views, custom controls | canvas(...), paint_quad, paint_path, PathBuilder | Native immediate-mode drawing |
| Game worlds, whiteboards, and large retained 2D surfaces | PortableScene2d / portable_scene(...) | Same bounded retained commands on native and browser renderers |
| Icons, diagrams, generated vector assets | svg() / PathBuilder | Keep assets inspectable and themeable |
| Motion graphics and loaders | lottie(...) with feature lottie | Decodes off the UI path |
| Frosted or filtered subtrees | backdrop_blur(...) / effect_layer(...) | Effect layers are partial CSS-filter coverage, not arbitrary shaders |
| Run a Kael canvas in a browser | kael / kael_ui feature browser | Same retained Scene through the WebGL2 renderer |
| External or hosted browser content | webview(id, url) | Native composition island on desktop; sandboxed iframe island in the wasm backend, with documented cross-origin limits |
| Golden-image or benchmark evidence | HeadlessRenderer / golden | Off-screen rendering is for tests and measurements |
| Public custom render target or custom shader | roadmap | The backend renderer is not yet a public arbitrary-shader API |
The public graphics_capability_report() API exposes this same truth for
readiness checks and agent planning. It reports full cross-backend coverage for
styled elements, canvas, the portable retained 2D surface, paths, gradients,
SVG, and Lottie; partial coverage for clip
shapes, effect layers, and headless rendering; WebView coverage for browser
graphics fallback; and roadmap status for public render targets/custom shaders.
Display density and text
Kael lays out in logical pixels and updates the backing scale whenever a native window or browser canvas moves between displays. On macOS, glyph masks use display-independent grayscale antialiasing and baselines are snapped to device pixels. Windows also uses grayscale DirectWrite coverage instead of caching panel-specific ClearType RGB stripes. This avoids stale-resolution text and RGB/BGR subpixel color fringing on scaled, rotated, or differently ordered external panels, while reducing glyph-atlas storage relative to four-channel subpixel masks.
Embed application fonts when typography is part of the product identity.
kael_ui::init already registers its bundled Inter and JetBrains Mono faces.
Font family, weight, layout scale, and backing resolution remain stable across
screens; small rasterization differences between operating-system and browser
text engines are still expected.
Canvas
For most custom graphics, use the immediate-mode canvas(size, draw) form. It
records native draw commands for the current pass and lets generated code
inspect composition before the commands flush into the window:
#![allow(unused)] fn main() { use kael::{canvas, point, px, size, stroke, Bounds}; canvas(size(px(320.0), px(180.0)), |draw, _window, _app| { draw.reserve_commands(6); draw.fill_rect( Bounds::new(point(px(0.0), px(0.0)), draw.size()), kael::rgb(0x1e1e1e), ); draw.fill_rects([ ( Bounds::new(point(px(24.0), px(112.0)), size(px(48.0), px(40.0))), kael::rgb(0x3b82f6).into(), ), ( Bounds::new(point(px(80.0), px(88.0)), size(px(48.0), px(64.0))), kael::rgb(0x60a5fa).into(), ), ]); draw.fill_circles([ (point(px(232.0), px(64.0)), px(12.0), kael::rgb(0xf59e0b).into()), (point(px(268.0), px(64.0)), px(12.0), kael::rgb(0xfbbf24).into()), ]); draw.stroke_rect( Bounds::new(point(px(24.0), px(24.0)), size(px(120.0), px(64.0))), stroke(px(2.0), kael::rgb(0xffffff)), ); tracing::info!(summary = draw.to_text(), "canvas draw"); }) }
For stable real-time workloads, call reserve_commands with the expected mixed
command count before drawing, and use fill_rects or fill_circles for batches.
The batch helpers reserve from the iterator's size hint. Circles are emitted as
rounded quads, reusing the renderer's quad fast path instead of tessellating a
vector path; this is the preferred route for particle systems, graph nodes, and
game sprites that are geometrically circular.
DrawContext::to_text() reports queued command count, path count, quad count,
filled/stroked quad counts, text count, image count, saved-state depth, and
canvas size without logging text, image data, colors, or drawing coordinates.
Use command_count(), path_count(), quad_count(), filled_quad_count(),
stroked_quad_count(), text_count(), image_count(), state_stack_depth(),
and is_empty() when agents or tests need to verify generated chart, timeline,
waveform, canvas editor, or game HUD drawing.
For a scene that persists across frames, use PortableScene2d. It accepts
bounded batches of solid or rounded quads, decoded-image sprites, pre-tessellated
filled paths, and triangles, with affine transforms, rectangular clips,
source-over opacity, typed limit failures, and transactional rollback. The
default public ceilings are 100,000 commands/objects, 1,000,000 path vertices,
256 decoded image frames, 64 MiB of decoded image data, and 128 MiB of estimated
retained payload. Static path transforms are baked when recorded rather than
recomputed on every frame.
#![allow(unused)] fn main() { use std::sync::Arc; use kael::{Bounds, PortableScene2d, PortableSolidQuad, point, portable_scene, px, rgb, size}; let mut scene = PortableScene2d::new(); scene.try_reserve_commands(100_000)?; let quads = (0..100_000).map(|index| { let x = (index % 500) as f32 * 3.0; let y = (index / 500) as f32 * 3.0; PortableSolidQuad::new( Bounds::new(point(px(x), px(y)), size(px(2.0), px(2.0))), rgb(0x60a5fa), ) }).collect::<Vec<_>>(); scene.push_solid_quads(&quads)?; let surface = portable_scene(size(px(1_500.0), px(600.0)), Arc::new(scene)); Ok::<_, kael::PortableSceneError>(surface) }
This is the portable game/creative-app escape hatch, not raw GPU access.
Custom blend modes, user shaders, compute, depth-tested 3D, and public renderer
handles return or report Unsupported and remain explicit roadmap work.
canvas also supports the lower-level two-closure form — a prepaint pass
(compute layout/state, returns a value) and a paint pass (draw into the bounds).
Inside paint you call window.paint_quad and window.paint_path:
#![allow(unused)] fn main() { use kael::{canvas, fill, quad, px, rgb, Bounds, Pixels, Window, App}; canvas( move |_bounds: Bounds<Pixels>, _window: &mut Window, _app: &mut App| { // prepaint: return any state the paint pass needs }, move |bounds: Bounds<Pixels>, _state, window: &mut Window, _app: &mut App| { window.paint_quad(fill(bounds, rgb(0x1e1e1e))); // window.paint_path(path, color); }, ) .size_full() }
High-fidelity pointer input and retained scenes
Use on_pointer_event for one drawing path across mouse, touch, and pen. Browser
events include stable pointer id/type, primary state, changed and held buttons,
pressure, tangential pressure, tilt, twist, contact geometry, cancellation, and
up to 256 coalesced samples. Pointer sequences remain routed to the element that
received the down event, including independent simultaneous touches. Give a
surface a stable .id(...) when it rerenders during a stroke or drag so its
capture set persists across frames:
#![allow(unused)] fn main() { use kael::{div, InteractiveElement as _, PointerPhase}; div().id("drawing-surface").on_pointer_event(|event, _window, _app| { if matches!(event.phase, PointerPhase::Down | PointerPhase::Move) { for sample in event.stroke_samples() { tracing::trace!( pointer = event.pointer_id.get(), pressure = sample.pressure, tilt_x = sample.tilt_x, tilt_y = sample.tilt_y, "stroke sample" ); } } }) }
Existing mouse callbacks remain source compatible. Legacy desktop mouse streams
are promoted to PointerInputEvent with a stable mouse id. Windows WM_POINTER
provides simultaneous touch plus pen pressure, tilt, rotation, contact geometry,
cancellation, and bounded chronological history. AppKit provides tablet
identity/proximity, pressure, tangential pressure, tilt, rotation, buttons, and
timestamps (but no macOS desktop touchscreen or contact ellipse). Wayland
wl_touch and X11 XI2.2 provide simultaneous contacts and cancellation;
Wayland also reports oriented contact geometry, while tablet pressure/tilt on
Linux remains compositor/device-protocol dependent. CapabilityReport exposes
these per-platform boundaries. Browser touch and pen expose the full Pointer
Events shape.
For large whiteboards and game scenes, SpatialIndex uses a bounded spatial
hash instead of scanning every entry. SceneGraph::hit_test and
SceneGraph::visible_in_rect reuse a cached index while preserving topmost
order. move_node patches only the moved entry's old and new spatial cells;
structural changes, visibility edits through get_mut, and hierarchy changes
retain the safe lazy full-rebuild fallback. Use
spatial_incremental_update_count, spatial_full_rebuild_count, and
last_spatial_candidate_count to verify dynamic-scene behavior without
inspecting content. Pair culling with TileDamageTracker: invalidate old and
new object bounds, repaint the sorted tiles returned by take, and retain every
other tile. Pathological regions promote explicitly to TileDamage::Full
instead of allocating without bound.
Window::export_frame_png returns real encoded PNG bytes at device-pixel
resolution from browser WebGL2, macOS Metal, Windows Direct3D 11, and the Blade
renderer used by Linux and optional macOS Blade builds. GPU readback validates
dimensions, row pitch, channel order, alpha representation, and a 256 MiB
allocation ceiling. It honors checked content protection and returns typed
WindowCaptureError variants rather than silently dropping WebView overlays or
live surfaces. Platform/compositor chrome and the system cursor are outside the
scene. Blade capture renders into a bounded app-owned texture before copying to
shared memory, so it does not depend on swapchain copy support; it returns a
typed backend error if the selected surface format is not one of the supported
8-bit RGBA/BGRA formats. Capability support remains partial because hosted/live
surfaces and operating-system chrome are intentionally outside the retained
scene, not because the release gate substitutes a headless renderer.
Vector paths
Build filled or stroked paths with PathBuilder, then hand the result to window.paint_path:
#![allow(unused)] fn main() { use kael::{PathBuilder, point, px}; let mut builder = PathBuilder::fill(); // or PathBuilder::stroke(px(2.0)) builder.move_to(point(px(50.0), px(50.0))); builder.line_to(point(px(130.0), px(50.0))); builder.curve_to(point(px(130.0), px(130.0)), point(px(160.0), px(90.0))); // quadratic builder.close(); let path = builder.build()?; }
Segment methods: move_to, line_to, curve_to (quadratic Bézier), cubic_curve_to, arc_to, and close. Stroked builders also accept dash_array / dash_offset.
Gradients
Gradients are backgrounds you pass to .bg(...):
#![allow(unused)] fn main() { use kael::{linear_gradient, linear_color_stop, rgb}; div().bg(linear_gradient( 45.0, linear_color_stop(rgb(0xff0080), 0.0), linear_color_stop(rgb(0x7928ca), 1.0), )) }
Also available: multi_stop_linear_gradient(angle, &[stops]), radial_gradient(cx, cy, radius, &[stops]), and conic_gradient(cx, cy, angle_offset, &[stops]).
Backdrop blur & frosted glass
backdrop_blur blurs whatever is painted behind an element — combine it with a translucent background for a frosted-glass panel:
#![allow(unused)] fn main() { use kael::{px, rgba}; div() .backdrop_blur(px(20.0)) .bg(rgba(0xffffff20)) .rounded_xl() }
Use cached(child) when a subtree is expensive but only depends on tracked
state, deferred(child) when a subtree should keep layout in-tree but paint
after ancestors, and effect_layer(child) when a subtree needs native
CSS-style content blur or drop shadow. Use LayerStack with LayerOptions
when the app needs native in-window modal, fullscreen, or anchored overlay
composition instead of a WebView-hosted DOM overlay:
#![allow(unused)] fn main() { use kael::{cached, deferred, effect_layer, LayerOptions, px}; let preview = cached(render_preview()).id("preview-cache"); tracing::info!(summary = preview.to_text(), "cached subtree"); let overlay = deferred(effect_layer(render_panel()).content_blur(px(8.))).with_priority(120); tracing::info!(summary = overlay.to_text(), "deferred overlay"); let modal = LayerOptions::modal(); tracing::info!(summary = modal.to_text(), "native layer options"); }
Inspect Cached::to_text(), Deferred::to_text(), and
EffectLayer::to_text() in generated graphics, overlays, previews, and
inspectors. Inspect LayerAnchor::to_text(), LayerOptions::to_text(), and
LayerStack::to_text() before generated modal/popover/fullscreen layer flows.
These helpers report child presence, explicit cache-key presence, draw
priority/class, effect combination, blur class, shadow presence, placement,
backdrop/dismissal policy, and active layer counts without logging cache ids,
child contents, colors, coordinates, margins, blur radii, shadow offsets, shadow
colors, or geometry.
SVG
svg() renders a vector asset; text_color fills monochrome SVGs and with_transformation applies rotation/scale:
#![allow(unused)] fn main() { use kael::{svg, Transformation, px, rgb, size}; let icon = svg() .path("icons/logo.svg") .with_transformation(Transformation::scale(size(1.25, 1.25))) .size(px(24.0)) .text_color(rgb(0x2563eb)); tracing::info!(summary = icon.to_text(), "svg"); }
Use Svg::to_text(), Transformation::to_text(), has_path(),
path_len_bytes(), has_transformation(), and transformation_key() when
generated icon, diagram, or vector-asset UI needs diagnostics. Summaries report
path presence/byte length and coarse transform kind without logging SVG paths,
asset names, transform coordinates, scale values, or rotation values.
Images and Surfaces
img(source) renders URL, embedded, file-path, cached, decoded, and custom
loader image sources with native object-fit behavior:
#![allow(unused)] fn main() { use kael::{img, ObjectFit, StyledImage}; let poster = img("https://cdn.example.com/poster.png") .object_fit(ObjectFit::Cover) .with_fallback(|| fallback_art().into_any_element()); tracing::info!(summary = poster.to_text(), "image"); }
Common application formats—including PNG, JPEG, GIF, WebP, TIFF, BMP, ICO, TGA, HDR, PNM, farbfeld, DDS, and QOI—are enabled without pulling parallel image-processing dependencies into every Kael application. Enable the heavier formats only when the product needs them:
[dependencies]
kael = { version = "0.4", features = ["image-avif", "image-exr"] }
AVIF decoding uses the native libdav1d library and pkg-config discovery. Install both through the platform package manager, or also provide Git, Meson, and Ninja so the binding can build libdav1d from source.
The built-in resource loader rejects empty or larger-than-64-MiB encoded
sources, raster or SVG dimensions above 16,384 pixels per axis, more than 256
MiB of decoded frame data, and animations above 10,000 frames. HTTP failures do
not retain response bodies or expose resource locations in error messages. Use
a custom ImageSource loader that returns a validated RenderImage when a
controlled workload intentionally needs a different budget.
Use ImageSource::to_text(), ImageStyle::to_text(), and Img::to_text() for
asset-heavy generated UI. The helpers expose source kind, resource identifier
byte length, grayscale state, object-fit key, loading/fallback hook presence,
and explicit cache binding without logging URLs, file paths, embedded asset
names, raw bytes, decoded image IDs, pixel dimensions, or child contents.
For native image caching, scope cache providers around the subtree that owns the asset working set:
#![allow(unused)] fn main() { use kael::{image_cache, lru, retain_all}; let cache = lru("gallery-cache", 64); tracing::info!(summary = cache.to_text(), "image cache policy"); image_cache(cache).child(gallery) }
Use retain_all(id) for bounded asset sets and lru(id, max_images) for
feeds, galleries, maps, and other churning image sets. Inspect
RetainAllImageCacheProvider::to_text(), LruImageCacheProvider::to_text(),
ImageCacheElement::to_text(), RetainAllImageCache::to_text(),
LruImageCache::to_text(), and ImageCacheItem::to_text() when generated UI or
agents need cache policy, entry counts, loading/loaded/error counts, capacity,
capacity class, and scoped child count without logging resource identifiers,
element ids, image ids, image bytes, error details, or asset names.
surface(source) renders platform-native external image buffers, such as
CoreVideo pixel buffers on macOS. Use SurfaceSource::to_text() and
Surface::to_text() to report source class and object-fit key without logging
pixel contents or dimensions.
Lottie
Enable Kael's lottie feature to add the native decoder and renderer:
[dependencies]
kael = { version = "0.4", features = ["lottie"] }
lottie() plays Lottie/dotLottie animations, decoding frames on a background thread so the UI stays responsive:
#![allow(unused)] fn main() { use kael::{lottie, LoopMode}; lottie("animations/loader.json") .autoplay() .loop_forever() // or .loop_mode(LoopMode::Loop) / .ping_pong() }
Builders: .autoplay(), .loop_forever(), .loop_mode(LoopMode), .ping_pong(), .object_fit(ObjectFit), .prefetch_frames(n), .with_loading(|| element), .with_fallback(|| element).
See the Astryx showcase's media and visual-effects sections for complete, runnable compositions.
Game input
Kael exposes one window-level API for controller state and relative mouse input
on desktop and WebAssembly builds. Enable game-input; it is also included by
portable-services and browser-full.
kael = { version = "0.4", features = ["game-input"] }
Browser builds read navigator.getGamepads() directly. Native builds use gilrs
for mapped controllers on macOS, Windows, Linux, and FreeBSD. Both paths produce
the browser-standard four-axis and 17-button ordering when
GamepadMapping::Standard is reported. Stick Y is normalized to -1 up and 1
down on every target.
Poll on display frames
Controller APIs are state snapshots, not UI events. Polling from a millisecond
timer wastes wakeups, can sample the same device state repeatedly, and drifts
away from rendering. Window::on_gamepad_frame coalesces onto Kael's display
frame callback instead:
let polling = window.on_gamepad_frame(|sample, window, cx| {
match sample {
Ok(snapshot) => {
for pad in &snapshot.gamepads {
let steer = pad.axis(StandardGamepadAxis::LeftStickX);
let jump = pad.button(StandardGamepadButton::South).pressed;
update_game(steer, jump, window, cx);
}
GamepadFrameControl::Continue
}
Err(error) => {
show_input_error(error, cx);
GamepadFrameControl::Stop
}
}
});
Keep the returned GamepadFrameSubscription alive. Dropping or cancelling it
prevents the already-coalesced next-frame callback from polling. For an existing
game loop, call Window::gamepads() once inside its own on_next_frame
callback instead.
Every snapshot is bounded:
- at most 16 connected controllers;
- at most 32 axes and 64 buttons per controller;
- at most 1,024 native controller events drained per display frame;
- controller identifiers truncated at a valid UTF-8 boundary to 256 bytes;
- non-finite analog values become zero and all values are clamped.
event_budget_exhausted reports when a native event storm reached the per-frame
drain limit. The next display frame continues the drain without blocking the
current render.
Pointer lock and relative motion
Check window.game_input_capabilities() before presenting a lock affordance.
In a browser, call request_pointer_lock() synchronously from a trusted click or
key handler; do not await unrelated work first.
div()
.on_click(|_, window, _| {
if let Err(error) = window.request_pointer_lock() {
eprintln!("pointer lock unavailable: {error}");
}
})
.on_pointer_event(|event, _, _| {
rotate_camera(event.movement.x.0, event.movement.y.0);
})
The lifecycle is explicit:
Unlockedbefore a request;Requestingwhile a browser or Wayland compositor decides;Lockedonly afterpointerlockchangeor the Waylandlockedevent confirms ownership; macOS, Windows, and X11 acquire synchronously;pointer_lock_error()contains the latest synchronous or asynchronous rejection. A synchronous browser exception restoresUnlockedimmediately and is also returned as a typedGameInputError;NotAllowedErrormaps toUserGestureRequiredso applications can request a fresh trusted activation.
exit_pointer_lock() only releases resources owned by that Kael window.
Destroying a window or losing focus releases native confinement, restores the
cursor, and returns the state to Unlocked; browser window destruction removes
the document listeners. PointerInputEvent::movement carries the unbounded
relative delta on every supported backend, while absolute position remains a
stable in-window coordinate.
Native implementations use CoreGraphics cursor disassociation on macOS, Raw
Input plus client-area confinement on Windows, and XI2 raw motion plus an X11
pointer grab on X11. Wayland support is intentionally runtime-conditional: the
active seat must expose a pointer and the compositor must advertise both
pointer-constraints-v1 and relative-pointer-v1. A Wayland compositor may
defer or revoke the lock; compositors lacking either protocol report
GameInputAvailability::Unsupported. Applications keep one code path by
branching on GameInputCapabilities::pointer_lock.
The maintained GTK4 WebView host binds those protocols on GTK's exact GDK-owned Wayland connection and uses the active GDK surface and pointer. On X11 it uses XI2 raw motion plus a pointer grab tied to the GTK surface XID. Relative motion, focus-loss cleanup, cursor restoration, the retained GSK scene, and WebKitGTK 6 children therefore share one window lifecycle on either compositor. Wayland motion is queued until after protocol dispatch releases backend state, preventing input callbacks from re-entering the native host borrow.
Release proof
Run the deterministic retained-window smoke:
bash scripts/verify-browser-game-input-smoke.sh
On a macOS desktop host, exercise the real native cursor and focus-loss cleanup path with:
cargo run -p kael --example native_pointer_lock_smoke --no-default-features
The Linux compositor-backed gates verify Wayland protocol discovery and the GTK4 X11/XWayland selection, raw handles, pointer-lock backend, and an event-driven idle interval with no permanent frame clock:
bash scripts/ci/run-linux-webview-wayland-gtk4.sh
bash scripts/ci/run-linux-webview-xwayland.sh
It packages the real Wasm example and verifies capability discovery, standard axis/button mapping, input bounds, pointer lock/change/release/error lifecycle, typed synchronous DOM exceptions, relative movement, and display-frame polling in headless Chromium. The mock device and pointer-lock provider make CI deterministic. Products should also acceptance-test real controller visibility and pointer-lock policy in the exact browser embedding modes they ship.
Gestures
Kael provides built-in gesture recognizers for touch and pointer interactions. Before relying on tablet-style input, check the platform capability report:
#![allow(unused)] fn main() { let input = CapabilityCheck::new() .require(PlatformFeature::PrecisionPointerInput) .prefer_available(PlatformFeature::GestureInput) .prefer_available(PlatformFeature::TouchInput) .prefer_available(PlatformFeature::PenInput) .evaluate(&CapabilityReport::current()); }
Pointer, scroll, and magnify gestures are the portable baseline today. Direct touch contact streams and pen pressure/tilt metadata are reported separately so apps can provide mouse/keyboard fallbacks when a backend does not expose them.
Pan gesture
Detect drag/pan movements with velocity tracking:
#![allow(unused)] fn main() { use kael::gesture::PanGesture; let pan = PanGesture::new() .min_distance(px(5.0)) .on_start(|position, _window, _cx| { /* drag started */ }) .on_update(|delta, velocity, _window, _cx| { /* dragging */ }) .on_end(|velocity, _window, _cx| { /* drag ended */ }); }
Swipe gesture
Detect directional swipes:
#![allow(unused)] fn main() { use kael::gesture::SwipeGesture; let swipe = SwipeGesture::new() .on_swipe(|direction, _window, _cx| { match direction { SwipeDirection::Left => { /* swipe left */ }, SwipeDirection::Right => { /* swipe right */ }, SwipeDirection::Up => { /* swipe up */ }, SwipeDirection::Down => { /* swipe down */ }, } }); }
Pinch gesture
Zoom/scale with pinch-to-zoom or Ctrl+scroll:
#![allow(unused)] fn main() { use kael::gesture::PinchGesture; let pinch = PinchGesture::new() .on_pinch(|scale, center, _window, _cx| { // scale: f64 (1.0 = no change, >1 = zoom in, <1 = zoom out) // center: Point<Pixels> (pinch center point) }); }
Drag and drop
File drop (from OS)
#![allow(unused)] fn main() { let filter = FileDropFilter::video().max_files(1); div() .id("drop-zone") .can_drop_external(filter.clone()) .on_external_drop(move |data, _window, _cx| { if let Some(paths) = data.accepted_paths_by(&filter) { for path in paths { /* import or open path */ } } if let Some(text) = data.text_value() { /* handle dropped text */ } for url in data.urls() { /* handle dropped URL */ } }) }
Operating-system file drops are translated into Kael's typed drag/drop system as
ExternalPaths for file-only payloads and ExternalDropData when text or URLs
are present. Use can_drop_external(filter) and on_external_drop(...) to
handle both shapes through one browser-like payload. Presets are available for
common file cases: FileDropFilter::single_file(), .images(), .audio(),
.video(), and .media().
For browser-style integrations that can carry text or URLs alongside files,
normalize to ExternalDropData:
#![allow(unused)] fn main() { let data = ExternalDropData::from_paths([path]) .with_text("Dropped label") .with_url("https://example.com/item"); if let Some(paths) = data.accepted_paths_by(&FileDropFilter::images()) { /* import image paths */ } let from_active_drag = ExternalDropData::from_drag_value(value); let from_uri_list = ExternalDropData::from_uri_list( "file:///tmp/poster.png\nhttps://example.com/item\n", ); let from_text = ExternalDropData::from_plain_text("https://example.com/item"); }
Native OS file-only drops still emit ExternalPaths for compatibility. Drops
that carry plain text or URLs emit ExternalDropData on macOS, Windows, and
Linux text/uri-list paths. Use ExternalDropData for custom platform
integrations, WebView bridge messages, and tests that need DataTransfer-like
files / text / urls payloads.
Sortable reordering
See SortableList for drag-to-reorder within lists.
Scroll events
#![allow(unused)] fn main() { div() .id("canvas") .on_scroll_wheel(|event, _window, _cx| { // event.delta: ScrollDelta (Pixels or Lines) // event.modifiers: Modifiers (detect Ctrl for zoom) }) }
Benchmarking Evidence
Do not claim Kael is lighter than a baseline from architecture alone. Measure the same workload on the same machine, then compare the results.
Kael exposes product-shaped benchmark scenarios and metrics through
kael::benchmark:
#![allow(unused)] fn main() { use kael::{ BenchmarkHarness, BenchmarkMeasurement, BenchmarkMetric, BenchmarkSampleApp, BenchmarkSamplePair, BenchmarkSampleRuntime, BenchmarkScenario, BaselineComparisonReport, MetricUnit, }; use std::time::Duration; let baseline_results = load_baseline_results()?; let mut harness = BenchmarkHarness::new(); harness.run(BenchmarkScenario::Dashboard, "kael", |measurements| { run_dashboard_workload(); measurements.push(BenchmarkMeasurement { metric: BenchmarkMetric::IdleMemory, value: measure_idle_memory_mb(), unit: MetricUnit::Megabytes, elapsed: Duration::from_secs(5), }); }); let interactions = BenchmarkScenario::Dashboard .workload_spec() .required_interactions; let sample_pair = BenchmarkSamplePair::new( BenchmarkSampleApp::builder( BenchmarkSampleRuntime::Baseline, BenchmarkScenario::Dashboard, "Baseline dashboard sample", ) .source("samples/baseline/dashboard") .run_command("npm run bench:dashboard") .interactions(interactions.clone()) .build_checked() .unwrap(), BenchmarkSampleApp::builder( BenchmarkSampleRuntime::Kael, BenchmarkScenario::Dashboard, "Kael dashboard sample", ) .source("samples/kael/dashboard") .run_command("cargo run --release -p dashboard_sample -- --bench") .interactions(interactions) .build_checked() .unwrap(), ); let report = BaselineComparisonReport::generate_with_sample_pairs( &baseline_results, harness.results(), &[sample_pair], Some("trace.json".into()), ); println!("{}", report.summary()); }
Use the same scenario name for both result sets so metrics line up. Each scenario exposes a workload contract:
#![allow(unused)] fn main() { let spec = BenchmarkScenario::Dashboard.workload_spec(); println!("required metrics: {:?}", spec.required_metrics); println!("required interactions: {:?}", spec.required_interactions); let issues = spec.validate_result(&kael_result); assert!(issues.is_empty(), "missing benchmark evidence: {issues:?}"); }
Available scenarios include chat, IDE/workspace, document, canvas/design tool, video editor, dashboard, messaging, and media-control workloads.
Important metrics for resource and performance claims:
| Claim | Metrics |
|---|---|
| Starts faster | ColdStart, WarmStart, FirstInteractiveFrame |
| Uses less memory | IdleMemory, MemoryGrowth |
| Stays idle | LongSessionCpu, IdlePower, WakeupsPerSecond |
| Feels responsive | InputLatency, ScrollLatency, frame-time percentiles |
| Handles real UI load | Scenario coverage plus AssetCacheHitRate |
BaselineComparisonReport classifies each shared metric as a Kael win,
baseline win, or tie while preserving the raw numbers. Lower-is-better metrics
such as memory and latency are handled differently from higher-is-better metrics
such as cache hit rate. The report also records evidence_issues when either
side is missing a counterpart result for the same scenario, when either side is
missing required metrics, when either side supplies duplicate results for the
same scenario, when baseline and Kael results were captured under different
hardware or OS conditions, when a compared scenario lacks matching
baseline/Kael sample descriptors, or when a sample omits required interactions;
do not make parity claims until those are resolved.
For CI, keep using CiReport and RegressionThresholds to compare a Kael
candidate against a previous Kael baseline. Use BaselineComparisonReport for
product/positioning evidence against a baseline sample app.
Browser artifact size
Release packaging runs pinned Binaryen 132 at -O3, then
scripts/verify-browser-artifact-budget.sh rejects the maintained retained-scene
proof above 12 MiB raw Wasm, 5 MiB gzip Wasm, or 100 KiB JavaScript glue. These
are regression ceilings, not a promise that every application has the same
size: enabled features, fonts, codecs, and product assets remain app-owned.
The workspace release profile also uses fat LTO and one codegen unit; measure
clean release-build time separately from runtime and transfer performance.
Dynamic SceneGraph probe
Large games and whiteboards have a different hot path from retained interface layout: objects move repeatedly while the scene population stays stable. Run the maintained release probe with:
cargo run --release -p kael --example scene_graph_move_query_probe
The probe builds and indexes exactly 100,000 nodes, then performs 10,000
cross-cell move_node plus point-query operations. It requires:
- zero full spatial-index rebuilds during bounds-only movement;
- exactly 10,000 incremental spatial updates;
- no more than two candidates in any representative point query; and
- the complete move/query phase to finish within two seconds.
The two-second ceiling is a deliberately generous regression budget for shared CI hosts, not a frame-time or cross-framework performance claim. Preserve the raw elapsed time and environment when publishing results. Correctness tests also cover z-order retention, moves between normal and oversized spatial lanes, and the full-rebuild fallback when cached entry metadata is unavailable.
Platform APIs
Kael exposes native desktop services through the core runtime and focused support crates. Availability varies by operating system, installed desktop services, permissions, packaging, and compile-time features.
Capability checks
Use CapabilityReport::current() before making a platform integration a hard
requirement:
#![allow(unused)] fn main() { use kael::{CapabilityCheck, CapabilityReport, PlatformFeature}; let report = CapabilityReport::current(); let readiness = CapabilityCheck::new() .require(PlatformFeature::GpuRendering) .prefer_available(PlatformFeature::GlobalHotkeys) .prefer_available(PlatformFeature::StatusBarItem) .evaluate(&report); if let Some(reason) = readiness.required_failure_summary() { return Err(reason.into()); } }
Use require for features that must be fully supported and
require_available only when the application has implemented the setup and
fallback paths for Partial or RequiresInit.
Compile-time features
The default kael build enables the native window backends but keeps WebView
support off. Applications that embed web content can opt into Wry and the
platform WebView dependencies:
[dependencies]
kael = { version = "0.4", features = ["webview"] }
The core exports the HttpClient interface without selecting a transport.
Enable http-client for Kael's Reqwest adapter, supply an app-owned transport,
or use kael_ui's default http feature, which enables the adapter for remote
assets:
[dependencies]
kael = { version = "0.4", features = ["http-client"] }
Use wayland instead of, or alongside, x11 as required. Other product
services are opt-in through core features or their focused crates. Enable
auto-update for Kael's signed-feed, checked-download, and platform-installer
pipeline, and lottie for native Lottie/dotLottie playback. Leaving these
features off keeps their implementation dependencies out of the application.
Service overview
| Area | API or crate | Notes |
|---|---|---|
| Windows, displays, input, clipboard, menus | kael | Native per-OS backends |
| Accessibility | kael + AccessKit bridges | Semantics still require correct app markup |
| Global hotkeys and tray | kael | Linux support depends on X11 or desktop portals/SNI |
| Basic notifications | kael | Portable async delivery is in kael_notifications; browser delivery is permission gated |
| Storage and cache | kael_storage, kael_cache | App-owned data and bounded caches |
| Credentials | kael_secrets | Keychain, Credential Manager, or Secret Service |
| Documents, Office, and PDF | kael_document, kael_office, kael_pdf | Lifecycle, autosave, portable OOXML packages, and PDF byte operations |
| Sharing | kael_share or core share feature | Native share services or the user-activated browser Web Share picker |
| Networking | core http-client, kael_http_client, kael_net | Bounded HTTP plus one native/browser WebSocket client; SSE remains descriptor-only |
| Diagnostics | kael_diagnostics | Bounded logs, metrics, reports, crash helper |
| Updates | kael_release and core auto-update feature | Signed feeds and product-controlled installation |
| Media and audio | kael-media, kael_audio, engine crates | Native streams plus asynchronous bounded browser Web Audio; codecs and engines remain opt-in |
| Web compatibility | core webview feature | WebView2 on Windows, WebKitGTK on Linux |
The portable WebSocket client is documented in
Realtime Networking. Opening a socket requires an
explicit checked host policy; the core AppRealtimeConnection adapter applies
its NetworkPolicy again at the live side-effect boundary.
Notifications
Basic OS notification delivery and action support live in kael. The
kael_notifications adds validated categories, immediate delivery,
native-only in-process interval scheduling, cancellation, event subscriptions,
and typed permission/backend errors. Use
NotificationCenter::schedule_local_async as the one-codebase call. Browser
delivery uses the Notification API after an asynchronous permission decision;
it does not fake durable scheduling, service-worker action buttons, push, named
sounds, or badge counts.
Packaged Windows applications must register an AppUserModelID in their installer
and call set_windows_app_user_model_id before delivering toasts. Push tokens,
calendar triggers, location triggers, and macOS text-input actions are not
implemented by Kael 0.4; product-specific push credentials and delegates remain
the application's responsibility.
Sharing
kael_share validates text, URL, image, and file payloads before platform
handoff. Query ShareSheet::platform_support() before presenting destinations.
macOS supports the broadest outbound sheet. Windows and Linux currently provide
narrower mail/clipboard paths; Windows file/image DataTransferManager support
and share-receiver registration are not implemented. In browsers,
ShareSheet::show_portable uses navigator.share during a transient user
activation and supports bounded ShareFile bytes and images when
navigator.canShare accepts them. Browser policy, cancellation, unavailable
APIs, unsupported payloads, and lost activation are separate typed errors.
Printing
Window::print, Window::show_print_dialog, and Window::print_checked now
execute native PrintJob content on every desktop backend. Fills, rounded fills,
strokes, lines, single-line and wrapped text, and retained images (including
fit, clipping, selected frame, and opacity) share a bounded portable renderer.
Landscape jobs expose rotated page/content dimensions to the render callback so
the same coordinates are not clipped when the output backend rotates the paper.
Windows shows the native common print dialog or silently targets the configured
default printer, then spools checked page rasters through the selected printer
DC with cleanup on every failure. Linux dialog printing uses the XDG desktop
Print portal and passes an exact PDF file descriptor; silent Linux printing uses
an absolute system lp/lpr client path. Accordingly, Linux printing remains a
partial runtime capability: dialog mode needs a portal backend, and silent mode
needs CUPS plus a default printer. Browser printing always shows browser-owned
UI because the web platform does not permit silent printer selection.
Capture and media
Use App::is_screen_capture_supported() and query sources at runtime. Permission
approval and source availability can change after launch. A checked
AppWindowCaptureRequest is only a descriptor; Kael 0.4 does not expose a native
app-window screenshot backend through that type.
The portable CaptureManager::with_default_backends() API supports screen and
window capture on desktop and browser builds. In a browser, enumeration returns
a privacy-safe synthetic picker entry and start must run directly inside a
trusted click or key activation. The browser then chooses the exact display,
window, or tab asynchronously. A session first reports Starting, then
Running or Error; inspect CaptureSession::last_error() for picker denial,
lost activation, or setup failure. Browser frames are bounded RGBA8 buffers and
requested video audio is rejected explicitly—compose microphone input through
kael_audio when recording or calling.
Media engines are separate crates so applications that only need UI primitives
do not pull codecs and editing infrastructure into their dependency graph.
Browser live mixing, device enumeration, and permission-gated microphone
capture are documented in Browser Audio. The capability
report marks microphone capture RequiresInit only when the audio feature is
compiled, and marks Kael's lightweight stereo spatial scene Partial; neither
status claims synchronous permission, HRTF, room processing, or non-default
browser speaker routing.
WebView boundaries
Treat every WebView as an external-content boundary:
- restrict navigation and new-window behavior;
- allow only required permissions;
- validate messages crossing the bridge;
- choose persistent or ephemeral storage deliberately;
- keep native app state outside browser storage where possible.
WebViews are native composition islands, not Kael scene primitives. Rectangular content-mask bounds, translation, visibility, and inherited opacity are applied to the native host. Scale, rotation, or skew hides the host because resizing a native WebView would reflow the page instead of reproducing the GPU transform. Native surfaces remain above Kael's GPU scene, so applications should place modals and popovers outside a visible WebView region or hide the island while presenting overlapping chrome.
Omitting storage_key creates an incognito/non-persistent profile. Supplying a
key creates stable isolated profiles on Windows and Linux and on macOS 14 or
newer; older macOS versions retain persistence but share the default data
store. Windows and Linux additionally namespace profiles by executable path so
unrelated Kael applications cannot share a profile key; moving the executable
starts a fresh profile. Use native_permission_policy for the browser engine's actual permission
boundary. on_permission_request remains a page-level JavaScript preflight for
app-owned browser APIs and is not a native security boundary.
Native coverage follows the engine. The detailed
native_permission_request_policy receives WebView2's requesting origin and
user-gesture state on Windows (frame identity remains unknown), the current
top-level origin as an explicitly labelled approximation on WebKitGTK, and the
requesting origin plus main/subframe identity for WKWebView camera and
microphone requests. Permission decisions are not persisted by Kael's WebView2
hook, so the application policy remains authoritative on every request. The
legacy native_permission_policy intentionally discards this context; use it
only for policies that are safe by permission kind alone.
Linux uses one maintained child-host path across X11, XWayland, and native
Wayland. The portable webview feature selects GTK4 + WebKitGTK 6: GTK owns the
top-level surface, Kael renders its retained scene through GSK, and WebKit views
are siblings in the same widget hierarchy. It is not a detached overlay.
Bounds, clipping, visibility, focus, scale changes, IME, touch, clipboard, file
drop, window lifetime, native pointer lock, and raw Wayland/X11 handles therefore
belong to the same production window. webview-gtk4 and
webview-wayland-gtk4 are compatibility aliases for Linux-only manifests.
Automatic selection follows GDK_BACKEND ordering when valid display sockets
exist, then prefers Wayland when both displays are available. Set
KAEL_LINUX_BACKEND=x11 or wayland to override it. The old GTK3/WebKitGTK 4.1
host is not shipped; the deprecated webview-legacy-gtk3 spelling redirects to
the same maintained GTK4/WebKitGTK 6 host.
When the webview feature is disabled, the capability report returns
SupportLevel::Disabled rather than claiming the OS backend is usable.
With a native host feature enabled, the report returns SupportLevel::Partial
because WebViews are rectangular native islands and cannot participate in
arbitrary retained-scene transforms. A raw Wayland or X11 build without the
GTK4 WebView host, and every headless backend, reports WebViews as unsupported.
This lets an application reject a hard requirement before it opens a window.
The implementation contract and acceptance gates are documented in
Linux WebView hosting.
Operation-level WebView capabilities
CapabilityReport::current() answers whether a WebView host is available.
WebViewCapabilityReport::current() is the release-grade operation matrix for
the selected backend. It reports each of composition, focus, navigation and
policy, load state, history, reload/stop, zoom, find, print, downloads, IPC,
custom protocols, developer tools, cookies, request headers, profile isolation,
user agent, JavaScript, native permission policy, and drag/drop as Full,
Partial, Unsupported, or Disabled, with an exact limitation note.
Important current boundaries are explicit:
- macOS, Windows, and Linux X11/XWayland preserve a live document when declarative focus changes; focus no longer recreates the host or loses history/profile state.
- Windows and Linux provide isolated named profiles. WKWebView named profile isolation requires macOS 14 or newer; older macOS versions share the persistent default data store.
- macOS WKWebView, Windows WebView2, and both Linux WebKitGTK hosts report
CustomProtocolsasFull. Registered app-owned routes preserve status, MIME type, response headers, and bounded body bytes for main documents and subresources. Browser iframes still report this operation as unsupported because web pages cannot register arbitrary URL schemes; use HTTP(S),blob:,data:, or inline HTML for browser-hosted islands. - Browser iframe host navigation policy covers initial/declarative/controller loads, but browser security prevents the parent from synchronously vetoing an arbitrary cross-origin page's self-navigation, popup, or download.
Use WebViewCapabilityReport::for_backend(...) in release tooling to render a
deterministic cross-platform matrix without pretending the current machine is
evidence for every engine.
Unsupported is an actionable result
Kael reports native push registration, geolocation, spellchecking, hardware device discovery/I/O, file-promise drag sources, and app-window snapshot backends as unsupported in 0.4. Applications can hide the workflow, provide an owned integration, or keep a scoped WebView fallback. Do not treat the presence of a request builder as backend support.
Native Capability Bridge
Kael's primary application surface combines Rust, native windows, and a retained GPU-rendered UI tree. A WebView is an explicit compatibility island for a dependency that is genuinely web-shaped, such as an OAuth page, payment flow, map, hosted document, or vendor widget.
Choose the smallest layer
| Need | Start with |
|---|---|
| Runtime, rendering, input, text, layout, and windows | kael |
| A custom design system | kael primitives and Styled |
| Ready-made, brandable controls | kael_ui |
| Product services | the focused kael_* support crates |
| A browser-owned surface | Kael's optional webview feature |
kael never depends on kael_ui. Applications can build their entire visual
language on the primitive crate or use only the component families they want.
Treat capability reports as runtime evidence
#![allow(unused)] fn main() { use kael::{CapabilityReport, PlatformFeature}; let report = CapabilityReport::current(); if report.is_supported(PlatformFeature::GlobalHotkeys) { // Enable the primary native workflow. } else if report.is_available(PlatformFeature::GlobalHotkeys) { // Explain setup or platform limitations and retain a fallback. } else { // Hide the workflow or use a deliberate alternative. } }
Full means Kael exposes a usable backend without a documented fallback.
Partial and RequiresInit require the caller to handle the note and setup.
Unsupported means a descriptor or OS API may exist, but Kael does not provide
the native operation. Disabled means the required Kael feature was not built.
Do not infer support from a builder type. Checked descriptors validate intent; they do not substitute for an OS backend.
Native-first decision rule
- Use native primitives for app chrome, editors, navigation, data surfaces, commands, menus, files, background work, and long-lived product state.
- Use a focused support crate for storage, secrets, documents, diagnostics, networking, notifications, sharing, media, and release services.
- Query the capability report for platform-dependent workflows.
- Use a WebView only for a scoped web dependency, with explicit navigation, permission, storage, and bridge policy.
Important 0.4 boundaries
The following are not native batteries in Kael 0.4 and are reported as unsupported: push-registration backends, native geolocation, USB/HID/serial/ Bluetooth discovery and I/O, outbound file-promise drag sources, app-window snapshot backends, and native spellchecking. Applications may supply their own integration without pretending Kael completed it.
Outbound sharing is feature-gated and platform-dependent. macOS has the
broadest destination support; Windows and Linux currently provide narrower
mail/clipboard handoffs. Registering an app as a share receiver is not yet
implemented. Browser builds use the transient-activation-gated Web Share API
for bounded text, URLs, images, and in-memory files; the browser owns the
destination list, and PWA share-target registration remains product work.
Browser notifications similarly require an asynchronous permission decision
and provide immediate page-created delivery, not a fake durable scheduler or
service-worker push/action backend. WebView support is reported as disabled when the webview feature
is absent and partial when enabled because it is a native composition island
with platform/runtime constraints rather than a GPU scene primitive.
Optional agent planning metadata
The agent-tools feature exposes Kael's structured desktop-capability planning
metadata. It is off by default because those types describe and audit
implementation work; normal applications should not pay to compile them.
[dependencies]
kael = { version = "0.4", features = ["agent-tools"] }
Agents do not need this feature to build Kael applications. The public Rust API,
crate documentation, concise llms.txt, and Astryx source are the primary
references.
Readiness rule
A capability is production-ready only when the native operation exists, errors are actionable, platform variance is represented, and CI exercises the relevant target. A descriptor, roadmap entry, or showcase rendering is not sufficient evidence by itself.
Browser and WebAssembly
Kael runs the same application model on desktop and in the browser. State, layout, components, painting, virtualization, animation, and accessibility stay in Rust. The browser backend renders the retained scene into WebGL2.
This is a real application target. It is not a DOM rewrite and it is not a WebView around the desktop build.
What works
The browser backend connects these inputs to Kael's normal event paths:
| Input or service | Browser path |
|---|---|
| Pointer and buttons | Pointer events and pointer capture |
| Trackpad and mouse wheel | Wheel events |
| Keyboard and shortcuts | Keyboard events |
| Text and IME | Input and composition events |
| Copy, cut, and paste | Clipboard events with bounded payloads |
| File intake | File picker and drag and drop bytes |
| Animation | requestAnimationFrame |
| Accessibility | A synchronized semantic DOM mirror |
| Embedded web content | Managed iframe layers |
Kael tracks the device pixel ratio, refresh rate, visibility, and WebGL context. Moving a page between displays updates the backing scale. Hidden or idle windows stop requesting frames. A restored WebGL context receives a complete retained frame without resetting application state.
Do I need JavaScript?
No JavaScript is needed for normal Kael UI, input, state, animation, or canvas
work. kael web build generates app.js. That file loads the Wasm module and
connects it to the browser.
Write JavaScript only when the product needs a browser feature that Kael does not expose, such as:
- a service worker
- a third party DOM widget
- a browser vendor SDK
- an unwrapped Web API
- custom host page integration
Keep that code at the product boundary. The application itself can remain one Rust codebase.
Build and run
Install the pinned packaging tools once:
rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli --version 0.2.122 --locked
npm install --global binaryen@132.0.0
Run the development build:
kael web serve --debug
Build the optimized site:
kael web build
The default output is:
dist/web/
├── index.html
├── app.js
└── app_bg.wasm
Use --package <name> or --bin <name> in a Cargo workspace. Use
--out-dir <path> to change the output directory. Use --html <file> for a
source-owned host page and --assets <directory> for product web assets.
kael web serve also accepts --port <number> and --no-open.
See Web build and deployment for the complete host contract.
Keep dependencies portable
A portable project selects native and browser features by target:
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
kael = { version = "0.4", features = ["runtime_shaders"] }
kael_ui = "0.4"
[target.'cfg(target_arch = "wasm32")'.dependencies]
kael = { version = "0.4", default-features = false, features = ["browser"] }
kael_ui = { version = "0.4", default-features = false, features = ["browser"] }
Use CapabilityReport::current() before a workflow depends on an operating
system service. This keeps the main view and state shared while making the
fallback explicit.
Files and documents
Use App::prompt_for_files or App::show_open_files to receive
ExternalFile values. They contain a safe name, optional MIME type, and bytes.
Desktop selections can also contain a native path. Browser selections never
invent one.
Use App::save_file_bytes for generated files. Desktop opens a Save dialog.
The browser starts a download. DOCX, XLSX, PPTX, PDF, and project bytes can use
the same parser and exporter on both targets.
Browser file intake is limited to 256 MiB per file and 512 MiB per selection or drop. Failed files remain visible with an error.
Browser boundaries
The browser cannot provide every desktop service. It does not provide native paths, subprocesses, global hotkeys, status items, keychain access, detached OS windows, auto update, system idle state, or unrestricted device access.
Notifications, microphone capture, and screen capture require browser permission and an enabled Kael feature. Sharing is partial. Push, durable notification actions, and share targets need product owned PWA or service worker code.
Secondary Kael windows are surfaces inside the page. They cannot detach to another display or become taskbar or dock windows.
See What remains for the current framework gaps.
Release evidence
The maintained browser suite covers Chromium, Firefox, and WebKit. The current release tested baseline is Chromium 151, Firefox 153, and WebKit 26.5. Those are the versions pinned by the release matrix, not a claim that older browsers work.
The smoke app renders one million logical table rows with no more than 64 rows mounted. It tests scroll input, direct jumps, IME, clipboard, retained damage, component animation frames, WebGL loss and restoration, and framebuffer output. Hardware and software rasterizer results are reported separately.
Read Benchmarking evidence for limits and reports, Browser workers for bounded background work, and Browser audio for the Web Audio path.
Web build and deployment
kael web build creates a static site. A server runtime is not required.
Build
Install the exact tools expected by Kael 0.4:
rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli --version 0.2.122 --locked
npm install --global binaryen@132.0.0
Then build:
kael web build
Release builds run wasm-opt -O3. Use kael web build --debug for a faster
local build without release optimization.
Useful options:
| Option | Purpose |
|---|---|
--out-dir <path> | Change dist/web |
--html <file> | Package a source-owned host page |
--assets <directory> | Copy product web assets into the output |
--package <name> | Select a workspace package |
--bin <name> | Select a binary target |
--port <number> | Change the development server port |
--no-open | Serve without opening a browser |
The last two options apply only to kael web serve.
Deploy
Upload the full output directory and preserve its relative paths:
dist/web/
├── index.html
├── app.js
└── app_bg.wasm
The host must:
- serve
app_bg.wasmasapplication/wasm - allow JavaScript modules
- serve all three files from the same origin by default
- use HTTPS for permission based browser APIs; localhost is valid for development
Deploy the three files together. Their names are stable, not content hashed. Do not apply immutable caching unless your own build step fingerprints them. Revalidate the HTML, JavaScript, and Wasm files after a release.
If the app uses history based routes, configure the host to fall back to
index.html.
Use a custom host page
Keep a custom host page in the source tree and pass it to either build command:
kael web build --html web/index.html --assets web/assets
The HTML file becomes dist/web/index.html. Asset directory contents keep
their relative paths. Kael rejects asset symlinks and reserved root files named
index.html, app.js, or app_bg.wasm, so product assets cannot silently
replace the generated application.
The host page must contain a canvas with the expected id and load the generated module:
<canvas id="blade" aria-label="Kael application"></canvas>
<script type="module">
import init from "./app.js";
await init({ module_or_path: "./app_bg.wasm" });
</script>
The default page contains inline style and module code. A strict Content Security Policy needs a custom page with external files, a nonce, or matching hashes.
The CLI does not fingerprint deployment assets or generate a service worker.
Add those product policies after kael web build when required.
Verify the deployment
Open the deployed page and confirm:
app.jsloads as a JavaScript module.app_bg.wasmreturnsapplication/wasm.- The browser creates a WebGL2 context.
- Pointer, keyboard, text input, and scrolling reach the application.
- Permission based workflows run from a user action.
No product JavaScript is needed for normal Kael interactivity. See Browser and WebAssembly for the runtime contract and limits.
Browser workers
Kael maps its typed native worker contract onto dedicated browser Web Workers.
The host and worker exchange the same WorkerRequest, WorkerResponse,
WorkerProgress, WorkerError, IpcMessage, and bootstrap handshake types on
both targets. The browser transport adds a versioned envelope and transfers a
framed Uint8Array rather than accepting arbitrary JavaScript objects.
Use the asynchronous APIs in shared desktop/browser source:
let info = ProcessInfo::worker(ProcessId(0), "sheet-recalculation")
.executable("./sheet_worker_bootstrap.js");
let mut host = WorkerHost::with_temp_dir();
let worker = host.spawn_worker(ProcessClass::Worker, info)?;
worker.health_check_async().await?;
let output: RecalculationResult = worker.request_async(input).await?;
host.terminate_worker(worker.id())?;
Desktop calls wait on the existing socket or named-pipe transport using a
blocking-pool task. Browser calls await postMessage responses and never block
the JavaScript UI thread. The legacy synchronous request, health_check, and
worker-pool request methods remain unchanged on desktop; browser builds return
an explicit error directing the caller to the async form.
BackgroundExecutor::spawn cannot automatically move an arbitrary Send + 'static Rust future into a Web Worker. A browser worker instantiates a separate
WebAssembly heap, so a closure containing Rust pointers is not transferable.
Use BackgroundExecutor::spawn_worker_request for a serializable, typed CPU
handoff. This schedules only the message-wait future on the UI event loop while
the registered worker handler performs the expensive work.
Worker bootstrap
A module-worker bootstrap initializes the same generated WebAssembly module:
import init from "./my_app.js";
const startupMessages = [];
const bufferStartupMessage = (event) => startupMessages.push(event);
self.addEventListener("message", bufferStartupMessage);
await init();
self.removeEventListener("message", bufferStartupMessage);
for (const event of startupMessages) {
self.onmessage?.call(self, event);
}
Buffering the startup window makes the handshake independent of how long the generated JavaScript and WebAssembly take to initialize.
The application's main or exported worker entry detects
DedicatedWorkerGlobalScope, calls WorkerClient::connect_from_env, and
installs its handler with WorkerClient::run. The host supplies the bootstrap
module URL through ProcessInfo::executable. Module workers are the default;
WorkerHost::classic_worker_scripts is available for a deliberately classic
script build.
Bounds and browser boundary
- The wire protocol rejects an unsupported version, malformed/trailing frames,
non-
Uint8Arraymessages, and payloads over the shared 16 MiB IPC limit. - Each worker retains at most 1,024 pending request callbacks. Async requests default to a 30-second response timeout; bootstrap defaults to 10 seconds.
- Capabilities are validated and must be granted exactly. The default is only
worker:execute; configure bothWorkerHost::with_capabilitiesandWorkerClient::connect_with_capabilitiesto expand it. javascript:anddata:worker URLs are rejected. Browser same-origin/CORS, Content Security Policy, and module-worker policy still apply.- Browser process arguments, environment variables, working directories, and
automatic restart policies have no faithful Web Worker equivalent and return
explicit errors. Recreate a failed worker from its
Exitedevent. - Cancellation is cooperative. A synchronous CPU handler cannot observe a queued cancel until it yields or returns; split interruptible algorithms into bounded requests.
- This bridge does not require shared memory or cross-origin isolation. That keeps deployment simple, but requests cross an explicit serialization boundary instead of sharing Rust references.
Maintained release probe
Build the independent worker probe without modifying the retained-scene smoke:
bash scripts/build-browser-worker-smoke.sh
python3 -m http.server 8000 --directory target/browser-worker-smoke
Or run the build, headless browser, and marker checks together:
bash scripts/verify-browser-worker-smoke.sh
The probe validates handshake/health, typed progress, a one-million-item CPU request, a bounded 25 ms worker CPU interval during which the UI event-loop timer must fire, the explicit synchronous-browser error, and termination. A headless CI gate should require all of these final DOM markers:
data-kael-worker-probe="passed"
data-kael-worker-protocol="1"
data-kael-worker-items="1000000"
data-kael-worker-progress="passed"
data-kael-worker-ui-thread="responsive"
data-kael-worker-terminated="passed"
The CI verifier keeps headless Chrome alive on real wall-clock time and waits
for an equivalent __kael_worker_pass__=1 HTTP beacon. Avoid Chrome virtual time
for this probe: it can advance the host's bounded request timer while the
independently scheduled worker is still loading JavaScript and WebAssembly.
Browser Audio
kael_audio keeps the Rust mixer, DSP sources, offline rendering, playlist,
session, and lightweight spatial-scene APIs available on desktop and
wasm32-unknown-unknown. Browser I/O is asynchronous where the platform is
asynchronous: output graph construction installs an AudioWorklet, device
enumeration awaits enumerateDevices, and capture awaits getUserMedia.
Native synchronous APIs and signatures are unchanged.
Live output
Create the browser engine asynchronously, add the same SampleSource values
used by a desktop engine, and resume it from a click or key handler:
use kael_audio::{AudioEngine, BrowserAudioEngineConfig, SineSource};
let engine = AudioEngine::new_async_with_config(
BrowserAudioEngineConfig::new(2, 256, 4)?,
).await?;
let voice = engine.play_source(
Box::new(SineSource::new(440.0, engine.sample_rate(), 0.2)),
1.0,
)?;
engine.set_voice_gain(voice, 0.5)?;
engine.resume_async().await?;
The output worklet requests only the space remaining in its fixed chunk window.
One request can be outstanding at a time. Rust reuses its mixer scratch buffer,
produces at most that requested count, and transfers each Float32Array rather
than retaining a second JavaScript copy. Samples are finite-checked and clamped
to -1.0..=1.0 before they reach the device. Control is event-driven through
MessagePort; there is no timer or animation-frame polling loop.
AudioEngineHandle is main-thread-only and weak in a browser build. Cloned
handles can control voices while the owning AudioEngine exists, but cannot
keep the graph alive. close_async, owner drop, processor failure, and message
delivery failure detach handlers, stop the processor, disconnect the node,
discard sources, close the port, and close the AudioContext. A setup future
dropped while the worklet module is loading also closes its pending context.
Use poll_event for the bounded Running, OutputUnderrun, ProcessorError,
and Closed stream, underrun_frames for its cumulative counter, and
take_error for the latest content-safe diagnostic. Event storage holds at most
64 entries and drops the oldest entry when full.
Devices and microphone capture
use kael_audio::{
AudioInputStream, BrowserAudioCaptureConfig, default_input_device_async,
input_devices_async, output_devices_async,
};
let outputs = output_devices_async().await?;
let inputs = input_devices_async().await?;
let microphone = default_input_device_async().await?;
let stream = AudioInputStream::from_input_device_async_with_config(
µphone,
BrowserAudioCaptureConfig::new(1, 1_024, 4)?
.with_signal_processing(true, true, true),
|samples, format| {
// Copy/enqueue promptly. Returning this callback releases one credit.
consume_promptly(samples, format);
},
).await?;
let _ = (outputs, inputs, stream);
Browsers commonly hide device labels before an origin has media permission.
Kael preserves that distinction through label_available and a neutral fallback
name. Origin-scoped device identifiers stay private and are redacted from
Debug. Enumeration retains at most 1,024 audio descriptors and 1,024 UTF-8
bytes per label or identifier. An empty list is valid. A default lookup returns
DeviceUnavailable when no matching kind exists.
Stable non-default output routing is not yet interoperable across Kael's browser
targets. AudioEngine::from_output_device_async accepts the default descriptor
and returns OutputRoutingUnsupported for another sink; it never silently
routes to a different speaker.
Capture converts the selected stream into the requested 1–8 interleaved
channels. The worklet owns a fixed number of credits and transfers a chunk only
when a credit is available. The main-thread callback returns that credit after
it completes. When every credit is in flight, the worklet drops frames and
reports the monotonic total through CaptureOverflow rather than extending the
port queue. Chunk length, sequence, and drop counters are validated before
application code runs. Invalid delivery, processor failure, track end, a
panicking callback in an unwind-enabled build, explicit close, or drop stops all
tracks and closes the graph.
getUserMedia requires a secure context and normally a user activation and
permission. The stable typed outcomes distinguish PermissionDenied,
DeviceUnavailable, UserActivationRequired, ApiUnavailable, and graph
failures without retaining the browser's potentially sensitive exception text.
The Web platform does not provide a portable abort signal for an outstanding
permission prompt. Keep the returned future alive until the user decides; Rust
future cancellation is not permission-prompt revocation. After a stream is
granted, Kael's pending-track guard stops it on any later setup cancellation or
failure.
Bounds and latency
| Boundary | Minimum | Default | Maximum |
|---|---|---|---|
| Channels | 1 | output 2, capture 1 | 8 |
| Frames per chunk | 128 | output 256, capture 1,024 | 4,096 |
| Pending chunks/credits | 2 | 4 | 32 |
| Live output voices | — | — | 1,024 |
| Browser audio sample rate | 8 kHz | browser-selected | 192 kHz |
Chunks must be a multiple of the browser's 128-frame render quantum. At the
maximum configuration, one bridge window contains 32 × 4,096 × 8 = 1,048,576
f32 samples (4 MiB), plus one chunk-sized assembly scratch buffer (at most
128 KiB) and browser-owned graph buffers. The default output window contains up
to 1,024 frames, about 21.3 ms at 48 kHz, before the browser's base/output/device
latency. Default capture produces one 1,024-frame callback, also about 21.3 ms at
48 kHz, with four delivery credits.
This design deliberately avoids SharedArrayBuffer, COOP/COEP headers, and a
shared-memory data race. The AudioWorklet stays on the browser audio rendering
thread, while Rust Mixer/DSP work and capture callbacks run only when a port
event reaches the browser main thread. UI or Wasm work that blocks that thread
can cause an underrun or capture overflow. Keep callbacks short and move larger
analysis through Kael's bounded Web Worker bridge.
For a game or workstation that requires worklet-owned Wasm DSP, sub-10-ms synthesis, hundreds of continuously expensive sources, HRTF, room acoustics, or multichannel device spatialization, use a specialized product audio worklet. Kael's current portable spatial scene is equal-power stereo panning with inverse-distance attenuation, not an HRTF or room renderer.
Deployment and release evidence
AudioWorklet and getUserMedia require a secure context; localhost is valid
for development. Kael currently installs its worklet from a temporary blob:
URL and revokes that URL after addModule settles. A strict Content Security
Policy must permit this worklet module in the directives enforced by each target
browser. If policy rejects it, construction returns WorkletUnavailable rather
than falling back to a high-latency polling path.
scripts/verify-browser-audio-smoke.sh builds an optimized Wasm example, serves
it locally, and launches a fresh headless Chrome profile. The gate proves real
worklet graph construction/resume, frame-clock progress, bounded playback and
control, device-enumeration privacy semantics, typed permission-denied capture,
and explicit close/weak-handle cleanup. CI denies microphone permission and uses
a fake media device, so it intentionally does not claim successful physical
microphone capture. Pure injected protocol tests separately cover output request
clamping plus capture size/order/drop-counter validation.
Office and PDF document bytes
Kael's document pipeline keeps file acquisition, persistence, container parsing, and application semantics separate. That separation lets a suite use the same Rust code on desktop and the web:
App::prompt_for_filesorFileUploadreturns boundedExternalFilebytes.kael_office::OfficePackage::openparses DOCX, XLSX, or PPTX bytes, whilekael_pdf::PdfDocument::from_bytesparses PDF bytes.- The app maps extracted content into its own document, sheet, slide, or canvas model and renders that model with ordinary Kael views.
OfficePackage::to_bytes,PdfDocument::to_bytes, or an app serializer produces output bytes.App::save_file_bytesperforms native Save As or a browser Blob download.
There is no browser-only document model and shared business logic does not need filesystem paths.
DOCX, XLSX, and PPTX foundation
Enable the office feature on kael, or depend directly on kael_office:
#![allow(unused)] fn main() { use kael::office::{OfficePackage, OfficeText}; fn import_office(bytes: &[u8]) -> anyhow::Result<()> { let package = OfficePackage::open(bytes)?; match package.extract_text()? { OfficeText::Document(document) => { println!("{} paragraphs", document.paragraphs.len()); } OfficeText::Spreadsheet(workbook) => { println!("{} worksheets", workbook.sheets.len()); } OfficeText::Presentation(deck) => { println!("{} slides", deck.slides.len()); } } Ok(()) } }
The portable OOXML/OPC API detects standard DOCX/XLSX/PPTX main content types, lists and reads parts, parses core properties and relationships, resolves safe internal targets, and safely replaces, adds, or removes raw parts. Export is deterministic: parts are sorted and ZIP timestamps, permissions, compression, and compression level are normalized. Unknown parts, including relationships, are retained byte for byte across a read/export round trip.
Text extraction provides an interchange baseline:
- DOCX: text runs grouped into paragraphs, including table-cell paragraphs, tabs, and explicit breaks;
- XLSX: shared strings, inline strings, booleans, numeric values, and cached formula results, grouped by worksheet and cell reference; and
- PPTX: DrawingML paragraphs grouped by slide in relationship order.
kael_office does not paginate Word files, calculate formulas, lay out slides,
render charts or SmartArt, execute macros or embedded objects, or promise pixel
parity with Microsoft Office. A suite should layer its semantic model, layout
engine, collaboration protocol, and high-fidelity adapters on this bounded
foundation. When adding or removing parts, callers must also maintain content
types and relationships; replacing an existing part is the safest primitive.
Portable PDF services
Enable pdf on kael, or use kael_pdf directly. PdfDocument::from_bytes
and async open_from_memory work on desktop and wasm32-unknown-unknown. Page
count and size, metadata, outlines, text, search, links, sidecar annotations,
and schematic previews share the same APIs. annotations_to_bytes and
load_annotations_from_bytes make annotations persistable through
kael_document, IndexedDB, or an app service. to_bytes provides bounded PDF
bytes for download.
Browsers do not expose arbitrary filesystem paths. PdfDocument::open, save,
and save_annotations return a downcastable PdfPlatformError there; use
picker bytes and save_file_bytes. Native path methods retain atomic file and
sidecar persistence.
The built-in schematic_preview is an extracted-text and annotation
placeholder, not PDF graphics rasterization. It does not draw original fonts,
images, vectors, forms, or signatures. Use a dedicated sandboxed renderer for
pixel-faithful PDF pages.
Bounds and hostile files
Office input and output are capped at 256 MiB, with at most 65,536 parts, 64 MiB per expanded part, 256 MiB total expanded data, a 500:1 ratio limit after a small-file allowance, safe UTF-8 relative names, no duplicates or encryption, and bounded XML depth, event, text, paragraph, string, and cell work. Traversal, package escapes, DTDs, unsafe relationship targets, and ambiguous names are rejected.
PDF input/output is capped at 256 MiB, with bounded object, page, text, cache, preview, annotation, link, outline, search, and metadata work. See its crate API for exact per-operation values.
These limits constrain Kael-owned work; they are not a process sandbox. Parse large or hostile files in a browser worker or restricted native worker process so malformed input cannot stall the UI or share the main application trust boundary.
Realtime Networking
kael_net::WebSocketClient is the shared live collaboration transport for
desktop and browser builds. It uses a private Tungstenite/Rustls worker on
native targets and web_sys::WebSocket in WebAssembly, but exposes the same
configuration, message, event, backpressure, close, and reconnection types.
It does not require a Tokio runtime.
Open a checked connection
Core realtime descriptors bridge directly to the transport. A network policy is mandatory at the side-effect boundary:
#![allow(unused)] fn main() { use kael::{ AppRealtimeConnection, AppRealtimeReconnectPolicy, NetworkPolicyBuilder, }; use std::time::Duration; let policy = NetworkPolicyBuilder::new() .allow_host("collab.example.com") .build_checked()?; let descriptor = AppRealtimeConnection::websocket( "wss://collab.example.com/session", ) .protocol("kael.collab.v1") .max_message_bytes(1024 * 1024) .reconnect_policy(AppRealtimeReconnectPolicy::new( 5, Duration::from_secs(1), Duration::from_secs(30), )) .network_policy(policy) .build_checked()?; let socket = descriptor.open_websocket_transport()?; Ok::<(), Box<dyn std::error::Error>>(()) }
Applications using kael_net without core descriptors can build a
WebSocketConfig directly and pass an implementation of
WebSocketHostPolicy to WebSocketClient::connect. DenyAllWebSocketHosts
and AllowAllWebSocketHosts make the decision explicit; production apps
should normally use Kael's checked host allow-list.
Poll socket.poll_event() from the application event loop. Every event has a
monotonically increasing sequence number and contains one of Open, Message,
Error, Reconnecting, or Closed. Text and binary payloads retain transport
order. try_send never blocks the UI thread and returns Backpressure when
either the outbound count or byte budget is full.
Bounds
The production defaults are:
| Bound | Default | Checked maximum |
|---|---|---|
| Queued inbound messages | 1,024 | 65,536 |
| Queued outbound messages | 256 | 65,536 |
| One message | 16 MiB | 128 MiB |
| Queued inbound payloads | 32 MiB | 512 MiB |
| Queued outbound payloads | 16 MiB | 512 MiB |
Browser bufferedAmount threshold | 4 MiB | 512 MiB |
| Native connect/TLS timeout | 15 seconds | 5 minutes |
| Reconnect attempts | disabled | 100 |
Lifecycle events use a small bounded reserve in addition to the configured
message count. If an app does not drain inbound messages within its count or
byte budget, the transport emits InboundBackpressure and closes rather than
growing memory without limit. Native Tungstenite enforces the message limit
while framing. The browser only exposes a complete MessageEvent, so one
oversized browser message is necessarily materialized by the browser before
Kael can reject it and close the socket.
The native timeout covers TCP connection attempts and the WebSocket/TLS
handshake. Hostname resolution is performed by the operating system resolver;
like std::net::ToSocketAddrs, it does not expose a separately cancellable DNS
deadline.
Delivery and reconnection
Automatic reconnect applies only to abnormal loss. An application close and
clean peer codes 1000 or 1001 are terminal. Messages still in Kael's
bounded outbound queue remain ordered across a reconnect. A message already
handed to the operating system or browser is not replayed because the transport
cannot know whether the peer processed it; collaboration protocols that need
exactly-once effects should use application message IDs and acknowledgements.
Each failed attempt emits sanitized Error, Closed, and Reconnecting
events in that order. A successful open resets the attempt counter. Dropping
the final client clone removes browser handlers and timers or asks the native
worker to terminate within its socket timeout. close is the deterministic
choice when application code needs a close event.
Browser security and parity boundaries
- Browsers own DNS, proxies, certificates, cookies, CSP, mixed-content rules,
and the HTTP upgrade. An HTTPS page normally needs a
wss://endpoint. - Browser WebSockets cannot set arbitrary handshake headers. The core adapter
rejects descriptors containing headers on every target so desktop and web do
not silently diverge. Authenticate with an appropriate cookie, a short-lived
signed endpoint, or an application message after
Open. - Browser WebSockets cannot originate protocol ping frames. The core adapter
therefore rejects descriptor-level protocol heartbeat intervals on every
target; portable collaboration protocols can send a bounded application
heartbeat with
try_sendinstead. - JavaScript only permits close code
1000or application codes3000..=4999. Kael uses4003,4009, and4013on the browser wire for its own terminal guards and normalizes the public close metadata to1003,1009, and1013to match native behavior. - Errors from browsers intentionally contain little diagnostic detail. Kael
exposes stable categories and never places a URL, token, payload, or close
reason in
Debug/Displayoutput. - Server-sent events remain a checked
AppRealtimeConnectionKinddescriptor, but do not yet have a shared live Kael transport.
The maintained release probe starts a local server and verifies the real Chrome path for policy rejection, pre-open queue backpressure, size limits, subprotocol negotiation, ordered text/binary echoes, explicit cancellation, normalized oversize error/close metadata, abnormal-loss reconnection, and teardown:
bash scripts/verify-browser-websocket-smoke.sh
Actions & Keybindings
Kael separates what happens (an action) from how it's triggered (a keybinding or click). Actions are dispatched up the focused element tree, so a keystroke is routed to the nearest handler in the currently focused context — the same model that powers editor-grade keyboard UX.
Defining actions
The actions! macro generates zero-field action types in a namespace:
#![allow(unused)] fn main() { use kael::actions; actions!(editor, [Save, Undo, Redo, Tab, TabPrev]); }
Each entry becomes a type (Save, Undo, …) implementing the Action trait, with a stable name like editor::Save used for keymaps and dispatch.
Binding keys
Register bindings once at startup with cx.bind_keys. KeyBinding::new takes the keystroke string, the action, and an optional key context that scopes the binding:
use kael::{App, Application, KeyBinding};
fn main() -> Result<(), Box<dyn std::error::Error>> {
Application::try_new()?.run(|cx: &mut App| {
cx.bind_keys([
KeyBinding::new("cmd-s", Save, None),
KeyBinding::new("cmd-z", Undo, None),
KeyBinding::new("cmd-shift-z", Redo, None),
KeyBinding::new("tab", Tab, Some("Editor")),
KeyBinding::new("shift-tab", TabPrev, Some("Editor")),
]);
// ... open windows ...
});
Ok(())
}
Keystroke syntax uses cmd / ctrl / alt / shift modifiers joined with -, and a space separates multi-key sequences (e.g. "cmd-k cmd-s"). Use cmd on macOS and ctrl on Windows/Linux.
Command Registry
Use CommandRegistry when the same app command should be available from a
command palette, menu, toolbar, or agent action list:
#![allow(unused)] fn main() { use kael::app_runtime::CommandRegistry; let mut commands = CommandRegistry::new(); commands.register_action_checked("editor.save", "Save", || { // persist the active document })?; commands.execute("editor.save")?; }
Prefer register_checked(...) and register_action_checked(...) for generated
app chrome. Checked registration rejects empty, padded, overly long, or
non-portable command IDs, rejects empty/padded/control-character/overly long
names, and catches duplicate IDs before menus or command palettes become
ambiguous. Raw register(...) and register_action(...) remain available when
an app intentionally wants replacement semantics.
Handling actions
In render, mark the element that owns a focus context with track_focus, then register handlers with on_action(cx.listener(...)). Handlers take &mut self, a reference to the action, the window, and the context:
#![allow(unused)] fn main() { use kael::{div, prelude::*, Context, FocusHandle, Render, Window}; struct Editor { focus_handle: FocusHandle } impl Editor { fn on_save(&mut self, _: &Save, _window: &mut Window, cx: &mut Context<Self>) { // ... persist ... cx.notify(); } } impl Render for Editor { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { div() .track_focus(&self.focus_handle) .on_action(cx.listener(Self::on_save)) .on_action(cx.listener(Self::on_undo)) .child("editor surface") } } }
Focus & tab order
Create focus handles from the context and arrange tab order with tab_index / tab_stop. Move focus from the window:
#![allow(unused)] fn main() { fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { let items = vec![ cx.focus_handle().tab_index(1).tab_stop(true), cx.focus_handle().tab_index(2).tab_stop(true), cx.focus_handle().tab_index(3).tab_stop(true), ]; let focus_handle = cx.focus_handle(); window.focus(&focus_handle); Self { focus_handle, items } } }
Window focus methods: window.focus(&handle), window.focus_next() (Tab), and window.focus_prev() (Shift-Tab). Query state with handle.is_focused(window) and style focused elements with .focus(|s| s.border_color(...)).
Dispatch resolution
An element's .key_context("Editor") scopes matching bindings to that part of
the tree. When a keystroke matches, Kael starts at the focused element and walks
through its ancestors until an on_action handler accepts the action. A closer
handler can therefore override application-level behavior without coupling the
keymap to a concrete view type.
Use global bindings for commands that are valid throughout the application and context bindings for editor modes, dialogs, lists, and other surfaces where the same keystroke has a local meaning. See the Astryx showcase for a complete focus-navigation composition.
Plugins & Extensions
Kael ships an extension system with a contribution-point architecture. Extensions run out-of-process and can target one of two execution models: a sandboxed WASM module, or an external process that speaks the extension RPC protocol. The host loads extensions from a manifest, mediates their capabilities through a permission broker, and dispatches commands and notifications to them.
Defining a manifest
Build a PluginManifest with the builder. The positional arguments are id, name, version, api_version, entry_point, and execution_model; contribution points and capabilities are added fluently.
#![allow(unused)] fn main() { use kael::{ContributedCommand, ExecutionModel, PluginManifest}; let manifest = PluginManifest::builder( "com.example.mock", // id "Mock Plugin", // display name "1.0.0", // plugin version "1.0.0", // host API version it targets "mock.wasm", // entry point ExecutionModel::Wasm, ) .command(ContributedCommand { id: "mock.hello".to_string(), title: "Say Hello".to_string(), keybinding: None, }) .build()?; }
Manifests can also be loaded from disk with PluginManifest::from_json, PluginManifest::from_toml, or PluginManifest::load(path).
Manifest validation rejects empty, padded, control-character, overly long, or
non-portable IDs/names/versions, unsafe entry-point text, duplicate command or
panel IDs, menu items that reference missing commands, and non-object settings
schemas before the extension is loaded.
Loading and activating
ExtensionHostRuntime owns the installed extensions for an app. Load a manifest, then activate it — activate_with_broker runs the extension's capability requests through a PermissionBroker first.
#![allow(unused)] fn main() { use kael::{ExtensionHostRuntime, PermissionBroker}; let mut runtime = ExtensionHostRuntime::new(&extensions_dir, "my-app"); runtime.load(manifest)?; let broker = PermissionBroker::new(); for ext in runtime.all().iter().map(|e| e.manifest.id.clone()).collect::<Vec<_>>() { runtime.activate_with_broker(&ext, &broker)?; } }
Other runtime operations: load_from_directory (dev mode), install_from_path, uninstall, activate / deactivate, unload, send_command, broadcast_notification, and all (returns &ExtensionInfo with manifest, is_active, process_id, load_path, dev_mode).
Crash and restart policy
Extension hosts should keep crash state outside the extension process. Use
CrashPolicy::validate, CrashRecord::new_checked,
record_crash_checked, should_restart_checked, and
next_restart_delay_checked when wiring restart loops. The checked helpers
reject invalid extension IDs, zero-delay restart loops, non-finite backoff
values, and backoff factors below 1.0; extremely large restart delays
saturate to u64::MAX.
#![allow(unused)] fn main() { use kael::{CrashPolicy, CrashRecord}; let policy = CrashPolicy::default(); policy.validate()?; let mut crashes = CrashRecord::new_checked("com.example.mock")?; crashes.record_crash_checked(&policy)?; if crashes.should_restart_checked(&policy)? { let delay_ms = crashes.next_restart_delay_checked(&policy)?; // Schedule the extension restart after delay_ms. } }
Contribution points
Extensions extend the host by contributing entries through the builder:
| Builder method | Contributes |
|---|---|
.command(ContributedCommand) | A command (id, title, optional keybinding) |
.menu_item(ContributedMenuItem) | A menu entry (target_menu, label, command_id) |
.panel(ContributedPanel) | A panel/view (id, title, default_position) |
.settings_schema(json) | A JSON schema for configurable settings |
.capability(Capability) | A requested capability (e.g. Capability::Notification) |
Panels position with PanelPosition::{Left, Right, Bottom, Floating}.
Execution models & RPC
ExecutionModel::Wasm runs the entry point as a sandboxed WebAssembly module. ExecutionModel::ExternalProcess launches a separate binary that connects over the platform transport and exchanges messages with the host.
The host and an external extension first complete a handshake (ExtensionHandshake, version-checked against EXTENSION_RPC_VERSION), then exchange a typed envelope:
| Direction | Type | Key variants |
|---|---|---|
| host → ext | ExtensionRequest | Activate, Deactivate, Shutdown, GetContributions, ExecuteCommand { command_id, args } |
| ext → host | ExtensionResponse | Ack, Contributions(Contributions) |
| host → ext | ExtensionNotification | SettingsChanged { key, value } |
Broadcast a notification to every active extension:
#![allow(unused)] fn main() { use kael::ExtensionNotification; runtime.broadcast_notification(ExtensionNotification::SettingsChanged { key: "theme".to_string(), value: serde_json::json!("dark"), }); }
For the complete contract, see the ExtensionRuntime, ExtensionBroker, and
ExternalExtensionProcess API documentation. The consolidated Astryx showcase
demonstrates the UI primitives used to present extension state, commands, and logs;
extension lifecycle and transport behavior are covered by the process integration
tests so the example gallery does not duplicate security-sensitive runtime code.
Multi-Process & IPC
Kael supports an native desktop multi-process architecture: the UI runs in the main process while heavy or untrusted work runs in supervised child processes that communicate over typed IPC. Transport is platform-native — Unix domain sockets on macOS/Linux, named pipes on Windows — and the framework handles framing, request/response correlation, progress streaming, and crash reporting for you.
Process model
Every process has a class describing its role:
#![allow(unused)] fn main() { use kael::ProcessClass; ProcessClass::Ui; // the main UI process ProcessClass::Worker; // background compute ProcessClass::Media; // media decode/playback ProcessClass::Extension; // sandboxed plugins (see Plugins & Extensions) }
A child process is described by a ProcessInfo. For generated host code,
prefer ProcessInfoBuilder so empty names, missing executables, invalid
environment keys, NUL-containing args, and missing required paths fail before
the supervisor starts:
#![allow(unused)] fn main() { use kael::{ProcessId, ProcessInfoBuilder}; let info = ProcessInfoBuilder::worker(ProcessId(0), "thumbnailer") .executable("/path/to/worker-binary") .require_existing_executable() .arg("--quiet") .env("RUST_LOG", "warn") .build_checked()?; }
Builders exist for each role: ProcessInfoBuilder::worker,
ProcessInfoBuilder::media, and ProcessInfoBuilder::extension. The raw
ProcessInfo::worker, ProcessInfo::media, and ProcessInfo::extension
constructors remain available when an app owns lower-level validation.
Spawning a worker (host side)
WorkerHost owns the socket directory and supervises spawned children. request sends a typed payload and blocks for the response; fire_and_forget sends without waiting; health_check pings the child.
#![allow(unused)] fn main() { use kael::{ ProcessClass, ProcessId, ProcessInfoBuilder, ProcessSpawnOptionsBuilder, WorkerHost, }; let mut host = WorkerHost::with_temp_dir(); let info = ProcessInfoBuilder::worker(ProcessId(0), "thumbnailer") .executable(worker_binary_path) .require_existing_executable() .build_checked()?; let spawn_options = ProcessSpawnOptionsBuilder::new() .restart_on_failure(3, std::time::Duration::from_secs(1)) .heartbeat_interval(std::time::Duration::from_secs(5)) .missed_heartbeats_before_unhealthy(3) .build_checked()?; let worker = host.spawn_worker_with_options(ProcessClass::Worker, info, spawn_options)?; worker.health_check()?; // round-trip ping let response: serde_json::Value = worker.request(serde_json::json!({ "op": "echo", "message": "hello from host", }))?; assert_eq!(response["message"], "hello from host"); }
The worker child
The child binary connects back to the host with WorkerClient::connect_from_env (it reads the KAEL_WORKER_SOCKET / KAEL_WORKER_PIPE environment variable the host sets) and serves requests with run. The handler receives a WorkerRequest and a progress callback for streaming intermediate updates, and returns a WorkerResponse or WorkerError.
use anyhow::Result; use kael::{WorkerClient, WorkerProgress, WorkerRequest, WorkerResponse}; fn main() -> Result<()> { let client = WorkerClient::connect_from_env()?; client.run(|request, progress| match request { WorkerRequest::Ping => Ok(WorkerResponse::Pong), WorkerRequest::Execute { payload } => { progress(WorkerProgress::Update(serde_json::json!({ "step": 1 }))); // ... do work ... Ok(WorkerResponse::Result(payload)) } }) }
The message types:
| Type | Variants |
|---|---|
WorkerRequest | Ping, Execute { payload: serde_json::Value } |
WorkerResponse | Pong, Result(serde_json::Value) |
WorkerProgress | Update(serde_json::Value) |
WorkerError | Execution(String), Cancelled |
Supervision & crash handling
Register an event callback to observe lifecycle events. A child that crashes is reported as a SupervisorEvent::Exited rather than taking down the host:
#![allow(unused)] fn main() { use kael::SupervisorEvent; host.on_event(|event| match event { SupervisorEvent::Exited { id, .. } => eprintln!("worker {id:?} exited"), _ => {} }); }
Each WorkerHandle exposes id() to correlate it with supervisor events.
ProcessSpawnOptionsBuilder validates restart and heartbeat policy: restart
counts must be non-zero when using restart_on_failure, backoff must be greater
than zero, heartbeat intervals must be greater than zero, and the missed
heartbeat threshold must be non-zero.
Extension processes
Extension children use the same transport but a richer RPC envelope (handshake, contribution discovery, command dispatch). They are managed by ExtensionHostRuntime rather than WorkerHost — see Plugins & Extensions.
Security & Permissions
Kael provides a capability-based security model for controlling what extensions and child processes can access.
Permission system
#![allow(unused)] fn main() { use kael::security::*; let mut manager = PermissionManager::new(); // Request permission let request = PermissionRequest::new( PermissionKind::FileSystem, "Read project files", ); match manager.check(&request) { PermissionStatus::Granted => { /* proceed */ }, PermissionStatus::Denied => { /* blocked */ }, PermissionStatus::Prompt => { /* ask user */ }, } }
Network policy
Control outbound network access:
#![allow(unused)] fn main() { let policy = NetworkPolicyBuilder::new() .allow_host("api.myapp.com") .allow_url("https://cdn.myapp.com/assets/app.js")? .build_checked()?; assert!(policy.check_url("https://api.myapp.com/v1/sync")?); assert!(!policy.check_url("https://evil.example.com/track")?); }
Use NetworkPolicy::DenyAll for sandboxed workers by default, AllowList
for app-owned services, and DenyList only when most hosts should be allowed.
The checked builder rejects malformed hosts, full URLs in host fields,
non-HTTP(S) URLs, duplicate host entries, and mixed allow/deny lists.
Process capabilities
Limit what child processes can do:
#![allow(unused)] fn main() { let limits = ProcessLimits { max_memory_bytes: Some(512 * 1024 * 1024), max_cpu_percent: Some(50.0), max_open_files: Some(256), network_allowed: true, }; let mut capability = ProcessCapability::new(42, "worker", limits); assert!(capability.check_network()); }
File access bookmarks
Kael's standard UI threat model grants only PathScope::UserSelected file
read/write capabilities by default, so open/save dialogs and browser file
pickers work after an explicit user gesture. This does not grant arbitrary path
access: PathScope::Any, app-data access, and worker access still require an
explicit application policy or delegated bookmark.
Use file access bookmarks after open/save dialogs, recent-project restore, or extension handoff flows. They keep app-owned path access explicit and can issue temporary tokens instead of passing raw paths everywhere.
#![allow(unused)] fn main() { let bookmark = FileAccessBookmark::builder("workspace.main", workspace_dir) .scope(PathScope::UserSelected) .read_write() .require_existing_path() .canonicalize_path() .ttl_seconds(3600) .build_checked()?; let mut tokens = AccessTokenStore::new(); let token = bookmark.issue_token(&mut tokens, now_unix_seconds)?; for capability in bookmark.capabilities() { broker.grant(worker_process, capability); } }
Credential storage
Secure credential management via OS keychain:
#![allow(unused)] fn main() { let keychain = KeychainStore::new("my-app"); keychain.write("api-token", "secret-value")?; let token = keychain.read("api-token")?; keychain.delete("api-token")?; }
Release dependency audit
Release CI runs cargo audit -D warnings, so a new vulnerability, unsoundness,
unmaintained dependency, or yanked crate fails the gate. The checked-in
scripts/ci/audit-dependencies.sh contains the complete reviewed exception
list. Cargo.lock still records Wry's target-conditional GTK3 metadata because
Wry is the supported Windows WebView2 host, including RUSTSEC-2024-0429 for
glib 0.18. The audit script separately proves that neither Linux WebView feature
spelling can reach Wry, GTK3, WebKitGTK 4.1, or Blade. Linux ships only the
GTK4/WebKitGTK 6 host. The other reviewed exceptions are unmaintained
transitive parser/build crates, not known exploitable vulnerabilities. Remove
an exception as soon as the dependency that leaves the lockfile permits it.
Linux WebView hosting
Kael's maintained Linux WebView stack uses one GTK4-owned window hierarchy on X11, XWayland, and native Wayland. The archived GTK3 stack is not shipped; WebViews never use a detached top-level overlay.
| Desktop path | Kael feature | Native stack | Contract |
|---|---|---|---|
| Native Wayland | webview | GTK4 + GSK + WebKitGTK 6.0 | Maintained production path |
| X11 or XWayland | webview | GTK4 + GSK + WebKitGTK 6.0 | Maintained production path |
| Deprecated feature spelling | webview-legacy-gtk3 | Redirects to GTK4 + WebKitGTK 6.0 | Source-compatible alias only |
| Raw Wayland/X11 without the GTK4 host | wayland / x11 | Kael-owned native surface | WebViews unavailable |
| Headless | any | no native browser surface | WebViews unavailable |
Maintained GTK4 production host
Enable the portable feature when an application needs WebViews on any desktop:
[dependencies]
kael = { version = "0.4", features = ["webview"] }
Ubuntu/Debian builders need libgtk-4-dev and libwebkitgtk-6.0-dev in
addition to Kael's standard Linux packages. webview-gtk4 and
webview-wayland-gtk4 remain Linux-specific compatibility spellings. The
deprecated webview-legacy-gtk3 name also selects this maintained graph; no
Kael feature pulls the archived GTK3/WebKitGTK 4.1 stack on Linux.
GTK owns ApplicationWindow -> Fixed, with a retained-scene Picture and each
WebKit view as siblings. The Kael scene is converted to cached GSK nodes; it is
not screen-scraped or copied into a detached overlay. This gives the window and
its WebViews one compositor surface, one scale/monitor lifecycle, one focus and
input hierarchy, and one minimize/workspace lifetime.
The host implements:
- retained Scene primitives, text/image atlases, paths, sprites, patterns, shadows and supported backdrop effects through GSK;
- declarative WebView bounds, clipping, visibility, opacity and focus;
- URL/header/HTML navigation, history, reload/stop, zoom, find, print, downloads, cookies, named/incognito profiles, user scripts, IPC and native permission policy;
- app-owned custom protocols, including main-document navigation and same-origin subresources with status, MIME type, headers, and bounded body;
- pointer, wheel, keyboard, touch, IME composition, focus traversal, window and WebView file drag/drop, rich bounded clipboard content, and AT-SPI updates;
- monitor/backing-scale changes, raw Wayland or X11 window/display handles, XDG print parenting, native pointer lock, move/resize/menu/decorations, mouse passthrough, and retained-scene PNG export.
WebViews remain rectangular native islands above the retained scene. Kael
hides a view when a non-translation transform would make its native rectangle
incorrect. Arbitrary rotation/perspective through WebKit content and drawing
retained content over the middle of a WebView are not claimed. That is why the
overall host support level remains Partial while many individual operations
report Full.
Backend selection
Selection is deterministic:
KAEL_HEADLESSselects headless mode.KAEL_LINUX_BACKEND=x11|wayland|headlesswins when set.GDK_BACKENDordering is honored when the named backend has a valid display.- Otherwise Wayland is selected when
WAYLAND_DISPLAYis available, followed by X11 whenDISPLAYis available. - With neither display available, Kael uses its headless fallback.
If an application selects a raw X11 or Wayland backend without compiling the GTK4 WebView host, the capability report marks every WebView operation unavailable and controller commands return a deterministic error. Kael never creates a visually adjacent top-level window and calls it embedded.
Why the GTK-owned host is necessary
The raw Wayland backend owns a wl_surface through Kael's Wayland client
connection. Wayland cannot attach a GTK surface from another client as a
positioned child, and Wry's Wayland constructor requires a GTK container. A
second GTK top level would break compositor placement, clipping, focus, input
methods, accessibility, minimization, and workspace movement.
The native host therefore changes ownership of the complete window rather than patching only WebView rendering. GTK/GDK supplies the Wayland or X11 handles, and Kael's scene, event translation, services, and WebKit widgets all live inside that hierarchy.
Release acceptance
scripts/ci/run-linux-webview-wayland-gtk4.sh starts a real headless Weston
session with DISPLAY unset. It runs two bounded proofs:
- a focused same-GDK-surface scene/WebKit composition test; and
- the production
PlatformWindowimplementation with retained-scene PNG export, raw Wayland handles, app-protocol navigation and subresources, page-to-host IPC, host-to-page messaging, JavaScript result serialization, current URL state, and clean application shutdown.
scripts/ci/run-linux-webview-xwayland.sh selects the same maintained host
through GDK's X11 backend and proves retained GSK output, raw X11 handles,
native pointer-lock acquisition and release after a native menu interaction,
custom protocols, IPC, JavaScript, URL state, and event-driven idle operation.
The scripts fail unless every stage marker is present. Mesa software rendering
is used on CI machines without a physical render node, while still exercising
the compositor/client EGL path. Logs are retained as release artifacts. The
deprecated feature alias is checked separately to prove it cannot reintroduce
the archived stack or substitute a different host.
Run both native proofs locally with:
bash scripts/ci/run-linux-webview-wayland-gtk4.sh
bash scripts/ci/run-linux-webview-xwayland.sh
For operation-specific decisions, query
WebViewCapabilityReport::current() instead of inferring support from the OS
name. It distinguishes full engine operations from native-island limitations
and from a feature that was not compiled.
Crash Reporting
Kael's kael_diagnostics crate captures crashes and persists reports so they can
be submitted on the next launch. There are two layers:
- Panic capture — a Rust panic hook (
CrashReporter::install_hook) records the panic message, a resolved backtrace, breadcrumbs, and host info. - Native capture — OS-level handlers (
CrashReporter::install_native) catch crashes that never unwind through Rust: segmentation faults, bus errors, illegal instructions, floating-point exceptions, aborts, and crashes originating in C/FFI/GPU-driver code.
Without native capture, a segfault or abort() produces nothing. With it, the
crash is recorded to disk and turned into a submittable report on the next run.
Installing
Install both layers at startup. Native capture is opt-in:
#![allow(unused)] fn main() { use kael_diagnostics::{BreadcrumbBuffer, CrashConsent, CrashReporter}; let mut reporter = CrashReporter::new("com.example.app", BreadcrumbBuffer::new(64))?; reporter.set_release(env!("CARGO_PKG_VERSION")); reporter.set_environment("production"); reporter.set_endpoint("https://crashes.example.com/submit"); reporter.set_http_client(http_client.clone()); reporter.install_hook(); // Rust panics reporter.install_native()?; // SIGSEGV/SIGBUS/SIGILL/SIGFPE/SIGABRT, FFI, etc. }
Pre-crash context (app version, environment, OS, architecture, session id, pid)
is captured at install_native() time into a pre-opened artifact — never inside
the crash handler, which must stay async-signal-safe.
Detecting and submitting prior crashes
Call check_and_submit_pending early in startup. It detects crashes left by the
previous run, converts them to JSON reports, and — only with consent — submits
all pending reports through the configured HTTP endpoint:
#![allow(unused)] fn main() { let summary = reporter.check_and_submit_pending(CrashConsent::granted()).await?; if summary.detected_any() { for message in &summary.messages { eprintln!("recovered from prior crash: {message}"); } } }
On orderly shutdown, mark the session clean so the next launch does not treat it as an unclean exit:
#![allow(unused)] fn main() { reporter.mark_clean_exit()?; }
PriorCrashSummary distinguishes:
native_crashes— a handler fired and a signal record was decoded.unclean_exits— the previous run left a marker but no native record (for exampleSIGKILL, an OOM kill, or power loss); reported but with no signal detail.
Consent
CrashConsent mirrors the release UpdatePolicy style and defaults to
withheld. Reports are always collected and retained on disk, but they are never
submitted unless the application explicitly opts in:
#![allow(unused)] fn main() { let consent = if user_enabled_crash_reporting { CrashConsent::granted() } else { CrashConsent::withheld() }; reporter.check_and_submit_pending(consent).await?; }
With consent withheld (or no endpoint/HTTP client configured), prior crashes are converted to JSON reports and kept on disk but not uploaded.
What is captured, per platform
The native handler path is deliberately minimal so it can run inside a signal / exception handler without allocating, locking, or formatting. It writes a small fixed-shape record; everything human-readable is reconstructed on the next launch.
| Capability | macOS | Linux | Windows |
|---|---|---|---|
| Mechanism | sigaction (SIGSEGV/SIGBUS/SIGABRT/SIGILL/SIGFPE) | sigaction (same set) | SetUnhandledExceptionFilter |
| Signal / exception code | Yes | Yes | Yes (exception code) |
| Fault address | Yes (si_addr) | Yes (si_addr) | Yes (ExceptionAddress) |
| Backtrace | Frame-pointer walk (x86_64/aarch64) | Frame-pointer walk (x86_64/aarch64) | RtlCaptureStackBackTrace |
| Symbolized frames | No (raw addresses) | No (raw addresses) | No (raw addresses) |
| Breadcrumbs at crash time | No | No | No |
| Pre-crash context | Yes (captured at install) | Yes | Yes |
| Verified by CI test | Yes (real SIGSEGV + abort) | cfg-gated, same code path | Implemented, not exercised here |
What is not captured by the native path, and why:
- Full minidumps. Writing a minidump in-process is not async-signal-safe, and the macOS minidump tooling is still early. Kael captures raw return addresses instead and symbolizes them offline (below).
- Resolved symbols / breadcrumbs / heap state. Resolving symbols or touching the breadcrumb buffer would allocate or lock inside the handler. Breadcrumbs remain available for the Rust panic path.
- Backtraces without frame pointers. The unix backtrace is a frame-pointer walk. Builds compiled with frame pointers omitted will capture fewer (or no) frames; see the symbolication notes.
Symbolication
Native frames are raw instruction addresses. To turn them into file/line/function information you need the unstripped binary (or its separate debug symbols) for the exact build that crashed.
Keep per-release symbols by archiving the build artifacts:
- macOS: keep the
.dSYMbundle produced alongside the binary. - Linux: keep the unstripped binary, or split debug info into a
.debugfile withobjcopy --only-keep-debug. - Windows: keep the
.pdbemitted next to the.exe.
Resolve captured addresses against the matching build:
# macOS — addresses are absolute load addresses
atos -o MyApp.app/Contents/MacOS/MyApp -arch arm64 0x1042684a0 0x1042687b0
# Linux
addr2line -e ./my-app -f -C 0x4001 0x4002
For frame-pointer-based backtraces to be useful in release builds, compile with frame pointers preserved:
# .cargo/config.toml
[build]
rustflags = ["-C", "force-frame-pointers=yes"]
If you later adopt full minidumps for richer post-mortem analysis, the captured
.dmp files can be inspected with
minidump-stackwalk against the
archived symbols; the current Kael implementation does not write minidumps.
Developer Tools
Kael ships an in-app element inspector — the desktop equivalent of a browser's DevTools. You hover and click any element to see its id, layout bounds, style summary, position in the element tree, and a live frame-timing strip.
The picking machinery (hit-testing, element-state reflection, the side panel
geometry) lives in the core kael crate. The panel UI is provided by
kael_ui, so the inspector adopts your theme tokens automatically.
One-call setup
Call kael_ui::devtools::install_inspector once at startup, behind
#[cfg(debug_assertions)] so release builds never include it:
use kael::{Application, App, KeyBinding, actions}; #[cfg(debug_assertions)] actions!(myapp, [ToggleInspector]); fn main() -> Result<(), Box<dyn std::error::Error>> { Application::try_new()?.run(|cx: &mut App| { kael_ui::init(cx); #[cfg(debug_assertions)] { kael_ui::devtools::install_inspector(cx); cx.bind_keys([KeyBinding::new("cmd-alt-i", ToggleInspector, None)]); } // ... open your window ... }); Ok(()) }
install_inspector does two things:
- Registers the default inspector renderer via
App::set_inspector_renderer. Without it, toggling the inspector opens a blank 30rem strip — the renderer is the missing piece that turns that strip into a populated panel. - Registers a
DivInspectorStatereflector viaApp::register_inspector_element, so everydivreports its bounds, content size, and base style to the panel when picked.
Toggling it
install_inspector only registers the renderer; it does not bind a key — that
is your app's choice. Wire an action to Window::toggle_inspector:
#![allow(unused)] fn main() { #[cfg(debug_assertions)] let root = root.on_action(|_: &ToggleInspector, window, cx| { window.toggle_inspector(cx); }); }
The dashboard template binds Cmd-Alt-I to this in debug builds. Toggling
on enters picking mode: hover an element to highlight it, click to pin the
selection. Toggle again to close the panel.
What the panel shows
| Section | Contents |
|---|---|
| Path | A breadcrumb of the picked element's GlobalElementId, deepening one indent per level, ending with the file:line:col where the element was constructed. |
| Element | Instance index, layout origin, size, and children content size — read from DivInspectorState. |
| Style | Any explicitly set display, size, background, and flex direction from the element's base style. Rows for unset properties are omitted. |
| Frames | Recent frame count, average frame time, derived FPS, and p95 / p99 frame times. |
Frame timing
In debug builds (and with the inspector feature) every window records a
FrameRecord at the end of each Window::draw. The rolling timeline — a 300-frame
ring buffer — is exposed via Window::frame_timeline():
#![allow(unused)] fn main() { let timeline = window.frame_timeline(); let avg_us = timeline.average_duration_us(); // Option<f64> let p95_us = timeline.p95_duration_us(); // Option<u64> let jank = timeline.detect_jank(16_667); // frames over ~60fps budget }
The recording hook is gated behind cfg(any(feature = "inspector", debug_assertions))
and does nothing in release builds, so there is no runtime cost in production.
Building without a Metal toolchain
The inspector lives behind debug_assertions, so a plain debug build includes
it. If you build on macOS without Xcode's metal compiler, add the
runtime_shaders feature so shaders compile at launch instead of at build time:
cargo run -p your-app --features kael/runtime_shaders
Testing
Browser engine matrix
The release-level browser proof runs the same packaged WebAssembly artifacts in Chromium, Firefox, and WebKit. Install the pinned Playwright dependency and browser binaries, then run:
cargo install wasm-bindgen-cli --version 0.2.122 --locked
python3 -m venv target/browser-matrix-venv
target/browser-matrix-venv/bin/python -m pip install \
-r scripts/browser-matrix-requirements.txt
target/browser-matrix-venv/bin/python -m playwright install \
chromium firefox webkit
KAEL_PLAYWRIGHT_PYTHON=target/browser-matrix-venv/bin/python \
bash scripts/verify-browser-matrix.sh
Use KAEL_BROWSER_MATRIX_ENGINES=firefox for one-engine diagnosis.
KAEL_BROWSER_MATRIX_SKIP_BUILD=1 reuses existing packaged artifacts, while
KAEL_BROWSER_MATRIX_SKIP_SUITE=1 and
KAEL_BROWSER_MATRIX_SKIP_REALTIME=1 are diagnostic-only reductions.
KAEL_BROWSER_MATRIX_SKIP_CAPTURE=1 omits only the injected canvas-backed
display-capture lifecycle fixture. Release CI uses none of these reductions.
The report records pointer activation, the retained frames scheduled by the
component ripple, IME state, virtual-scroll latency, and renderer identity.
Hardware runs require multiple post-click frames. Software-fallback runs require
one post-click frame because a loaded SwiftShader frame can outlast the ripple;
the report stores both the observed and required frame counts.
Evidence is written to target/browser-matrix.
Generated project parity
The maintained release gate invokes the actual CLI, leaves its generated
src/main.rs and Cargo.toml byte-for-byte unchanged, and checks the same
source against both target dependency sets:
KAEL_PLAYWRIGHT_PYTHON=target/browser-matrix-venv/bin/python \
bash scripts/verify-generated-project-parity.sh
For a fast local compiler and packager preflight without launching Chromium:
KAEL_GENERATED_PARITY_SKIP_BROWSER=1 \
bash scripts/verify-generated-project-parity.sh
Release CI never uses that reduction.
The verifier creates a temporary nested workspace under target, applies
kael and kael_ui through a parent workspace [patch.crates-io] table, seeds
resolution from the repository Cargo.lock, and deletes only that temporary
workspace on exit. This lets release CI prove the next unpublished Kael version
without rewriting the generated project or selecting an unrelated fresh set of
transitive versions. It checks the native target, fetches the locked wasm graph,
then packages the unchanged binary offline through kael web build. It tests
both the default host page and a source-owned HTML and asset shell. The gate
requires the pinned wasm-bindgen and Binaryen optimization pass before it
applies the release artifact budget and launches the custom static output in
pinned Chromium. The browser proof requires successful Wasm initialization, at least
two retained frames, non-blank composited pixels, a viewport-filling canvas,
and no page or request errors.
The untouched generated source and manifest, metadata, hashes, logs, packaged
web files, a screenshot, and the JSON report are retained in
target/generated-project-parity-evidence.
The publish workflow attests that the exact candidate SHA already has a successful Platform Readiness run, so this proof remains release-blocking without running the whole matrix twice. The native renderer jobs independently scaffold the same unchanged template on Windows and Linux to prove that the generated desktop binary launches there too.
Native renderer runtime smoke
Platform readiness does not treat a successful native compile as renderer
evidence. native_renderer_smoke rejects KAEL_HEADLESS, opens and activates a
real window, advances four visually different retained scene revisions, reads
the selected GPU identity, and exports the final scene through the backend at
device-pixel resolution. The gate decodes the PNG and checks its dimensions,
visible-pixel ratio, color diversity, and luminance range before exiting itself
within a 20-second deadline.
Linux runs the X11 surface path under a private Xvfb server and selects Mesa
lavapipe explicitly on the GPU-less hosted runner. This proves Blade/Vulkan
window, presentation, and readback behavior with an honestly reported software
adapter; it is not a hardware-throughput or native-Wayland-compositor claim.
Windows runs the same scene through Direct3D 11. Normal applications prefer a
compatible hardware adapter and fall back to WARP if enumeration finds none.
Hosted CI explicitly sets the strictly parsed KAEL_FORCE_WARP=1 proof switch,
then requires the adapter to identify itself as software. This is likewise a
correctness/liveness gate rather than Direct3D hardware performance evidence.
# Linux, with Xvfb and Mesa Vulkan packages installed
KAEL_NATIVE_RENDERER_USE_SOFTWARE=1 \
bash scripts/ci/verify-linux-native-renderer.sh
# Windows
pwsh -File scripts/ci/verify-windows-native-renderer.ps1
Both scripts then invoke kael new, build the untouched generated source, and
prove that its visible native window is mapped. Windows closes the starter via
its normal Win32 window lifecycle. The Linux starter is deliberately an
interactive app with no test-only exit branch, so CI captures its X11 window
geometry and pixels and then stops it externally under a bound. Evidence lives
under target/native-renderer-smoke/{linux,windows} and includes adapter logs,
the decoded-scene PNG, generated-source snapshots, and native-window evidence.
Native WebView runtime smoke
Platform readiness executes webview_smoke against WKWebView on macOS,
WebView2 on Windows, and the maintained GTK4 + WebKitGTK 6 host on both
Weston/XWayland and native Wayland. The
macOS verifier explicitly removes KAEL_HEADLESS from the child environment
and requires page-load, page-to-host IPC, host-to-page messaging, JavaScript
result, and current-URL stages plus successful focus and zoom commands before
accepting the final marker:
bash scripts/ci/verify-macos-wkwebview.sh
The log is retained in target/macos-wkwebview-smoke/wkwebview.log. This is a
real platform runtime gate; a headless capability check cannot satisfy it.
The native-Wayland gate runs
scripts/ci/run-linux-webview-wayland-gtk4.sh. It requires the focused
same-surface composition proof and the production PlatformWindow proof,
including retained-scene PNG export, raw Wayland handles, app-owned custom
protocol navigation and subresources, page/host IPC, JavaScript results, URL
state, and clean shutdown under a headless Weston compositor.
scripts/ci/run-linux-webview-xwayland.sh selects the same production host
through GDK's X11 backend and requires X11 raw handles, GSK scene export, the
native XI2 pointer-lock implementation to acquire and release after a native
context menu has been shown, the full WebView protocol/IPC stages, event-driven
idle behavior, and clean shutdown without terminating XWayland. The legacy
feature spelling is compiled in isolation to prove it redirects to the
maintained host and cannot reintroduce GTK3.
Kael ships a headless test platform so you can drive real windows, views, and input from ordinary unit tests — no display server, GPU, or windowing backend required. Tests run the same on macOS, Windows, and Linux CI.
#[kael::test]
Annotate a test with #[kael::test] and declare a &mut TestAppContext
parameter. The macro builds the headless app context, seeds the RNG, and tears
everything down afterward:
#![allow(unused)] fn main() { use kael::{TestAppContext, WindowOptionsBuilder}; #[kael::test] fn opens_a_window(cx: &mut TestAppContext) { let window = cx.update(|cx| { cx.open_window(WindowOptionsBuilder::new().title("Test"), |_, cx| { cx.new(|_| MyView::default()) }) .unwrap() }); window.update(cx, |view, _window, _cx| { assert!(view.is_ready()); }).unwrap(); } }
Parameter types the macro recognizes:
| Parameter | What you get |
|---|---|
&mut TestAppContext | A headless app context (open one per &mut TestAppContext parameter). |
BackgroundExecutor | The test's deterministic background executor. |
StdRng | A seeded RNG (seed 0, or SEED env var, or #[kael::test(seed = N)]). |
Async tests are supported — declare the fn async and the macro drives it on the
test executor.
TestAppContext and VisualTestContext
TestAppContext is the entry point. For tests that need a rendered window, use
add_window_view, which opens a maximized headless window, renders your root
view, and hands back the view plus a VisualTestContext scoped to that window:
#![allow(unused)] fn main() { let (view, vcx) = cx.add_window_view(|_window, _cx| MyView::default()); }
VisualTestContext is where window-level testing happens:
| Method | Use |
|---|---|
simulate_click(point, modifiers) | Synthesize a left mouse down + up. |
simulate_mouse_move / simulate_mouse_down / simulate_mouse_up | Lower-level pointer events. |
simulate_keystrokes("cmd-s a b") | Type a space-separated keystroke sequence. |
dispatch_action(MyAction) | Dispatch an action into the focused tree. |
run_until_parked() | Flush the executor so effects, notifies, and redraws settle. |
draw(origin, space, fn) | Lay out and paint an element directly. |
The golden rule: after mutating view state or simulating input, call
run_until_parked() before asserting, so pending effects and re-renders complete.
Testing kael_ui components
kael_ui is a normal crate, so the same pattern works for its components. The
trick is enabling the headless platform: add kael as a dev-dependency with
the test-support feature so TestAppContext is in scope during tests.
# crates/kael_ui/Cargo.toml
[dev-dependencies]
kael = { path = "../kael", version = "0.4.1", features = ["test-support"] }
test-supportis platform-agnostic: the test platform mocks the windowing layer, so it does not pull the Linux Wayland/X11 crates into a macOS or Windows test build. (Linux CI that needs real-window rendering under tests can opt in with thetest-support-linux-windowingfeature.)
Then write a root harness view that renders the component under test and assert on observable behavior. A button-click test, end to end:
#![allow(unused)] fn main() { use std::cell::Cell; use std::rc::Rc; use kael::{div, point, px, Context, IntoElement, ParentElement as _, Render, Styled as _, InteractiveElement as _, TestAppContext, Window}; use kael_ui::prelude::*; struct Harness { clicks: Rc<Cell<usize>> } impl Render for Harness { fn render(&mut self, _w: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement { let clicks = self.clicks.clone(); div().size_full().child( div().absolute().top(px(0.0)).left(px(0.0)).child( Button::new("btn", "Click me") .size(ButtonSize::Lg) .on_click(move |_e, _w, _cx| clicks.set(clicks.get() + 1)), ), ) } } #[kael::test] fn button_click_fires_handler(cx: &mut TestAppContext) { cx.update(|cx| kael_ui::theme::install_theme(cx, kael_ui::theme::Theme::dark())); let clicks = Rc::new(Cell::new(0)); let (_view, vcx) = cx.add_window_view(|_w, _cx| Harness { clicks: clicks.clone() }); vcx.simulate_click(point(px(40.0), px(20.0)), Default::default()); vcx.run_until_parked(); assert_eq!(clicks.get(), 1); } }
Notes:
- Install a theme first — most components read
Theme::dark()tokens during render andkael_ui::theme::install_thememakes them available. - Position the component deterministically (here, top-left) so the simulated click lands on it.
- State that lives on the view (toggles, counters, transition state) is asserted
by calling
view.update(vcx, |view, _| { ... })afterrun_until_parked().
See crates/kael_ui/tests/component_tests.rs for the full set of patterns,
including an implicit-transition test on a real div.
CI recipe
Run the suites with the runtime_shaders feature so macOS CI does not need
Xcode's metal compiler, and the test-support platform stays headless:
# .github/workflows/ci.yml (excerpt)
jobs:
test:
strategy:
matrix:
os: [macos-latest, windows-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: 1.97.1
- name: Test kael
run: cargo test -p kael --lib --features test-support,runtime_shaders
- name: Test kael_ui
run: cargo test -p kael_ui --features kael/runtime_shaders
- name: Test xtask
run: cargo test -p xtask
Locally, the same commands work:
cargo test -p kael --lib --features test-support,runtime_shaders
cargo test -p kael_ui --features kael/runtime_shaders
cargo test -p xtask
Release Process
Kael releases are made from one reviewed commit on main. Crate publication,
native installers, updater metadata, and the Git tag must all identify that
same commit and version. Never publish from a dirty worktree.
Documentation-only changes
Documentation is not a framework release. Changes limited to docs/**,
Markdown files such as README.md or crate READMEs, llms.txt, or the docs
workflow do not require a workspace version bump, changelog release section,
platform rebuild, crate publication, or Git tag.
The Documentation workflow validates those changes on pull requests. A push to
main builds and deploys the guide to GitHub Pages. Platform Readiness runs one
small classifier for required-check compatibility, then skips the macOS, Linux,
Windows, and browser build jobs. The stable Platform readiness check passes
after classification, so documentation pull requests remain mergeable.
Keep unreleased documentation improvements on main. They become part of the
next crate release naturally when a later code change requires a new version.
Do not dispatch Publish crates for a documentation-only commit.
Changes to source, manifests, lockfiles, build scripts, fixtures, or platform workflows are not documentation-only and still require the normal code gates.
Prepare the release candidate
Update the workspace version, kael.dist.toml, scaffold dependency version,
and the dated changelog section together. Then run the local gates:
cargo fmt --all --check
bash scripts/ci/verify-cross-targets.sh
bash scripts/ci/audit-dependencies.sh
bash scripts/ci/verify-kael.sh default
bash scripts/publish-all.sh --preflight
bash scripts/ci/verify-docs.sh
On macOS, these commands require full Xcode rather than Command Line Tools for
the package archive's default Metal shader build. Select it either system-wide
with xcode-select or per shell with DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer.
The documentation gate builds every page, copies llms.txt, rejects orphaned
pages, broken local links, duplicate HTML IDs, stale LLM routes, oversized font
assets, and guides that stop including the compiled quick-start source. The
quick-start example is compiled on native and Wasm targets in Platform
Readiness.
The publication preflight selects all 34 crates in dependency order, checks
their license and package contents, builds the actual .crate archives as one
unpublished workspace set, compiles every extracted archive, and enforces the
crates.io 10 MiB compressed archive limit. It does not upload anything.
verify-cross-targets.sh uses Zig to link the portable Linux test graphs from
the development machine and separately checks the public Wasm graphs. It is a
fast compiler/linker preflight, not runtime evidence: it cannot execute Metal,
Direct3D, WebView2, WKWebView, WebKitGTK, or browser engines.
Commit and push the complete candidate before treating runtime evidence as release evidence. That exact push starts the compact Platform Readiness gate: Linux quality/package checks, Linux renderer/WebView runtime, the browser matrix, macOS native/WKWebView/Metal/browser-hardware checks, and two Windows native/WebView/MSI compatibility runners. The Metal browser job must report a non-software WebGL adapter; merely omitting the forced SwiftShader query is not sufficient.
After Platform Readiness is green, run Publish crates with publish=false.
This is a cheap attestation: it requires a successful Platform Readiness run
whose head_sha exactly matches the checked-out candidate and does not rerun
the same platform matrix.
gh workflow run release.yml --ref main -f publish=false
gh run list --workflow release.yml --branch main --limit 1
gh run watch <run-id> --exit-status
Do not substitute a Zig cross-compile for the hosted platform runtime jobs.
Publish crates and tag the commit
The crates-io GitHub environment must contain CARGO_REGISTRY_TOKEN. After
the non-publishing workflow run is green, dispatch the same workflow with
publish=true:
gh workflow run release.yml --ref main -f publish=true
gh run list --workflow release.yml --branch main --limit 1
gh run watch <run-id> --exit-status
The workflow only publishes from refs/heads/main, requires the exact
confirmation generated from the workspace version, uploads crates in
dependency order, waits for each registry version to become visible, and can
resume a partial upload without overwriting an immutable crates.io version.
After all crates are visible, create the annotated version tag on the exact published commit and push only that tag:
git tag -a v0.4.1 <published-commit-sha> -m "Kael 0.4.1"
git push origin v0.4.1
macOS distribution order
kael.dist.toml may contain the public Developer ID identity and team ID. Keep
notary credentials out of the repository. Store them once with Apple's tool and
provide only the profile name through KAEL_NOTARY_PROFILE:
xcrun notarytool store-credentials <profile> \
--apple-id <apple-id> --team-id <team-id>
The production order is deliberate: sign the hardened-runtime app, create the DMG, timestamp-sign the DMG, notarize it, and staple the accepted ticket.
cargo build --release -p kael-cli --bin kael
cargo run -p xtask -- bundle kael.dist.toml \
--output dist --binary target/release/kael
cargo run -p xtask -- sign kael.dist.toml --artifact dist/kael.dmg
KAEL_NOTARY_PROFILE=<profile> cargo run -p xtask -- \
notarize kael.dist.toml --artifact dist/kael.dmg
codesign --verify --deep --strict --verbose=4 dist/Kael.app
codesign --verify --strict --verbose=4 dist/kael.dmg
xcrun stapler validate dist/kael.dmg
Bundling signs the app before it enters the disk image. The standalone signer
uses a trusted timestamp and omits app-only hardened-runtime flags for a DMG.
Production notarization fails closed when KAEL_NOTARY_PROFILE is missing.
Windows and Linux installers
On Windows, install the pinned WiX v4 toolchain used by CI. Supply the PFX path
and password through KAEL_WINDOWS_CERTIFICATE and
KAEL_WINDOWS_CERTIFICATE_PASSWORD; do not commit the password. Bundling signs
the MSI with SHA-256 and a trusted timestamp. Run
scripts/ci/verify-windows-msi.ps1 against the result to prove the MSI database,
payload hash, extracted executable, and Authenticode status.
Linux bundling produces the .deb and AppImage payloads. The standalone Linux
signer creates an armored detached GPG signature using the maintainer's selected
key. Verify installer behavior on the same supported distribution baseline used
by platform-readiness CI.
Signed updater metadata
Generate the Ed25519 updater key pair once:
cargo run -p xtask -- generate-update-key
Store the private value as KAEL_UPDATE_SIGNING_KEY; never commit or print it
in CI logs. Put only the matching public key in updater.public_key in
kael.dist.toml. A production xtask publish requires real regular-file
artifacts, non-placeholder hashes and sizes, a selected update artifact that is
also uploaded, and a private key that matches the configured public key. Dry-run
feeds are deliberately unsigned and are not release artifacts.
Browser artifacts
The browser target is published through the same crates and source tree, not as a forked UI implementation. Before tagging, retain the generated-project parity report, optimized Wasm size report, suite-scale report, and Chromium/Firefox/ WebKit matrix report from the exact SHA. Also retain the macOS hardware report, its renderer/vendor identity, the compositor screenshots, and the raw retained framebuffer PNGs. The latter keep visual evidence deterministic when an automation compositor omits a restored WebGL plane. Browser security boundaries such as permission prompts and cross-origin iframe restrictions remain capability differences; they must be handled through Kael's typed capability reports rather than platform-specific view code.
Showcase and starter applications
Kael keeps one maintained example instead of a large set of overlapping demo binaries. The Astryx showcase is an interactive catalog of the framework's UI surface, organized into focused sections for actions, inputs, selection, data display, charts, feedback, navigation, overlays, typography, media, and layout.
git clone https://github.com/Augani/kael.git
cd kael
cargo run -p kael_ui --example astryx_showcase \
--features "media kael/runtime_shaders"
The sidebar switches sections without launching another process. Controls are live: you can type, drag, sort, resize, open overlays, navigate with the keyboard, and inspect accessible states in the same application.
For deterministic visual or accessibility inspection, the showcase supports
section filters such as ASTRYX_SHOWCASE_CHART_SECTION,
ASTRYX_SHOWCASE_OVERLAY_SECTION, and ASTRYX_SHOWCASE_LAYOUT_SECTION.
Starter applications
The workspace also contains three application templates. They are maintained as real packages because they demonstrate application architecture rather than isolated component examples:
cargo run -p dashboard-app
cargo run -p messaging-app
cargo run -p workspace-app
dashboard-appcombines application navigation, cards, charts, and tables.messaging-appcombines a conversation list, message history, and composer.workspace-appcombines a file tree, editor surface, panels, and status bar.
Create a standalone project from any template with the Kael CLI:
cargo run -p kael-cli -- new my-app
Performance harness
The production performance workload is a Cargo benchmark, not an example:
cargo bench -p kael --bench framework
Use scripts/bench/generate-baseline.sh and
scripts/bench/run-comparison.sh to create and compare the checked workload.
Core platform workflows—windows, menus, capture, WebView, printing, plugins, background processes, and release integration—live in the focused guide chapters and automated tests. Keeping security- or platform-sensitive code there prevents copy-pasted example implementations from drifting away from the supported API.
API Documentation
Kael has two complementary documentation surfaces:
- This guide explains architecture, workflows, platform choices, and complete application concerns.
- docs.rs renders the public Rust API for every Kael crate release, including searchable modules, types, traits, methods, and source links.
Start with the guide when deciding how a feature should fit into an application. Use rustdoc while implementing it.
Primary API references
| Need | API reference |
|---|---|
| Runtime, entities, elements, windows, input, text, layout, rendering | kael |
| Ready-made controls and product UI | kael_ui |
| Procedural macros | kael_macros |
| Storage and migrations | kael_storage |
| HTTP and connected application patterns | kael_http_client and kael_net |
| Diagnostics and crash data | kael_diagnostics |
| Documents, Office/PDF bytes, and sharing | kael_document, kael_office, kael_pdf, and kael_share |
| Audio and media | kael_audio, kael_media, and kael_media_engines |
| Application engines | kael_engines |
Core module map
The kael crate re-exports its most common types at the crate root. These
modules provide focused entry points for larger systems:
| Module | Purpose |
|---|---|
prelude | Traits and types most views import |
animation, interpolate | Timelines, easing, keyframes, and value interpolation |
app_runtime, runtime, worker_api | Application lifecycle and background execution |
virtual_data | Virtualized lists, tables, and tree data |
text_engine | Editing, selection, composition, and document text behavior |
platform_caps | Runtime capability truth for platform-dependent workflows |
security | Permissions, policies, validation, and safe handoffs |
process_model, ipc_transport, supervisor | Multi-process applications and worker supervision |
plugin, extension_host, extension_rpc | In-process and external extension systems |
headless_render, golden, benchmark | Rendering tests and performance evidence |
scene_graph, graphics_capabilities, gpu | Creative surfaces and GPU control |
dev_tools | Inspector, metrics, and development-time tooling |
The Core Concepts, Platform APIs, and Testing chapters explain how these pieces cooperate.
Feature flags
The core crate keeps costly or specialized integrations optional:
| Feature | Adds |
|---|---|
auto-update | Signed update feeds, checked download queues, and platform installers |
lottie | Native Lottie and dotLottie decoding and playback |
webview | Explicit hosted web surfaces |
media | Native media playback integration |
storage | Storage primitives through kael_storage |
icons | Compact embedded icon catalog with application-asset overrides |
diagnostics | Metrics, breadcrumbs, and crash-report integration |
document | Document lifecycle helpers |
audio | Audio integration |
pdf | PDF services |
office | Portable DOCX/XLSX/PPTX OPC parsing, extraction, and deterministic export |
notifications-full | Notification services |
share | Platform sharing workflows |
screen-capture | Screen-capture backend support |
agent-tools | Structured capability-planning metadata |
runtime_shaders | Runtime shader compilation for development |
kael_ui separately gates Markdown, native HTML rendering, audio, media, and
additional editor grammars. Feature-gated APIs appear in the relevant crate
documentation when that documentation profile enables the feature.
Build API docs locally
Build the two primary references without documenting dependencies:
RUSTDOCFLAGS="-D warnings" \
cargo doc -p kael -p kael_ui --all-features --no-deps
Open target/doc/kael/index.html or run cargo doc -p kael --open for a faster
default-feature build. The production-readiness workflow also documents every
public library crate with all features enabled, so broken intra-doc links and
other rustdoc warnings anywhere in that crate set block a release candidate.
Documentation guarantees
kaelandkael_uiprovide crate-level landing pages with dependency and usage guidance.- docs.rs profiles are explicit so documentation builds do not depend on an accidental feature set or unsupported cross-compilation target.
- Public API links are checked with rustdoc warnings treated as errors.
- The mdBook guide is built independently, so conceptual documentation cannot hide API-documentation failures.
- Platform-specific support is described through
CapabilityReport; the existence of a type alone is never presented as proof that every backend implements it.
For LLMs
Kael publishes an llms.txt file at
the site root. It gives an assistant the current architecture, platform limits,
browser build contract, and the right source files to inspect.
Use it as context when an assistant writes or reviews a Kael application:
https://augani.github.io/kael/llms.txt
Every guide page also has a Copy page action in the top bar. It copies clean
Markdown for a focused question. Use llms.txt for framework wide context and
Copy page for the guide you are working from.
Useful entry points: