Iced Integration
Iced is a retained-mode GUI framework
following the Elm architecture. truce-iced wraps it so you can use
iced's layout system and canvas widgets inside a plugin window.
#Getting started
Add the dependencies:
[dependencies]
truce-iced = { workspace = true }
iced = { version = "0.13", default-features = false, features = ["canvas", "wgpu"] }
The simplest approach is auto mode — generate a UI from your layout with no custom code:
use truce::prelude::*;
use truce_iced::IcedEditor;
impl PluginLogic for MyPlugin {
fn custom_editor(&self) -> Option<Box<dyn truce_core::editor::Editor>> {
Some(Box::new(IcedEditor::from_layout(
self.params.clone(),
self.layout(),
)))
}
}
This renders the same widgets as the built-in GUI but through iced's rendering pipeline.
#Custom UI
For full control, implement IcedPlugin. Here's a minimal example:
use std::sync::Arc;
use truce_iced::{
knob, meter, IcedEditor, IcedPlugin, IntoElement,
Message, ParamCache,
};
use iced::widget::{Column, Row, text};
use iced::Element;
use MyParamsParamId as P;
#[derive(Debug, Clone)]
pub enum Msg {}
pub struct MyEditor;
impl IcedPlugin<MyParams> for MyEditor {
type Message = Msg;
fn new(_params: Arc<MyParams>) -> Self { Self }
fn view<'a>(
&'a self,
params: &'a ParamCache<MyParams>,
) -> Element<'a, Message<Msg>> {
Column::new()
.push(text("MY PLUGIN").size(14))
.push(
Row::new()
.push(knob(P::Gain, params).label("Gain").size(60.0).el())
.push(knob(P::Pan, params).label("Pan").size(60.0).el())
.spacing(10)
)
.spacing(10)
.padding(10)
.into()
}
}
Wire it up:
fn custom_editor(&self) -> Option<Box<dyn truce_core::editor::Editor>> {
Some(Box::new(IcedEditor::<MyParams, MyEditor>::new(
self.params.clone(),
(400, 300),
)))
}
A few things to note:
type Message = Msgis your custom message enum. Use()if you don't need custom messages.new()is required.update()defaults to a no-op if you don't override it..el()converts a truce-iced widget into an icedElement. ImportIntoElementto use it.
#The .el() shorthand
Iced's type system requires explicit conversions when pushing widgets
into rows and columns. Without .el() you'd write:
.push(Into::<Element<'a, Message<Msg>>>::into(knob(P::Gain, params).label("Gain")))
With .el():
.push(knob(P::Gain, params).label("Gain").size(60.0).el())
Import IntoElement from truce_iced and call .el() on any widget.
#Reading and writing parameters
ParamCache<P> is a per-tick read-only snapshot — iced's view
function can't have side effects, so the cache is what widgets read
from. The host bridge (gestures, automation writes) lives on
PluginContext<P> and is passed to update():
// Read — from ParamCache (passed to view + update)
params.get(P::Gain) // normalized 0.0-1.0
params.get_plain(P::Gain) // plain value
params.label(P::Gain) // formatted string
params.meter(P::MeterLeft) // meter level
// Write — from PluginContext (passed to update)
ctx.automate(P::Gain, 0.75) // begin + set + end (one shot)
ctx.begin_edit(P::Gain) // gesture: start
ctx.set_param(P::Gain, new_value) // gesture: in progress
ctx.end_edit(P::Gain) // gesture: end
The built-in widgets (knob, param_slider, param_toggle, etc.)
emit their own Message::Param(...) variants — the iced runtime
forwards those to the host via the underlying PluginContext, so
direct writes are only needed for custom widgets and Msg::* handling.
#Widgets
use truce_iced::{knob, param_slider, param_toggle, param_selector, xy_pad, meter};
knob(P::Gain, params).label("Gain").size(60.0)
param_slider(P::Pan, params).label("Pan")
param_toggle(P::Bypass, params).label("Bypass")
param_selector(P::Mode, params).label("Mode")
xy_pad(P::Pan, P::Gain, params).label("XY").size(130.0)
meter(&[P::MeterLeft, P::MeterRight], params).size(16.0, 200.0)
All use builder pattern. Call .el() to push into iced layouts.
#Handling custom messages
If your UI has buttons, tabs, or other interactive elements, define
messages and handle them in update():
#[derive(Debug, Clone)]
pub enum Msg {
ResetGain,
TabChanged(usize),
}
fn update(
&mut self,
msg: Message<Msg>,
params: &ParamCache<MyParams>,
ctx: &PluginContext<MyParams>,
) -> Task<Message<Msg>> {
match msg {
Message::Custom(Msg::ResetGain) => {
// `automate` collapses the begin / set / end triple
// into one call for single-shot edits.
ctx.automate(P::Gain, 0.5);
}
_ => {}
}
Task::none()
}
Parameter messages (from knob drags, toggle clicks) are handled
automatically — you never see them in update().
#Custom state
If your plugin has persistent state beyond parameters, use
StateBinding<T> in your IcedPlugin model. Initialize it as
default() and sync on first update:
#[derive(State, Default)]
pub struct MyState {
pub instance_name: String,
}
pub struct MyEditor {
state: StateBinding<MyState>,
initialized: bool,
}
impl IcedPlugin<MyParams> for MyEditor {
type Message = ();
fn new(_params: Arc<MyParams>) -> Self {
Self { state: StateBinding::default(), initialized: false }
}
fn update(
&mut self,
_msg: Message<()>,
_params: &ParamCache<MyParams>,
ctx: &PluginContext<MyParams>,
) -> Task<Message<()>> {
if !self.initialized {
self.state = StateBinding::new(ctx.clone().dyn_erase());
self.initialized = true;
}
Task::none()
}
fn view<'a>(
&'a self,
_params: &'a ParamCache<MyParams>,
) -> Element<'a, Message<()>> {
text(&self.state.get().instance_name).into()
}
fn state_changed(&mut self) {
self.state.sync();
}
}
state_changed() is called when the DAW restores state (preset recall,
undo, session load). It re-reads custom state so the UI stays in sync.
To write state from the GUI:
self.state.update(|s| s.instance_name = new_name);
You can also access state directly via ctx.get_state() /
ctx.set_state() on the PluginContext passed to update().
If your plugin only uses #[param] fields, you don't need any of this —
parameter values sync automatically through ParamCache.
#Screenshot testing
#[test]
fn gui_screenshot() {
truce_test::screenshot!(Plugin, "screenshots/default.png").run();
}
See screenshot testing for more.
#Example
examples/truce-example-gain-iced has a complete plugin with custom header, knobs,
XY pad, meter, .el() usage, and screenshot test.