Introduction
You have an egui_dock app with three panels and they keep fighting over
state. Two panels both render every frame, both write to the same bool,
and whichever rendered last wins. You add a "currently active panel"
field somewhere; every panel polls it and tries to react to changes.
The polling code multiplies. Adding a fourth panel means touching three
existing ones.
egui_citizen fixes this. It gives each panel a persistent identity,
reactive lifecycle state, and a central dispatcher that routes
activation through an encoded set/reset — exactly one panel active at
a time, atomically, no per-frame races.
But the panel race is just the entry point. egui itself is, by design, a primitive — its own README is direct about that:
egui is not a framework. egui is a library you call into, not an environment you program for.
That is true and intentional. It also leaves an obvious gap. The egui community has produced excellent individual components, but a kit of components is not a framework. There is no widely adopted opinionated way to organize a non-trivial egui app — where state lives, how panels coordinate, how the UI talks to backend threads.
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.

A typical multi-panel app layout. Every labelled region is a candidate citizen-panel; the ribbons are app-shared chrome.
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.
egui_citizen fills that gap. It is, in practice, a framework for
organizing egui apps — clean, reusable, and easily expandable.
Persistent identity, reactive state, and central message dispatch form
a small composable kit of building blocks that scale as the app grows.
It evolved from egui_mobius in direct response to that ecosystem
gap (the Lineage section covers the technical
inheritance); CopperForge and other real applications are built
end-to-end on these primitives.
This book teaches the design vocabulary and the non-obvious rules. By the end you should know:
- Where each piece of state in your app belongs (
CitizenState, panel-local fields, or app-shared services). - What "reactive" actually means in this codebase, and what cloning
a
CitizenStatedoes (and doesn't) do. - When to keep panels stored on your app struct vs. construct them per-frame — and which trap will silently break the second form.
- How to forward citizen lifecycle events to backend threads without the UI having to know about them.
Who this book is for
Rust developers building dockable egui applications. Familiarity with egui itself is assumed — this book is not a tutorial on
egui::Uioregui_dock. It is a guide to usingegui_citizento organize a real app on top of those.
Key vocabulary
Three terms appear throughout this book. Fix them in your head now — every chapter that follows leans on these:
- 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, panel-to-backend messaging, or both at once.
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.
Lineage
egui_citizen evolved from
egui_mobius, a broader
reactive framework for egui. It now stands as its own architectural
framework, with one piece of that lineage retained as a hard
dependency: the
egui_mobius_reactive
crate, specifically its Dynamic<T> type.
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.
Where it sits in the wider crate
egui_mobius_reactive also provides Value<T> (an older API with the
same idea), Derived<T> (computed values that recalculate when their
inputs change), and a SignalRegistry for app-wide signal wiring.
egui_citizen uses only Dynamic<T>, so that is all this book
covers. If you later want a Derived<T> that reads a CitizenState
field and recomputes downstream, the reactive crate's own documentation
is the next stop.
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 problem
The introduction sketched the per-frame race in
broad strokes. This chapter pins down exactly why the race happens
in an egui_dock app, and exactly which API mechanic the rest of the
book is built around.
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. For how to use it across a real app — including which panel-author state goes where — see Where does state live?.
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.
Minimum-viable impl
use egui_citizen::{Citizen, CitizenId, CitizenState};
struct PlotPanel {
citizen_id: CitizenId,
citizen_state: CitizenState,
}
impl PlotPanel {
fn new(state: CitizenState) -> Self {
Self {
citizen_id: CitizenId::new("plot"),
citizen_state: state,
}
}
}
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
}
}
Five lines of trait impl, all delegating to fields. That's the ceremony to wire a panel into the citizen system.
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).
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 the hub between citizens and the rest of your
app. It does three jobs:
- Owns the canonical reactive state. Every registered citizen's
CitizenStatelives in aHashMapinside the dispatcher. Panels hold clones of those handles; the dispatcher holds the original entries. - 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.
That is the whole job. The dispatcher does not know about dock
layout, does not fire UI events on its own, and does not observe
arbitrary Dynamic<T> writes (see
the coupling chapter).

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.
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.
Coupling paths: UI-to-UI and UI-to-backend
egui_citizen gives you two distinct ways for a state change in one
place to influence work somewhere else. They serve different jobs,
they have different timing, and a single widget — what we'll call an
atom (a widget inside a citizen panel) — can use either path or
both at once.
Get this vocabulary clear before you design anything substantial.
The two paths
| Path | Mechanism | Good for | Timing |
|---|---|---|---|
| A | Shared Dynamic<T> | Panel → panel | 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)
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.
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 introduction covers
what Dynamic<T> looks like from outside: a thread-safe cell with
get, set, lock, and on_change. 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.
Your first citizen
Stub. Adapt from
examples/getting_started/.
Single panel, no dock. Goal: see on_activate fire and a
Dynamic<bool> flip in response.
Build the panel struct, register with a dispatcher, call activate()
from a button click, watch the state field update. Smallest possible
demonstration of the loop.
Wiring into egui_dock
Stub. Adapt from
examples/citizen_dock/.
Implement TabViewer, route on_tab_button into
dispatcher.activate(), drain messages once after DockArea::show().
The key insight: the dispatcher doesn't know about dock at all.
That boundary is user code, by design — egui-citizen has no dependency
on egui_dock.
Two panels talking
Stub. Needs a new example:
examples/two_panels_reactive/.
One panel's activation drives another panel's content. Pure reactive —
no messages, just Dynamic<T> reads in panel B's show().
Frame this by contrast with the problem chapter:
two panels racing on a shared bool become two panels reading from a
shared Dynamic<bool> and never colliding.
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.
Where does state live?
State is a struct. Where it lives — and which struct it lives in —
is the difference between code that scales and code that fights
itself. A real egui_citizen app has three of these structs, sitting
in three different places.
The three structs
1. CitizenState — lifecycle facts only
The library's CitizenState holds lifecycle
data: is this panel active, was it clicked this frame, is it visible,
has it moved. Six fields, fixed shape, all Dynamic<T>. Reactive by
design — other panels and threads can read these values directly.
What goes here: questions the dock or other panels ask about this panel's status.
What does not go here: business data. CitizenState is a shared
library type with a fixed contract. Don't try to extend it with
domain-specific fields.
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). These fields don't
need to be reactive — only the panel itself touches them.
struct LoggerPanelState {
log_buffer: Vec<LogEntry>,
filter_text: String,
follow_tail: bool,
}
struct LoggerPanel {
citizen_id: CitizenId,
citizen_state: CitizenState, // bucket 1: library-defined lifecycle
panel_state: LoggerPanelState, // bucket 2: panel-local data
}
What goes in PanelState: UI scratch state, caches, buffers,
modal-open flags, filter text, scroll positions, accumulated log
entries.
Why a 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 — just 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 SharedServices
struct passed by reference into every panel's show().
struct App {
services: Arc<SharedServices>, // shared
dispatcher: Dispatcher, // shared
logger: LoggerPanel, // owns its panel-local state
bom: BomPanel,
}
fn show(&mut self, ui: &mut egui::Ui) {
self.logger.show(ui, &self.services);
self.bom.show(ui, &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 is: 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
Adding business fields to CitizenState. CitizenState 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,
bom_rows: Vec<BomRow>,
}
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 (or whatever your app calls
its shared bag) and pass &services into each panel's show().
Putting PanelState fields in Dynamic<T> "in case someone needs
them later." Reactivity is not free — every Dynamic<T> is an Arc
plus a lock. If only the panel reads its own filter text, a plain
String field is fine. Promote to Dynamic<T> the day a second
reader actually appears.
Worked example
A CAD app with three panels: BomPanel (lists components), DrcPanel
(design rule check results), ViewSettings (camera and grid options).
| Data | Bucket | Why |
|---|---|---|
BomPanel.citizen_state.active | CitizenState | Dock asks which panel is active. |
BomPanel.panel_state.search_text | PanelState | Only BomPanel reads it. |
BomPanel.panel_state.cached_rows | PanelState | Cached view of project data, panel's own. |
project.components[] | App-shared | DRC reads it; BOM mutates it. |
view.camera | App-shared | Viewport reads, settings writes. |
ViewSettings.panel_state.is_dirty | PanelState | Only ViewSettings tracks pending edits. |
The split scales. Adding a fourth panel that reads
project.components[] is a one-line change — accept &SharedServices
in its constructor — not a refactor.
What about 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 switching the view is panel A's
citizen_state.active, which panel B reads reactively. Lifecycle
drives the transition; the data being viewed never moves into
CitizenState.
Summary
Three structs, in priority order: CitizenState for lifecycle facts,
app-shared services for cross-panel data, PanelState for everything
else. Don't extend CitizenState. Don't duplicate shared data. Don't
reach for Dynamic<T> until a second reader exists.
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 one
the problem chapter is built around. 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.