pane_ui

A immediate-mode UI library for wgpu. UI is defined in RON files and rendered each frame via wgpu. Supports hot reload of layouts, styles, and shaders.
Quick Start
[]
= "0.1.0"
Create assets/menu.ron:
(
hot_reload: true,
start_root: "main",
default_style: Some("plain"),
style_dirs: ["styles"],
roots: [(
name: "main",
buttons: [
( id: "btn_ok", x: -110.0, y: 0.0, width: 100.0, height: 40.0, text: "OK", on_press: Custom("ok") ),
( id: "btn_quit", x: 10.0, y: 0.0, width: 100.0, height: 40.0, text: "Quit", on_press: Quit ),
],
)],
)
For a callback on each action:
run_with;
Examples in the demos folder
Integration Modes
Standalone
Pane owns the window and event loop.
run;
// or with a callback:
run_with;
Overlay
Renders into a caller-owned wgpu surface. Your render loop stays intact.
// At startup
let mut ui = overlay;
// In your event loop
ui.handle_event;
// After your own draw calls
let actions = ui.draw;
for action in actions
Pass Some(gilrs) to share an existing gamepad context, or None to let pane create its own.
Headless
No GPU, no window. Useful for testing or driving UI logic server-side.
let mut menu = headless;
let actions = menu.update; // advance by dt, returns PaneActions
menu.press; // requires headless_accessible: true in the RON
let root = menu.active_root;
Coordinate System
Origin (0, 0) is the center of the screen. Height is always 1080 units regardless of resolution; width scales with aspect ratio.
y = -540
┬
│ ← negative Y is up
(-960) ──── 0,0 ──── (+960) (16:9)
│ ← positive Y is down
┴
y = +540
A widget at x: 0, y: 0 is centered on every screen and resolution.
RON File Reference
Root file
(
hot_reload: bool, // watch files and reload on change
headless_accessible: bool, // allow PaneHeadless::press()
background: Option<String>, // background image path
default_style: Option<String>, // fallback style for widgets with no explicit style
start_root: String, // which root to show first
shader_dirs: Vec<String>, // directories to scan for .wgsl files
style_dirs: Vec<String>, // directories to scan for .ron styles
roots: Vec<Root>,
)
Root
(
name: String,
buttons: Vec<Button>,
toggles: Vec<Toggle>,
sliders: Vec<Slider>,
text_boxes: Vec<TextBox>,
dropdowns: Vec<Dropdown>,
radio_groups: Vec<RadioGroup>,
scroll_lists: Vec<ScrollList>,
scroll_panes: Vec<ScrollPane>,
bars: Vec<Bar>,
popouts: Vec<Popout>,
tabs: Vec<Tab>,
labels: Vec<FreeLabel>,
dividers: Vec<Divider>,
images: Vec<Image>,
progress_bars: Vec<ProgressBar>,
actors: Vec<Actor>,
)
Common widget fields
Every interactive widget shares id, x, y, width, height, style, tooltip, and disabled.
Button on_press values:
on_press: Custom("my_tag") // emits PaneAction::Custom("my_tag")
on_press: SwitchRoot("settings") // switches active root (pane handles it automatically)
on_press: Quit // emits PaneAction::Quit
on_press: Print // prints to stdout
Widgets
| Widget | Description |
|---|---|
Button |
Clickable, with text, tooltip, and disabled state |
Toggle |
On/off switch with separate styles per state |
Slider |
Draggable range input with optional step size |
TextBox |
Single-line text input with placeholder and max length |
Dropdown |
Collapsible option selector |
RadioGroup |
Mutually exclusive option set |
ScrollList |
Scrollable list; children are auto-positioned |
ScrollPane |
Scrollable container for mixed content; children are auto-positioned |
Bar |
Edge-anchored panel (top/bottom/left/right); children are auto-positioned |
Popout |
Spring-animated panel that slides between open/closed |
Tab |
Multi-page container; pages switch via SwitchTabPage press actions |
Label |
Non-interactive text |
Divider |
Solid rectangle for visual separation |
Image |
Static image or animated GIF |
ProgressBar |
Read-only fill indicator (0.0–1.0) |
Actor |
Animated element with cursor-reactive behaviors |
Bar, ScrollList, and ScrollPane auto-position their children — you don't set coordinates on items inside them.
Actor
An Actor is an animated element that reacts to cursor events via a trigger/action behavior system.
actors: [(
id: "cursor_glow",
origin_x: 0.0, origin_y: 0.0,
width: 120.0, height: 120.0,
gif: Some("glow"),
behaviours: [
(trigger: Always, action: MoveTo(x: 0.0, y: 0.0, speed: 3.0)),
(trigger: OnHoverSelf, action: FollowCursor(speed: 8.0, trail: 0.3)),
(trigger: OnPressSelf, action: SwapGif(texture: "glow_bright", shader: "textured")),
],
)]
Triggers: Always · OnHoverSelf · OnPressSelf · OnClickSelf · OnClickAnywhere
Actions: FollowCursor { speed, trail } · MoveTo { x, y, speed } · SwapGif { texture, shader }
Actors can also be controlled from code:
ui.actor_follow_cursor;
ui.actor_move_to;
ui.actor_reset; // restore RON behaviors
ui.actor_set_pos; // teleport, no spring
Actions
PaneOverlay::draw and PaneHeadless::update return Vec<PaneAction> each frame. run_with passes them one at a time to the callback.
use PaneAction;
match action
Runtime API
All three handles (StandaloneHandle, PaneOverlay, PaneHeadless) share the same core API.
Reading widget state
if let Some = ui.read
WidgetState fields (all bool):
| Field | Meaning |
|---|---|
hovered |
Cursor is over the widget |
pressed |
Being held down |
focused |
Has keyboard/gamepad focus |
disabled |
Non-interactive |
checked |
Toggle is on |
grabbed |
Slider thumb is being dragged |
open |
Dropdown or popout is expanded |
Writing widget state
Pushes a value into a widget without emitting a PaneAction. Use this to sync UI to app state.
use WriteValue;
ui.write;
ui.write;
ui.write;
ui.write; // Dropdown or RadioGroup index
| Variant | Applies to | Behaviour |
|---|---|---|
Slider(f32) |
Slider, ProgressBar | Clamped to min..=max |
Toggle(bool) |
Toggle | Sets checked state |
Text(String) |
TextBox | Truncated to max_len if set |
Selected(usize) |
Dropdown, RadioGroup | Clamped to valid range |
Unknown id or mismatched widget type logs a warning and does nothing.
Default style
let current = ui.default_style; // Option<&str>
ui.set_default_style; // returns false if name not registered
Affects all widgets that don't have an explicit style set in the RON. Widgets with an explicit style are unaffected.
Toasts
ui.push_toast;
// msg secs x y w h
Creating and destroying widgets at runtime
use ButtonBuilder;
ui.create; // returns false if id exists or root is missing
ui.destroy; // returns false if not found
To create an empty root in code:
ui.create_root;
ui.create;
Available builders: ButtonBuilder · ToggleBuilder · SliderBuilder · TextBoxBuilder · DropdownBuilder · RadioGroupBuilder
To use a specific style, resolve its id first:
let s = ui.style_id;
let builder = new.pos.size.text.on_press;
let builder = if let Some = s else ;
ui.create;
Styling
Built-in styles
| Style | Description |
|---|---|
frosted_glass |
Frosted, translucent glass |
glass_pill |
Pill-shaped glass |
plain |
Flat solid colors |
emboss |
Raised surface with shadow |
sharp_outline |
Bordered, minimal |
retro |
Pixelated arcade aesthetic |
Set default_style in the root RON to apply a style to all widgets that don't specify one explicitly.
Custom styles
Drop a .ron file in your style directory and reference it by filename (no extension):
// styles/neon.ron
(
shader: "flat",
idle: (
shape: RoundedRectangle,
color: (r: 0.05, g: 0.05, b: 0.12, a: 0.95),
border_width: 1.5,
border_color: (r: 0.2, g: 0.8, b: 1.0, a: 1.0),
shadow_size: 8.0,
shadow_color: (r: 0.2, g: 0.8, b: 1.0, a: 0.4),
),
hovered: (
color: (r: 0.08, g: 0.08, b: 0.18, a: 0.98),
border_color: (r: 0.4, g: 0.9, b: 1.0, a: 1.0),
shadow_size: 16.0,
),
pressed: (
color: (r: 0.02, g: 0.02, b: 0.08, a: 1.0),
scale: 0.95,
),
disabled: (
color: (r: 0.05, g: 0.05, b: 0.08, a: 0.4),
border_color: (r: 0.2, g: 0.2, b: 0.3, a: 0.3),
),
)
Each visual state (idle, hovered, pressed, disabled) supports:
shape · color · corner_radius · border_width · border_color · shadow_size · shadow_color · scale · shader · texture · gif_mode · text_color · font_size · text_align · font · bold · italic
scale is spring-animated — 0.95 on pressed compresses the widget physically; values above 1.0 overshoot.
Custom shaders
Drop .wgsl files in your shader directory and reference them by filename (no extension) in a style or widget:
// styles/neon.ron
hovered: (
shader: "liquid_glass",
...
),
Built-in shaders: flat · frosted_glass · liquid_glass · retro · outline · emboss · textured
Hot Reload
Set hot_reload: true in the root RON. While the app is running:
| File saved | What updates |
|---|---|
menu.ron |
Layout, widget config, root structure |
styles/*.ron |
Visual design for all widgets using that style |
shaders/*.wgsl |
GPU shader recompiles and swaps live |
Requires the dev feature for style hot reload:
= { = "0.1.0", = ["dev"] }
Controller Support
All widgets support controller navigation with no configuration:
| Input | Action |
|---|---|
| Left stick / D-pad | Move focus to nearest widget in that direction |
| South (A / Cross) | Confirm / activate |
| East (B / Circle) | Cancel / close |
| Left / Right | Adjust slider; navigate dropdown/radio |
| Up / Down | Scroll list; navigate options |
In overlay mode, pass a gilrs::Event to ui.handle_gamepad_event(event), or let pane manage its own gamepad context.
GIF Support
Assign a gif to an Image or Actor. Three playback modes:
idle: ( texture: "spinner", gif_mode: Loop ), // plays forever
pressed: ( texture: "burst", gif_mode: Once ), // plays once, holds last frame
// gif_mode: OnceHide // plays once, then hides widget
Platform Support
| Platform | Status |
|---|---|
| Linux (Wayland) | Tested — primary development platform (Fedora) |
| Linux (X11) | Untested - Supported via winit |
| Windows | Untested - Supported |
| macOS | Untested - Supported |
| Web (WASM) | Not supported — winit event loop incompatible |
Building & Running
PANE_DEBUG=1
Dependencies
| Crate | Version | Role |
|---|---|---|
| wgpu | 29.0 | GPU rendering |
| winit | 0.30 | Window & event loop |
| glyphon | 0.11 | Text rendering |
| gilrs | 0.11 | Controller input |
| ron | 0.12 | Config format |
| serde | 1.0 | Deserialization |
| image | 0.25 | Image & GIF loading |
License
Licensed under the Apache License, Version 2.0.