Tutorial: Writing a citizen app
This chapter walks through examples/filter_plotter/ end-to-end —
the project layout, every module, and how the citizen pattern wires
the pieces together. By the end you'll have written one citizen
app, and most of the scaffolding carries over directly to the next
one.
The app itself is small but realistic: a 50 Hz sine wave with 200 kHz noise added, run through a Butterworth biquad lowpass filter, plotted with linked-axis subplots (matplotlib-style). Three panels — a stacked input/output plot, a settings panel with sliders, and a scrolling log panel — wired together by the dispatcher.
Run it now
cargo run -p filter_plotterClick Generate in the Settings panel. The noisy input on top gets cleaned up in the filtered output below. Drop the cutoff slider and click Generate again to see the noise creep back in.
The reusable scaffolding
Before the code, the punchline of the citizen pattern: most of what you're about to build also fits the next app you build. When you start the next citizen app, these files change very little:
dispatcher.rs— register citizens, drain messages, route AppMessagetabs.rs— the TabKind enum, Tab struct, TabViewer implmessages.rs— the AppMessage enum (specifically the Citizen variant)main.rs— App struct + drain loop patternstate.rs— SharedState shape with reactive parameterstheme.rs— visuals + font scaling
What does change app-to-app:
- The contents of the panels (
panels/) - The backend (
backend/) - The non-Citizen variants of
AppMessage
The dispatcher's plumbing is the part that scales sideways — write it once and you're 80% of the way through every future citizen app.
Project layout
examples/filter_plotter/
├── Cargo.toml
└── src/
├── main.rs # eframe::App, dock layout, drain loop
├── theme.rs # apply_visuals, apply_font_scale
├── tabs.rs # TabKind, Tab, TabViewer
├── messages.rs # AppMessage enum
├── dispatcher.rs # register / drain / handle
├── state.rs # SharedState, ParamsState
├── backend/
│ ├── mod.rs # BackendKind trait, FilterParams, Traces
│ └── iir.rs # InProcessIir biquad lowpass
└── panels/
├── mod.rs
├── plot.rs # linked stacked plots
├── settings.rs # sliders + Generate button
└── logger.rs # log scrollback
Each file has one job. The settings panel doesn't know how the filter works; the backend doesn't know what egui is. The dispatcher routes messages between them.
The shape
The data flow on a "Generate" click:
[Settings panel] ── click ──> AppMessage::Generate
│
v (settings.outbox)
[main.rs drain loop] ── handle Generate ──> backend.run(params)
│
v
[SharedState::traces]
│
v
[Plot panel reads + renders]
The settings panel does not call the backend directly. It
pushes a message into its outbox; the drain loop in
main.rs::update() picks it up, calls the backend, stores the
result in SharedState, and the plot panel renders it on the next
frame.
Shared state — state.rs
use eframe::egui;
use egui_mobius_reactive::Dynamic;
use crate::backend::{FilterParams, Traces};
pub struct ParamsState {
pub signal_freq_hz: Dynamic<f32>,
pub noise_freq_hz: Dynamic<f32>,
pub cutoff_hz: Dynamic<f32>,
pub sample_rate_hz: Dynamic<f32>,
pub duration_ms: Dynamic<f32>,
}
pub struct SharedState {
pub params: ParamsState,
pub traces: Dynamic<Traces<f32>>,
pub log: Dynamic<Vec<String>>,
pub plot_link: egui::Id,
}
Three reactive fields (Dynamic<T>) for things multiple places
read or write: params (settings panel writes, backend reads on
Generate), traces (drain loop writes, plot panel reads), log
(drain loop writes, logger panel reads). One non-reactive field
plot_link because both plot widgets only need the same Id; it
never changes after construction.
The f32 in Dynamic<Traces<f32>> is where this app commits to
the in-process IIR backend's sample type — see
The backend below for why Traces<T> is
generic and how a fixed-point backend would change just this one
type.
This is the app-shared services bucket from Where does state live? in concrete form.
The backend — backend/
Two plain data types and a trait. The data types come first because the trait signature uses them.
/// Parameters captured at "Generate" time — a snapshot of the reactive
/// fields on `SharedState::params` so the backend has a stable, owned
/// view of what to compute.
#[derive(Debug, Clone, Copy)]
pub struct FilterParams {
pub signal_freq_hz: f32,
pub noise_freq_hz: f32,
pub noise_amplitude: f32,
pub cutoff_hz: f32,
pub sample_rate_hz: f32,
pub duration_ms: f32,
}
impl FilterParams {
pub fn num_samples(&self) -> usize {
(self.sample_rate_hz * self.duration_ms / 1000.0).round() as usize
}
}
/// One pair of traces resulting from a Generate run.
///
/// `T` is the sample type. The in-process IIR backend uses `f32`; a
/// serial-port backend feeding raw ADC counts could use `i16` or `i32`
/// without a lossy upcast at the boundary. Time stays `f64` regardless
/// — timestamps are the same kind of value across all backends.
#[derive(Debug, Clone)]
pub struct Traces<T> {
pub time: Vec<f64>, // seconds
pub input: Vec<T>, // raw noisy signal
pub filtered: Vec<T>, // lowpass output
}
impl<T> Default for Traces<T> {
fn default() -> Self {
Self {
time: Vec::new(),
input: Vec::new(),
filtered: Vec::new(),
}
}
}
impl<T> Traces<T> {
pub fn is_empty(&self) -> bool { self.time.is_empty() }
}
Three things worth pointing out:
FilterParamsisCopybecause it's a plain bag off32s. The settings panel writes reactiveDynamic<f32>fields; on Generate we snapshot them into aFilterParams(seestate.rs::ParamsState::snapshot()) so the backend gets a stable, owned value. No reactivity crosses the trait boundary.Traces<T>is generic over the sample type, not the time type. This is the difference between an emulator backend producingf32samples and a serial-port backend producingi16ADC counts — neither needs to lossily upcast at the boundary. The time axis staysVec<f64>because seconds-since-start is the same kind of quantity everywhere; only the sample magnitudes vary in representation.Tracesuses parallelVecs columnar, three vectors of the same length, not aVec<(f64, T, T)>of points. Columnar is what the plot library wants —traces.time.iter() .zip(traces.input.iter())to produce points only at render time — and it's what a streaming backend would also produce. Keeping the shape columnar from day one means swapping in a streaming backend later doesn't reshape the data.
Default is implemented manually instead of derived because the
derive would inject a spurious T: Default bound; Vec::new()
doesn't need it.
The BackendKind trait then abstracts what produces a Traces
from a FilterParams. The sample type is an associated type, so
each backend names exactly one:
pub trait BackendKind {
type Sample;
fn run(&mut self, params: &FilterParams) -> Traces<Self::Sample>;
fn name(&self) -> &'static str;
}
The tutorial ships InProcessIir (in backend/iir.rs) with
type Sample = f32; — it generates a sine wave, adds a 200 kHz
tone for noise, applies a biquad lowpass, and returns both traces
as Traces<f32>. A SerialPort impl would set
type Sample = i16; (or whatever the ADC width is) and run
would read samples off a port. The rest of the app — settings
panel, plot panel, dispatcher — does not change shape. Swap the
backend type and the wiring stays.
The one place that commits to a sample type is SharedState:
pub traces: Dynamic<Traces<f32>>,
The reactive cell has to hold a concrete T. Using a different
backend means changing this f32 to match
Backend::Sample, but the dispatcher's handle function uses
B: BackendKind<Sample = f32> to enforce the match at compile
time, so the wiring stays honest.
The biquad itself is direct-form-II-transposed Butterworth (Q = 1/√2):
fn lowpass(cutoff_hz: f32, sample_rate_hz: f32) -> Self {
let q = std::f32::consts::FRAC_1_SQRT_2;
let omega = 2.0 * std::f32::consts::PI * cutoff_hz / sample_rate_hz;
let cos_w = omega.cos();
let alpha = omega.sin() / (2.0 * q);
let b0 = (1.0 - cos_w) / 2.0;
/* ... a0/a1/a2 via bilinear transform ... */
Self { /* normalized coefficients */ }
}
fn process(&mut self, x: f32) -> f32 {
let y = self.b0 * x + self.z1;
self.z1 = self.b1 * x + self.z2 - self.a1 * y;
self.z2 = self.b2 * x - self.a2 * y;
y
}
Standard textbook biquad — see the file for the full coefficients.
The math is incidental to the tutorial; the point is that this
struct lives in backend/iir.rs and only the BackendKind trait
crosses the module boundary.
The settings panel — panels/settings.rs
pub struct SettingsPanel {
pub citizen_id: CitizenId,
pub citizen_state: CitizenState,
pub outbox: Vec<AppMessage>,
}
Three fields. citizen_id and citizen_state are the boilerplate
that lets the dispatcher route activation to this panel. The
interesting one is outbox: a Vec<AppMessage> the panel pushes
to when something interesting happens, drained each frame by
main.rs.
The Generate button is one line:
if ui.add_sized([ui.available_width(), 28.0],
egui::Button::new("Generate")).clicked() {
self.outbox.push(AppMessage::Generate);
}
The panel does not call backend.run() directly. It does not call
dispatcher.send() either. It just enqueues an AppMessage for
the drain loop to handle. This keeps show() free of dependencies
on the backend or the dispatcher's internals — the panel is
testable in isolation, the message is the contract.
The sliders update reactive parameters via the standard get / set loop:
let mut cutoff = state.params.cutoff_hz.get();
if ui.add(egui::Slider::new(&mut cutoff, 100.0..=50_000.0)
.text("Lowpass cutoff (Hz)")
.logarithmic(true))
.changed()
{
state.params.cutoff_hz.set(cutoff);
}
Read the current value from the Dynamic<f32>, hand egui a &mut
local, on change push the local back. Verbose, but transparent —
nothing is happening behind a wrapper.
The plot panel — panels/plot.rs
const PLOT_STRIDE: usize = 50;
impl PlotPanel {
pub fn show(&mut self, ui: &mut egui::Ui, state: &SharedState) {
let traces = state.traces.get();
if traces.is_empty() {
ui.centered_and_justified(|ui| {
ui.label("Click Generate to compute traces.");
});
return;
}
let half = (ui.available_height() - 8.0).max(120.0) / 2.0;
ui.allocate_ui([ui.available_width(), half].into(), |ui| {
Plot::new("input_plot")
.link_axis(state.plot_link, [true, false])
.height(half)
.show(ui, |plot_ui| {
let pts: PlotPoints = traces.time.iter()
.zip(traces.input.iter())
.step_by(PLOT_STRIDE)
.map(|(&t, &y)| [t, y])
.collect();
plot_ui.line(Line::new("input", pts));
});
});
// ...same shape for the filtered output...
}
}
Two key bits:
- Linked axes via the same
egui::Id. BothPlot::new(...)calls passstate.plot_link(the sameId) tolink_axis, so panning or zooming the input plot drives the filtered plot too. That's the matplotlib-style behavior. - Decimation. The backend computes 100,000 samples (1 MHz ×
100 ms). Rendering all of them is wasteful — every 50th sample
looks identical to the eye.
step_by(PLOT_STRIDE)cheaply produces 2,000 plot points per trace.
The panel reads state.traces once at the top, holds the result
locally for the rest of the frame.
The logger panel — panels/logger.rs
The simplest panel. Reads state.log, prints each line in a
scrolling area:
let log = state.log.get();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.stick_to_bottom(true)
.show(ui, |ui| {
if log.is_empty() {
ui.weak("(no events yet)");
} else {
for line in log.iter() {
ui.monospace(line);
}
}
});
The log is populated entirely from the drain loop in
dispatcher.rs::handle(). The logger panel doesn't write to it
— it just renders.
The dispatcher module — dispatcher.rs
This is where the pattern earns its name. Three jobs:
pub fn register_citizens(dispatcher: &mut Dispatcher) -> RegisteredCitizens {
let plot = dispatcher.register(CitizenId::new(PLOT_ID));
let settings = dispatcher.register(CitizenId::new(SETTINGS_ID));
let logger = dispatcher.register(CitizenId::new(LOGGER_ID));
dispatcher.activate(&CitizenId::new(PLOT_ID));
RegisteredCitizens { plot, settings, logger }
}
pub fn drain_citizen(dispatcher: &mut Dispatcher, log: &Dynamic<Vec<String>>) {
for msg in dispatcher.drain_messages() {
append_log(log, format_citizen(&msg));
}
}
pub fn handle<B>(
msg: AppMessage,
state: &SharedState,
backend: &mut B,
log: &Dynamic<Vec<String>>,
)
where
B: BackendKind<Sample = f32>,
{
match msg {
AppMessage::Citizen(_) => {} // already drained directly
AppMessage::Generate => {
let params = state.params.snapshot();
let traces = backend.run(¶ms);
let n = traces.input.len();
state.traces.set(traces);
append_log(log, format!("[INFO] backend ({}) produced {} samples",
backend.name(), n));
}
AppMessage::GenerateCompleted { samples } => {
append_log(log, format!("[INFO] generate completed: {} samples", samples));
}
}
}
register_citizens runs once at startup. drain_citizen and
handle run once per frame. handle is generic over backend
shape (B: BackendKind<Sample = f32>), which is what makes the
dispatcher app-agnostic at the behavior layer — the same module
would work with a SerialPort backend, a CsvImporter, or
anything else implementing BackendKind whose Sample matches
what SharedState::traces holds. Pinning the sample type at the
where clause means the compiler catches any backend swap that
forgot to update SharedState.
The tabs module — tabs.rs
egui_dock needs two things from your app: a tab type, and a
TabViewer impl that knows how to render each tab. tabs.rs is
where both live, plus the citizen IDs that name each panel.
pub const PLOT_ID: &str = "plot";
pub const SETTINGS_ID: &str = "settings";
pub const LOGGER_ID: &str = "logger";
#[derive(Clone, Copy)]
pub enum TabKind {
Plot,
Settings,
Logger,
}
pub struct Tab {
pub kind: TabKind,
}
impl Tab {
pub fn new(kind: TabKind) -> Self { Self { kind } }
pub fn title(&self) -> &'static str {
match self.kind {
TabKind::Plot => "Plot",
TabKind::Settings => "Settings",
TabKind::Logger => "Logger",
}
}
pub fn citizen_id(&self) -> CitizenId {
CitizenId::new(match self.kind {
TabKind::Plot => PLOT_ID,
TabKind::Settings => SETTINGS_ID,
TabKind::Logger => LOGGER_ID,
})
}
}
TabKind is the closed set of panels the app knows about. Tab
wraps a TabKind because egui_dock::DockState<T> stores T
directly — wrapping it in a struct gives us a stable place to hang
helpers like title() and citizen_id(). Adding a fourth panel is
one new variant plus a match arm in each helper; no other file
moves.
The citizen_id() method is what links egui_dock's tab-click
event back into the citizen layer — clicking the Settings tab needs
to activate CitizenId::new("settings") so the dispatcher knows
that panel is now in focus. Keeping the IDs as pub const strings
in this file means dispatcher.rs and tabs.rs agree by import,
not by typo-prone string duplication.
The TabViewer bridge
egui_dock::TabViewer is the trait the dock area calls into to
render each tab. Our impl is the one place in the app that holds
mutable references to every panel and the dispatcher at once:
pub struct TabViewer<'a> {
pub state: &'a SharedState,
pub dispatcher: &'a mut Dispatcher,
pub plot: &'a mut PlotPanel,
pub settings: &'a mut SettingsPanel,
pub logger: &'a mut LoggerPanel,
}
impl egui_dock::TabViewer for TabViewer<'_> {
type Tab = Tab;
fn title(&mut self, tab: &mut Self::Tab) -> egui::WidgetText {
tab.title().into()
}
fn ui(&mut self, ui: &mut egui::Ui, tab: &mut Self::Tab) {
match tab.kind {
TabKind::Plot => self.plot.show(ui, self.state),
TabKind::Settings => self.settings.show(ui, self.state, self.dispatcher),
TabKind::Logger => self.logger.show(ui, self.state),
}
}
fn on_tab_button(&mut self, tab: &mut Self::Tab, response: &egui::Response) {
if response.clicked() {
self.dispatcher.activate(&tab.citizen_id());
}
}
}
Three methods, each doing one thing:
title— return whatever the tab strip should display. We delegate toTab::title()so the strings live next to the enum.ui—egui_dockcalls this once per visible tab per frame. We match ontab.kindand dispatch to the corresponding panel'sshow(). Note thatsettings.showtakes the dispatcher too — most panels won't need it, but the settings panel uses it for activation hooks. The other panels just need&SharedState.on_tab_button— fired when the user clicks a tab header. We forward the click intodispatcher.activate(...). This is the canonical citizen hook:egui_dockknows about the click; the dispatcher knows about activation; this method is the bridge. Even if your app doesn't currently do anything on activation, register the click — the dispatcher's queue stays accurate, and adding behavior later doesn't require revisiting this file.
The borrow story is worth a moment: TabViewer holds five &mut
references at once, which would be a problem in a long-lived
struct, but it's constructed inline in update() and dropped at
the end of DockArea::show(). Short-lived, single-frame — the
compiler is happy because no two methods on TabViewer borrow
overlapping fields.
Why this file barely changes between apps
Look back at the reusable scaffolding list at the top of the
chapter. tabs.rs is on it because:
- The
TabKindenum changes (new panels) but the shape doesn't. - The
Tabstruct,title(),citizen_id()pattern is verbatim across apps. - The
TabViewerimpl gains/loses fields as panels come and go, but the three methods (title,ui,on_tab_button) are always the same three methods doing the same three jobs.
Adding a panel is mechanical: new const, new TabKind variant,
new title, new citizen_id arm, new &mut Panel field on
TabViewer, new arm in ui. Six edits, no thinking — exactly
the kind of change the citizen pattern is designed to make boring.
Wiring it together — main.rs
impl eframe::App for App {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
DockArea::new(&mut self.dock_state).show(ctx, &mut TabViewer {
state: &self.state,
dispatcher: &mut self.dispatcher,
plot: &mut self.plot,
settings: &mut self.settings,
logger: &mut self.logger,
});
dispatcher::drain_citizen(&mut self.dispatcher, &self.state.log);
let outbox = std::mem::take(&mut self.settings.outbox);
for msg in outbox {
dispatcher::handle(msg, &self.state, &mut self.backend, &self.state.log);
}
}
}
Five lines of orchestration:
- Hand the dock area to
egui_dockwith ourTabViewer. - Drain citizen activation messages into the log so the logger panel can show them.
- Take the settings panel's outbox.
- Process each
AppMessagethroughhandle.
That's it. Adding a new panel means: register it, add a
TabKind variant, wire it into TabViewer::ui(). Adding a new
domain message means: a new variant in AppMessage and a match
arm in handle. Neither is a refactor.
Where to take it next
Concrete extensions, ordered by ambition:
- Replace
InProcessIirwithSerialPort. ImplementBackendKind::runto read N samples off a port instead of generating them locally. UI doesn't change. - Stream the data instead of snapshotting. Spawn a worker
thread in a
Backend::start()method, push samples through a channel, drain the channel into a ring buffer inSharedStateeach frame. ChangeAppMessage::GeneratetoStart/Stop. Plot panel reads the ring buffer. - Add a second filter stage. Two
Biquadsections cascaded for a 4th-order filter; expose a "filter order" combo box in Settings. - Persist the filter coefficients. Write
ParamsStateto a RON file when the app exits, restore on startup. Routes through anotherAppMessage::Save/Load.
Each of these is one or two new modules and zero changes to the
dispatcher.rs, tabs.rs, or main.rs scaffolding. That's the
citizen pattern's transplant value, made concrete.
Source
Full source lives in
examples/filter_plotter/
in the egui_mobius repo. Run cargo run -p filter_plotter from
the workspace root.