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.
[dependencies]
pane_ui = "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 ),
],
)],
)fn main() {
pane::run("assets/menu.ron");
}For a callback on each action:
pane::run_with("assets/menu.ron", |ui, action| {
if let pane::PaneAction::Custom(ref tag) = action {
println!("pressed: {tag}");
}
});Pane owns the window and event loop.
pane::run("assets/menu.ron");
// or with a callback:
pane::run_with("assets/menu.ron", |ui, action| { ... });Renders into a caller-owned wgpu surface. Your render loop stays intact.
// At startup
let mut ui = pane::overlay("assets/hud.ron", &device, &queue, format, None);
// In your event loop
ui.handle_event(&event, pw, ph);
// After your own draw calls
let actions = ui.draw(&mut encoder, &view, pw, ph);
for action in actions { ... }Pass Some(gilrs) to share an existing gamepad context, or None to let pane create its own.
No GPU, no window. Useful for testing or driving UI logic server-side.
let mut menu = pane::headless("assets/menu.ron");
let actions = menu.update(0.016); // advance by dt, returns PaneActions
menu.press("btn_ok"); // requires headless_accessible: true in the RON
let root = menu.active_root();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.
(
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>,
)(
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>,
)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| 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.
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("cursor_glow", 8.0, 0.3);
ui.actor_move_to("cursor_glow", 0.0, 0.0, 3.0);
ui.actor_reset("cursor_glow"); // restore RON behaviors
ui.actor_set_pos("cursor_glow", x, y); // teleport, no springPaneOverlay::draw and PaneHeadless::update return Vec<PaneAction> each frame. run_with passes them one at a time to the callback.
use pane::PaneAction;
match action {
PaneAction::Custom(tag) => { ... }
PaneAction::Slider(id, value) => { ... } // value: f32
PaneAction::Toggle(id, checked) => { ... } // checked: bool
PaneAction::TextChanged(id, text) => { ... } // every keystroke
PaneAction::TextSubmitted(id, text) => { ... } // Enter pressed
PaneAction::Dropdown(id, idx, label) => { ... }
PaneAction::Radio(id, idx, label) => { ... }
PaneAction::SwitchRoot(name) => { ... } // pane switches automatically
PaneAction::Quit => { ... }
}All three handles (StandaloneHandle, PaneOverlay, PaneHeadless) share the same core API.
if let Some((item, state)) = ui.read("volume_slider") {
println!("hovered={} grabbed={}", state.hovered, state.grabbed);
}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 |
Pushes a value into a widget without emitting a PaneAction. Use this to sync UI to app state.
use pane::WriteValue;
ui.write("volume", &WriteValue::Slider(0.8));
ui.write("mute", &WriteValue::Toggle(true));
ui.write("username", &WriteValue::Text("Alice".into()));
ui.write("quality", &WriteValue::Selected(2)); // 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.
let current = ui.default_style(); // Option<&str>
ui.set_default_style("retro"); // returns false if name not registeredAffects all widgets that don't have an explicit style set in the RON. Widgets with an explicit style are unaffected.
ui.push_toast("Saved!", 2.0, 0.0, -400.0, 300.0, 60.0);
// msg secs x y w huse pane::ButtonBuilder;
ui.create("main", ButtonBuilder::new("btn_dynamic")
.pos(-100.0, 200.0)
.size(200.0, 50.0)
.text("Click me")
.on_press("dynamic_action")); // returns false if id exists or root is missing
ui.destroy("btn_dynamic"); // returns false if not foundTo create an empty root in code:
ui.create_root("generated");
ui.create("generated", ButtonBuilder::new("btn_ok").pos(0.0, 0.0).size(200.0, 50.0).text("OK").on_press("ok"));Available builders: ButtonBuilder · ToggleBuilder · SliderBuilder · TextBoxBuilder · DropdownBuilder · RadioGroupBuilder
To use a specific style, resolve its id first:
let s = ui.style_id("frosted_glass");
let builder = ButtonBuilder::new("btn").pos(0.0, 0.0).size(200.0, 50.0).text("Hi").on_press("hi");
let builder = if let Some(s) = s { builder.style(s) } else { builder };
ui.create("main", builder);| 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.
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.
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
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:
pane_ui = { version = "0.1.0", features = ["dev"] }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.
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 | 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 |
cargo build # build library + binary
cargo run # run the built-in demo
PANE_DEBUG=1 cargo run # run demo with debug overlay
cargo test # run tests
cargo test <test_name> # run a single test
cargo doc --open # build and browse API docs
cargo build --features dev # enable hot-reload for styles| 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 |
Licensed under the Apache License, Version 2.0.
