Chapter 8

GUI

truce ships a built-in GUI designed for audio plugins. You declare a layout — rows of widgets — and the framework draws it, routes input events, and keeps everything in sync with the parameter Arc. Zero pixel math.

If that's not enough, truce has adapters for egui, iced, Slint, and Vizia, plus a raw-window-handle escape hatch for anything else. Start with the built-in and reach for a framework when you hit its limits.

#The built-in GUI

#Declaring a layout

use truce_gui::IntoLayoutEditor;
use truce_gui_types::layout::{GridLayout, knob, slider, toggle,
                        dropdown, meter, xy_pad, widgets, section};
use MyParamsParamId as P;

impl PluginLogic for MyPlugin {
    // ... reset, process ...

    fn editor(&self) -> Box<dyn Editor> {
        GridLayout::build(vec![
            widgets(vec![
                knob(P::Gain, "Gain"),
                knob(P::Pan,  "Pan"),
                toggle(P::Bypass, "Bypass"),
            ]),
            section("FILTER", vec![
                knob(P::Cutoff,    "Cutoff"),
                knob(P::Resonance, "Reso"),
            ]),
        ])
        .into_editor(&self.params)
    }
}

GridLayout::build(sections) — each section is either a widgets(vec![...]) row or a labelled section("NAME", vec![...]) group. Widgets flow left-to-right; use .cols(n) / .rows(n) to span cells. By default there's no header, cols resolves to the widest section's widget count, and the cell size is 50 logical points. Override any of those:

  • .with_title("MY PLUGIN") — adds a header band with a title only.
  • .with_subtitle("v0.1") — header band with the right-aligned subtitle slot only.
  • .with_titles(HeaderTitles::pair("MY PLUGIN", "v0.1")) — both slots at once. HeaderTitles::title(...) / ::subtitle(...) / ::pair(...) / ::none() cover every combination.
  • .with_cols(n) — force a specific column count (useful to wrap a long row into a grid).
  • .with_cell_size(s) — bigger / smaller cells.
  • .with_grid(cols, cell_size) — both at once.

#Resizable and maximizable

Editors are fixed-size by default. Opt into host-driven resize with .resizable(true), and clamp the range with .min_size((a, b)) / .max_size((a, b)):

GridLayout::build(sections)
    .resizable(true)
    .min_size((4, 3))     // grid: (cols, rows) cells
    .max_size((12, 9))
    .maximizable(true)
    .into_editor(&self.params)

.maximizable(true) lets the standalone host's window be maximized. It's off by default for a reason: maximizing grows the window to the whole screen, past the editor's max_size, leaving the clamped GUI in an empty margin. Leave it off and the maximize affordance is removed; turn it on only for editors that render correctly at any size. When a window does end up larger than the editor, the GUI is centered and the extra space painted black rather than stretched or pinned to a corner.

The same four methods exist on the egui, iced, and slint editor builders, where min_size / max_size are logical points rather than grid cells. Ship vizia plugins fixed-size — don't call .resizable(true) there.

#The seven widgets

Constructor Widget Typical use
knob(P::X, "Label") rotary gain, cutoff, resonance, any FloatParam
slider(P::X, "Label") linear slider pan, mix, sometimes easier to read than a knob
toggle(P::X, "Label") pill on/off BoolParam, bypass
dropdown(P::X, "Label") click-to-open list EnumParam<T> / IntParam
meter(&[P::L, P::R], "Label") vertical level meters peak / RMS output
xy_pad(P::X, P::Y, "Label") 2-axis pad two continuous params on one surface

selector(P::X, "Label") is deprecated since 0.56.0; use dropdown instead.

#Spanning cells

knob(P::Gain, "Gain"),                          // 1×1 cell (default)
dropdown(P::Wave, "Wave").cols(2),              // 2 cells wide
meter(&[P::L, P::R], "Level").rows(3),          // 3 cells tall
xy_pad(P::X, P::Y, "Pad").cols(2).rows(2),      // 2×2 cell block

Explicit positions work too: knob(P::Gain, "Gain").at(col, row).

#Meters

Declare meters as #[meter] pub x: MeterSlot fields alongside your params (see parameters.md § Meters), push from process() with context.set_meter(P::MeterL, peak), and render them in editor():

meter(&[P::MeterL, P::MeterR], "Level").rows(3)

The DSP side is atomic and realtime-safe. The GUI reads the latest value every frame.

#Interaction for free

  • Drag on a knob / slider → change the param.
  • Scroll-wheel on a knob → fine-tune.
  • Double-click a knob → reset to default.
  • Click a toggle / dropdown → set / open.
  • Right-click a widget → host context menu (automation, reset, enter value).

You don't write any of this. The framework knows the widget kind from the layout and the parameter behaviour from the ParamId.

#Rendering and theming

The built-in GUI rasterises on the CPU through tiny-skia by default. Opt into GPU rendering (wgpu → Metal on macOS, DX12 on Windows, Vulkan on Linux) with the gpu feature on truce-gui.

Colours come from a named theme — dark by default. Swap to light or a custom palette:

GridLayout::build(sections)
    .with_title("MY PLUGIN")
    .theme(truce_gui_types::theme::Theme::light())

Text renders via fontdue with JetBrains Mono Regular embedded at compile time — no font file on disk, no runtime load.

See the built-in GUI reference for every widget constructor, cell-spanning option, and theming detail.

#Alternatives

The built-in GUI covers knobs / sliders / meters / dropdowns — the common audio-plugin shape. Reach for an alternative backend when you need:

  • Text input fields beyond the built-in value pop-in.
  • Lists or tables (preset browsers, modulation matrices, sample browsers).
  • Custom graphics — analyzer curves, waveforms, drawable envelopes.
  • Specific aesthetics the built-in theme system can't reach.

All alternatives integrate the same way: return the backend's editor from editor() on PluginLogic, finishing the builder chain with .into_editor().

Backend Crate When
egui truce-egui Immediate-mode. Good for prototyping, CPU-graph-heavy debugging UIs, and dev tools. Full guide: gui/egui.
iced truce-iced Retained-mode with Elm architecture. Good for complex custom UIs where you want a proper widget tree and state machine. Auto-generated from GridLayout is also available. gui/iced.
Slint truce-slint Declarative markup (.slint files) with data binding. Good for visually rich UIs designed outside Rust. gui/slint.
Vizia truce-vizia Retained-mode with reactive data binding and CSS. Per-param Signal<f32> keeps widgets in sync without manual wiring. Desktop only — no iOS, no Windows ARM64. gui/vizia.
BYO truce-core + RawWindowHandle Full control — Metal, OpenGL, Skia, anything. You handle painting, input, DPI, and lifecycle yourself. gui/raw-window-handle.

See gui/README for a side-by-side comparison of the backends.

#Screenshot tests

Catch visual regressions by rendering your GUI headlessly and diffing the result against a committed reference PNG. One line across every backend:

#[test]
fn gui_screenshot() {
    truce_test::screenshot!(Plugin, "screenshots/default.png").run();
}

The screenshot! macro takes the plugin type plus an explicit path to the committed reference PNG (relative to the crate's Cargo.toml directory, or absolute). The current render lands in target/screenshots/ (gitignored). The first time you run the test the reference doesn't exist yet — the test fails and points at cargo truce screenshot --out <ref-path> to create the baseline. Works for every backend (built-in GUI, egui, iced, slint, vizia).

See gui/screenshot-testing for the full flow — promoting new references, state-dependent shots via setup / state_file, cross-OS reference handling via cfg(target_os = …) gating, and the cargo truce screenshot CLI for renders that don't need a #[test].

#What's next