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:

  1. A property in the .slint file (in-out property <float> pan: 0.5;)
  2. A callback in the .slint file (callback pan-changed(float);)
  3. 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.