Introduction
Book version: 0.4.0 · Last updated: 2026-05-05 · tracks
egui_mobiusv0.4.0The book is live — it evolves alongside the framework. Each chapter footer notes the date of its last substantive revision. When egui_mobius ships v0.5.0, this book becomes 0.5.0.
This book is the know-how for building solid, professional GUI
applications in Rust on top of egui and the egui_mobius
framework. The framework is a workspace of coordinated crates that
together make modern dockable, threaded, reactive UI cheap to
assemble.
Citizens are plug-ins
The single most important idea in this book is that citizens are
plug-ins. Once a panel implements the Citizen trait, it drops
into any host app with a four-line integration: cargo add the
crate, declare one Dynamic<T> field, add a TabKind variant,
render it from the TabViewer. No glue code. Real apps grow by
accumulating citizens, not by extending a core — and the
Dispatcher is the registry those citizens
register with.
egui_lens and egui_quill are the shipped examples; the same
shape applies to future citizens, and to any third-party citizen on
crates.io. This is what makes the ecosystem composable rather
than just architecturally tidy.

CopperForge — a
real-world egui_citizen + egui_dock application for PCB gerber
inspection. Each docked region is a citizen-panel; the panels share
state through reactive cells, and the 3D rendering thread is
coordinated through the dispatcher.
Three levels of mobius-citizen apps
A mobius-citizen application sits at one of three levels, each adding capability without throwing away what came before:
- Level 1 — shared
Dynamic<T>between panels; the dispatcher manages panel state. Examples:getting_started,citizen_dock. - Level 2 — the dispatcher is extended to handle synchronous
backend processing — filter, parser, anything in-process.
Examples:
filter_plotter,citizen_fetch. - Level 3 —
egui_mobiussignals and slots wire the dispatcher to async / multi-threaded backends. Example:citizen_signal_async.
Levels 1–2 use egui_citizen and egui_mobius_reactive; level 3
brings in egui_mobius itself.
Who this book is for
Familiarity with
egui, with dockable widgets — Qt's Advanced Docking Widgets oregui_dockdirectly — and with shared-memory threading concepts. If those are comfortable, the rest is just learning the pattern.
How to read this book
Background covers Dynamic<T>, egui_dock, and the vocabulary.
Concepts cover the Citizen trait, the dispatcher, messages, and
coupling. The Tutorial is a worked example end-to-end — you can
go straight there and refer back to Concepts as needed. The book
closes with patterns, common pitfalls, and a reference sheet.
What is a citizen?
Before any of the trait, dispatcher, or reactive-state machinery, get the picture from the UI side first.
A citizen is a panel. A docked, movable, resizable region of the application window with a stable identity and a known set of widgets inside it.
That's the user-facing definition. Everything else — the Citizen
trait, the Dispatcher, the reactive CitizenState — is the
plumbing that makes the behavior of those panels predictable across
an application. The plumbing matters, but the panel is what the user
actually sees and interacts with.
The general characteristics
A citizen panel has all of the following:
-
Identity. Each panel has a stable name (a
CitizenId—"plot","settings","logger","gerber_view"). Two panels cannot share an identity within the same app. The identity outlives any individual frame and survives layout changes. -
Dockable. The panel slots into the application's dock layout via
egui_dock(or a sibling dock library). It can sit alongside other citizen panels in a tabbed group, in a split, or as a free-floating window. -
Movable. The user can drag the panel by its tab bar to a different dock position. Citizen identity is preserved through the move — the panel knows it's still the same panel after landing in a new spot.
-
Resizable. Dock split handles let the user reapportion space between citizens. The panel adapts; it does not lose state when its size changes.
-
Atomic content. A panel contains atoms — the widgets inside it: a slider, a button, a checkbox, a text field, a scrollable list, a plot. Atoms are the panel's interactive surface. A panel without atoms is a static label; the citizen pattern shines when atoms drive shared state that other citizens observe.
-
Lifecycle awareness. At any moment exactly one citizen is the active one in its group. Activation flips when the user clicks the panel's tab. The pattern guarantees this is exclusive — when "alpha" activates, "beta" deactivates atomically. Other panels can react to this without polling.
-
Reactive state. A small bundle of
Dynamic<T>cells — active, clicked, selected, visible, location, moved — published by every citizen and readable from anywhere in the application. Other panels and backend threads observe these without holding references to the panel itself.
What this buys you that egui_dock alone doesn't
egui_dock is a layout library. It tells you which panel is
visible on screen and which sits in which split. It does not
tell you which panel is currently the active citizen of
interest — i.e., which panel the user clicked last, which one
should be receiving keyboard focus, which one a backend thread
should be feeding fresh data to.
The citizen pattern fills that gap by giving every panel its
own reactive state (CitizenState) and routing tab clicks
through a central Dispatcher. The dispatcher's activate(...)
call is an atomic set/reset: when "alpha" becomes active, every
other citizen's active cell flips to false in the same
operation, and lifecycle messages (Activated { id: alpha },
Deactivated { id: beta }) drop into the dispatcher's queue.
This means:
- A backend thread can poll the dispatcher (or observe each
citizen's
activeDynamic<bool>) and discern which citizen is currently of interest without holding a reference to any panel. Background work — fetching data, running computations, reading hardware — knows where to direct its results. - A sibling panel can react to another panel becoming active without any per-frame polling: the reactive cell delivers the change, the rendering panel re-reads on its next frame.
- The pattern guarantees one-hot activation — exactly one
citizen active at a time per group — atomically. Two panels
can never both think they're active because of a frame-order
race.
egui_dockmakes no such guarantee; you'd have to wire it manually.
This separation — dock library handles geometry, citizen dispatcher handles interest — is the load-bearing distinction. Without it, every app reinvents some ad-hoc "which panel did the user mean?" logic. With it, that's framework infrastructure you inherit for free.
Citizens as plug-ins
The other consequence of the citizen contract is that citizens
become plug-ins. Once a panel implements the Citizen trait,
exposes its reactive state, and integrates with the dispatcher,
it doesn't need to know anything about the host app to drop in.
The host app, conversely, just needs to:
- Add the citizen's crate as a dependency.
- Carry its
Dynamic<T>state field on the shared state struct. - Add a
TabKindvariant for it. - Render it from the
TabViewer.
That's the whole integration. No glue code, no event-bus wiring, no manual subscription setup. The citizen pulls its weight as a self-contained unit. And the Dispatcher is the registry those plug-ins register with — the same registry pattern familiar from backend systems, applied here to UI panels.
egui_lens (the reactive event logger) and egui_quill (the
syntax-highlighted editor) are the canonical examples of this in
action. Both ship as their own workspace crates with stable
public APIs (ReactiveEventLogger + ReactiveEventLoggerState;
ReactiveEditor + ReactiveEditorState). The host app's
integration is small enough to fit on a sticky note:
// One field on shared state.
pub log: Dynamic<ReactiveEventLoggerState>,
// One render call per frame.
let logger = ReactiveEventLogger::new(&state.log);
logger.show(ui);
Same pattern for quill, same pattern for the canonical citizen
panels coming next — Project, Settings, Terminal, Data Table.
Same pattern for any third-party citizen someone publishes on
crates.io: cargo add egui_their_panel, declare the state
field, render in a tab, done.
This is what makes the framework genuinely composable rather than just architecturally tidy. Real apps grow by accumulating citizens, not by extending their core.
Caveat: citizens are compile-time plug-ins — adding one rebuilds the host app, not a runtime extension load. The Rust toolchain doesn't ship a stable plug-in ABI; runtime loading would require dynamic library tricks that aren't worth the complexity. The four-line integration is small enough that it feels plug-in-shaped in practice, and the rebuild is fast.
What it is not
A citizen is not:
- A modal dialog or a popover. Those have transient lifetimes and no stable identity.
- A widget. Widgets are atoms; they live inside citizens.
- A non-docked sub-region of a single window. The dockability is intrinsic; without it, a panel is just a layout container.
- A backend thread. Background work runs separately and communicates with citizens through reactive state and message channels.
A picture
┌───────────────────────────────────────────────────────────────┐
│ App window │
│ ┌──────────────┬──────────────────────┬──────────────────────┐ │
│ │ ▶ Settings ✕ │ ▶ Plot ✕ │ ▶ Logger ✕ │ │
│ ├──────────────┼──────────────────────┼──────────────────────┤ │
│ │ │ │ │ │
│ │ citizen │ citizen │ citizen │ │
│ │ "settings" │ "plot" │ "logger" │ │
│ │ │ │ │ │
│ │ atoms: │ atoms: │ atoms: │ │
│ │ - slider │ - plot widget │ - filter btn │ │
│ │ - combobox │ - link checkbox │ - clear btn │ │
│ │ - generate │ │ - save btn │ │
│ │ button │ │ - column toggles│ │
│ │ │ │ - scroll list │ │
│ └──────────────┴──────────────────────┴──────────────────────┘ │
└───────────────────────────────────────────────────────────────┘
Three citizens. Each is dockable / movable / resizable. Each holds atoms that are the user's actual interaction surface. Reactive state flows through the framework's plumbing so the plot panel can react to a settings-panel atom, and the logger can show a backend-thread message, without any of them needing direct references to the others.
Where the rest of the book goes from here
- The Citizen trait chapter is the code
shape: what
impl Citizen for MyPanellooks like, the lifecycle hooks, theCitizenIdandCitizenStatetypes. - The Dispatcher chapter is the coordinator: how activation propagates and how messages drain.
- The
Dynamic<T>background chapter is the reactive primitive everyCitizenStatefield rests on. - The tutorial is the worked example — three citizens (Plot / Settings / Logger), built end to end.
Chapter last revised: 2026-05-04 — egui_mobius v0.4.0.
Key vocabulary
Three terms appear throughout this book. Fix them in your head now — every chapter that follows leans on these:
Dynamic<T>— the reactive primitive that citizen-panels and atoms both sit on top of. A thread-safe, observable cell that any number of handles can point at. Writes through any handle are visible through every other handle. Covered in depth below. There is also a correspondentDerived<T>fromegui_mobius_reactivethat can automatically produce side effects.- citizen-panel — a dock panel that carries a persistent identity
(
CitizenId) and reactive lifecycle state (CitizenState), wired into a centralDispatcher. The citizen-panel is the unit of organization in anegui_citizenapp. - atom — a single widget inside a citizen-panel: a slider, a button, a text field, a checkbox. Atoms are where user input originates. They fire events on their citizen-panel's behalf and often hold their own reactive state that other panels or backend threads read. See the coupling chapter for how an atom can wire into panel-to-panel state sharing (Path A), panel-to-backend messaging (Path B), or both at once.
Coupling paths
Two named mechanisms for moving information between citizen-panels. Encountered throughout the concepts chapters and fully treated in coupling; listed here so the names land before the first chapter that uses them.
- Path A — shared
Dynamic<T>. Two panels hold clones of the same cell; one writes, the other reads on the next frame. Instant, in-frame, no queue. Carries state, not events. The default for panel-to-panel coordination. - Path B — dispatcher messages. A panel calls
dispatcher.send(...); the app's update loop drains the queue once per frame and forwards each message onward to a backend thread or logger. Queued, lands next drain. Carries events, not state. Use when the change needs to leave the UI thread.
The Dynamic<T> primitive
Before going further, know the one building block that everything
reactive in egui_citizen rests on:
Every reactive field in
egui_citizenis aDynamic<T>.
What it is
A Dynamic<T> is a thread-safe, observable container for a single
value. Internally (quoting egui_mobius_reactive verbatim):
pub struct Dynamic<T> {
inner: Arc<Mutex<T>>,
notifiers: Arc<parking_lot::Mutex<Vec<Sender<()>>>>,
}
Two Arcs. The first holds the value behind a standard Mutex. The
second holds a list of channel senders used to wake subscribers when
the value changes. Dynamic<T> derives Clone — cloning bumps the
refcount on each Arc and copies nothing else, so every clone refers
to the same storage and the same notifier list.
Aside:
Clonein Rust is per-type. Rust has no language-level default of "deep" vs. "shallow" — each type'sCloneimpl decides what cloning means for that type.
- Owned types like
String,Vec<T>,Box<T>,HashMap<K, V>duplicate their heap data on.clone()— what C++ would call a deep copy.- Reference-counted types like
Arc<T>andRc<T>are documented to clone as a refcount increment — a new handle pointing at the same allocation. C++ would call this shallow.
Mutex<T>itself does not implementCloneat all — that is why theArcwrapper is necessary. Cloning aDynamic<T>is therefore exactly twoArc::clonecalls: two refcount bumps, zero data duplication. The shared storage is not an accident; it is the precise contract ofArc.
Core API
use egui_mobius_reactive::Dynamic;
let counter = Dynamic::new(0); // construct
let n = counter.get(); // read (clones T out of the lock)
counter.set(42); // write, then notify listeners
let mut guard = counter.lock(); // direct MutexGuard if you need it
*guard += 1;
Dynamic::new(initial)— requiresT: Clone + Send + 'static.get()— returns a clone of the value; the lock is released before you work with the result.set(value)— takes the lock, writes, drops the lock, then sends()into every registered notifier channel.lock()— gives you a rawMutexGuardfor in-place mutation. Other readers and writers block until you drop the guard.
Observing changes
Reading on every frame works — that's what UI panels do via .get()
in their render methods. For event-driven work, ValueExt::on_change
registers a callback that fires on every mutation:
use egui_mobius_reactive::{Dynamic, ValueExt};
let counter = Dynamic::new(0);
counter.on_change(|| println!("changed!"));
counter.set(1); // prints "changed!"
counter.set(2); // prints "changed!"
Under the hood, on_change spawns a dedicated background thread that
waits on the notifier channel. The callback runs off the UI thread —
which is why T needs Send + Sync + PartialEq + 'static for this
path. The full mechanics — including what it actually costs per
subscriber and why the canonical reactive path inside egui_citizen
is panel-side polling rather than callbacks — get a chapter of their
own: Inside Dynamic<T>.
Why this shape matters for egui_citizen
Because clones share storage, a CitizenState — a bundle of
Dynamic<T> fields — is a handle, not an owned value:
#![allow(unused)] fn main() { use egui_citizen::CitizenState; let a = CitizenState::new(); let b = a.clone(); a.active.set(true); assert!(b.active.get()); // true — same Arc<Mutex<bool>> }
The dispatcher keeps one clone of each citizen's state; your panel
holds another. When the dispatcher writes .active.set(true), your
panel sees true on its next .get(). No event bus, no subscription
to wire up, no polling loop — just a shared Arc.
Permissive type, disciplined use
Dynamic<T> itself is multi-producer, multi-consumer — any clone
can call .set(), any clone can .get(). The type doesn't restrict
who writes.
egui_citizen layers a single-writer-per-field discipline on top:
| Field | Canonical writer |
|---|---|
active | The Dispatcher (via activate) |
clicked | The panel's on_click hook |
selected, visible, moved | The panel or app-level code |
location | The dock-integration layer |
Readers are unrestricted: any panel, any backend thread. Writers are by convention, not enforcement. This is why the dispatcher is central (it's the one place that serializes activation writes across all citizens), and why the pitfall on two dispatchers in one app exists — two writers to the same logical field break the one-hot invariant.
Keep to "one writer per field" and the reactive story stays clean; violate it and you're back to the per-frame race the crate was built to avoid.
ValueExt and Derived<T>
egui_citizen itself only reaches for Dynamic<T>, but the reactive
crate ships two related building blocks that you'll want when an app
outgrows pure Dynamic storage. They both ride on the same notifier
infrastructure described above (the shared Vec<Sender<()>>), so
understanding them is mostly a matter of seeing what each one wraps.
ValueExt — the on_change extension trait
ValueExt<T> is a tiny extension trait whose only method is
on_change:
pub trait ValueExt<T: Clone + Send + Sync + 'static> {
fn on_change<F>(&self, callback: F) -> Arc<F>
where
F: Fn() + Send + Sync + 'static;
}
It's implemented for Dynamic<T> (when T: PartialEq) and is the
public surface for callback-style subscription. Importing it brings
.on_change(...) into scope:
use egui_mobius_reactive::{Dynamic, ValueExt};
let counter = Dynamic::new(0);
counter.on_change(|| println!("changed"));
Without ValueExt in scope, calling .on_change on a Dynamic
won't compile — that's the whole reason the trait exists. Every
example earlier in this chapter that uses on_change is implicitly
relying on ValueExt being imported.
The cost of each on_change registration — one OS thread per
subscriber, no unsubscribe — is detailed in the Inside
Dynamic<T> chapter. Within
egui_citizen itself, panel-side .get() polling is the canonical
path for UI reactivity; on_change is for off-thread reactions
(file logging, network sends, anything outside the egui frame loop).
Derived<T> — auto-recomputed values
A Derived<T> is a read-only reactive cell whose value is computed
from one or more Dynamics (or other Deriveds). It recomputes
automatically whenever one of its inputs changes:
use egui_mobius_reactive::{Dynamic, Derived};
use std::sync::Arc;
let count = Dynamic::new(0);
let count_arc = Arc::new(count.clone());
let doubled = Derived::new(&[count_arc.clone()], move || {
count_arc.get() * 2
});
count.set(5);
// `doubled.get()` now returns 10 — the closure re-ran when count changed.
Two facts make Derived<T> cheap and predictable:
-
get()is a clone of a cached value, not a recomputation. The closure runs only when an input changes, not every time you read the result. Readingdoubled.get()60 times per frame is just 60 lock-acquire-and-clone operations on aMutex<T>. -
Inputs subscribe to the closure via the same notifier plumbing.
Derived::new(deps, compute)callsdep.subscribe(...)on each dependency, which pushes aSender<()>into the dependency's notifier vec — the same vec that backsValueExt::on_change. When a dep'sset()rings the doorbell, theDerivedre-runs its closure and stores the new value in its own cache.
Practically, this means Derived<T> is the right tool when you
have a value that must always agree with other reactive state —
"the formatted version of current_time," "the filtered subset of
logs," "the sum of two Dynamic<i32>s." Use a Derived and the
arithmetic stays correct without anyone remembering to call an
update function.
Three honest caveats:
- The closure re-runs once per
set()on any dependency. If a panel writes its dependency 100 times during a slider drag, the closure runs 100 times. No coalescing. - The closure takes ownership of all captured state, so the
dependency you read inside is typically a separate clone bound
via the closure (the
count_arcin the example above). - Cycles aren't detected.
DerivedA depending onDerivedB depending onDerivedA will recurse and panic. Don't.
Together
ValueExt::on_change and Derived::new are two consumers of the
same primitive. The notifier vec inside Dynamic<T> is just a list
of "things to wake when this value changes" — on_change adds one
that runs your closure on a worker thread, Derived::new adds one
that re-runs the compute closure and caches the result. Same hook,
different jobs. This is also the reason both APIs cost an entry in
that vec and neither has an unsubscribe — they're pinned for the
Dynamic's lifetime.
egui_mobius_reactive also provides Value<T> (an older API with
the same shape as Dynamic<T>) and a SignalRegistry for app-wide
signal wiring. The book doesn't cover those — the reactive crate's
own documentation is the next stop.
Where this leads next
The chapter on reactive lifecycle builds on
this foundation and walks through the trap that bites users who
construct a CitizenState with CitizenState::default() instead of
obtaining one from Dispatcher::register(). The Inside
Dynamic<T> chapter opens up the
notifier mechanism in detail — read it before writing code that
subscribes to a Dynamic<T>.
One of those individual components deserves explicit recognition.
egui_dock is the key piece of
the egui ecosystem that lets a non-trivial app look and feel
finished — multi-panel docking, splittable workspaces, drag-and-drop
tab rearrangement, persistent layouts. It plays roughly the same role
in the egui world that the Qt Advanced Docking System plays in Qt:
without it an app tends to feel like a demo, and with it an app can
feel professional.
But egui_dock is, by intent, a layout and interaction primitive — not
an organizational framework. It hands you the shell. It does not tell
you how the panels living inside that shell should share state,
coordinate activation, or reach a backend. Wiring those decisions is
left to the application author, which is precisely what this book —
and egui_citizen — are about.
Every visible tab's ui() runs every frame
egui_dock is an immediate-mode dock. There is no "active tab"
notion baked into its rendering: every tab that is currently visible
in the dock layout — meaning its node is rendered, even if other
nodes are also visible alongside it — has its ui() callback fire
every single frame.
This is by design. egui as a whole is immediate-mode: the entire UI
is reconstructed each frame from scratch. egui_dock extends that
model to multiple panels in a dock layout. Visibility, not a
focus/active flag, drives whether ui() fires.
The implication is small but load-bearing: ui() is a render
callback, not an event hook. Anything you do inside it happens
once per frame per visible tab, not once per user action.
Writing an egui_citizen app means implementing TabViewer
The bridge between egui_dock and your application is a single
trait, egui_dock::TabViewer, that you implement. Its skeleton looks
like this:
struct MyTabViewer<'a> {
app: &'a mut App, // your app's shared state
dispatcher: &'a mut Dispatcher, // egui_citizen's dispatcher
}
impl egui_dock::TabViewer for MyTabViewer<'_> {
type Tab = MyTab; // your tab type
fn title(&mut self, tab: &mut Self::Tab) -> egui::WidgetText {
tab.title.clone().into()
}
fn ui(&mut self, ui: &mut egui::Ui, tab: &mut Self::Tab) {
// RENDERING. Runs every frame, for every visible tab.
tab.show(ui, self.app);
}
fn on_tab_button(
&mut self,
tab: &mut Self::Tab,
response: &egui::Response,
) {
// STATE TRANSITIONS. Fires once when the tab is clicked.
if response.clicked() {
self.dispatcher.activate(&tab.citizen_id());
}
}
}
That impl is not optional, it is the integration shape of any
egui_dock + egui_citizen app. Two of its methods are the topic of
this chapter: ui and on_tab_button. Their roles are entirely
distinct, and conflating them is the root of the per-frame race.
The wrong-hook trap: state transitions in ui()
Suppose a naive author wants to track which tab is "active." They
write this inside ui(), reasoning that this tab is rendering, so
mark it active:
fn ui(&mut self, ui: &mut egui::Ui, tab: &mut Self::Tab) {
self.app.active_tab = tab.kind; // ← every visible tab does this
tab.show(ui, self.app);
}
What actually happens, given egui_dock's rendering model:
- Frame N renders. Tab Plot is visible, Tab Logger is visible.
ui()fires for Plot —app.active_tab = Plot.ui()fires for Logger —app.active_tab = Logger.- Last write wins.
active_tabisLogger. - Frame N+1 renders. The cycle repeats.
active_tabflickers betweenPlotandLoggerevery frame depending on render order.
The race is inherent. It does not go away with locking, refactoring, or moving the assignment into a method. As long as state-transition logic lives in a callback that fires per-frame-per-visible-tab, the last visible tab to render wins, every frame, regardless of which tab the user actually interacted with.
This is the foot-gun. Most authors hit it, work around it with ad-hoc
"who clicked last?" hacks (a click_time epoch, an is_focused
field that flips itself every frame, etc.), and the workarounds
don't scale.
The right hook: on_tab_button with response.clicked()
egui_dock does provide a one-shot click hook. It is
TabViewer::on_tab_button, and response.clicked() is the gate that
distinguishes the click frame from the rendering frames around it:
fn on_tab_button(
&mut self,
tab: &mut Self::Tab,
response: &egui::Response,
) {
if response.clicked() {
self.dispatcher.activate(&tab.citizen_id());
}
}
This callback runs once per tab button per frame, and the clicked()
predicate fires exactly once per actual user click. State transitions
made inside this guard are single-shot events, not per-frame
overwrites. The race goes away because the assignment only happens
on the frame the user actually clicked.
The discoverability problem
Here is the catch: on_tab_button is named like a render or styling
callback. It sounds like "called while drawing the tab button" —
which it is, but only secondarily. Its primary job, in practice, is
to be the place where clicked() is true. The name does not
telegraph that role.
Compared to ui() — which sounds exactly like a render callback,
because that is what it is — on_tab_button reads as a sibling
"render the button" hook. The natural mental model is "do styling
work in on_tab_button, do main work in ui()," and that mental
model puts state-transition code in the wrong place.
Names like on_panel_selected or on_focus_changed would
immediately telegraph "this is a state-transition event hook, not a
render callback." But the API uses on_tab_button, and the
distinction it draws between event-time and render-time logic is
precisely the distinction egui_citizen exists to enforce.
egui_citizen's answer
egui_citizen makes the event-time / render-time distinction
concrete and unmissable:
- The dispatcher exposes one canonical state-transition primitive:
Dispatcher::activate(&id). - That primitive is only ever called from
on_tab_button(or equivalent user-driven event hooks). It is never called fromui(). ui()reads —tab.show(ui, ...),self.is_active(),self.state.active.get()— but it never writes lifecycle state.- The dispatcher's queue means the consequences of an
activate()call (theActivated/Deactivatedmessages, the reactive flag flips) propagate at well-defined boundaries: in the frame's drain pass, not partway through a render.
The integration shape becomes:
| Callback | Role | Allowed to do |
|---|---|---|
ui() | Render the panel | Read state. Never write lifecycle state. |
on_tab_button | Detect tab clicks | Call dispatcher.activate(&id) on click. |
| Drain loop | Process state-change messages | Mutate app-shared state, forward to backend. |
That separation — events in on_tab_button, rendering in ui(),
consequences drained once per frame — is what makes a multi-panel
egui_dock app stop fighting itself. The rest of this book is the
mechanics of how that works: identities, reactive state, the
dispatcher, the message queue, the coupling paths.
Summary
ui()runs every frame for every visible tab. It is a render callback.on_tab_buttonwithresponse.clicked()is the one-shot click hook. It is the right place for state transitions.- The name
on_tab_buttondoes not telegraph that role, which is why most authors initially put state-transition code inui()and hit the per-frame race. Discoverability is the foot-gun. egui_citizenenforces the distinction by makingDispatcher::activate()the canonical state-transition primitive and routing it exclusively throughon_tab_button.ui()only reads.
The Citizen trait
The Citizen trait is how a panel struct becomes a citizen. It
gives the panel three things:
- A persistent identity — a
CitizenId. - A handle to its reactive lifecycle state — a
CitizenState. - Default lifecycle hooks (
on_activate,on_deactivate,on_click) and reader convenience methods (is_active,is_selected).
This chapter covers the trait surface, the way state flows between the dispatcher and the panel, where panel-author state lives across the three structs that any non-trivial app uses, and the runtime story for how the trait actually gets exercised.
The trait
pub trait Citizen {
fn id(&self) -> &CitizenId;
fn citizen_state(&self) -> &CitizenState;
fn citizen_state_mut(&mut self) -> &mut CitizenState;
// Defaulted hooks (override if you need custom behavior):
fn on_activate(&mut self) { self.citizen_state_mut().active.set(true); }
fn on_deactivate(&mut self) { self.citizen_state_mut().active.set(false); }
fn on_click(&mut self) { self.citizen_state_mut().clicked.set(true); }
// Defaulted readers:
fn is_active(&self) -> bool { self.citizen_state().active.get() }
fn is_selected(&self) -> bool { self.citizen_state().selected.get() }
}
Three required methods. Three defaulted hooks. Two defaulted readers. That is the whole trait.
A working panel
The trait's three required methods (id, citizen_state,
citizen_state_mut) are pure plumbing — they hand the trait
references back to the fields you store on the struct. They're
required because the dispatcher and the defaulted hooks need a
uniform way to reach into your panel; that's the entire purpose.
What the trait actually buys you is the rest of the contract:
is_active(), is_selected(), and the defaulted on_activate /
on_deactivate / on_click hooks. A panel that uses those is
the real minimum:
struct PlotPanel {
citizen_id: CitizenId,
citizen_state: CitizenState,
samples: Vec<f32>,
}
impl PlotPanel {
fn new(state: CitizenState) -> Self {
Self {
citizen_id: CitizenId::new("plot"),
citizen_state: state,
samples: Vec::new(),
}
}
fn show(&mut self, ui: &mut egui::Ui, dispatcher: &mut Dispatcher) {
ui.heading("Plot");
// Read direction: dispatcher → panel.
// The dispatcher's activate() writes self.citizen_state.active
// through the shared Arc; we observe it reactively via
// is_active(). No method call from this side is needed.
if self.is_active() {
ui.label("(active — drawing live)");
// ... actual plotting against self.samples ...
} else {
ui.label("(inactive — paused)");
}
// Write direction: panel → dispatcher.
// The panel can hand control to a sibling citizen explicitly.
if ui.button("Switch to settings").clicked() {
dispatcher.activate(&CitizenId::new("settings"));
}
}
}
// The trait impl below is required boilerplate — three accessors that
// hand the trait its own data back. There's nothing interesting here.
impl Citizen for PlotPanel {
fn id(&self) -> &CitizenId { &self.citizen_id }
fn citizen_state(&self) -> &CitizenState { &self.citizen_state }
fn citizen_state_mut(&mut self) -> &mut CitizenState {
&mut self.citizen_state
}
}
is_active() is what makes the panel do something — it's a
defaulted method on the trait that reads
self.citizen_state().active.get() for you. Without the trait,
you'd write that path manually every time you wanted to check
activation. The accessor boilerplate is the price; the readers
and hooks are what you actually buy.
The two state-flow directions are intentionally asymmetric.
Dispatcher → panel happens reactively — the dispatcher writes
through the Arc underneath citizen_state.active, and the panel
sees the new value the next time is_active() reads it. No
method call from the panel side is needed. Panel → dispatcher
requires a method-call surface, which is what &mut Dispatcher
in the show() signature provides — the panel can call
dispatcher.activate(...) to hand control to a sibling, or
dispatcher.send(...) to push a custom message. Some apps wrap
the dispatcher and shared services in a PanelCtx struct
(fn show(&mut self, ui: &mut egui::Ui, ctx: &mut PanelCtx)) to
keep the parameter list short; either shape works.
The state argument to PlotPanel::new should always come from
Dispatcher::register(),
never from CitizenState::new() or CitizenState::default().
The latter allocate fresh disconnected storage and silently sever
the reactive link with the dispatcher (see
the trap in the state chapter).
Atoms — widget state alongside CitizenState
A citizen-panel almost always carries its own widget state: slider
values, combo-box selections, text-input buffers, checkbox flags.
The vocabulary chapter calls these
atoms. They live on the panel struct alongside citizen_state,
not inside it — CitizenState has a fixed library-defined shape and
is for lifecycle facts only. Where you place an atom depends on
whether anyone outside the panel reads or writes it.
Atoms only the panel itself touches
These are plain (non-reactive) fields. The panel reads them in
show(), egui mutates them in place via &mut. Nothing fancy.
#[derive(Debug, Clone, PartialEq)]
enum PlotStyle { Line, Scatter, Bar }
struct PlotPanel {
citizen_id: CitizenId,
citizen_state: CitizenState,
samples: Vec<f32>,
// Atoms — panel-local widget state:
sample_rate_hz: f32,
plot_style: PlotStyle,
show_grid: bool,
}
impl PlotPanel {
fn show(&mut self, ui: &mut egui::Ui) {
ui.heading("Plot");
ui.add(egui::Slider::new(&mut self.sample_rate_hz, 1.0..=1000.0)
.text("Sample rate (Hz)"));
egui::ComboBox::from_label("Style")
.selected_text(format!("{:?}", self.plot_style))
.show_ui(ui, |ui| {
ui.selectable_value(&mut self.plot_style, PlotStyle::Line, "Line");
ui.selectable_value(&mut self.plot_style, PlotStyle::Scatter, "Scatter");
ui.selectable_value(&mut self.plot_style, PlotStyle::Bar, "Bar");
});
ui.checkbox(&mut self.show_grid, "Show grid");
// ... draw the plot using these values ...
}
}
Plain f32, plain bool, plain enum. The citizen layer never sees
them, doesn't care about them. This is the right shape for "only
this panel uses these values."
Atoms another panel or thread reads
When something outside the panel needs the value — another panel
mirroring it, a backend thread parameterizing its work, a logger
recording every change — promote the field to a Dynamic<T> so it
can be cloned and shared:
use egui_mobius_reactive::Dynamic;
struct PlotPanel {
citizen_id: CitizenId,
citizen_state: CitizenState,
samples: Vec<f32>,
// Reactive atom — other panels / threads can hold a clone:
sample_rate_hz: Dynamic<f32>,
}
impl PlotPanel {
fn show(&mut self, ui: &mut egui::Ui) {
let mut local = self.sample_rate_hz.get();
if ui
.add(egui::Slider::new(&mut local, 1.0..=1000.0).text("Sample rate (Hz)"))
.changed()
{
self.sample_rate_hz.set(local);
}
// ... rest of show() ...
}
}
Dynamic<f32> is the same shape as the fields inside CitizenState —
an Arc-backed reactive cell. Cloning it gives another panel or
backend thread a handle to the same value (see
Inside Dynamic<T> for the mechanics, and
Coupling paths for how an atom can fan out to
UI-to-UI sharing, UI-to-backend messaging, or both at once).
Don't reach for Dynamic<T> until a second reader exists
Reactivity has a real cost — every Dynamic<T> is an Arc plus a
lock plus a notifier list. If only the panel itself reads its slider
value, a plain f32 is the right type. Promote to Dynamic<f32>
the day a second reader actually appears. Speculative reactivity
"in case someone needs this later" is the same kind of mistake as
speculative Arc<Mutex<...>> — it pays a cost for an option you
may never exercise.
Where does state live?
The atoms section above showed the panel-local choice between a
plain field and a Dynamic<T>. Step back, and that's part of a
broader three-struct model that any non-trivial citizen-panel app
converges on.
The three structs
1. CitizenState — lifecycle facts only
The library type. Six fixed Dynamic<T> fields, reactive by design,
shared by Arc. You cannot extend it — it has a fixed contract.
What goes here: questions other panels or the dock ask about this panel's status (active, clicked, selected, moved, location, visible).
What does not go here: business data, widget values, anything outside the six lifecycle facts.
2. PanelState — your panel-local struct
Whatever the panel needs to do its own job that nobody else reads
or writes. By convention, give it its own struct named
FooPanelState (or just PanelState if scoped inside a panel
module):
struct LoggerPanelState {
log_buffer: Vec<LogEntry>,
filter_text: String,
follow_tail: bool,
}
struct LoggerPanel {
citizen_id: CitizenId,
citizen_state: CitizenState, // bucket 1: library lifecycle
panel_state: LoggerPanelState, // bucket 2: panel-local data
}
Atoms (slider values, combo-box selections, checkbox flags) live
here when only the panel reads them — as plain fields, not
Dynamic<T>. Promote to Dynamic<T> only when a second reader
shows up.
Why a named struct instead of loose fields on the panel? Three reasons:
- It names the bucket. A reader scanning
LoggerPanelsees three things — id, citizen state, panel state — instead of a flat list that mixes concerns. - It mirrors
CitizenState. Both are "state for one panel," one library-defined and one app-defined. The parallel makes the design rule visible. - It survives refactors. When the panel grows, panel-local fields stay clustered. When you eventually need to persist or snapshot panel state, it's already a single value.
For tiny panels with one or two fields, inlining on the panel struct is fine — promote to a named struct the moment a third field appears.
3. App-shared state — data many panels touch
Anything two or more panels need to read or mutate. Project config,
a database handle, the layer store in a CAD app, a theme. Lives at
the app level and gets passed by reference into each panel's
show():
struct App {
services: Arc<SharedServices>,
dispatcher: Dispatcher,
logger: LoggerPanel,
bom: BomPanel,
}
// And inside each panel's show():
self.logger.show(ui, &mut self.dispatcher, &self.services);
self.bom.show(ui, &mut self.dispatcher, &self.services);
Whether the shared bits are themselves reactive (Dynamic<T>
inside SharedServices) or guarded by Arc<Mutex<...>> is a
separate design choice. The point: shared data lives at the app
level, not stuffed inside CitizenState and not duplicated across
panel structs.
The rule of thumb
Ask in this order:
| Question | Bucket |
|---|---|
| Is this a lifecycle fact? | CitizenState |
| Do two or more panels need it? | App-shared |
| Otherwise | PanelState |
If a piece of data fits the lifecycle list — active, clicked,
selected, moved, location, visible — it belongs in CitizenState.
If not, ask whether anything outside the panel reads or mutates
it. If yes, app-shared. Otherwise, PanelState.
Anti-patterns
Trying to extend CitizenState with business fields. It has a
fixed shape from the library. Wrap it inside your own panel struct
alongside your fields; don't try to extend it.
// WRONG — won't compile, and shouldn't
struct CitizenState {
active: Dynamic<bool>,
// ...
bom_rows: Vec<BomRow>, // no
}
// RIGHT
struct BomPanel {
citizen_id: CitizenId,
citizen_state: CitizenState,
panel_state: BomPanelState, // bom_rows lives in here
}
Duplicating shared data across panels. If three panels need to
read the project config, don't give each one its own copy and try
to synchronize. Put it in SharedServices and pass &services
into each panel's show().
Putting PanelState fields in Dynamic<T> "in case someone
needs them later." Reactivity has real cost (every Dynamic<T>
is an Arc plus a lock plus a notifier list). Promote to
Dynamic<T> the day a second reader actually appears, not before.
Activation-driven business state
A common variant: "when panel A activates, panel B should switch
to a particular view of the shared data." That data still lives in
app-shared state — the trigger for the switch is panel A's
citizen_state.active, which panel B reads reactively. Lifecycle
drives the transition; the data being viewed never moves into
CitizenState.
Identities
pub struct CitizenId(pub String);
A CitizenId is a stable string identifier. The same id must be used
consistently across:
CitizenId::new("plot")when constructing the panel.dispatcher.register(CitizenId::new("plot"))at startup.dispatcher.activate(&CitizenId::new("plot"))when the user clicks the corresponding tab.
If the strings disagree, the dispatcher silently treats them as
different citizens — activate("plt") will do nothing visible to a
panel registered as "plot", and you'll burn an evening debugging
why a click does nothing.
Define ids as constants once and reference them everywhere:
const PLOT_ID: &str = "plot";
const SETTINGS_ID: &str = "settings";
dispatcher.register(CitizenId::new(PLOT_ID));
dispatcher.register(CitizenId::new(SETTINGS_ID));
dispatcher.activate(&CitizenId::new(PLOT_ID));
This turns a typo into a compile error rather than a silent runtime disconnect.
When to override the hooks
The defaulted hooks just flip the corresponding CitizenState flag.
Override them when you need extra behavior alongside the flag flip:
impl Citizen for FetchPanel {
// ... required methods ...
fn on_activate(&mut self) {
self.citizen_state_mut().active.set(true);
self.start_background_fetch(); // app-specific
}
fn on_deactivate(&mut self) {
self.citizen_state_mut().active.set(false);
self.cancel_in_flight_fetch();
}
}
In practice, most apps do not override the hooks. They let the
dispatcher do the flag flip and route side-effect logic through
CitizenMessage instead — backend threads receive
Activated { id: "fetch" } and start the fetch from there. Override
the hooks only when the response is genuinely synchronous and
panel-local.
How the trait is used at runtime
The Citizen trait is a contract, not a polymorphism mechanism:
- The dispatcher does not hold trait objects. It stores
CitizenStateclones in aHashMap<CitizenId, CitizenState>. - Your
TabViewerimpl pulls panels by tab kind and calls each panel's ownshow()(or whatever you named it). The trait gives you uniform access toid()andis_active()if rendering needs it, but the dispatcher never walks an array ofdyn Citizen. - The hooks exist so panel code can call them on its own (e.g. from
inside
show()when a button is clicked), not because the dispatcher fires them.
The trait earns its keep by giving consistent shape across panels — not by enabling runtime polymorphism over them.
Summary
- Three required methods (
id,citizen_state,citizen_state_mut), three defaulted hooks, two defaulted readers. - Always obtain the
CitizenStatefield fromDispatcher::register(). Constructing it directly severs reactivity. - Define citizen ids as
consts so typos become compile errors. - Override hooks only when the panel itself does synchronous extra
work; otherwise route through
CitizenMessage. - The trait is a contract for shape, not a vehicle for runtime polymorphism.
Reactive lifecycle: what CitizenState actually is
"Reactive lifecycle state" is jargon. Strip it apart:
- Lifecycle — the events a panel goes through during its time on screen: shown, hidden, activated, deactivated, clicked, moved.
- Reactive — when one of those facts changes, anyone reading it sees the new value automatically. No polling, no callback wiring.
Put together: CitizenState is a small bundle of lifecycle facts that
other code can read without asking "did this change since last time?"
The fields
#![allow(unused)] fn main() { pub struct CitizenState { pub active: Dynamic<bool>, pub clicked: Dynamic<bool>, pub selected: Dynamic<bool>, pub moved: Dynamic<bool>, pub location: Dynamic<[f32; 2]>, pub visible: Dynamic<bool>, } }
| Field | Meaning |
|---|---|
active | This citizen is the one currently active (the one-hot winner). |
clicked | True for the frame this citizen was clicked. |
selected | Persistent selection toggle, independent of activation. |
moved | True if the citizen was moved to a new dock location. |
location | Last known position in the dock layout. |
visible | Whether the citizen is currently visible. |
Each field is a Dynamic<T> from egui_mobius_reactive — a handle into
shared reactive storage. It supports .get() and .set(), and the
underlying value lives behind an Arc.
What "reactive" buys you
Imagine two panels: a tab strip and a plot. When the tab strip
activates the freq_watt citizen, the plot should redraw with frequency
versus watts.
The non-reactive version polls every frame:
// In the plot panel, every frame:
if app.current_tab != self.last_seen_tab {
self.refresh();
self.last_seen_tab = app.current_tab.clone();
}
You manually compare against a remembered value and act on the diff. Every consumer that cares about "which tab is active" repeats this dance. Each new consumer is another place to forget the comparison.
The reactive version just reads:
// In the plot panel, every frame:
if self.freq_watt_state.active.get() {
// draw freq-watt data
}
self.freq_watt_state is a clone of the CitizenState the dispatcher
registered. The dispatcher writes .active.set(true) once when the
user clicks the tab; from that moment onward, every clone of that state
sees true on the next .get(). No diffing, no polling, no "last
seen" cache.
Clones share storage — this is the whole game
#![allow(unused)] fn main() { use egui_citizen::CitizenState; let state = CitizenState::new(); let clone = state.clone(); state.active.set(true); assert!(clone.active.get()); // true }
Each Dynamic<T> is, internally, an Arc over reactive storage.
Cloning a CitizenState clones the Arcs — both copies point at the
same underlying value. Set on one, see it on the other. This is what
makes "reactive" work across panels and threads.
A CitizenState is therefore not "owned" by anyone in particular. It's
a handle. The dispatcher holds one handle, your panel holds another,
and they refer to the same storage.
The trap that bites everyone
CitizenState::new() and CitizenState::default() are public. They
look like ordinary constructors. They are not interchangeable with
"obtain a state from the dispatcher."
// WRONG — fresh storage, disconnected from the dispatcher
let state = CitizenState::new();
let panel = MyPanel::new(state);
dispatcher.activate(&CitizenId::new("my_panel"));
panel.citizen_state.active.get(); // still false!
Why: dispatcher.activate() writes to the CitizenState that the
dispatcher itself owns, registered at register() time. A separately
constructed CitizenState has its own fresh Arcs — the dispatcher
has no idea it exists, and the writes go somewhere else entirely.
The right way is always:
let state = dispatcher.register(CitizenId::new("my_panel"));
let panel = MyPanel::new(state);
register() builds a CitizenState, keeps one clone in the
dispatcher's table, and hands the other clone back to you. Both point
at the same storage. Now dispatcher.activate() and your panel see
the same value.
When CitizenState::new() is fine
If a panel only displays its own citizen state, never reads from
another panel's, and never has another panel reading from it, and
activation isn't driving its UI — CitizenState::default() is
harmless. You just have a panel with its own private reactive bag of
bools.
In practice that case is rare. Default to dispatcher.register().
Summary
CitizenStateis six reactiveDynamic<T>fields covering the panel lifecycle:active,clicked,selected,moved,location,visible.- "Reactive" means readers see writes immediately, with no polling and no callback wiring.
- Cloning a
CitizenStateshares storage. Constructing a fresh one does not. - Always obtain a
CitizenStatefromdispatcher.register()unless you are certain no one outside the panel reads or writes it.
The Dispatcher
The Dispatcher is a registry of citizens. Each citizen registers
a CitizenState handle with it, and the registry coordinates one-hot
activation and message buffering across the registered set. If you've
built backend systems, this is the same pattern you've seen before — a
central table that knows what's plugged in and routes accordingly. The
citizens-as-plug-ins framing
is the other side of this contract: plug-ins register, the registry
coordinates, no party needs direct references to the others.
It is also opt-in infrastructure, not the entry point of an
egui_citizen app. Many apps that share state between panels through
Dynamic<T> never reach for one. You reach for a dispatcher when the
reactive primitives don't already give you what you need:
- One-hot activation arbitration. Exactly one panel "active" at a time, atomically, across an arbitrary number of citizens. Useful any time focus, selection, or panel priority matters — even in a pure-UI app with no backend.
- A queue for outbound events. A place to push events that the update loop drains once per frame and forwards onward to backend threads, loggers, persistence, or anywhere else outside the UI tick.
Whichever of those you need, the dispatcher does three jobs:
- Owns the registered citizens'
CitizenStatehandles. Every citizen you callregister()on has itsCitizenStatecloned into aHashMapinside the dispatcher. Panels hold the other clone; both refer to the sameArc-backed storage. - Enforces the one-hot activation invariant.
Dispatcher::activate(&id)sets the named citizen'sactiveflag totrueand clears every other registered citizen's flag, atomically. - Buffers outbound messages. Lifecycle changes and explicit
send()calls accumulate in a queue that you drain once per frame.
Why the dispatcher, not just shared state?
If panels can share state through Dynamic<T> clones already, what
does the dispatcher add?
Three things that don't fall out of shared Dynamic<T> alone:
- Atomic one-hot activation.
activate(&id)flips one citizen'sactivetotrueand clears every other registered citizen's flag in a single call. Doing this with shared state alone means wiring each panel to clear every other panel's flag — N² coordination and a new wire every time a panel is added. - Lookup by stable id. Panels and backend threads address each
other by
CitizenId, not by holding pointers to one another's structs. The dispatcher is the directory; backend threads in particular have no other way to find the right reactive cell to write to. - Frame-aligned event buffering.
activate()andsend()push lifecycle events into a queue thatdrain_messages()consumes once per frame. This is the seam between event-time (a tab was clicked just now) and frame-time (work reacts to it next tick) — backend threads see batched updates rather than per-mutation callbacks.
And three things it does not do, each a common confusion:
- It is not a runtime gateway. Buffering events is not the same as
performing the work those events trigger. The dispatcher does not
spawn threads, schedule async tasks, or own
JoinHandles. Its queue is the interface to the runtime boundary; the boundary itself is the backend thread your app starts and feeds fromdrain_messages(). - It is not a reactive bus. The registry tracks
CitizenState(six lifecycle fields) and nothing else. Your slider'sDynamic<f32>is invisible to the dispatcher until you explicitly callsend(). See the coupling chapter for why that asymmetry is deliberate. - It does not observe dock layout or fire UI events on its own. A
tab click triggers
activate(...)because youron_tab_buttoncalls it; the dispatcher never spies on the dock and fires activations for you. Give it an event, it routes; don't give it an event, it sits idle.

Registration topology. Every panel hands its CitizenState to the
one dispatcher; the dispatcher keeps a clone in its table while the
panel keeps the other clone. Both refer to the same Arc-backed
storage, which is exactly why activations issued from the dispatcher
become immediately visible to every panel that holds a clone.
API surface
pub struct Dispatcher { /* private */ }
impl Dispatcher {
pub fn new() -> Self;
pub fn register(&mut self, id: CitizenId) -> CitizenState;
pub fn get(&self, id: &CitizenId) -> Option<&CitizenState>;
pub fn send(&mut self, message: CitizenMessage);
pub fn activate(&mut self, id: &CitizenId);
pub fn drain_messages(&mut self) -> Vec<CitizenMessage>;
pub fn len(&self) -> usize;
pub fn is_empty(&self) -> bool;
}
Eight methods. The four that matter day-to-day are register,
activate, drain_messages, and send.
register(id) -> CitizenState
let plot_state = dispatcher.register(CitizenId::new("plot"));
let plot = PlotPanel::new(plot_state);
Registers a citizen and hands you back a CitizenState handle into
the dispatcher's storage. The dispatcher keeps a clone of that handle
in its table; you take the other clone and pass it to your panel
struct. Both point at the same underlying Arc<Mutex<...>> storage,
so writes by either side are immediately visible to the other (see
reactive lifecycle: clones share storage).
This is the only correct way to obtain a CitizenState for a
panel that the dispatcher will activate. CitizenState::default()
allocates fresh disconnected storage; the dispatcher and the panel
end up holding different Arcs and the reactive link is silently
severed.
activate(&id)
dispatcher.activate(&CitizenId::new("plot"));
The encoded set/reset. After this call:
- The named citizen's
activeflag istrue. - Every other registered citizen's
activeflag isfalse. - The message queue contains an
Activated { id }for the named citizen — always, even if the citizen was already active in the previous frame. - The queue contains
Deactivated { id }for each citizen that was previously active and has now been turned off. (Citizens that were already off do not produce aDeactivated.)
Call activate() from a user-driven event. For egui_dock apps,
that's TabViewer::on_tab_button when response.clicked(). Do
not call it from the render path unconditionally — that fires
Activated (and possibly Deactivated) every frame and floods the
queue. See pitfalls.
drain_messages() -> Vec<CitizenMessage>
for msg in dispatcher.drain_messages() {
match msg {
CitizenMessage::Activated { id } => { /* ... */ }
CitizenMessage::Deactivated { id } => { /* ... */ }
_ => {}
}
}
Removes and returns all pending messages. Call once per frame, after
DockArea::show() (so that on_tab_button has had its chance to
call activate()). The queue is now empty until the next activate
or send produces more.
If you forget to drain, the queue grows forever. No errors, no warnings — just a slow memory leak.
send(message)
dispatcher.send(CitizenMessage::Clicked { id: alpha_id.clone() });
Pushes a message onto the queue without going through activate().
Common uses:
- Custom lifecycle events. Emit
Selected,Moved,VisibilityChangedfrom app-level code that detects them — the dispatcher only emitsActivated/Deactivatedon its own. - App-message bridging. A backend thread that needs to inject a citizen-shaped event back into the UI loop.
- Testing / replay. Replaying a recorded session from a log file.
The dispatcher does no validation here — it accepts any
CitizenMessage you give it.
Worked example
use egui_citizen::{CitizenId, CitizenMessage, Dispatcher};
let mut dispatcher = Dispatcher::new();
let alpha = dispatcher.register(CitizenId::new("alpha"));
let beta = dispatcher.register(CitizenId::new("beta"));
dispatcher.activate(&CitizenId::new("alpha"));
let msgs = dispatcher.drain_messages();
// msgs == [Activated { id: alpha }]
// (beta was never active, so no Deactivated)
dispatcher.activate(&CitizenId::new("beta"));
let msgs = dispatcher.drain_messages();
// msgs == [Activated { id: beta }, Deactivated { id: alpha }]
assert!(beta.active.get());
assert!(!alpha.active.get());
One dispatcher per app
The one-hot invariant is per-dispatcher. Two dispatchers in the
same app each maintain their own one-hot, and activate on one does
not deactivate citizens registered on the other. If two halves of
your UI ever race over "who is active," you have two dispatchers (or
worse, two CitizenStates for the same logical citizen).
The dispatcher typically lives on the app struct:
struct App {
dispatcher: Dispatcher,
plot: PlotPanel,
settings: SettingsPanel,
/* ... */
}
Backend threads that need to send messages do so via a
crossbeam_channel whose receiver lives on the UI thread; the UI
thread drains the channel and forwards messages with
dispatcher.send(). The Dispatcher itself is &mut self-only and
is not shared across threads directly.
Summary
register()is the only correct way to obtain a panel'sCitizenState.activate()is an encoded set/reset; call it on user-driven events, never every frame unconditionally.drain_messages()is the once-per-frame backend boundary; forgetting to call it leaks memory.send()is for explicit Path B messages outside the activation flow.- One dispatcher per app — never two.
CitizenMessage — the backend bridge
CitizenMessage is the discriminated lifecycle event the dispatcher
emits and your code consumes. It is the data payload of
Path B —
the UI-to-backend coupling channel.
The variants
pub enum CitizenMessage {
Activated { id: CitizenId },
Deactivated { id: CitizenId },
Clicked { id: CitizenId },
Selected { id: CitizenId, selected: bool },
Moved { id: CitizenId, location: [f32; 2] },
VisibilityChanged { id: CitizenId, visible: bool },
}
| Variant | Fired by | Payload |
|---|---|---|
Activated | Dispatcher::activate(&id) | id that became active |
Deactivated | Dispatcher::activate(&id) for previously-active citizens | id that lost active |
Clicked | App code (via Dispatcher::send) | id that was clicked |
Selected | App code (selection toggling) | id + new selection state |
Moved | App code (after a dock-layout move) | id + new [x, y] location |
VisibilityChanged | App code (after a tab is shown / hidden) | id + new visibility |
Note the asymmetry. Activated and Deactivated are produced
automatically by Dispatcher::activate(). The other four exist
so that app code can route the corresponding lifecycle facts through
the same queue, but you must push them yourself via
Dispatcher::send().
CitizenMessage derives Clone and Debug. It does not derive
PartialEq — if you need to compare messages, match on the variants
explicitly.
Identity: CitizenId
pub struct CitizenId(pub String);
The id is a stable string. Same identity rules as in
the Citizen trait chapter — define them as
consts and pass through CitizenId::new(...) consistently.
Consuming messages
The canonical loop:
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
DockArea::new(&mut self.tabs).show(ctx, &mut self.tab_viewer);
for msg in self.dispatcher.drain_messages() {
match msg {
CitizenMessage::Activated { id } => {
self.log.push(format!("[{id}] activated"));
}
CitizenMessage::Deactivated { id } => {
self.log.push(format!("[{id}] deactivated"));
}
_ => {}
}
}
}
Drain once per frame, after DockArea::show() has had a chance
to fire on_tab_button (and therefore dispatcher.activate()). If
you drain before show(), you'll see one frame of latency on every
activation — the message produced this frame won't be observed until
next frame's drain.
Forwarding to a backend thread
The typical Path B shape: the UI drains the dispatcher and forwards each message into a channel that a backend thread is reading.
use crossbeam_channel::{unbounded, Sender};
// At startup:
let (tx, rx) = unbounded::<CitizenMessage>();
std::thread::spawn(move || {
for msg in rx {
match msg {
CitizenMessage::Activated { id } if id.0 == "fetch" => {
start_http_request();
}
CitizenMessage::Deactivated { id } if id.0 == "fetch" => {
cancel_in_flight();
}
_ => {}
}
}
});
// In update():
for msg in self.dispatcher.drain_messages() {
let _ = tx.send(msg.clone()); // forward
/* ... and process locally if needed ... */
}
The _ = tx.send(...) discards "receiver disconnected" errors,
which can happen if the backend thread has exited. The backend
thread's match decides what each message means in its domain —
the dispatcher doesn't care.
Do not invoke egui or wgpu state from the backend thread. If the
backend needs to surface results back to the UI, send them through a
return channel that the UI thread drains in its update loop, or
write them into a Dynamic<T> that the relevant panel observes via
Path A.
Wrapping in your own app message enum
For non-trivial apps, you will have many messages that are not
lifecycle events — file open and close, view manipulations,
computed-result notifications, hotkey actions, background-task
completions. The idiomatic pattern is to wrap CitizenMessage
inside your own AppMessage enum, then run all app-level events
through that one queue.
A realistic shape, drawn from a real-world app — CopperForge, a
KiCad PCB tool with twelve dockable panels:
use std::path::PathBuf;
use egui_citizen::CitizenMessage;
#[derive(Debug, Clone)]
pub enum AppMessage {
/// A citizen lifecycle event (activated, deactivated, etc.)
Citizen(CitizenMessage),
// ── Project ─────────────────────────────────────────────
ProjectLoaded { path: PathBuf },
ProjectClosed,
PcbFileSelected { path: PathBuf },
// ── Layers ──────────────────────────────────────────────
LayersReloaded,
LayerVisibilityChanged { layer_name: String, visible: bool },
// ── View ────────────────────────────────────────────────
ResetView,
FlipBoard,
Rotate { degrees: f32 },
// ── DRC ─────────────────────────────────────────────────
DrcRunRequested,
DrcCompleted { violation_count: usize },
// ── Hotkeys ─────────────────────────────────────────────
HotkeyPressed(Hotkey),
}
#[derive(Debug, Clone)]
pub enum Hotkey {
Flip,
Rotate,
ToggleUnits,
/* ... */
}
Citizen lifecycle is one variant out of a dozen-plus. That ratio is typical: in any non-trivial app, lifecycle events are a minority of message traffic, and the dispatcher's drain point becomes the single funnel for all app-level events. Three patterns make the shape legible at scale.
Section dividers via comments
The // ── Project ──...─ separators turn a variant-heavy enum into
something a reader can scan in one pass. Group by domain, dividers
between groups. rustfmt leaves comment lines alone, so the layout
survives formatting passes. Purely a legibility tool — but it earns
its place quickly as the enum grows.
Intent and outcome both flow as messages
Notice the pairing of DrcRunRequested with DrcCompleted. Both
travel through the same queue. The intent variant ("the user pressed
Run DRC") is what triggers the work; the outcome variant ("DRC
finished, here are the results") is what consumers react to.
This is the Elm-style discipline at work: the message loop is a
temporal sequence of events, not a function-call graph. The
drained log reads back as a record of what happened in order —
inspectable, loggable, replayable. Resist the temptation to call a
function directly when the user clicks "Run DRC"; emit a
DrcRunRequested message and let the drain loop dispatch it to the
worker thread.
The pairing pattern generalizes:
| Intent variant | Outcome variant |
|---|---|
DrcRunRequested | DrcCompleted |
ProjectOpenRequested | ProjectLoaded |
BomRebuildRequested | BomUpdated |
Not every action needs both ends. Some are pure intent (ResetView)
because there is no meaningful "completed" state. Some are pure
outcome (LayersReloaded, fired by a file-watcher) because there is
no UI-side intent. Use the pair when there is asynchronous work
between the two and consumers care about both endpoints.
Cancellation rides on the lifecycle queue
Long-running async work raises a third question alongside intent and
outcome: what cancels in-flight work when the user moves on? The
answer is already in the queue — it's the Deactivated { id } message
that Dispatcher::activate() produces
automatically whenever the previously-active panel loses its slot.
The pattern, drawn from the same forwarding loop above:
// Backend thread, consuming the channel:
for msg in rx {
match msg {
CitizenMessage::Activated { id } if id.0 == "fetch" => start_fetch(),
CitizenMessage::Deactivated { id } if id.0 == "fetch" => cancel_in_flight(),
_ => {}
}
}
The dispatcher does not return a work handle from send(). It cannot —
it doesn't own any work. The backend thread that does own the work
also owns its own in-flight state (a JoinHandle, a CancellationToken,
an AbortHandle, whatever the runtime provides) and reacts to the
deactivation message by aborting that work itself.
For synchronous, panel-local cleanup that runs on the UI thread,
override Citizen::on_deactivate
alongside the flag flip:
fn on_deactivate(&mut self) {
self.citizen_state_mut().active.set(false);
self.flush_local_buffers(); // synchronous, on the UI thread
}
For asynchronous cancellation — anything that involves stopping a thread, aborting a task, or interrupting IO — route it through the message queue, never through the override. The override runs on the UI thread and must not block.
The long-running work pattern, end to end
Putting intent, outcome, and cancellation together yields a predictable shape for any unit of async work in a citizen app:
- Intent message — a user-initiated event lands in the dispatcher
queue (
DrcRunRequested,ProjectOpenRequested) and gets drained into the backend channel. - Backend dispatch — the backend thread spawns the work and
remembers its in-flight handle (
JoinHandle,AbortHandle, etc.) keyed by the originating panel'sCitizenId. - Cancellation lifecycle — if the user activates a different panel,
Deactivated { id }arrives on the same channel; the backend thread aborts the remembered handle for that id. - Outcome message — when work completes (or is cancelled), the
backend pushes an outcome message back to the UI thread through a
return channel, drained into the next frame's
update().
Every step is a message. Every message flows through the same queue (or
a paired return channel). The UI thread holds no futures, no join
handles, no cancellation tokens — only Dynamic<T> cells that observe
the results when outcome messages land.
This is the Elm discipline applied to a Rust GUI: a temporal sequence of events, fully inspectable, never a tangled call graph. The app can be paused, logged, and replayed by recording the message stream alone.
Sub-domain nesting
HotkeyPressed(Hotkey) is a single top-level variant that wraps a
separate Hotkey enum. The flat alternative —
HotkeyFlipPressed, HotkeyRotatePressed,
HotkeyToggleUnitsPressed, … — bloats the top-level enum and forces
hotkey-handling code to match on a sprawling pattern instead of a
focused sub-enum.
Use sub-domain nesting whenever a domain has its own internal vocabulary that is likely to grow. Keyboard hotkeys, view controls, file operations, background-task progress reports, vendor-specific release packaging — all of these are good candidates for their own sub-enum nested under one outer variant.
Putting it together: the drain loop
The dispatcher's queue still carries only CitizenMessage. The app
wraps each citizen message as it drains, and non-citizen variants
are produced by app code emitting them directly through the same
backend channel:
fn update(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) {
DockArea::new(&mut self.tabs).show(ctx, &mut self.tab_viewer);
// Drain citizen messages, wrap as AppMessage, forward.
for msg in self.dispatcher.drain_messages() {
let app_msg = AppMessage::Citizen(msg);
self.event_log.push(app_msg.clone());
let _ = self.tx_backend.send(app_msg);
}
// App code emits its own non-citizen variants through the same
// channel — e.g. when a worker thread reports DRC results back:
if let Some(result) = self.drc_worker.try_recv() {
let _ = self.tx_backend.send(
AppMessage::DrcCompleted { violation_count: result.len() },
);
}
}
One queue, many message families, one drain pass per frame.
CopperForge runs this exact shape across twelve dockable panels.
What CitizenMessage is not
It is not a general-purpose event bus. The dispatcher does not
provide subscriptions, filtering, prioritization, or replay. If you
need those, build them on top — typically in your AppMessage layer
or as a separate logger / event-store.
It is not — emphatically — a replacement for shared Dynamic<T>
state. UI-to-UI coupling should still go through
Path A (shared Dynamic<T>).
Messages are reserved for genuine event signals (things happened)
rather than continuous state updates (things are). A panel that
needs to mirror another panel's slider value should clone the
slider's Dynamic<f32> and read it; it should not subscribe to a
SliderChanged message stream.
Summary
- Six variants, all carrying at least a
CitizenId. ActivatedandDeactivatedare emitted automatically byDispatcher::activate(). The other four require explicitdispatcher.send(...).- Drain once per frame, after
DockArea::show(). - Forward to backend threads via
crossbeam_channel. Don't touch egui from the consumer side. - For app-level events beyond lifecycle, wrap
CitizenMessagein your ownAppMessageenum. - Reserve messages for events. Use
Dynamic<T>for state.
What citizen is (and is not)
By this point you have met the Citizen trait, the
Dispatcher, and CitizenMessage, the
backend bridge. This chapter steps back from the parts and asks what
the pattern is — by contrasting it with the architecture people
most often assume it to be.
It is not Elm
Citizen has Elm-like aspects, and the comparison comes up often enough that it is worth being precise about where the two diverge.
The Elm architecture's update function is total: every message
in the entire application funnels through one update. That totality
is exactly what makes Elm predictable, and exactly what makes it
rigid. There are no optional edges — every message is mandatory,
through the one loop.
Citizen takes the opposite stance, and it is best stated as a single phrase: total observation, partial routing.
- Total observation. The
Dispatcheralways tracks the state of every registered citizen. Nothing about a citizen's lifecycle is invisible to it. - Partial routing. Data is dispatched to the backend only when an atom requests it. And atoms in any citizen may share data directly with atoms in any other citizen, without the backend being involved at all.
That split is the actual invention. It is not Elm — Elm has no optional edges. It is also not a plain pub/sub bus — a bus forwards and forgets; it has no central state model. What citizen has is a stateful switchboard.
The topology that results is a graph, not a tree: atoms and
citizens are nodes, dispatch connections are edges, and the
Dispatcher is the layer that makes the graph observable even
though it does not make the graph constrained. This is why the
neural-network framing fits — it is a graph of nodes with signal
propagation, not a hierarchy.
The invariant that keeps it sound
A graph topology where any atom can talk to any atom is powerful, and it is also the classic recipe for spaghetti. The very property that makes Elm restrictive on purpose is the property citizen gives up.
What saves citizen from that fate is the other half of the split.
Communication is graph-shaped, but state stays centrally legible,
because the Dispatcher observes everything.
That yields one invariant, and it must be protected permanently:
No atom shares data in a way the
Dispatchercannot see.
The moment a back-channel is introduced — "for performance," "just this once" — the central legibility is gone. A graph topology without central observability is genuinely harder to reason about than an Elm tree. The flexibility of citizen is only safe because of the discipline of total observation. The pattern and the rule are inseparable: keep every data path visible to the dispatcher, or you do not have the citizen pattern any more.
The panel-oriented middle ground
Citizen is a bet on a particular granularity of UI composition: the panel — larger than a component, smaller than an application. There is prior art that both validates the bet and warns about its failure mode.
Eclipse RCP (Rich Client Platform) made exactly this bet: the panel/view as the unit of a professional tool, a workbench that gives panels identity, a contribution model for wiring them together. The entire Eclipse IDE, and a generation of EDA, CAD, and finance tools, are RCP-shaped. That is the proof the granularity is right. The warning is also from RCP: it became a byword for heavyweight — XML extension points, lifecycle ceremony, sluggishness. Citizen's differentiator has to remain that it is lightweight and reactive, not declarative-config driven.
Qt's dock-widgets plus signals/slots is the other close relative. Citizen's atom-to-atom sharing is signals/slots — but value-typed and observed, rather than callback-typed and invisible.
So the honest peer set for citizen is Eclipse RCP, the VS Code contribution model, and Qt Creator's plugin architecture. It is not dioxus, leptos, or iced — those are component- or page-oriented and web-derived, solving a different problem at a different granularity. The claim that the panel-oriented middle ground is where the most value lies is really a claim that professional tools are built from panels, not pages or screens. The roster of tools above bears it out.
The pattern eats itself
The strongest sign that the abstraction is real is that the panel builder is recursive.
The egui_grafica canvas — nodes with ports, edges between ports, a
registry as the backend model — is structurally identical to
"citizens with atoms, dispatch connections between atoms, a
Dispatcher as the model." A RAD tool where you drag citizens into a
dock layout and draw atom-to-atom wires would, structurally, be
egui_grafica pointed at its own framework. The canvas built for
diagrams is the panel builder's canvas.
That is not a coincidence. Real abstractions tend to eat themselves like this — the same graph showing up at two scales (the fine-grained reactive value graph, and the coarse-grained citizen/dispatcher graph) is evidence the abstraction describes something true rather than something arbitrary.
Summary
| Aspect | Elm | Pub/sub bus | Citizen |
|---|---|---|---|
| State model | central, total | none | central, total |
| Message routing | total (mandatory) | partial (fire-and-forget) | partial (optional) |
| Topology | tree / loop | graph | graph |
| Observability | inherent | absent | inherent (via Dispatcher) |
| Failure mode | rigidity | unobservable spaghetti | spaghetti if the invariant breaks |
Citizen keeps Elm's observability without Elm's rigidity, by
separating observing state from routing data. The price of that
flexibility is a single non-negotiable rule: every data path is
visible to the Dispatcher.
Coupling
Panel-to-panel coupling is what shared Dynamic<T> already gives you:
one panel writes, another reads, egui redraws on the next frame.
Most apps need nothing more than that to get state from one panel to
another. This chapter calls that Path A, and it is the default.
There is also an opt-in second path — dispatcher.send() — for the
specific case where a state change needs to leave the UI thread or
land on a queue: a backend thread, an off-thread logger, a persistence
sink, anything that wants events rather than just the current value.
That's Path B. Most of this chapter exists to keep its trade-offs
straight from Path A's, because conflating the two is where designs
go sideways.
A single widget — what we'll call an atom (a widget inside a citizen panel) — can use either path or both at once.
| Path | Mechanism | Good for | Timing |
|---|---|---|---|
| A | Shared Dynamic<T> | Panel ↔ panel (default) | Immediate |
| B | dispatcher.send() | Panel → backend / logger | Next drain pass |
Path A — shared Dynamic<T> (panel to panel)
Two panels share a clone of the same Dynamic<T>. One writes, the
other reads. That's the whole mechanism.
// settings_panel.rs
struct SettingsPanelState {
pub slider_value: Dynamic<f32>,
}
// logger_panel.rs
struct LoggerPanelState {
pub observed_slider: Dynamic<f32>, // clone of settings.slider_value
}
The two Dynamic<f32> handles point at the same Arc<Mutex<f32>>.
When settings calls .set(...), the write lands in shared memory.
When logger.ui() runs on the next frame and calls .get(), it sees
the new value.
No subscription, no callback, no event bus. egui already redraws frequently enough that polling-per-frame is effectively free. Path A carries state, not events.
Path B — dispatcher messages (panel to backend, opt-in)
The settings panel explicitly enqueues a message; the app's update
loop drains it after DockArea::show() and forwards it onward — to a
backend thread, a logger sink, a persistence layer.
// In settings_panel.rs, when the slider changes:
dispatcher.send(AppMessage::SliderChanged(local));
// In App::update(), once per frame:
for msg in dispatcher.drain_messages() {
match msg {
AppMessage::SliderChanged(v) => tx_backend.send(v).unwrap(),
// ...
}
}
Path B carries events, not state. Each .send() enqueues one
record; drain_messages() consumes the queue. Nothing is "shared" —
the message is a value, not a handle.
Aside on
Dynamic::on_change.egui_mobius_reactivedoes provide a callback-style subscription onDynamic<T>itself, which looks superficially like a third coupling option. It is — but it spawns one OS thread per subscriber, has no unsubscribe API, and doesn't coalesce wakeups. For mostegui_citizenapps, Path B through the dispatcher is the better way to do "react off the UI thread when this value changes." The full mechanics live in InsideDynamic<T>.
Atoms can wire to both
A single atom can fan out to both paths from the same user event. The fan-out happens at the change handler:
if ui.add(Slider::new(&mut local, 0.0..=100.0)).changed() {
self.slider_value.set(local); // Path A
dispatcher.send(AppMessage::SliderChanged(local)); // Path B
}
This is a common and correct pattern. The atom is the single write site; each path is a derived consequence of the one event. Readers on Path A see the new value next frame; consumers on Path B get the message on the next drain cycle.
When dual wiring is right
Dual-wire an atom when a change needs to:
- Update shared UI state, and
- Trigger side-effect work (log it, persist it, send it to a backend thread, recompute a derived value off-thread).
Single-path atoms are fine when the change only needs one of those. Don't dual-wire out of habit — extra messages with no consumers are noise.
Source-of-truth discipline
The trap: once an atom writes to both paths, downstream code can read from either one and the two representations can drift. Pick a discipline and hold it.
Discipline 1 — Dynamic<T> is canonical, the message is a ping.
dispatcher.send(AppMessage::SliderChanged); // no value in the message!
// Consumers re-read `settings.slider_value.get()` when the message arrives.
Consumers of the message reach back into shared state for the current
value. This guarantees consistency — there is exactly one value, the
one in the Dynamic<f32>. The message says only "something happened,
go look."
Discipline 2 — Message carries the value, Dynamic<T> is a UI mirror.
dispatcher.send(AppMessage::SliderChanged(local));
The message is the canonical record of the event. The Dynamic<f32>
exists only so other panels can render the current value without
intercepting messages. Consumers of the message trust the message and
do not re-read the Dynamic.
Either discipline works. Mixing them silently — some consumers
trusting the message, others reading the Dynamic — is where bugs
live.
Timing
Within a single frame, the two paths do not tick in lockstep:
- Path A is instant.
.set(v)returns after writing; any panel calling.get()on a clone seesvimmediately. - Path B is queued.
.send(msg)appends to the dispatcher's queue; consumers don't see it until the update loop callsdrain_messages().
Backend threads therefore observe the Path B message after the UI has already observed the Path A value. For typical use (the backend does work and replies asynchronously), that one-frame gap is invisible. For anything tighter — a dependency on in-frame ordering between UI and backend — you need to redesign, not lean harder on this.
The dispatcher is not a reactive bus
Worth saying explicitly, because it is the mistake everyone makes coming from other reactive systems:
The dispatcher does not observe
Dynamic<T>writes.
It only knows about the CitizenState fields it registered, and its
queue only fills from activate() and explicit send(). Your
slider's Dynamic<f32> on SettingsPanelState is invisible to it
until the panel explicitly bridges the two paths with a .send()
call.
Shared state (Path A) and dispatcher messages (Path B) compose — they don't chain automatically.
Citizens as a propagation graph
Step back from the mechanism for a moment. What shape does an
egui_mobius application actually have at runtime?
It's a graph. Citizens are the nodes; Dynamic<T> cells are
the edges; the dispatcher is a registry that knows about every
node but does not itself carry data between them.

Three propagation modes ride this graph:
-
Adjacent. A citizen writes
Dynamic<T>, an adjacent citizen reads it on the next frame. Sync, in-frame, no queue. This is Path A from the previous section. Two panels sharing a slider value, a selection set, a cursor position — that's adjacent propagation. Topology is whatever clones share the sameArc-backed cell; siblings, cousins, however far apart in the panel tree, all see the same value. -
Forward. A
Derived<T>cell wraps aDynamic<T>and recomputes automatically when the input changes. The new value flows downstream to whoever holds a clone of theDerived. This is the chain in the graph: input cell → derived cell → readers. Useful when one citizen owns the source-of-truth state and other citizens want a transformation of it without each computing the same transform locally. -
Outbound. A citizen calls
dispatcher.send(); a backend thread reads the queue. Async, queued, next-drain. This is Path B. The graph extends past the UI thread out to whatever does the heavy lifting — IO, compute, network — and the backend's responses come back through the same queue.
The combination matters. Most reactive frameworks bind state to a
component lifetime: state lives inside the widget tree, and
sharing across siblings means lifting up or threading context. The
egui_mobius model inverts that. Dynamic<T> cells are
free-standing reactive nodes anyone can hold a clone of —
including backend threads that don't have an egui context at all.
The widget tree borrows the cells; it doesn't own them. That's
why the same primitive that wires Settings to Plotter also wires
Plotter to a Tokio task.
The neural-network analogy is approximate but useful. A citizen graph propagates values to adjacent neighbours through cells and forward through derived chains. The dispatcher is more like a directory than a layer; it doesn't compute, it doesn't transform, it just knows who's plugged in and routes lifecycle and outbound events. The actual data movement happens through the cells.
Summary
- Two coupling paths. Path A for UI-to-UI state sharing (shared
Dynamic<T>). Path B for UI-to-backend events (dispatcher.send()+drain_messages()). - Atoms can wire to one path or both. Fan-out happens at the write site, not downstream.
- Dual-wired atoms require a source-of-truth discipline:
Dynamiccanonical with the message as a ping, or message canonical with theDynamicas a UI mirror. Don't mix. - Path A is in-frame; Path B lands at the next drain. Backend threads see changes one frame after the UI does.
- The dispatcher is a lifecycle registry plus an explicit outbound queue. It is not an automatic observer of reactive state.
Inside Dynamic<T>
The background chapter on Dynamic<T>
covers what the type looks like from outside: a thread-safe cell with
get, set, lock, and on_change (via ValueExt). This chapter
opens the box.
You don't need this material to use egui_citizen — Path A
(shared-state polling, see Coupling paths) only needs
the high-level API. But you do need it the moment you reach for
on_change, profile reactive overhead, or wonder why two seemingly
identical-looking subscriptions behave differently. Read this chapter
before you write code that subscribes to a Dynamic<T>.
The struct, peeled outside-in
pub struct Dynamic<T> {
inner: Arc<Mutex<T>>,
notifiers: Arc<parking_lot::Mutex<Vec<Sender<()>>>>,
}
Two Arcs. The first holds the value; the second holds the
notification machinery. The intro already covered the value side. Here
we open the second one.
Arc<...>
Same role as on inner: every clone of a Dynamic<T> must refer to
the same notifier list. If clone A registers a subscriber via
on_change and clone B calls .set(...), B's set must wake A's
subscriber — otherwise the whole "shared reactive cell" story
collapses. Cloning the outer Dynamic<T> clones this Arc, so all
clones share one list.
parking_lot::Mutex<...>
Note this is not std::sync::Mutex. The value side uses
std::sync::Mutex<T>; the notifier side uses parking_lot::Mutex.
Two reasons that matter in practice:
- Lower overhead.
parking_lot::Mutexis one word, no poisoning bookkeeping, faster on the uncontended path. The notifier lock is touched on every.set()call and on everyon_change()call, so the constant factor adds up. - No poison ceremony.
std::sync::Mutex::lock()returnsLockResult<...>, which isErrif a previous holder panicked.parking_lot::Mutex::lock()returns the guard directly. The notifier path doesn't want to threadunwrap()through every iteration.
For the value side, std::sync::Mutex is fine because access is
mediated by get/set/lock and the cost is negligible relative to
whatever the user is doing with the value.
Vec<Sender<()>>
A growable list of channel senders. One sender per subscriber.
Each call to on_change(cb) creates a fresh mpsc channel (tx, rx),
pushes tx into this vec, and spawns a thread that blocks on
rx.recv(). The vec accumulates these senders as subscribers
register over the program's lifetime.
Sender<()>
std::sync::mpsc::Sender<()>. The payload is unit. The channel is a
pure doorbell — "the value changed, wake up." It carries no
information about what changed, because the subscriber already
captured the Dynamic<T> in its closure and can call .get()
directly to read the current value.
This is also why every subscriber's channel has the same payload
type: they're all signaled the same way, regardless of what T is on
the parent Dynamic.
What set() actually does
pub fn set(&self, value: T) {
let mut guard = self.inner.lock().unwrap();
*guard = value;
// (inner mutex drops here)
for notifier in self.notifiers.lock().iter() {
let _ = notifier.send(()); // ignore closed-channel errors
}
}
Two locks taken, sequentially:
- Lock the value mutex, write the new value, drop the lock.
- Lock the notifier mutex, iterate, fan out one wakeup per sender, drop the lock.
The two locks never overlap, so writers don't hold the value mutex
while iterating subscribers — important for keeping get() calls on
other threads from blocking unnecessarily.
Sends are fire-and-forget: if a receiver thread has died, the
send returns Err, and the let _ = discards it. The dead sender
stays in the vec, but no panic and no bubbled error.
What on_change actually does
fn on_change<F>(&self, callback: F) -> Arc<F>
where
F: Fn() + Send + Sync + 'static,
{
let cb = Arc::new(callback);
let cb_clone = cb.clone();
let (tx, rx) = channel();
self.notifiers.lock().push(tx);
thread::spawn(move || {
while rx.recv().is_ok() {
cb_clone();
}
});
cb
}
Three allocations and one OS thread per subscriber:
Arc<F>wrapping the callback — returned to the caller, also held by the worker thread.- An mpsc channel —
txlives in the notifier vec,rxlives on the worker thread's stack. thread::spawn— a dedicated, non-pooled OS thread that loops onrx.recv()until the channel closes.
The worker thread is not shared. Subscribe to 50 Dynamics and you
spawn 50 threads.
Putting the producer and consumer together
┌─────────────────────┐ ┌──────────────────────┐
│ Dynamic<T> │ │ subscriber thread │
│ │ │ (one per on_change) │
│ inner ◀─writes──│ caller does set() │ │
│ notifiers ┐ │ │ │
│ │ │ │ │
│ └─[tx]───┼──── mpsc channel ───┼───[rx] rx.recv() loop│
│ │ send(()) │ calls callback() │
└─────────────────────┘ └──────────────────────┘
set() writes the value and rings every doorbell in the notifier
list. Each subscriber's worker thread wakes up, runs its callback,
and goes back to waiting. Dead simple, deliberately so.
Practical implications
These follow directly from the implementation. They are the reasons
egui_citizen recommends Path A polling over on_change for most
in-app reactivity:
Subscriptions cost an OS thread each
Cheap for a handful, less cheap for hundreds. The threads are
dedicated, not pooled. For dense reactive UI inside an egui app,
prefer reading .get() once per frame in ui() (Path A) over
spawning a worker thread per Dynamic.
There is no unsubscribe API
The Vec<Sender<()>> only grows. Once you call on_change, the
sender lives in the notifier list until the Dynamic<T> itself is
dropped — which happens when the last outer Arc is released, and
the dispatcher and panels typically hold those for the program's
lifetime.
Dropping the returned Arc<F> does not tear down the worker
thread. The thread waits on rx.recv(), which only returns Err
when the sender side is dropped — and the sender is in the notifier
vec, where nothing removes it.
If you need teardown, you must wrap the Dynamic<T> itself in a
container whose Drop releases all Arc references — i.e., teardown
is at the granularity of the entire reactive cell, not the individual
subscription.
No coalescing
mpsc::channel() is unbounded. Rapid .set() calls enqueue one
wakeup each, and the worker thread runs the callback once per
wakeup. If you set 100 times in quick succession (e.g., dragging a
slider), the worker runs the callback 100 times.
The callback can read .get() and observe whatever the latest value
is at that moment, but the wakeups themselves do not merge. Some
reactive systems coalesce ("one redraw per microtask"); this one does
not. If coalescing matters to you, debounce in your callback or in
the consumer of whatever channel your callback feeds.
Wakeups run off the UI thread
The worker is a vanilla thread::spawn. Do not touch egui or wgpu
state from inside an on_change callback. The egui context is not
Send/Sync in the way you'd need for that.
The legitimate jobs for a callback:
- Push to a
crossbeam_channel::Senderthat the UI thread drains in its update loop. - Trigger a
Dispatcher::send(...)if you have access to a thread-safe wrapper around it. - Do off-thread work (write to a log file, fire an HTTP request, recompute something heavy and stash the result for the UI to pick up).
set() holds the notifier lock while iterating
Concurrent on_change calls block until the iteration completes.
Concurrent set() calls also serialize through the notifier lock.
In practice, the notifier vec is small (a handful of subscribers per
Dynamic in real apps) and the iteration is fast (each send(()) is
a constant-time channel operation). It is not a hotspot. But it is a
bottleneck if you imagine pathological cases — thousands of
subscribers, microsecond-scale sets, many threads. Don't build
those.
When to use what
Three coupling tools, three different jobs:
| Tool | Best for | Cost |
|---|---|---|
.get() in ui() | UI-to-UI state sharing | One atomic read per frame |
dispatcher.send | UI-to-backend events with one drain point | One queue push, drained once |
Dynamic::on_change | Off-thread reactions independent of the UI loop | One OS thread per subscriber |
The default in egui_citizen is the top row. Reach for the bottom
row only when something genuinely needs to react outside the UI's
frame cycle — and even then, prefer routing through the dispatcher
rather than spawning per-Dynamic worker threads, since the
dispatcher gives you one drain point instead of N callbacks.
Summary
Arc<parking_lot::Mutex<Vec<Sender<()>>>>
└── shared, mutex-protected, growable list of mpsc senders, each
representing one subscriber's "doorbell" line
set() rings every doorbell in the vec. Each subscriber's worker
thread wakes up and runs its callback. That is the entire mechanism.
The simplicity of this notification subsystem is what makes
Dynamic<T> cheap and predictable for the dominant use case
(panel-side polling). It is also why callback-style subscriptions —
while supported — are best used sparingly, off the UI thread, and
ideally via the dispatcher rather than directly.
egui_lens — the reactive event logger
egui_lensis a citizen. A docked, movable, resizable panel with stable identity ("logger") and a known set of atoms inside — System Info, Filters, Logger Colors, Save Logs, Clear Logs, the column-toggle checkboxes, the scrollable log area itself. Like every other citizen panel, it observes shared reactive state throughDynamic<T>and participates in dispatcher-coordinated activation.
If "citizen" doesn't ring a bell yet, read What is a
citizen? first — that chapter
walks through the panel-level characteristics every citizen has,
which egui_lens is the canonical example of.
What it does
egui_lens is the canonical event logger for the egui_mobius
ecosystem. It provides a terminal-style log panel — log levels,
per-type colors, filtering, file export — built on Dynamic<T> so
log entries flow reactively between writers (any panel, any
backend thread) and the panel that displays them.
As of v0.4.0, lens lives in crates/egui_lens/ inside the
egui_mobius workspace. It supersedes the older
egui_mobius_components::event_logger, which was built on the
signal/slot architecture and is now deprecated.
Implementation note: the lens crate currently ships as a widget that consuming apps wrap in their own panel struct. The wrapper is small — a few lines — but it's an extra step that shouldn't be needed; lens is a citizen. A
LoggerCitizenthat implements the trait directly is on the roadmap, at which point the wrapper goes away. Until then, the consuming-app pattern in this chapter shows the wrapper.
The shape
Two types matter: state and view.
ReactiveEventLoggerStateis the data — aDynamic-wrapped buffer of log entries, plus filter and pagination metadata.ReactiveEventLogger<'a>is the view — a per-frame widget borrowing references to the state. You construct it insideui(), push entries via.log_info(...)/.log_warning(...)/ etc., and render via.show(ui).
use egui_lens::{ReactiveEventLogger, ReactiveEventLoggerState};
use egui_mobius_reactive::Dynamic;
let logger_state = Dynamic::new(ReactiveEventLoggerState::new());
// Anywhere — UI thread, backend thread, lifecycle hook:
let logger = ReactiveEventLogger::new(&logger_state);
logger.log_info("Hello, world");
logger.log_warning("Slider out of range");
logger.log_custom("network", "Connected to 192.168.1.5");
// In a panel's `ui()`:
logger.show(ui);
Dynamic::clone() is cheap (Arc refcount bump), so you hand
clones of logger_state to any thread that needs to write logs:
let state = logger_state.clone();
std::thread::spawn(move || {
let logger = ReactiveEventLogger::new(&state);
for i in 0..5 {
logger.log_info(&format!("Background log #{i}"));
}
});
Custom log types and colors
Beyond the standard levels (info, warning, error, debug),
lens supports custom typed logs identified by string tags:
let log_colors = Dynamic::new(LogColors::default());
let mut colors = log_colors.get();
colors.set_custom_color("network", egui::Color32::from_rgb(100, 149, 237));
colors.set_custom_color("database", egui::Color32::from_rgb(106, 90, 205));
log_colors.set(colors);
// Use `with_colors` constructor to attach the color theme:
let logger = ReactiveEventLogger::with_colors(&logger_state, &log_colors);
logger.log_custom("network", "Client connected from 192.168.1.5");
logger.log_custom("database", "Inserted 5 records in 18ms");
Each custom type renders in its configured color. You can also set
distinct colors for the level prefix vs the message body via
set_custom_colors(level_color, message_color).
Wiring lens into a citizen panel
ReactiveEventLogger is a widget, not a citizen. To use it inside a
docked citizen app, wrap it in a Citizen-impl panel struct that
delegates rendering to the logger:
use egui_citizen::{Citizen, CitizenId, CitizenState};
use egui_lens::{ReactiveEventLogger, ReactiveEventLoggerState};
struct LoggerPanel {
citizen_id: CitizenId,
citizen_state: CitizenState,
logger_state: Dynamic<ReactiveEventLoggerState>,
}
impl Citizen for LoggerPanel {
fn id(&self) -> &CitizenId { &self.citizen_id }
fn citizen_state(&self) -> &CitizenState { &self.citizen_state }
fn citizen_state_mut(&mut self) -> &mut CitizenState { &mut self.citizen_state }
}
impl LoggerPanel {
fn show(&self, ui: &mut egui::Ui) {
let logger = ReactiveEventLogger::new(&self.logger_state);
logger.show(ui);
}
}
The citizen lifecycle (activation, click, deactivation) is unchanged — lens is just the rendering inside the panel.
See also
examples/logger_component— full working example (port of lens'sbasic_custom) demonstrating custom log types, colors, system info logging, and thewith_colorsconstructor.egui_mobius_components— the predecessor logger built on signal/slot. Deprecated as of v0.4.0;#[deprecated]attributes surface migration warnings on use.
Chapter last revised: 2026-05-03 — egui_mobius v0.4.0.
egui_quill — the syntax-highlighted editor
egui_quillis a citizen. A docked, movable, resizable panel with stable identity ("editor") and a known set of atoms inside — the editable monospace text area, the language picker, the theme picker. Like every other citizen panel, it observes shared reactive state throughDynamic<T>and participates in dispatcher-coordinated activation.
If "citizen" doesn't ring a bell yet, read What is a citizen? first.
What it does
egui_quill is the canonical text editor for the egui_mobius
ecosystem. It provides a syntax-highlighted, monospace editing
panel with language and theme pickers as in-panel controls. The
buffer lives in a Dynamic<ReactiveEditorState>, so every keystroke
is observable from any other panel or backend thread.
Out of the box: Rust, JSON, YAML, Python, JavaScript, Markdown, Plain Text. Themes from syntect's default set (base16-ocean.dark, Solarized, etc.).
Quill lives in crates/egui_quill/ as a sibling of
egui_lens. It launched in egui_mobius v0.4.0 alongside the
broader canonical-citizen-panels initiative.
Implementation note: like lens, quill currently ships as a widget that consuming apps wrap in a thin panel struct. Once a sibling
EditorCitizenlands — parallel to lens's path — the wrapper goes away and the dock layout uses the citizen view directly.
The shape
Two types matter: state and view.
ReactiveEditorStateis the data — text buffer, active language name, active theme name. Held inDynamic<ReactiveEditorState>for cross-panel observability.ReactiveEditor<'a>is the view — a per-frame widget borrowing the state. Construct insideui(), render via.show(ui).
use egui_mobius_reactive::Dynamic;
use egui_quill::{ReactiveEditor, ReactiveEditorState};
// At app construction
let editor_state = Dynamic::new(
ReactiveEditorState::new()
.with_content("// hello, world\nfn main() {}\n")
.with_language("Rust")
.with_theme("base16-ocean.dark"),
);
// Per frame inside `ui()`
let editor = ReactiveEditor::new(&editor_state);
editor.show(ui);
That's the entire integration. Other panels and backend threads
see edits by reading editor_state.get().content — same reactive
pattern as lens, no callbacks, no wiring.
The atoms
Quill's atoms are the user-visible widgets inside the panel:
| Atom | Type | What it controls |
|---|---|---|
| Language picker | ComboBox | Active syntax (Rust, JSON, YAML, etc.) |
| Theme picker | ComboBox | Color theme (base16, Solarized, …) |
| Text area | TextEdit::multiline | The buffer content; emits edits to editor_state |
Each atom corresponds to a field on ReactiveEditorState. Setting
the field via the picker is reactive: a backend thread observing
editor_state sees the language change immediately and could, for
example, trigger a parser run.
Performance
Two layers of caching keep frame cost small:
SyntaxSetandThemeSetarethread_local— loaded once per thread, shared across all editor instances. Cold-load is ~50 ms; warm reads are zero-cost.- The
LayoutJobis cached keyed on(content_hash, language, theme). Egui calls the layouter every frame; with no edits and no picker changes, the cache returns the priorLayoutJobdirectly — no re-highlight cost.
For the typical editor session (occasional keystrokes, mostly read-only) the per-frame cost is dominated by egui's normal text layout, not syntax highlighting.
WASM
Quill is fully WASM-compatible. The syntect crate is configured
with regex-fancy (pure Rust) instead of regex-onig (C
bindings), so the crate compiles for wasm32-unknown-unknown.
Bundle size impact in release wasm with wasm_opt = "z" is
~700KB additional for the syntect grammars and themes.
The filter_plotter example demonstrates this — its WASM build
includes a working quill panel as one of its dock tabs, and the
Pages-deployed demo is a click-and-edit reactive editor in the
browser.
Custom languages — beyond the defaults
Out of the box, quill exposes whatever languages syntect's default
syntax set ships. For domain-specific languages — including HDL
(Verilog / SystemVerilog / VHDL) for engineering apps — the
roadmap is hand-rolled parser crates following the
lexer / parser / ast / error / highlight shape used elsewhere in
the saturn77 ecosystem (see RustQt/venerate/svx_parser/ and
simcore/simcore-lang/).
Each parser crate exposes a Highlighter trait impl that quill
consumes; ReactiveEditorState carries an
Option<Arc<dyn Highlighter>> to enable language-specific
highlighting beyond the defaults.
Wiring quill into a citizen panel (current pattern)
Until EditorCitizen lands, the consuming app provides a thin
wrapper panel struct:
use egui_quill::ReactiveEditor;
use crate::state::SharedState;
pub struct EditorPanel {}
impl EditorPanel {
pub fn new() -> Self { Self {} }
pub fn show(&mut self, ui: &mut egui::Ui, state: &SharedState) {
let editor = ReactiveEditor::new(&state.editor);
editor.show(ui);
}
}
SharedState carries the Dynamic<ReactiveEditorState> field;
the egui_dock TabViewer calls panel.show(ui, state) for the
editor tab.
See also
examples/filter_plotter— full working example. Quill is the fourth dock tab grouped with Plot. Default content is the example's ownbackend/iir.rsso the editor renders real Rust syntax highlighting on first paint.egui_lens— sibling citizen for logging. Same state/view shape; quill's API was designed to mirror lens for consistency.
Chapter last revised: 2026-05-04 — egui_mobius v0.4.0.
egui_3d_viewer — the 3D viewer citizen
egui_3d_vieweris a citizen. A docked, movable, resizable panel with stable identity, with hand-rolled OpenGL rendering throughegui_glow'sPaintCallback. Atoms include the grid / axes toggles, the measure tool, and the standard creature comforts — orbit, zoom, zoom-to-region, double-click reset.
If "citizen" doesn't ring a bell yet, read What is a citizen? first.
What it does
egui_3d_viewer is the canonical 3D viewport for egui_mobius
applications. It renders consumer-supplied triangle and line meshes
plus a default XYZ axes gizmo and ground grid, with mouse-driven
orbit / zoom / pan and a measure tool on the Z=0 plane.
The crate sits at crates/egui_3d_viewer/ as a sibling of
egui_lens and egui_quill. It launched in egui_mobius v0.4.0
extracted from CopperForge's render3d module. Backend is
glow, the same low-level GL
binding the rest of the eframe + egui_glow stack uses; the crate is
wasm-portable through WebGL2.
The shape — divergent from lens / quill
Lens and quill follow a (state, view) split: state in a
Dynamic<T>, a per-frame view that borrows the state. The 3D
viewer can't follow that pattern cleanly. Persistent GL handles,
the orbit camera, and in-flight drag state all belong on a struct
that lives across frames, none of which fit cleanly inside a
reactive cell.
So ViewerCitizen owns everything:
ReactiveViewerState— atom UI state:show_grid,show_axes,measure_active,background_color, plus areset_view_requestedcommand flag. Held inDynamic<ReactiveViewerState>for cross-panel observability.ViewerCitizen— the citizen struct itself. Carries the reactive state cell, theCamera, lazily-initialisedGpuResources, in-flight drag state, and theCitizenStatehandle. Itsshow(ui, gl)method is the per-frame render call.
use egui_3d_viewer::ViewerCitizen;
use egui_citizen::{CitizenId, Dispatcher};
// At app construction
let mut dispatcher = Dispatcher::new();
let viewer_state = dispatcher.register(CitizenId::new("viewer"));
let mut viewer = ViewerCitizen::new("viewer", viewer_state);
// Per frame inside `ui()` — pass the glow context from eframe::Frame
viewer.show(ui, frame.gl());
That's the integration. Other panels read atom state via
viewer.state().get().show_grid and similar; the viewer reads its
own state each frame and acts on the toggles.
The atoms
The viewer's atoms — the user-facing controls inside the panel:
| Atom | Trigger | What it does |
|---|---|---|
| Orbit | Left-drag on canvas | Yaw + pitch the camera around the scene |
| Zoom | Scroll wheel (canvas-hovered) | Multiplicative camera distance |
| Zoom-to-region | Right-drag, release | Frame the dragged box; un-projects to Z=0 |
| Reset view | Double-click | Snap back to the default tilted top-down |
| Toggle grid | G key (canvas-hovered) | Flip state.show_grid |
| Measure | M key (canvas-hovered) | Flip state.measure_active; left-drag draws a Z=0 distance line |
| Toggle axes | Set state.show_axes | Hide / show the axes gizmo + screen labels |
Hover-gating on G and M matters — typing those keys in another
panel must not flip the viewer's settings under the user. The
zoom-to-region overlay and the measure-tool line are painted with
egui's 2D painter on top of the GL pass so they stay visible
regardless of camera angle.
Scene injection
The default scene is the axes gizmo + a ground grid; consumer apps push their own meshes through:
viewer.set_scene_triangles(verts)— flatxyz rgbbuffer, six floats per vertex, drawn with theTRIANGLESprimitive.viewer.set_scene_lines(verts)— same stride, drawn asLINES. Useful for wireframe overlays or vector-style content.viewer.clear_scene()— drop both back to the empty default.
Uploads are deferred to the next show() call — a glow context is
only available there. The buffer format is intentionally minimal:
the citizen knows nothing about the consumer's domain. Build your
scene however makes sense — CSG with csgrs, gerber polygon
extrusion, hand-built meshes — then convert to the float-buffer
shape and hand it over.
let plate = build_plate_with_holes(); // your scene
let verts = mesh_to_xyz_rgb(&plate, color); // your conversion
viewer.set_scene_triangles(verts);
viewer.set_axes_length(scene_max_dim * 0.15);
viewer.camera_mut().fit_to_bbox(scene_w, scene_h);
WASM
The viewer is wasm-portable through WebGL2. egui_glow ships a
WebGL2 backend out of the box, and the underlying renderer code
uses only OpenGL 3.3 / WebGL2-safe features — no compute shaders,
no extension-gated texture formats. The same crate compiles for
wasm32-unknown-unknown with default-features = false on
egui_glow.
Performance
GPU resources are lazily initialised on the first frame where a
glow context is in scope, then cached on the citizen. The shader
program, axes mesh, grid mesh, and the two scene-mesh slots are
allocated once. Re-uploads happen only when the consumer calls a
set_scene_* method — the pending vec is drained inside show(),
not on every frame.
Per-frame cost on an empty default scene is two line-draw calls plus the egui paint pass. Per-frame cost on a populated scene adds one or two more draw calls — depth-tested geometry first, then grid + lines + axes layered on top.
Backend stack — glow now, wgpu later
The viewer is built on hand-rolled OpenGL 3.3 through egui_glow's
PaintCallback. The shader/mesh shape — single VAO+VBO meshes with
xyz rgb stride, the Arc<Mutex<_>> callback wrapper — comes
directly from
Tim Schmidt's alumina-interface,
which is the reference implementation for this integration pattern.
A future migration to wgpu is on the roadmap once the API is
stable. The migration is mechanical: the public surface —
ViewerCitizen, ReactiveViewerState, set_scene_* — does not
need to change; only the underlying UnlitProgram / ColoredMesh
implementations port to wgpu's pipeline / buffer model.
See also
examples/viewer3d_csgrs— full working example. Builds a 6"×4"×1/8" PCB mounting plate with six mounting holes viacsgrsconstructive solid geometry, hands the triangulated mesh to the viewer, demonstrates every creature comfort.egui_lens— sibling citizen for logging.egui_quill— sibling citizen for text editing.alumina-interface— the reference implementation foregui_glow+ glow + nalgebra 3D rendering inside an egui app.
Chapter last revised: 2026-05-05 — egui_mobius v0.4.0.
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.
Run it in the browser (WASM)
filter_plotteris also the workspace's reference implementation for browser deployment. One-time setup:cargo install trunk rustup target add wasm32-unknown-unknownThen, from the example directory:
cd examples/filter_plotter trunk serve --open # development; opens http://127.0.0.1:8080 trunk build --release # production; output in ./dist/The release
dist/directory is a self-contained static site — drop it on any web host. Everything in this tutorial works in the browser identically: the citizen pattern, the dispatcher, the reactive cells, the IIR backend. The only platform-specific code is the#[cfg(target_arch = "wasm32")]entrypoint inmain.rsthat hands eframe a canvas instead of a native window. Seeexamples/filter_plotter/README.mdfor the full wasm story.
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_lens::{LogColors, ReactiveEventLoggerState};
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<ReactiveEventLoggerState>,
pub log_colors: Dynamic<LogColors>,
pub plot_link: egui::Id,
}
Four 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 — backed by
egui_lens::ReactiveEventLoggerState, see the lens
chapter), and log_colors (configures
per-type colors for custom log channels like "citizen" and
"backend"). 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 logger panel is itself a citizen with named atoms —
egui_lens::ReactiveEventLogger provides the panel identity (the
"logger" citizen) and the in-panel widgets (System Info, Filters,
Logger Colors, Save Logs, Clear Logs, the column-toggle
checkboxes, the scrollable log area) are its atoms. The panel
struct in this app is a thin wrapper that hands lens its shared
state and renders it:
use egui_lens::ReactiveEventLogger;
use crate::state::SharedState;
pub struct LoggerPanel {}
impl LoggerPanel {
pub fn new() -> Self { Self {} }
pub fn show(&mut self, ui: &mut egui::Ui, state: &SharedState) {
let logger = ReactiveEventLogger::with_colors(
&state.log,
&state.log_colors,
);
logger.show(ui);
}
}
The logger reads state.log (a Dynamic<ReactiveEventLoggerState>)
and state.log_colors (a Dynamic<LogColors>); writes flow in
through the dispatcher. The logger panel doesn't push entries
itself — it just renders what the drain loop in dispatcher.rs
puts there.
Forward-looking note: lens will eventually implement
Citizendirectly and theLoggerPanelwrapper here disappears entirely — the dock layout will use the citizenReactiveEventLogger, or its siblingLoggerCitizen, without any custom panel type. For now this thin wrapper is the boundary.
What the lens-backed logger gives you
Compared to a hand-rolled Vec<String> log:
- Per-type colors via
Dynamic<LogColors>— the dispatcher routes citizen lifecycle events throughlog_custom("citizen", ...)and backend events throughlog_custom("backend", ...), each rendered in its own color (configured at app construction instate.rs). - Filters — info / warning / error / debug / custom / system toggles plus a text-search box, all stored on the shared state.
- Save Logs — exports current contents via
rfd::AsyncFileDialog, which works on both native and WASM (browser builds get a download; native opens an OS file dialog). - System Info button — sets a memory flag the consuming app
drains. The "atom" pattern at work: lens publishes the click;
the app provides the actual implementation (see
platform/details.rsfor native,platform/details_wasm.rsfor the browser variant). - Cross-thread writes —
Dynamic::clone()is cheap; hand a clone to any thread and callReactiveEventLogger::new(&clone) .log_info(...)from anywhere.
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, state: &SharedState) {
let logger = ReactiveEventLogger::with_colors(&state.log, &state.log_colors);
for msg in dispatcher.drain_messages() {
// "citizen" is a named custom log type — its color is
// configured in state.rs and renders distinctly from
// info/warning/error/debug.
logger.log_custom("citizen", &format_citizen(&msg));
}
}
pub fn handle<B>(msg: AppMessage, state: &SharedState, backend: &mut B)
where
B: BackendKind<Sample = f32>,
{
let logger = ReactiveEventLogger::with_colors(&state.log, &state.log_colors);
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);
// "backend" is also a named custom log type — its
// color is configured alongside "citizen" so the two
// event streams are visually distinct.
logger.log_custom("backend",
&format!("{} produced {} samples", backend.name(), n));
}
AppMessage::GenerateCompleted { samples } => {
logger.log_info(&format!("generate completed: {} samples", samples));
}
}
}
Compared to the older shape that pushed pre-formatted strings into
a Vec<String>, the lens-aware version routes each event through
its appropriate level (log_info/log_warning/log_error/
log_debug) or named custom type (log_custom("citizen", ...),
log_custom("backend", ...)). The logger panel renders each in
its configured color automatically. Less manual formatting; more
visual signal in the panel.
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.
Stored vs stateless panels
Two lawful ways to use a citizen, both correct, and the choice depends on what state the panel itself holds.
- Stored — the panel is a field on the app struct, constructed
once in
App::new(), rendered viaself.panel.show(ui, ...)each frame. Panel-local state survives between frames because the panel struct does. - Stateless per-frame — the panel is constructed fresh inside the
TabKinddispatch arm of yourTabViewer, used once, dropped at the end ofui(). Anything panel-local is wiped between frames; the panel relies on app-shared services for everything it needs to render.
Both forms work. Pick the one that matches the state the panel owns.
Stored panels
struct App {
dispatcher: Dispatcher,
logger: LoggerPanel, // stored: lives across frames
bom: BomPanel, // stored
/* ... */
}
impl App {
fn new(cc: &eframe::CreationContext) -> Self {
let mut dispatcher = Dispatcher::new();
let logger = LoggerPanel::new(
dispatcher.register(CitizenId::new("logger")),
);
let bom = BomPanel::new(
dispatcher.register(CitizenId::new("bom")),
);
Self { dispatcher, logger, bom }
}
}
In your TabViewer::ui, the panel field gets passed by mutable
reference:
match tab.kind {
TabKind::Logger => self.logger.show(ui, &self.services),
TabKind::Bom => self.bom.show(ui, &self.services),
/* ... */
}
Use stored panels for any panel that owns non-trivial local state. Concretely:
- Accumulating buffers — log entries, terminal scrollback, command history.
- Caches — image / texture caches, parsed file caches, computed layout caches.
- Per-panel UI state that must persist — scroll position (when egui's own memory doesn't already handle it), filter text, modal open-state.
Anything that should not vanish between frames belongs in a stored
panel's PanelState (see
Where does state live?).
Stateless per-frame panels
match tab.kind {
TabKind::Drc => DrcPanel::new(self.drc_state.clone())
.show(ui, &mut self),
TabKind::Settings => SettingsPanel::new(self.settings_state.clone())
.show(ui, &mut self),
/* ... */
}
The panel struct is constructed every frame, used once, dropped. It
holds no panel-local fields beyond its CitizenId and CitizenState
— everything else it renders comes from &self (the app) or
&mut services.
Use stateless panels when the panel is a pure view over data that already lives somewhere else:
- DRC results panel — DRC results live in shared services (computed from the project model). The panel is a view; nothing needs to survive between frames.
- View settings panel — settings are shared application state.
The panel reads and writes them through
&mut services, never caching anything locally. - Project picker / file picker — the project list comes from the filesystem each frame, or from a service that caches it; the panel itself doesn't.
The trap that kills reactivity
This is the single most common foot-gun, and it is silent — no panic, no error, no warning. Reactivity quietly stops working.
The stateless form looks like it should work with
CitizenState::default():
// WRONG — fresh storage, disconnected from the dispatcher
match tab.kind {
TabKind::Drc => DrcPanel::new(CitizenState::default()) // ← !!!
.show(ui, &mut self),
}
The panel constructs, renders, and drops cleanly. The dispatcher's
activate(&drc_id) runs without complaint. The DRC tab even
highlights when clicked, because egui_dock handles its own visual
state.
But: any code that reads drc_state.active.get() from outside the
DRC panel reads from a CitizenState that the dispatcher knows
nothing about — because the panel constructed its own with
::default(). Subscribers across the app see the value never change,
even though the dispatcher's internal table says the DRC citizen is
active. The dispatcher's storage and the panel's storage are two
completely different Arcs.
The fix: always obtain the CitizenState from
dispatcher.register(), store it somewhere durable, and clone it
into the per-frame panel.
struct App {
dispatcher: Dispatcher,
drc_state: CitizenState, // stored on the app even though
// the panel itself is stateless
/* ... */
}
impl App {
fn new(cc: &eframe::CreationContext) -> Self {
let mut dispatcher = Dispatcher::new();
let drc_state = dispatcher.register(CitizenId::new("drc"));
Self { dispatcher, drc_state }
}
}
// In TabViewer:
match tab.kind {
TabKind::Drc => DrcPanel::new(self.drc_state.clone())
.show(ui, &mut self),
}
The CitizenState lives on the app struct (so it survives across
frames and the dispatcher and panel agree on storage); the panel
struct itself is still constructed fresh. Reactivity works because
the Arc clones share underlying storage (see
Reactive lifecycle: clones share storage).
The shorthand: panels can be stateless; their CitizenState
cannot.
Mixing the two in one app
Real apps mix freely. CopperForge — a non-trivial example — keeps stored panels for those that own buffers or caches:
| Stored panel | Reason |
|---|---|
logger | log buffer accumulates over the session |
bom | parsed / cached BOM rows |
terminal | shell scrollback |
shell | command history |
gerber_view_3d | geometry cache + camera state |
…and stateless for panels that are pure views over shared data:
| Stateless panel | Reason |
|---|---|
DrcPanel | DRC results live in shared services |
ViewSettingsPanel | settings live in shared services |
SettingsPanel | configuration lives in shared services |
ProjectsPanel | project list comes from filesystem / services |
The dispatcher is unaware of the difference. From its perspective,
every citizen is just (CitizenId, CitizenState), regardless of
whether the surrounding panel struct is stored or constructed
per-frame. The split is purely a question of where panel-local state
lives.
Decision rule
Ask one question:
Does this panel own state that must survive between frames?
| Answer | Use |
|---|---|
| Yes | Stored |
| No | Stateless |
State that "must survive" includes: buffers, caches, per-panel UI
state that egui itself doesn't remember, accumulated history. State
that does not count: anything you can re-derive from app-shared
services or read from the panel's CitizenState flags.
If you're unsure, default to stored. The cost of a panel with no local state being stored is one extra struct field; the cost of a stateless panel that secretly needed local state is hours of debugging why something doesn't update across frames.
Summary
- Stored panels are app fields constructed once. Use when the panel owns local state that must persist across frames.
- Stateless panels are constructed per-frame in the tab-dispatch arm. Use when the panel is a pure view over shared services.
- The
CitizenStatealways comes fromdispatcher.register()and lives somewhere durable — on the panel struct for stored, on the app struct for stateless. Constructing it with::default()silently severs reactivity. - When in doubt, choose stored.
Common pitfalls
Six foot-guns that real egui_citizen apps hit. Each is presented as
a concrete broken snippet, an explanation of why it fails, and the
fix. None of them produce a panic or compile error — every one is a
silent bug, which is what makes them worth their own chapter.
1. Constructing CitizenState fresh per frame
Broken:
match tab.kind {
TabKind::Drc => DrcPanel::new(CitizenState::default())
.show(ui, &mut self),
}
What goes wrong: CitizenState::default() allocates fresh
Arc<Mutex<...>> storage that the dispatcher knows nothing about.
The dispatcher's activate(&drc_id) writes to its table; the panel
reads from its freshly-allocated state; the two never agree. The
DRC tab still highlights when clicked (egui_dock handles its own
visual state), but anything reading drc_state.active.get() from
elsewhere in the app sees false forever. Reactivity is silently
severed.
Fix: obtain the CitizenState from Dispatcher::register(),
store it somewhere durable (the app struct), and clone it into the
per-frame panel:
struct App {
dispatcher: Dispatcher,
drc_state: CitizenState, // registered once, lives on the app
}
impl App {
fn new(_: &eframe::CreationContext) -> Self {
let mut dispatcher = Dispatcher::new();
let drc_state = dispatcher.register(CitizenId::new("drc"));
Self { dispatcher, drc_state }
}
}
// In TabViewer:
match tab.kind {
TabKind::Drc => DrcPanel::new(self.drc_state.clone())
.show(ui, &mut self),
}
Panels can be stateless; their CitizenState cannot. See
Stored vs stateless panels and
Reactive lifecycle: the trap.
2. Forgetting drain_messages()
Broken:
fn update(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) {
DockArea::new(&mut self.tabs).show(ctx, &mut self.tab_viewer);
// (no dispatcher.drain_messages() anywhere)
}
What goes wrong: Dispatcher::activate() and any explicit
Dispatcher::send() calls push into an internal Vec<CitizenMessage>
that has no upper bound. If nothing drains it, the vec grows
forever. The app keeps running, the UI keeps rendering, but RSS
climbs every minute the user holds the app open. No panic, no error
log — just a slow leak.
Fix: drain once per frame, after DockArea::show():
fn update(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) {
DockArea::new(&mut self.tabs).show(ctx, &mut self.tab_viewer);
for msg in self.dispatcher.drain_messages() {
// process or forward
}
}
If you have nothing to do with the messages yet, drain into an
ignored binding (let _ = self.dispatcher.drain_messages();) so the
queue still empties. Don't leave the dispatcher's queue
unattended — ever.
3. Calling activate() every frame unconditionally
Broken:
fn ui(&mut self, ui: &mut egui::Ui, tab: &mut Tab) {
// The author wanted "this tab is rendering, mark it active":
self.dispatcher.activate(&tab.citizen_id());
tab.show(ui, self.app);
}
What goes wrong: ui() runs every frame for every visible tab.
Each call to activate() emits an Activated message — even when
the citizen was already active in the previous frame. The queue
fills with redundant Activated events at the frame rate (60+/sec).
Backend consumers see "fetch was just activated" 60 times a second
and either dedupe defensively or kick off 60 fetches.
When two tabs are visible side-by-side, it's worse: each frame
deactivates the other, so consumers see a flood of
Activated/Deactivated pairs in alternation.
Fix: call activate() from TabViewer::on_tab_button gated on
response.clicked(), never from ui():
fn on_tab_button(&mut self, tab: &mut Tab, response: &egui::Response) {
if response.clicked() {
self.dispatcher.activate(&tab.citizen_id());
}
}
fn ui(&mut self, ui: &mut egui::Ui, tab: &mut Tab) {
// ui() only renders. It does not write lifecycle state.
tab.show(ui, self.app);
}
This is the single most common foot-gun and the reason
the egui_dock background chapter draws the
distinction between ui() (render) and on_tab_button (event) so
sharply. ui() is for rendering; on_tab_button is for state
transitions. Keep them separate.
4. Mixing panel-local state into CitizenState
Broken:
// Trying to model "this panel's slider value" through CitizenState
// (which has fixed library-defined fields):
self.citizen_state.active.set(true); // hijacking `active` as
// "is showing project X"
…or, equivalently:
struct LoggerPanel {
citizen_id: CitizenId,
citizen_state: CitizenState,
log_buffer: Dynamic<Vec<LogEntry>>, // overkill; nothing else
// outside the panel reads it
}
What goes wrong: CitizenState has a fixed shape — six
Dynamic<T> fields with library-defined semantics (active, clicked,
selected, moved, location, visible). Reusing those fields to mean
something domain-specific (e.g. active = "showing project X")
breaks the dispatcher's invariants the moment you call
activate(), which clobbers your overload.
The variant is treating every panel-local field as reactive
"because reactivity feels good." Reactivity is not free — every
Dynamic<T> is an Arc plus a lock plus a notifier list. If only
the panel itself reads its log buffer, a plain Vec<LogEntry> is
the right type.
Fix: use a separate PanelState struct alongside CitizenState
on the panel struct, and use plain (non-reactive) types unless a
second reader actually exists:
struct LoggerPanelState {
log_buffer: Vec<LogEntry>,
filter_text: String,
follow_tail: bool,
}
struct LoggerPanel {
citizen_id: CitizenId,
citizen_state: CitizenState, // library lifecycle, fixed
panel_state: LoggerPanelState, // panel-author's fields
}
See Where does state live? for the full three-bucket model.
5. Expecting visible to track egui_dock's open/closed state
Broken:
// Assumes `visible` updates automatically when egui_dock opens or
// closes the tab — it doesn't.
if self.plot_state.visible.get() {
self.start_streaming_data();
}
What goes wrong: egui_citizen and egui_dock are independent
crates. egui_dock does not know egui_citizen exists, so it never
calls state.visible.set() on its own. The visible field starts
at false, stays at false, and your "is the panel showing?" check
returns false even when the user is staring at the panel.
Fix: drive visible yourself, either by setting it directly
when you detect a tab open/close in TabViewer, or — preferred —
by routing through the VisibilityChanged
message:
// Detect close in TabViewer:
fn on_close(&mut self, tab: &mut Tab) -> bool {
self.dispatcher.send(CitizenMessage::VisibilityChanged {
id: tab.citizen_id(),
visible: false,
});
true
}
// In the drain loop, sync the reactive flag:
for msg in self.dispatcher.drain_messages() {
if let CitizenMessage::VisibilityChanged { id, visible } = &msg {
if let Some(state) = self.dispatcher.get(id) {
state.visible.set(*visible);
}
}
}
egui_citizen provides the vocabulary (a Dynamic<bool> field, a
message variant). The plumbing from egui_dock's tab-close into that
vocabulary is your code's responsibility, by design — it's the
boundary that keeps egui_citizen independent of the dock crate.
6. Two dispatchers in one app
Broken:
struct App {
plot_dispatcher: Dispatcher,
settings_dispatcher: Dispatcher,
/* ... */
}
What goes wrong: the one-hot activation invariant is
per-dispatcher. plot_dispatcher.activate(&plot_id) deactivates
every other citizen registered with plot_dispatcher — but it
cannot deactivate a citizen registered with settings_dispatcher,
because the two dispatchers maintain entirely separate
HashMap<CitizenId, CitizenState> tables. Two panels — one
registered to each dispatcher — can both be "active" simultaneously,
which the rest of the codebase does not expect.
Fix: one Dispatcher per app. Always.
struct App {
dispatcher: Dispatcher, // exactly one
/* ... */
}
If you find yourself wanting a second dispatcher because "these
panels are unrelated to those panels," the right answer is still one
dispatcher with all panels registered. Citizen ids are namespaced
strings — use "editor.plot", "sidebar.settings", etc., to
disambiguate. The dispatcher does not care about logical grouping;
it only enforces the one-hot invariant across everything it knows
about.
Summary
Five of the six foot-guns above are silent — no panic, no compile
error, just behavior that drifts from what the author expected. The
defenses are vocabulary-level: hold the CitizenState somewhere
durable, drain the queue every frame, never write lifecycle state in
ui(), keep panel-local data out of CitizenState, drive visible
yourself, and never run two dispatchers in one app. Once these
become habits, the rest of egui_citizen works out of the box.
Reference
A single-page summary of the public API. For full signatures, doc comments, and version-tracked details, see docs.rs/egui_citizen.
Dispatcher
use egui_citizen::Dispatcher;
| Call | What it does |
|---|---|
Dispatcher::new() | Empty dispatcher. |
register(id) -> CitizenState | Register a citizen; return a CitizenState handle. |
get(&id) -> Option<&CitizenState> | Look up a registered citizen's state. |
activate(&id) | One-hot: this one on, all others off. Emits messages. |
send(message) | Push a CitizenMessage onto the queue without activating. |
drain_messages() -> Vec<CitizenMessage> | Take all pending messages. Call once per frame. |
len() / is_empty() | Citizen count / emptiness. |
See the dispatcher chapter for the one-hot invariant and the canonical drain loop.
Citizen trait
use egui_citizen::Citizen;
| Method | Provided? | Purpose |
|---|---|---|
id() -> &CitizenId | required | Stable identity. |
citizen_state() -> &CitizenState | required | Read access to lifecycle state. |
citizen_state_mut() -> &mut CitizenState | required | Mutable access to lifecycle state. |
on_activate() | default | Sets citizen_state.active = true. |
on_deactivate() | default | Sets citizen_state.active = false. |
on_click() | default | Sets citizen_state.clicked = true. |
is_active() -> bool | default | citizen_state.active.get(). |
is_selected() -> bool | default | citizen_state.selected.get(). |
See the Citizen trait chapter for the minimum-viable impl and override semantics.
CitizenState
use egui_citizen::CitizenState;
Six reactive Dynamic<T> fields:
| Field | Type | Meaning |
|---|---|---|
active | Dynamic<bool> | This citizen is the active one (one-hot). |
clicked | Dynamic<bool> | True for the frame this citizen was clicked. |
selected | Dynamic<bool> | Persistent selection toggle. |
moved | Dynamic<bool> | Citizen moved to a new dock location. |
location | Dynamic<[f32; 2]> | Last known dock-layout position. |
visible | Dynamic<bool> | Citizen is currently visible. |
Cloning a CitizenState clones the Arcs — every clone refers to
the same storage. See Reactive
lifecycle and Inside
Dynamic<T> for the underlying machinery.
CitizenMessage
use egui_citizen::CitizenMessage;
| Variant | Emitted by |
|---|---|
Activated { id } | Dispatcher::activate(&id) |
Deactivated { id } | Dispatcher::activate(&id) for the previously active citizen |
Clicked { id } | App code via Dispatcher::send |
Selected { id, selected: bool } | App code via Dispatcher::send |
Moved { id, location: [f32; 2] } | App code via Dispatcher::send |
VisibilityChanged { id, visible: bool } | App code via Dispatcher::send |
Derives: Debug, Clone. See the messages chapter.
CitizenId
pub struct CitizenId(pub String);
impl CitizenId {
pub fn new(id: impl Into<String>) -> Self { /* ... */ }
}
Stable string identifier for a citizen. Define ids as consts in
your app to make typos a compile-time error:
const PLOT_ID: &str = "plot";
const LOGGER_ID: &str = "logger";
Common idioms
Register and activate
let mut dispatcher = Dispatcher::new();
let plot_state = dispatcher.register(CitizenId::new("plot"));
dispatcher.activate(&CitizenId::new("plot"));
Implement Citizen on a panel struct
struct PlotPanel {
citizen_id: CitizenId,
citizen_state: CitizenState,
}
impl Citizen for PlotPanel {
fn id(&self) -> &CitizenId { &self.citizen_id }
fn citizen_state(&self) -> &CitizenState { &self.citizen_state }
fn citizen_state_mut(&mut self) -> &mut CitizenState {
&mut self.citizen_state
}
}
Wire activation through egui_dock::TabViewer
fn on_tab_button(&mut self, tab: &mut Tab, response: &egui::Response) {
if response.clicked() {
self.dispatcher.activate(&tab.citizen_id());
}
}
Drain messages once per frame
for msg in self.dispatcher.drain_messages() {
match msg {
CitizenMessage::Activated { id } => { /* ... */ }
CitizenMessage::Deactivated { id } => { /* ... */ }
_ => {}
}
}
Wrap in your own AppMessage
pub enum AppMessage {
Citizen(CitizenMessage),
/* domain variants ... */
}
for msg in self.dispatcher.drain_messages() {
let _ = self.tx_backend.send(AppMessage::Citizen(msg));
}
See also
- docs.rs/egui_citizen — full rustdoc.
- docs.rs/egui_mobius_reactive —
Dynamic<T>,Value<T>,Derived<T>. - docs.rs/egui_dock —
DockArea,TabViewer.