# pane_ui

[](LICENSE)
[](https://github.com/gfx-rs/wgpu)
[](https://crates.io/crates/pane_ui)
A immediate-mode UI library for [wgpu](https://github.com/gfx-rs/wgpu). UI is defined in RON files and rendered each frame via wgpu. Supports hot reload of layouts, styles, and shaders.
---
## Quick Start
```toml
[dependencies]
pane_ui = "0.1.0"
```
Create `assets/menu.ron`:
```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 ),
],
)],
)
```
```rust
fn main() {
pane::run("assets/menu.ron");
}
```
For a callback on each action:
```rust
pane::run_with("assets/menu.ron", |ui, action| {
if let pane::PaneAction::Custom(ref tag) = action {
println!("pressed: {tag}");
}
});
```
---
## Examples in the demos folder
## Integration Modes
### Standalone
Pane owns the window and event loop.
```rust
pane::run("assets/menu.ron");
// or with a callback:
pane::run_with("assets/menu.ron", |ui, action| { ... });
```
### Overlay
Renders into a caller-owned wgpu surface. Your render loop stays intact.
```rust
// 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.
### Headless
No GPU, no window. Useful for testing or driving UI logic server-side.
```rust
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();
```
---
## 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
```ron
(
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
```ron
(
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:
```ron
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
| `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.
```ron
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:
```rust
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 spring
```
---
## Actions
`PaneOverlay::draw` and `PaneHeadless::update` return `Vec<PaneAction>` each frame. `run_with` passes them one at a time to the callback.
```rust
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 => { ... }
}
```
---
## Runtime API
All three handles (`StandaloneHandle`, `PaneOverlay`, `PaneHeadless`) share the same core API.
### Reading widget state
```rust
if let Some((item, state)) = ui.read("volume_slider") {
println!("hovered={} grabbed={}", state.hovered, state.grabbed);
}
```
`WidgetState` fields (all `bool`):
| `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.
```rust
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
```
| `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
```rust
let current = ui.default_style(); // Option<&str>
ui.set_default_style("retro"); // 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
```rust
ui.push_toast("Saved!", 2.0, 0.0, -400.0, 300.0, 60.0);
// msg secs x y w h
```
### Creating and destroying widgets at runtime
```rust
use 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 found
```
To create an empty root in code:
```rust
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:
```rust
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);
```
---
## Styling
### Built-in styles
| `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):
```ron
// 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:
```ron
// 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:
| `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:
```toml
pane_ui = { version = "0.1.0", features = ["dev"] }
```
---
## Controller Support
All widgets support controller navigation with no configuration:
| 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:
```ron
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
| 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
```bash
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
```
---
## Dependencies
| [wgpu](https://github.com/gfx-rs/wgpu) | 29.0 | GPU rendering |
| [winit](https://github.com/rust-windowing/winit) | 0.30 | Window & event loop |
| [glyphon](https://github.com/grovesNL/glyphon) | 0.11 | Text rendering |
| [gilrs](https://gitlab.com/gilrs-project/gilrs) | 0.11 | Controller input |
| [ron](https://github.com/ron-rs/ron) | 0.12 | Config format |
| [serde](https://github.com/serde-rs/serde) | 1.0 | Deserialization |
| [image](https://github.com/image-rs/image) | 0.25 | Image & GIF loading |
---
## License
Licensed under the [Apache License, Version 2.0](LICENSE).