Built-in GUI
The built-in GUI renders widgets from a layout you define in code.
No custom editor, no framework dependency — declare what you want
and truce draws it. This is the default; override layout() on
PluginLogic and you're done.
For a first walkthrough see the GUI chapter. This page is the reference for every option.
#GridLayout::build
use truce_gui_types::layout::{GridLayout, knob, slider, toggle, widgets};
use MyParamsParamId as P;
fn layout(&self) -> GridLayout {
GridLayout::build(vec![widgets(vec![
knob(P::Gain, "Gain"),
slider(P::Pan, "Pan"),
toggle(P::Bypass, "Bypass"),
])])
}
Signature:
GridLayout::build(sections: Vec<Section>) -> GridLayout
Defaults:
- No header band. Add a title with
.with_title("MY PLUGIN"), a right-aligned subtitle with.with_subtitle("v1.0"), or both with.with_titles(HeaderTitles::pair("MY PLUGIN", "v1.0")). Each slot is independently optional. cols= the widest section's widget count (extended to fit any explicitly-positioned widget). Override with.with_cols(n)to force wrapping — e.g..with_cols(2)on a 4-widget section produces a 2×2 grid.cell_size=GRID_DEFAULT_CELL_SIZE(50.0 logical points). Override with.with_cell_size(s). Set both at once with.with_grid(cols, cell_size).
GridLayout::build(sections) // 95% case
GridLayout::build(sections).with_title("EQ") // title only
GridLayout::build(sections).with_cols(2) // force 2-col wrapping
GridLayout::build(sections).with_grid(4, 60.0) // both at once
Widget constructors accept impl Into<u32>, so both typed enum IDs
(recommended — P::Gain) and raw u32 values work.
#Widgets
| Constructor | Widget | Default span | Typical param type |
|---|---|---|---|
knob(P::X, "Label") |
rotary knob | 1×1 | FloatParam, IntParam |
slider(P::X, "Label") |
horizontal slider | 1×1 | FloatParam |
toggle(P::X, "Label") |
pill on/off | 1×1 | BoolParam |
selector(P::X, "Label") |
click-to-cycle | 1×1 | EnumParam<T>, IntParam |
dropdown(P::X, "Label") |
click-to-open list | 1×1 | EnumParam<T>, IntParam |
meter(&[P::L, P::R], "Label") |
level meter (one bar per ID) | 1×1 | MeterSlot |
xy_pad(P::X, P::Y, "Label") |
2D control pad | 2×2 | two FloatParams |
If you don't specify a widget type, the default is inferred from
the parameter type: BoolParam → toggle, EnumParam →
selector, everything else → knob.
#Sections
Group widgets under labelled headers with section(). Use
widgets() for the ungrouped rows.
use truce_gui_types::layout::{GridLayout, knob, section, widgets};
GridLayout::build(vec![
section("LOW", vec![
knob(P::LowFreq, "Freq"),
knob(P::LowGain, "Gain"),
knob(P::LowQ, "Q"),
]),
section("MID", vec![
knob(P::MidFreq, "Freq"),
knob(P::MidGain, "Gain"),
knob(P::MidQ, "Q"),
]),
widgets(vec![knob(P::Output, "Output")]),
])
.with_title("EQ")
cols auto-resolves to 3 here (the widest section has 3 widgets),
so each section renders as one row.
Each section starts a new row with a header strip. Widgets inside
a section flow left-to-right within that section's row.
#Spanning and positioning
Widgets default to 1×1 cells. Override with .cols(), .rows(),
and .at():
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), // already 2×2 by default
meter(&[P::L, P::R], "Level").at(2, 0).rows(3), // pinned to column 2, row 0
.at(col, row) is useful when you want a widget anchored (e.g. a
tall meter in the corner) while the rest flow freely.
#Meters
Declare meter slots in your params struct with #[meter]:
#[derive(Params)]
pub struct MyParams {
#[param(name = "Gain", range = "linear(-60, 6)", unit = "dB")]
pub gain: FloatParam,
#[meter] pub meter_left: MeterSlot,
#[meter] pub meter_right: MeterSlot,
}
Meter IDs auto-assign starting at 256 and appear in the generated
MyParamsParamId enum alongside parameters.
Push from process() (realtime-safe atomic write):
context.set_meter(P::MeterLeft, buffer.output_peak(0));
context.set_meter(P::MeterRight, buffer.output_peak(1));
Draw in the layout:
meter(&[P::MeterLeft, P::MeterRight], "Level").rows(3)
#Theming
Colours come from a named theme. Dark is the default. Switch themes or override individual colours:
use truce_gui_types::theme::{Theme, Color};
GridLayout::build(sections)
.with_title("MY PLUGIN")
.theme(Theme::light())
GridLayout::build(sections)
.with_title("MY PLUGIN")
.theme(Theme {
primary: Color::rgb(0x00, 0xd2, 0xff),
..Theme::dark()
})
Fonts: fontdue rasterisation with JetBrains Mono Regular embedded at compile time. No font file on disk, no runtime load.
Rendering: truce-gpu through wgpu (Metal on macOS, DX12 on
Windows, Vulkan on Linux). Tiny-skia CPU rasterisation is the
fallback.
#Interaction
The framework handles all of the following automatically — you don't wire any of it by hand:
- Knob / slider: drag to adjust. Scroll-wheel to fine-tune. Double-click to reset to default.
- Toggle: click to flip.
- Selector: click to cycle forward.
- Dropdown: click to open the popup list, click an option to select.
- XY pad: drag anywhere on the pad to set both parameters.
- Right-click: opens the host's context menu (automation, reset, enter value).
#A full example
use GainParamsParamId as P;
use truce_gui_types::layout::{GridLayout, knob, meter, widgets, xy_pad};
fn layout(&self) -> GridLayout {
GridLayout::build(vec![widgets(vec![
knob(P::Gain, "Gain"),
knob(P::Pan, "Pan"),
xy_pad(P::Pan, P::Gain, "XY"),
meter(&[P::MeterLeft, P::MeterRight], "Level").rows(2),
])])
.with_title("GAIN")
}
#Moving beyond the built-in GUI
The built-in widget set covers the common audio-plugin shape — knobs / sliders / meters / dropdowns. When you need text input fields, lists, tables, analyzer curves, or a specific visual style the theme system can't reach, switch to a framework backend:
- egui — immediate-mode, fast to prototype.
- iced — retained-mode, Elm architecture, good for complex custom UIs.
- slint — declarative
.slintmarkup with data binding. - raw-window-handle — full control: Metal, OpenGL, Skia, anything.