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.

egui README

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 egui_citizen / egui_dock app: top and bottom ribbons frame a 2×2 dock layout — Project / Settings (top-left), Plotter 1 / Plotter 2 (top-right), Logger (bottom-left), and Terminal / Shell (bottom-right).

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 CitizenState does (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::Ui or egui_dock. It is a guide to using egui_citizen to 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 central Dispatcher. The citizen-panel is the unit of organization in an egui_citizen app.
  • 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_citizen is a Dynamic<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: Clone in Rust is per-type. Rust has no language-level default of "deep" vs. "shallow" — each type's Clone impl 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> and Rc<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 implement Clone at all — that is why the Arc wrapper is necessary. Cloning a Dynamic<T> is therefore exactly two Arc::clone calls: two refcount bumps, zero data duplication. The shared storage is not an accident; it is the precise contract of Arc.

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) — requires T: 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 raw MutexGuard for 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:

FieldCanonical writer
activeThe Dispatcher (via activate)
clickedThe panel's on_click hook
selected, visible, movedThe panel or app-level code
locationThe 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_tab is Logger.
  • Frame N+1 renders. The cycle repeats. active_tab flickers between Plot and Logger every 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 from ui().
  • 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 (the Activated / Deactivated messages, the reactive flag flips) propagate at well-defined boundaries: in the frame's drain pass, not partway through a render.

The integration shape becomes:

CallbackRoleAllowed to do
ui()Render the panelRead state. Never write lifecycle state.
on_tab_buttonDetect tab clicksCall dispatcher.activate(&id) on click.
Drain loopProcess state-change messagesMutate 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_button with response.clicked() is the one-shot click hook. It is the right place for state transitions.
  • The name on_tab_button does not telegraph that role, which is why most authors initially put state-transition code in ui() and hit the per-frame race. Discoverability is the foot-gun.
  • egui_citizen enforces the distinction by making Dispatcher::activate() the canonical state-transition primitive and routing it exclusively through on_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:

  1. A persistent identity — a CitizenId.
  2. A handle to its reactive lifecycle state — a CitizenState.
  3. 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 CitizenState clones in a HashMap<CitizenId, CitizenState>.
  • Your TabViewer impl pulls panels by tab kind and calls each panel's own show() (or whatever you named it). The trait gives you uniform access to id() and is_active() if rendering needs it, but the dispatcher never walks an array of dyn 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 CitizenState field from Dispatcher::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>,
}
}
FieldMeaning
activeThis citizen is the one currently active (the one-hot winner).
clickedTrue for the frame this citizen was clicked.
selectedPersistent selection toggle, independent of activation.
movedTrue if the citizen was moved to a new dock location.
locationLast known position in the dock layout.
visibleWhether 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

  • CitizenState is six reactive Dynamic<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 CitizenState shares storage. Constructing a fresh one does not.
  • Always obtain a CitizenState from dispatcher.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:

  1. Owns the canonical reactive state. Every registered citizen's CitizenState lives in a HashMap inside the dispatcher. Panels hold clones of those handles; the dispatcher holds the original entries.
  2. Enforces the one-hot activation invariant. Dispatcher::activate(&id) sets the named citizen's active flag to true and clears every other registered citizen's flag, atomically.
  3. 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).

Six panels (Project, Settings, Plotter 1, Plotter 2, Logger, Terminal/Shell) in a 2×2 dock layout. Each panel contains a labelled state cloud — ProjectState, SettingsState, Plotter1State, Plotter2State, LoggerState, TerminalState. Arrows from every state cloud converge on a single DISPATCHER block on the right.

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 active flag is true.
  • Every other registered citizen's active flag is false.
  • 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 a Deactivated.)

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, VisibilityChanged from app-level code that detects them — the dispatcher only emits Activated/Deactivated on 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's CitizenState.
  • 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 },
}
VariantFired byPayload
ActivatedDispatcher::activate(&id)id that became active
DeactivatedDispatcher::activate(&id) for previously-active citizensid that lost active
ClickedApp code (via Dispatcher::send)id that was clicked
SelectedApp code (selection toggling)id + new selection state
MovedApp code (after a dock-layout move)id + new [x, y] location
VisibilityChangedApp 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 variantOutcome variant
DrcRunRequestedDrcCompleted
ProjectOpenRequestedProjectLoaded
BomRebuildRequestedBomUpdated

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.
  • Activated and Deactivated are emitted automatically by Dispatcher::activate(). The other four require explicit dispatcher.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 CitizenMessage in your own AppMessage enum.
  • 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

PathMechanismGood forTiming
AShared Dynamic<T>Panel → panelImmediate
Bdispatcher.send()Panel → backend / loggerNext 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_reactive does provide a callback-style subscription on Dynamic<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 most egui_citizen apps, 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 Inside Dynamic<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 sees v immediately.
  • Path B is queued. .send(msg) appends to the dispatcher's queue; consumers don't see it until the update loop calls drain_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: Dynamic canonical with the message as a ping, or message canonical with the Dynamic as 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:

  1. Lower overhead. parking_lot::Mutex is one word, no poisoning bookkeeping, faster on the uncontended path. The notifier lock is touched on every .set() call and on every on_change() call, so the constant factor adds up.
  2. No poison ceremony. std::sync::Mutex::lock() returns LockResult<...>, which is Err if a previous holder panicked. parking_lot::Mutex::lock() returns the guard directly. The notifier path doesn't want to thread unwrap() 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:

  1. Lock the value mutex, write the new value, drop the lock.
  2. 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:

  1. Arc<F> wrapping the callback — returned to the caller, also held by the worker thread.
  2. An mpsc channel — tx lives in the notifier vec, rx lives on the worker thread's stack.
  3. thread::spawn — a dedicated, non-pooled OS thread that loops on rx.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::Sender that 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:

ToolBest forCost
.get() in ui()UI-to-UI state sharingOne atomic read per frame
dispatcher.sendUI-to-backend events with one drain pointOne queue push, drained once
Dynamic::on_changeOff-thread reactions independent of the UI loopOne 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 via self.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 TabKind dispatch arm of your TabViewer, used once, dropped at the end of ui(). 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 panelReason
loggerlog buffer accumulates over the session
bomparsed / cached BOM rows
terminalshell scrollback
shellcommand history
gerber_view_3dgeometry cache + camera state

…and stateless for panels that are pure views over shared data:

Stateless panelReason
DrcPanelDRC results live in shared services
ViewSettingsPanelsettings live in shared services
SettingsPanelconfiguration lives in shared services
ProjectsPanelproject 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?

AnswerUse
YesStored
NoStateless

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 CitizenState always comes from dispatcher.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:

  1. It names the bucket. A reader scanning LoggerPanel sees three things — id, citizen state, panel state — instead of a flat list that mixes concerns.
  2. It mirrors CitizenState. Both are "state for one panel," one library-defined and one app-defined. The parallel makes the design rule visible.
  3. 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:

QuestionBucket
Is this a lifecycle fact?CitizenState
Do two or more panels need it?App-shared
OtherwisePanelState

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).

DataBucketWhy
BomPanel.citizen_state.activeCitizenStateDock asks which panel is active.
BomPanel.panel_state.search_textPanelStateOnly BomPanel reads it.
BomPanel.panel_state.cached_rowsPanelStateCached view of project data, panel's own.
project.components[]App-sharedDRC reads it; BOM mutates it.
view.cameraApp-sharedViewport reads, settings writes.
ViewSettings.panel_state.is_dirtyPanelStateOnly 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;
CallWhat it does
Dispatcher::new()Empty dispatcher.
register(id) -> CitizenStateRegister 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;
MethodProvided?Purpose
id() -> &CitizenIdrequiredStable identity.
citizen_state() -> &CitizenStaterequiredRead access to lifecycle state.
citizen_state_mut() -> &mut CitizenStaterequiredMutable access to lifecycle state.
on_activate()defaultSets citizen_state.active = true.
on_deactivate()defaultSets citizen_state.active = false.
on_click()defaultSets citizen_state.clicked = true.
is_active() -> booldefaultcitizen_state.active.get().
is_selected() -> booldefaultcitizen_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:

FieldTypeMeaning
activeDynamic<bool>This citizen is the active one (one-hot).
clickedDynamic<bool>True for the frame this citizen was clicked.
selectedDynamic<bool>Persistent selection toggle.
movedDynamic<bool>Citizen moved to a new dock location.
locationDynamic<[f32; 2]>Last known dock-layout position.
visibleDynamic<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;
VariantEmitted 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