egui Integration

truce-egui embeds egui into a plugin window via wgpu + baseview. You get the full egui API — CentralPanel, SidePanel, Window, Canvas, third-party crates — with parameter hosting handled for you.

#Setup

[dependencies]
truce-egui = { workspace = true }
egui = "0.31"

Override custom_editor() and return an EguiEditor:

use truce::prelude::*;
use truce_egui::EguiEditor;
use truce_egui::widgets::param_knob;
use MyParamsParamId as P;

impl PluginLogic for MyPlugin {
    fn custom_editor(&self) -> Option<Box<dyn truce_core::editor::Editor>> {
        Some(Box::new(EguiEditor::new(
            self.params.clone(),
            (400, 300),
            |ctx: &egui::Context, state: &PluginContext<MyParams>| {
                egui::CentralPanel::default().show(ctx, |ui| {
                    ui.heading("My Plugin");
                    param_knob(ui, state, P::Gain, "Gain");
                });
            },
        )))
    }
}

The closure is your egui frame function — it runs every frame, same as eframe::App::update. (400, 300) is the window size in logical points. PluginContext<MyParams> is typed for direct Deref access to the plugin's Params fields (state.gain.read() etc.) inside the closure.

#PluginContext

PluginContext<P> is the bridge between egui and the DAW's parameter system. It wraps begin_edit / set_param / end_edit into an ergonomic API; IDs use #[derive(Params)]'s generated *ParamId enum and convert to u32 through impl Into<u32>:

// Read
state.get_param(P::Gain)         // normalized 0.0-1.0
state.get_param_plain(P::Gain)   // plain value (-60.0 dB)
state.format_param(P::Gain)      // display string ("0.0 dB")
state.get_meter(P::MeterLeft)    // meter level 0.0-1.0

// Write (one-shot, for clicks/toggles)
state.automate(P::Bypass, 1.0);

// Write (continuous drag — records smooth automation)
state.begin_edit(P::Gain);
state.set_param(P::Gain, new_value);  // call each frame during drag
state.end_edit(P::Gain);

#Widgets

truce-egui provides parameter-aware widgets that handle the gesture protocol internally. Use these or roll your own with raw egui widgets.

use truce_egui::widgets::{
    param_knob,     // rotary knob
    param_slider,   // horizontal slider
    param_toggle,   // on/off switch
    param_selector, // click-to-cycle for enums
    param_xy_pad,   // 2D pad for two params
    level_meter,    // vertical bar meter
};

Typical layout:

fn my_ui(ctx: &egui::Context, state: &PluginContext<MyParams>) {
    egui::CentralPanel::default().show(ctx, |ui| {
        ui.horizontal(|ui| {
            param_knob(ui, state, P::Gain, "Gain");
            param_knob(ui, state, P::Pan, "Pan");
            level_meter(ui, state, &[P::MeterLeft, P::MeterRight], 200.0);
        });
        param_xy_pad(ui, state, P::Pan, P::Gain, "Pan / Gain", 130.0, 130.0);
    });
}

#Using raw egui widgets

Any egui widget works — just wire the gesture protocol manually:

let mut value = state.get_param(P::Gain) as f32;
let response = ui.add(egui::Slider::new(&mut value, 0.0..=1.0));
if response.drag_started() { state.begin_edit(P::Gain); }
if response.changed()      { state.set_param(P::Gain, value as f64); }
if response.drag_stopped() { state.end_edit(P::Gain); }

#Theme

A dark theme is applied by default. Pass any egui::Visuals to override it — use egui's built-in light/dark themes, the truce defaults, or your own:

// Use egui's built-in light theme
EguiEditor::new(self.params.clone(), (400, 300), my_ui)
    .with_visuals(egui::Visuals::light())

// Or customize the truce dark theme as a starting point
EguiEditor::new(self.params.clone(), (400, 300), my_ui)
    .with_visuals(truce_egui::theme::dark())
    .with_font(truce_gui::font::JETBRAINS_MONO)

You can also call ctx.set_visuals() inside your frame function to switch themes at runtime.

The truce theme exports color constants for consistency with the built-in GUI widgets:

use truce_egui::theme::{BACKGROUND, SURFACE, PRIMARY, TEXT, TEXT_DIM,
                         HEADER_BG, HEADER_TEXT, KNOB_FILL, METER_CLIP};

#Stateful UIs (EditorUi trait)

The closure API works for simple UIs. For state across frames (tabs, caches, animations), implement EditorUi<P>:

use truce_egui::EditorUi;

struct MyUi { tab: usize }

impl EditorUi<MyParams> for MyUi {
    fn ui(&mut self, ctx: &egui::Context, state: &PluginContext<MyParams>) {
        egui::CentralPanel::default().show(ctx, |ui| {
            ui.horizontal(|ui| {
                ui.selectable_value(&mut self.tab, 0, "Controls");
                ui.selectable_value(&mut self.tab, 1, "Settings");
            });
            // draw based on self.tab
        });
    }
}

// In custom_editor():
EguiEditor::with_ui(self.params.clone(), (640, 480), MyUi { tab: 0 })

EditorUi<P> has three methods:

Method When Use for
opened(&mut self, &PluginContext<P>) Editor window opens Initialize StateBinding, load resources
ui(&mut self, &egui::Context, &PluginContext<P>) Every frame Draw your UI
state_changed(&mut self, &PluginContext<P>) Preset recall, undo, session load Re-sync cached state

All have default no-ops. Only ui() is required.

#Custom persistent state

If your plugin has state beyond parameters (save_state / load_state), use StateBinding<T> to keep the editor in sync:

#[derive(State, Default)]
pub struct MyState {
    pub instance_name: String,
    pub view_mode: u8,
}

struct MyUi {
    state: StateBinding<MyState>,
}

impl EditorUi<MyParams> for MyUi {
    fn opened(&mut self, ctx: &PluginContext<MyParams>) {
        self.state = StateBinding::new(ctx.clone().dyn_erase());
    }

    fn ui(&mut self, egui_ctx: &egui::Context, _ctx: &PluginContext<MyParams>) {
        egui::CentralPanel::default().show(egui_ctx, |ui| {
            ui.label(&self.state.get().instance_name);
        });
    }

    fn state_changed(&mut self, _ctx: &PluginContext<MyParams>) {
        self.state.sync();
    }
}

Write state back from the GUI:

self.state.update(|s| s.instance_name = new_name);

For the closure API, use .on_state_changed():

EguiEditor::new(self.params.clone(), (400, 300), |ctx, state| { /* ui */ })
    .on_state_changed(|state| { /* re-read cached state */ })

If your plugin only uses #[param] fields, skip this section — parameters sync automatically every frame.

#Screenshot testing

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

See screenshot testing for more.

#Examples

examples/truce-example-gain-egui — complete plugin with knobs, XY pad, meter, header, custom font, and screenshot test.

truce-analyzer — non-trivial out-of-tree example built on egui: real-time spectrum analyzer with a diff overlay. Shows what scaled-up egui code looks like for a plugin GUI.