Slint Integration
Slint is a declarative GUI toolkit.
You write your UI in .slint markup files, Slint compiles them to Rust
at build time, and truce-slint handles the window embedding and
parameter communication.
#Getting started
Add the dependencies:
[dependencies]
truce-slint = "0.34"
slint = { version = "=1.15.1", default-features = false, features = ["compat-1-2", "renderer-software", "std"] }
[build-dependencies]
truce-slint-build = "0.34"
Create build.rs:
fn main() {
truce_slint_build::compile("ui/main.slint").unwrap();
}
truce_slint_build::compile wraps slint-build with the truce
widget library and the bundled JetBrains Mono font already wired
in, so import { ... } from "@truce"; and import "JetBrainsMono-Regular.ttf"; in your .slint files just work —
no need to point the Slint compiler at any truce source paths
yourself.
#A simple plugin UI
Create ui/main.slint:
import { Knob, Meter } from "@truce";
import { HorizontalBox } from "std-widgets.slint";
export component MyPluginUi inherits Window {
in-out property <float> gain: 0.5;
in-out property <float> meter-left: 0.0;
in-out property <float> meter-right: 0.0;
callback gain-changed(float);
preferred-width: 200px;
preferred-height: 120px;
background: #1f1f24;
HorizontalBox {
padding: 10px;
spacing: 10px;
Knob {
label: "Gain";
value <=> root.gain;
changed(v) => { root.gain-changed(v); }
}
Meter {
level-left: root.meter-left;
level-right: root.meter-right;
}
}
}
Wire it up in Rust:
use truce::prelude::*;
use truce_slint::{SlintEditor, SyncFn};
slint::include_modules!();
use MyParamsParamId as P;
impl PluginLogic for MyPlugin {
fn custom_editor(&self) -> Option<Box<dyn truce_core::editor::Editor>> {
Some(Box::new(SlintEditor::new(
self.params.clone(),
(200, 120),
|state: PluginContext<MyParams>| -> SyncFn<MyParams> {
let ui = MyPluginUi::new().unwrap();
// UI → host: when the user drags the knob
let s = state.clone();
ui.on_gain_changed(move |v| s.automate(P::Gain, v as f64));
// host → UI: sync every frame
Box::new(move |state: &PluginContext<MyParams>| {
ui.set_gain(state.get_param(P::Gain) as f32);
ui.set_meter_left(meter_display(state.get_meter(P::MeterLeft)));
ui.set_meter_right(meter_display(state.get_meter(P::MeterRight)));
})
},
)))
}
}
The setup closure receives a PluginContext<MyParams> (typed for
direct Deref access to MyParams fields) and returns a SyncFn<P>.
Slint calls the sync function every frame (~60fps) to push host
values into the UI.
#Adding more parameters
For each parameter, you need three things:
- A property in the
.slintfile (in-out property <float> pan: 0.5;) - A callback in the
.slintfile (callback pan-changed(float);) - Wiring in Rust (callback + sync)
The bind! macro handles steps 2-3 for simple float and bool params:
let ui = MyPluginUi::new().unwrap();
truce_slint::bind! { state, ui,
P::Gain => gain, // float
P::Pan => pan, // float
P::Bypass => bypass: bool, // boolean
P::Mode => mode: choice(4), // enum with 4 options
}
This generates all the on_*_changed callbacks and the sync closure.
You still need the properties and callbacks in your .slint file.
#Showing formatted values
By default, the Knob widget shows the raw normalized value. To show
formatted text like "0.0 dB", add a string property:
in-out property <string> gain-text: "";
Knob {
value <=> root.gain;
value-text: root.gain-text;
}
// In the sync closure:
ui.set_gain_text(slint::SharedString::from(state.format_param(P::Gain)));
#Available truce widgets
Import from "@truce":
import { Knob, Meter, XYPad, ParamSlider, Toggle, Selector } from "@truce";
- Knob — 270-degree rotary control with arc, pointer, label
- Meter — dual-channel vertical level meter
- XYPad — 2D drag pad for two parameters
- ParamSlider — horizontal slider
- Toggle — on/off switch
- Selector — click-to-cycle for enum params
You can also use Slint's built-in widgets (Slider, Switch,
ComboBox) from "std-widgets.slint" and wire them manually.
#Mixing manual wiring with bind!
For parameters that need custom conversion (log scales, enums), wire
them before the bind! macro:
let s = state.clone();
ui.on_freq_changed(move |hz| {
let norm = (hz.log2() - 20f32.log2()) / (20000f32.log2() - 20f32.log2());
s.automate(P::Freq, norm as f64);
});
// bind! must come last — it returns the sync closure
truce_slint::bind! { state, ui,
P::Gain => gain,
}
#PluginContext
Same as the other backends — PluginContext<P> exposes its host
bridge as methods. IDs use #[derive(Params)]'s generated
*ParamId enum and convert to u32 through impl Into<u32>:
state.get_param(P::Gain) // normalized 0.0-1.0
state.get_param_plain(P::Gain) // plain value
state.format_param(P::Gain) // formatted string
state.get_meter(P::MeterLeft) // meter level
state.automate(P::Gain, v) // begin + set + end (one shot)
state.begin_edit(P::Gain) // gesture: start
state.set_param(P::Gain, v) // gesture: in progress
state.end_edit(P::Gain) // gesture: end
PluginContext<P> is Clone-able (cheap — internally an
Arc<dyn EditorBridge>), so Slint callbacks can capture copies.
Deref to &P is also available, so state.gain.read() works
directly when you need to peek at param metadata or smoothed
values inside the sync closure.
#Custom state
If your plugin has persistent state beyond parameters, read it via
state.get_state() in the sync callback:
#[derive(State, Default)]
pub struct MyState {
pub instance_name: String,
}
// In the setup closure:
let mut cached_state = MyState::default();
// In the sync callback (runs every frame):
let data = state.get_state();
if !data.is_empty() {
if let Some(s) = MyState::deserialize(&data) {
if s.instance_name != cached_state.instance_name {
ui.set_instance_name(s.instance_name.clone().into());
cached_state = s;
}
}
}
To write state back (e.g., user renames an instance):
cached_state.instance_name = new_name;
state.set_state(cached_state.serialize());
See the state persistence
section for the full #[derive(State)] pattern.
If your plugin only uses #[param] fields, you don't need any of this —
parameter values sync automatically through PluginContext<P>.
#Screenshot testing
Slint screenshots use the software renderer — no GPU needed:
#[test]
fn gui_screenshot() {
truce_test::screenshot!(Plugin, "screenshots/default.png").run();
}
See screenshot testing for more.
#Licensing
Slint has a royalty-free license for proprietary desktop applications, which covers audio plugins. See slint.dev for terms.
#Example
examples/truce-example-gain-slint has a complete plugin with knobs, XY pad, meter,
formatted values, and screenshot test.