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 registry 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 registry 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,
/// App-level intents, drained by the app's update loop each frame.
outbox: Vec<AppMessage>,
}
impl PlotPanel {
fn new(state: CitizenState) -> Self {
Self {
citizen_id: CitizenId::new("plot"),
citizen_state: state,
outbox: Vec::new(),
}
}
fn show(&mut self, ui: &mut egui::Ui, shared: &SharedState) {
ui.heading("Plot");
// Registry → panel: reactive, no method call.
// The registry's activate() writes self.citizen_state.active
// through the shared Arc; we observe it via is_active().
if self.is_active() {
ui.label("(active — drawing live)");
// ... actual plotting against shared.traces ...
} else {
ui.label("(inactive — paused)");
}
// Panel → app: push an intent, don't call anything.
// The app's drain loop routes this — including any
// registry.activate(...) it decides to make.
if ui.button("Switch to settings").clicked() {
self.outbox.push(AppMessage::FocusSettings);
}
}
}
// 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.
Notice what the panel's show() signature is — and what it isn't.
The panel sees &SharedState, the reactive state shared between
panels, and its own outbox. It does not take &mut Registry.
Both flow directions stay declarative:
- Registry → panel is reactive. The registry writes through
the
Arcunderneathcitizen_state.active, and the panel sees the new value the next timeis_active()reads it. No method call in either direction. - Panel → app is a message. The panel pushes an
AppMessageonto its outbox; the app's update loop drains outboxes once per frame and routes each message. If a message means "activate a sibling," it is the app layer that callsregistry.activate(...). The registry lives with the app and theTabViewer— panels never hold a reference to it.
Keeping the registry out of panel signatures is what keeps
panels composable: a panel is a function of SharedState plus its
own atoms, and its only side channel is the outbox. It also draws
the backend boundary cleanly. SharedState is a UI-side
structure — it never crosses into backend processing. When a
drained message needs backend work, the app snapshots the relevant
Dynamic<T> values into an owned request and sends that over
the signal/channel; the backend thread receives plain data, never
a reference into shared state (see
CitizenMessage — the backend bridge).
The state argument to PlotPanel::new should always come from
Registry::add().with_name(),
never from CitizenState::new() or CitizenState::default().
The latter allocate fresh disconnected storage and silently sever
the reactive link with the registry (see
the trap in the state chapter).
Skipping the boilerplate: citizen_panel!
The struct definition, the new(citizen_state) constructor, and the
three-accessor trait impl above are identical for every panel that
doesn't override the lifecycle hooks. The citizen_panel! macro
generates all three from one declaration:
use egui_citizen::citizen_panel;
citizen_panel!(PlotPanel, "plot",
samples: Vec<f32> = Vec::new(),
autoscale: bool = true,
);
This expands to exactly the shape shown in the previous section: a
PlotPanel struct holding citizen_id (initialized from "plot")
and citizen_state, plus the extra fields as pub members with
their defaults; a new(citizen_state: CitizenState) constructor;
and the Citizen impl wired to those fields. Your impl PlotPanel
block with show() and any other methods lives alongside the macro
call as usual — the macro only owns the struct and the trait
plumbing.
The construction rule from the previous section still applies
unchanged: the CitizenState you pass to the generated new()
must come from Registry::add().with_name(), never from
CitizenState::new().
Two cases still call for writing the impl by hand:
- Custom lifecycle hooks. The macro emits the trait impl with
the default
on_activate/on_deactivate/on_clickbodies. A panel that needs to do real work on activation — start a fetch, reset a cache — implementsCitizenitself. - A constructor that does more than store defaults. The
generated
new()only assigns the declared defaults. If construction needs arguments beyondCitizenState, write the struct by hand.
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 {
shared: SharedState, // reactive state panels read and write
registry: Registry, // app-layer only — panels never see it
logger: LoggerPanel,
bom: BomPanel,
}
// The app's update loop hands each panel the shared state — and
// only the shared state. The registry stays behind.
self.logger.show(ui, &self.shared);
self.bom.show(ui, &self.shared);
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.registry.add().with_name("plot")at startup.registry.activate("plot")when the user clicks the corresponding tab.
If the strings disagree, the registry 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";
registry.add().with_name(PLOT_ID);
registry.add().with_name(SETTINGS_ID);
registry.activate(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
registry 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 registry 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 registry 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 registry 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 fromRegistry::add().with_name(). 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.