Introduction

Book version: 0.4.0  ·  Last updated: 2026-05-05  ·  tracks egui_mobius v0.4.0

The book is live — it evolves alongside the framework. Each chapter footer notes the date of its last substantive revision. When egui_mobius ships v0.5.0, this book becomes 0.5.0.

This book is the know-how for building solid, professional GUI applications in Rust on top of egui and the egui_mobius framework. The framework is a workspace of coordinated crates that together make modern dockable, threaded, reactive UI cheap to assemble.

Citizens are plug-ins

The single most important idea in this book is that citizens are plug-ins. Once a panel implements the Citizen trait, it drops into any host app with a four-line integration: cargo add the crate, declare one Dynamic<T> field, add a TabKind variant, render it from the TabViewer. No glue code. Real apps grow by accumulating citizens, not by extending a core — and the Dispatcher is the registry those citizens register with.

egui_lens and egui_quill are the shipped examples; the same shape applies to future citizens, and to any third-party citizen on crates.io. This is what makes the ecosystem composable rather than just architecturally tidy.

CopperForge running on egui_citizen — a docked layout with a 3D gerber view, settings, terminal, and logger panels updating live as the user drives the app.

CopperForge — a real-world egui_citizen + egui_dock application for PCB gerber inspection. Each docked region is a citizen-panel; the panels share state through reactive cells, and the 3D rendering thread is coordinated through the dispatcher.

Three levels of mobius-citizen apps

A mobius-citizen application sits at one of three levels, each adding capability without throwing away what came before:

  • Level 1 — shared Dynamic<T> between panels; the dispatcher manages panel state. Examples: getting_started, citizen_dock.
  • Level 2 — the dispatcher is extended to handle synchronous backend processing — filter, parser, anything in-process. Examples: filter_plotter, citizen_fetch.
  • Level 3egui_mobius signals and slots wire the dispatcher to async / multi-threaded backends. Example: citizen_signal_async.

Levels 1–2 use egui_citizen and egui_mobius_reactive; level 3 brings in egui_mobius itself.

Who this book is for

Familiarity with egui, with dockable widgets — Qt's Advanced Docking Widgets or egui_dock directly — and with shared-memory threading concepts. If those are comfortable, the rest is just learning the pattern.

How to read this book

Background covers Dynamic<T>, egui_dock, and the vocabulary. Concepts cover the Citizen trait, the dispatcher, messages, and coupling. The Tutorial is a worked example end-to-end — you can go straight there and refer back to Concepts as needed. The book closes with patterns, common pitfalls, and a reference sheet.

What is a citizen?

Before any of the trait, dispatcher, or reactive-state machinery, get the picture from the UI side first.

A citizen is a panel. A docked, movable, resizable region of the application window with a stable identity and a known set of widgets inside it.

That's the user-facing definition. Everything else — the Citizen trait, the Dispatcher, the reactive CitizenState — is the plumbing that makes the behavior of those panels predictable across an application. The plumbing matters, but the panel is what the user actually sees and interacts with.

The general characteristics

A citizen panel has all of the following:

  • Identity. Each panel has a stable name (a CitizenId"plot", "settings", "logger", "gerber_view"). Two panels cannot share an identity within the same app. The identity outlives any individual frame and survives layout changes.

  • Dockable. The panel slots into the application's dock layout via egui_dock (or a sibling dock library). It can sit alongside other citizen panels in a tabbed group, in a split, or as a free-floating window.

  • Movable. The user can drag the panel by its tab bar to a different dock position. Citizen identity is preserved through the move — the panel knows it's still the same panel after landing in a new spot.

  • Resizable. Dock split handles let the user reapportion space between citizens. The panel adapts; it does not lose state when its size changes.

  • Atomic content. A panel contains atoms — the widgets inside it: a slider, a button, a checkbox, a text field, a scrollable list, a plot. Atoms are the panel's interactive surface. A panel without atoms is a static label; the citizen pattern shines when atoms drive shared state that other citizens observe.

  • Lifecycle awareness. At any moment exactly one citizen is the active one in its group. Activation flips when the user clicks the panel's tab. The pattern guarantees this is exclusive — when "alpha" activates, "beta" deactivates atomically. Other panels can react to this without polling.

  • Reactive state. A small bundle of Dynamic<T> cells — active, clicked, selected, visible, location, moved — published by every citizen and readable from anywhere in the application. Other panels and backend threads observe these without holding references to the panel itself.

What this buys you that egui_dock alone doesn't

egui_dock is a layout library. It tells you which panel is visible on screen and which sits in which split. It does not tell you which panel is currently the active citizen of interest — i.e., which panel the user clicked last, which one should be receiving keyboard focus, which one a backend thread should be feeding fresh data to.

The citizen pattern fills that gap by giving every panel its own reactive state (CitizenState) and routing tab clicks through a central Dispatcher. The dispatcher's activate(...) call is an atomic set/reset: when "alpha" becomes active, every other citizen's active cell flips to false in the same operation, and lifecycle messages (Activated { id: alpha }, Deactivated { id: beta }) drop into the dispatcher's queue.

This means:

  • A backend thread can poll the dispatcher (or observe each citizen's active Dynamic<bool>) and discern which citizen is currently of interest without holding a reference to any panel. Background work — fetching data, running computations, reading hardware — knows where to direct its results.
  • A sibling panel can react to another panel becoming active without any per-frame polling: the reactive cell delivers the change, the rendering panel re-reads on its next frame.
  • The pattern guarantees one-hot activation — exactly one citizen active at a time per group — atomically. Two panels can never both think they're active because of a frame-order race. egui_dock makes no such guarantee; you'd have to wire it manually.

This separation — dock library handles geometry, citizen dispatcher handles interest — is the load-bearing distinction. Without it, every app reinvents some ad-hoc "which panel did the user mean?" logic. With it, that's framework infrastructure you inherit for free.

Citizens as plug-ins

The other consequence of the citizen contract is that citizens become plug-ins. Once a panel implements the Citizen trait, exposes its reactive state, and integrates with the dispatcher, it doesn't need to know anything about the host app to drop in. The host app, conversely, just needs to:

  1. Add the citizen's crate as a dependency.
  2. Carry its Dynamic<T> state field on the shared state struct.
  3. Add a TabKind variant for it.
  4. Render it from the TabViewer.

That's the whole integration. No glue code, no event-bus wiring, no manual subscription setup. The citizen pulls its weight as a self-contained unit. And the Dispatcher is the registry those plug-ins register with — the same registry pattern familiar from backend systems, applied here to UI panels.

egui_lens (the reactive event logger) and egui_quill (the syntax-highlighted editor) are the canonical examples of this in action. Both ship as their own workspace crates with stable public APIs (ReactiveEventLogger + ReactiveEventLoggerState; ReactiveEditor + ReactiveEditorState). The host app's integration is small enough to fit on a sticky note:

// One field on shared state.
pub log: Dynamic<ReactiveEventLoggerState>,

// One render call per frame.
let logger = ReactiveEventLogger::new(&state.log);
logger.show(ui);

Same pattern for quill, same pattern for the canonical citizen panels coming next — Project, Settings, Terminal, Data Table. Same pattern for any third-party citizen someone publishes on crates.io: cargo add egui_their_panel, declare the state field, render in a tab, done.

This is what makes the framework genuinely composable rather than just architecturally tidy. Real apps grow by accumulating citizens, not by extending their core.

Caveat: citizens are compile-time plug-ins — adding one rebuilds the host app, not a runtime extension load. The Rust toolchain doesn't ship a stable plug-in ABI; runtime loading would require dynamic library tricks that aren't worth the complexity. The four-line integration is small enough that it feels plug-in-shaped in practice, and the rebuild is fast.

What it is not

A citizen is not:

  • A modal dialog or a popover. Those have transient lifetimes and no stable identity.
  • A widget. Widgets are atoms; they live inside citizens.
  • A non-docked sub-region of a single window. The dockability is intrinsic; without it, a panel is just a layout container.
  • A backend thread. Background work runs separately and communicates with citizens through reactive state and message channels.

A picture

┌───────────────────────────────────────────────────────────────┐
│ App window                                                     │
│ ┌──────────────┬──────────────────────┬──────────────────────┐ │
│ │ ▶ Settings ✕ │ ▶ Plot ✕             │ ▶ Logger ✕           │ │
│ ├──────────────┼──────────────────────┼──────────────────────┤ │
│ │              │                      │                      │ │
│ │  citizen     │     citizen          │      citizen         │ │
│ │  "settings"  │     "plot"           │      "logger"        │ │
│ │              │                      │                      │ │
│ │  atoms:      │     atoms:           │      atoms:          │ │
│ │  - slider    │     - plot widget    │      - filter btn    │ │
│ │  - combobox  │     - link checkbox  │      - clear btn     │ │
│ │  - generate  │                      │      - save btn      │ │
│ │    button    │                      │      - column toggles│ │
│ │              │                      │      - scroll list   │ │
│ └──────────────┴──────────────────────┴──────────────────────┘ │
└───────────────────────────────────────────────────────────────┘

Three citizens. Each is dockable / movable / resizable. Each holds atoms that are the user's actual interaction surface. Reactive state flows through the framework's plumbing so the plot panel can react to a settings-panel atom, and the logger can show a backend-thread message, without any of them needing direct references to the others.

Where the rest of the book goes from here

  • The Citizen trait chapter is the code shape: what impl Citizen for MyPanel looks like, the lifecycle hooks, the CitizenId and CitizenState types.
  • The Dispatcher chapter is the coordinator: how activation propagates and how messages drain.
  • The Dynamic<T> background chapter is the reactive primitive every CitizenState field rests on.
  • The tutorial is the worked example — three citizens (Plot / Settings / Logger), built end to end.

Chapter last revised: 2026-05-04 — egui_mobius v0.4.0.

Key vocabulary

Three terms appear throughout this book. Fix them in your head now — every chapter that follows leans on these:

  • Dynamic<T> — the reactive primitive that citizen-panels and atoms both sit on top of. A thread-safe, observable cell that any number of handles can point at. Writes through any handle are visible through every other handle. Covered in depth below. There is also a correspondent Derived<T> from egui_mobius_reactive that can automatically produce side effects.
  • 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 (Path A), panel-to-backend messaging (Path B), or both at once.

Coupling paths

Two named mechanisms for moving information between citizen-panels. Encountered throughout the concepts chapters and fully treated in coupling; listed here so the names land before the first chapter that uses them.

  • Path A — shared Dynamic<T>. Two panels hold clones of the same cell; one writes, the other reads on the next frame. Instant, in-frame, no queue. Carries state, not events. The default for panel-to-panel coordination.
  • Path B — dispatcher messages. A panel calls dispatcher.send(...); the app's update loop drains the queue once per frame and forwards each message onward to a backend thread or logger. Queued, lands next drain. Carries events, not state. Use when the change needs to leave the UI thread.

The Dynamic<T> primitive

Before going further, know the one building block that everything reactive in egui_citizen rests on:

Every reactive field in egui_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.

ValueExt and Derived<T>

egui_citizen itself only reaches for Dynamic<T>, but the reactive crate ships two related building blocks that you'll want when an app outgrows pure Dynamic storage. They both ride on the same notifier infrastructure described above (the shared Vec<Sender<()>>), so understanding them is mostly a matter of seeing what each one wraps.

ValueExt — the on_change extension trait

ValueExt<T> is a tiny extension trait whose only method is on_change:

pub trait ValueExt<T: Clone + Send + Sync + 'static> {
    fn on_change<F>(&self, callback: F) -> Arc<F>
    where
        F: Fn() + Send + Sync + 'static;
}

It's implemented for Dynamic<T> (when T: PartialEq) and is the public surface for callback-style subscription. Importing it brings .on_change(...) into scope:

use egui_mobius_reactive::{Dynamic, ValueExt};

let counter = Dynamic::new(0);
counter.on_change(|| println!("changed"));

Without ValueExt in scope, calling .on_change on a Dynamic won't compile — that's the whole reason the trait exists. Every example earlier in this chapter that uses on_change is implicitly relying on ValueExt being imported.

The cost of each on_change registration — one OS thread per subscriber, no unsubscribe — is detailed in the Inside Dynamic<T> chapter. Within egui_citizen itself, panel-side .get() polling is the canonical path for UI reactivity; on_change is for off-thread reactions (file logging, network sends, anything outside the egui frame loop).

Derived<T> — auto-recomputed values

A Derived<T> is a read-only reactive cell whose value is computed from one or more Dynamics (or other Deriveds). It recomputes automatically whenever one of its inputs changes:

use egui_mobius_reactive::{Dynamic, Derived};
use std::sync::Arc;

let count = Dynamic::new(0);
let count_arc = Arc::new(count.clone());
let doubled = Derived::new(&[count_arc.clone()], move || {
    count_arc.get() * 2
});

count.set(5);
// `doubled.get()` now returns 10 — the closure re-ran when count changed.

Two facts make Derived<T> cheap and predictable:

  1. get() is a clone of a cached value, not a recomputation. The closure runs only when an input changes, not every time you read the result. Reading doubled.get() 60 times per frame is just 60 lock-acquire-and-clone operations on a Mutex<T>.

  2. Inputs subscribe to the closure via the same notifier plumbing. Derived::new(deps, compute) calls dep.subscribe(...) on each dependency, which pushes a Sender<()> into the dependency's notifier vec — the same vec that backs ValueExt::on_change. When a dep's set() rings the doorbell, the Derived re-runs its closure and stores the new value in its own cache.

Practically, this means Derived<T> is the right tool when you have a value that must always agree with other reactive state — "the formatted version of current_time," "the filtered subset of logs," "the sum of two Dynamic<i32>s." Use a Derived and the arithmetic stays correct without anyone remembering to call an update function.

Three honest caveats:

  • The closure re-runs once per set() on any dependency. If a panel writes its dependency 100 times during a slider drag, the closure runs 100 times. No coalescing.
  • The closure takes ownership of all captured state, so the dependency you read inside is typically a separate clone bound via the closure (the count_arc in the example above).
  • Cycles aren't detected. Derived A depending on Derived B depending on Derived A will recurse and panic. Don't.

Together

ValueExt::on_change and Derived::new are two consumers of the same primitive. The notifier vec inside Dynamic<T> is just a list of "things to wake when this value changes" — on_change adds one that runs your closure on a worker thread, Derived::new adds one that re-runs the compute closure and caches the result. Same hook, different jobs. This is also the reason both APIs cost an entry in that vec and neither has an unsubscribe — they're pinned for the Dynamic's lifetime.

egui_mobius_reactive also provides Value<T> (an older API with the same shape as Dynamic<T>) and a SignalRegistry for app-wide signal wiring. The book doesn't cover those — the reactive crate's own documentation is the next stop.

Where this leads next

The chapter on reactive lifecycle builds on this foundation and walks through the trap that bites users who construct a CitizenState with CitizenState::default() instead of obtaining one from Dispatcher::register(). The Inside Dynamic<T> chapter opens up the notifier mechanism in detail — read it before writing code that subscribes to a Dynamic<T>.

One of those individual components deserves explicit recognition. egui_dock is the key piece of the egui ecosystem that lets a non-trivial app look and feel finished — multi-panel docking, splittable workspaces, drag-and-drop tab rearrangement, persistent layouts. It plays roughly the same role in the egui world that the Qt Advanced Docking System plays in Qt: without it an app tends to feel like a demo, and with it an app can feel professional.

But egui_dock is, by intent, a layout and interaction primitive — not an organizational framework. It hands you the shell. It does not tell you how the panels living inside that shell should share state, coordinate activation, or reach a backend. Wiring those decisions is left to the application author, which is precisely what this book — and egui_citizen — are about.

Every visible tab's ui() runs every frame

egui_dock is an immediate-mode dock. There is no "active tab" notion baked into its rendering: every tab that is currently visible in the dock layout — meaning its node is rendered, even if other nodes are also visible alongside it — has its ui() callback fire every single frame.

This is by design. egui as a whole is immediate-mode: the entire UI is reconstructed each frame from scratch. egui_dock extends that model to multiple panels in a dock layout. Visibility, not a focus/active flag, drives whether ui() fires.

The implication is small but load-bearing: ui() is a render callback, not an event hook. Anything you do inside it happens once per frame per visible tab, not once per user action.

Writing an egui_citizen app means implementing TabViewer

The bridge between egui_dock and your application is a single trait, egui_dock::TabViewer, that you implement. Its skeleton looks like this:

struct MyTabViewer<'a> {
    app: &'a mut App,                   // your app's shared state
    dispatcher: &'a mut Dispatcher,     // egui_citizen's dispatcher
}

impl egui_dock::TabViewer for MyTabViewer<'_> {
    type Tab = MyTab;                   // your tab type

    fn title(&mut self, tab: &mut Self::Tab) -> egui::WidgetText {
        tab.title.clone().into()
    }

    fn ui(&mut self, ui: &mut egui::Ui, tab: &mut Self::Tab) {
        // RENDERING. Runs every frame, for every visible tab.
        tab.show(ui, self.app);
    }

    fn on_tab_button(
        &mut self,
        tab: &mut Self::Tab,
        response: &egui::Response,
    ) {
        // STATE TRANSITIONS. Fires once when the tab is clicked.
        if response.clicked() {
            self.dispatcher.activate(&tab.citizen_id());
        }
    }
}

That impl is not optional, it is the integration shape of any egui_dock + egui_citizen app. Two of its methods are the topic of this chapter: ui and on_tab_button. Their roles are entirely distinct, and conflating them is the root of the per-frame race.

The wrong-hook trap: state transitions in ui()

Suppose a naive author wants to track which tab is "active." They write this inside ui(), reasoning that this tab is rendering, so mark it active:

fn ui(&mut self, ui: &mut egui::Ui, tab: &mut Self::Tab) {
    self.app.active_tab = tab.kind;   // ← every visible tab does this
    tab.show(ui, self.app);
}

What actually happens, given egui_dock's rendering model:

  • Frame N renders. Tab Plot is visible, Tab Logger is visible.
  • ui() fires for Plot — app.active_tab = Plot.
  • ui() fires for Logger — app.active_tab = Logger.
  • Last write wins. active_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, the way state flows between the dispatcher and the panel, where panel-author state lives across the three structs that any non-trivial app uses, and the runtime story for how the trait actually gets exercised.

The trait

pub trait Citizen {
    fn id(&self) -> &CitizenId;
    fn citizen_state(&self) -> &CitizenState;
    fn citizen_state_mut(&mut self) -> &mut CitizenState;

    // Defaulted hooks (override if you need custom behavior):
    fn on_activate(&mut self)   { self.citizen_state_mut().active.set(true); }
    fn on_deactivate(&mut self) { self.citizen_state_mut().active.set(false); }
    fn on_click(&mut self)      { self.citizen_state_mut().clicked.set(true); }

    // Defaulted readers:
    fn is_active(&self)   -> bool { self.citizen_state().active.get() }
    fn is_selected(&self) -> bool { self.citizen_state().selected.get() }
}

Three required methods. Three defaulted hooks. Two defaulted readers. That is the whole trait.

A working panel

The trait's three required methods (id, citizen_state, citizen_state_mut) are pure plumbing — they hand the trait references back to the fields you store on the struct. They're required because the dispatcher and the defaulted hooks need a uniform way to reach into your panel; that's the entire purpose.

What the trait actually buys you is the rest of the contract: is_active(), is_selected(), and the defaulted on_activate / on_deactivate / on_click hooks. A panel that uses those is the real minimum:

struct PlotPanel {
    citizen_id: CitizenId,
    citizen_state: CitizenState,
    samples: Vec<f32>,
}

impl PlotPanel {
    fn new(state: CitizenState) -> Self {
        Self {
            citizen_id: CitizenId::new("plot"),
            citizen_state: state,
            samples: Vec::new(),
        }
    }

    fn show(&mut self, ui: &mut egui::Ui, dispatcher: &mut Dispatcher) {
        ui.heading("Plot");

        // Read direction: dispatcher → panel.
        // The dispatcher's activate() writes self.citizen_state.active
        // through the shared Arc; we observe it reactively via
        // is_active(). No method call from this side is needed.
        if self.is_active() {
            ui.label("(active — drawing live)");
            // ... actual plotting against self.samples ...
        } else {
            ui.label("(inactive — paused)");
        }

        // Write direction: panel → dispatcher.
        // The panel can hand control to a sibling citizen explicitly.
        if ui.button("Switch to settings").clicked() {
            dispatcher.activate(&CitizenId::new("settings"));
        }
    }
}

// The trait impl below is required boilerplate — three accessors that
// hand the trait its own data back. There's nothing interesting here.
impl Citizen for PlotPanel {
    fn id(&self) -> &CitizenId               { &self.citizen_id }
    fn citizen_state(&self) -> &CitizenState  { &self.citizen_state }
    fn citizen_state_mut(&mut self) -> &mut CitizenState {
        &mut self.citizen_state
    }
}

is_active() is what makes the panel do something — it's a defaulted method on the trait that reads self.citizen_state().active.get() for you. Without the trait, you'd write that path manually every time you wanted to check activation. The accessor boilerplate is the price; the readers and hooks are what you actually buy.

The two state-flow directions are intentionally asymmetric. Dispatcher → panel happens reactively — the dispatcher writes through the Arc underneath citizen_state.active, and the panel sees the new value the next time is_active() reads it. No method call from the panel side is needed. Panel → dispatcher requires a method-call surface, which is what &mut Dispatcher in the show() signature provides — the panel can call dispatcher.activate(...) to hand control to a sibling, or dispatcher.send(...) to push a custom message. Some apps wrap the dispatcher and shared services in a PanelCtx struct (fn show(&mut self, ui: &mut egui::Ui, ctx: &mut PanelCtx)) to keep the parameter list short; either shape works.

The state argument to PlotPanel::new should always come from Dispatcher::register(), never from CitizenState::new() or CitizenState::default(). The latter allocate fresh disconnected storage and silently sever the reactive link with the dispatcher (see the trap in the state chapter).

Atoms — widget state alongside CitizenState

A citizen-panel almost always carries its own widget state: slider values, combo-box selections, text-input buffers, checkbox flags. The vocabulary chapter calls these atoms. They live on the panel struct alongside citizen_state, not inside it — CitizenState has a fixed library-defined shape and is for lifecycle facts only. Where you place an atom depends on whether anyone outside the panel reads or writes it.

Atoms only the panel itself touches

These are plain (non-reactive) fields. The panel reads them in show(), egui mutates them in place via &mut. Nothing fancy.

#[derive(Debug, Clone, PartialEq)]
enum PlotStyle { Line, Scatter, Bar }

struct PlotPanel {
    citizen_id: CitizenId,
    citizen_state: CitizenState,
    samples: Vec<f32>,
    // Atoms — panel-local widget state:
    sample_rate_hz: f32,
    plot_style: PlotStyle,
    show_grid: bool,
}

impl PlotPanel {
    fn show(&mut self, ui: &mut egui::Ui) {
        ui.heading("Plot");

        ui.add(egui::Slider::new(&mut self.sample_rate_hz, 1.0..=1000.0)
            .text("Sample rate (Hz)"));

        egui::ComboBox::from_label("Style")
            .selected_text(format!("{:?}", self.plot_style))
            .show_ui(ui, |ui| {
                ui.selectable_value(&mut self.plot_style, PlotStyle::Line,    "Line");
                ui.selectable_value(&mut self.plot_style, PlotStyle::Scatter, "Scatter");
                ui.selectable_value(&mut self.plot_style, PlotStyle::Bar,     "Bar");
            });

        ui.checkbox(&mut self.show_grid, "Show grid");

        // ... draw the plot using these values ...
    }
}

Plain f32, plain bool, plain enum. The citizen layer never sees them, doesn't care about them. This is the right shape for "only this panel uses these values."

Atoms another panel or thread reads

When something outside the panel needs the value — another panel mirroring it, a backend thread parameterizing its work, a logger recording every change — promote the field to a Dynamic<T> so it can be cloned and shared:

use egui_mobius_reactive::Dynamic;

struct PlotPanel {
    citizen_id: CitizenId,
    citizen_state: CitizenState,
    samples: Vec<f32>,
    // Reactive atom — other panels / threads can hold a clone:
    sample_rate_hz: Dynamic<f32>,
}

impl PlotPanel {
    fn show(&mut self, ui: &mut egui::Ui) {
        let mut local = self.sample_rate_hz.get();
        if ui
            .add(egui::Slider::new(&mut local, 1.0..=1000.0).text("Sample rate (Hz)"))
            .changed()
        {
            self.sample_rate_hz.set(local);
        }
        // ... rest of show() ...
    }
}

Dynamic<f32> is the same shape as the fields inside CitizenState — an Arc-backed reactive cell. Cloning it gives another panel or backend thread a handle to the same value (see Inside Dynamic<T> for the mechanics, and Coupling paths for how an atom can fan out to UI-to-UI sharing, UI-to-backend messaging, or both at once).

Don't reach for Dynamic<T> until a second reader exists

Reactivity has a real cost — every Dynamic<T> is an Arc plus a lock plus a notifier list. If only the panel itself reads its slider value, a plain f32 is the right type. Promote to Dynamic<f32> the day a second reader actually appears. Speculative reactivity "in case someone needs this later" is the same kind of mistake as speculative Arc<Mutex<...>> — it pays a cost for an option you may never exercise.

Where does state live?

The atoms section above showed the panel-local choice between a plain field and a Dynamic<T>. Step back, and that's part of a broader three-struct model that any non-trivial citizen-panel app converges on.

The three structs

1. CitizenState — lifecycle facts only

The library type. Six fixed Dynamic<T> fields, reactive by design, shared by Arc. You cannot extend it — it has a fixed contract.

What goes here: questions other panels or the dock ask about this panel's status (active, clicked, selected, moved, location, visible).

What does not go here: business data, widget values, anything outside the six lifecycle facts.

2. PanelState — your panel-local struct

Whatever the panel needs to do its own job that nobody else reads or writes. By convention, give it its own struct named FooPanelState (or just PanelState if scoped inside a panel module):

struct LoggerPanelState {
    log_buffer: Vec<LogEntry>,
    filter_text: String,
    follow_tail: bool,
}

struct LoggerPanel {
    citizen_id: CitizenId,
    citizen_state: CitizenState,    // bucket 1: library lifecycle
    panel_state: LoggerPanelState,  // bucket 2: panel-local data
}

Atoms (slider values, combo-box selections, checkbox flags) live here when only the panel reads them — as plain fields, not Dynamic<T>. Promote to Dynamic<T> only when a second reader shows up.

Why a named struct instead of loose fields on the panel? Three reasons:

  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 — promote to a named struct the moment a third field appears.

3. App-shared state — data many panels touch

Anything two or more panels need to read or mutate. Project config, a database handle, the layer store in a CAD app, a theme. Lives at the app level and gets passed by reference into each panel's show():

struct App {
    services: Arc<SharedServices>,
    dispatcher: Dispatcher,
    logger: LoggerPanel,
    bom: BomPanel,
}

// And inside each panel's show():
self.logger.show(ui, &mut self.dispatcher, &self.services);
self.bom.show(ui, &mut self.dispatcher, &self.services);

Whether the shared bits are themselves reactive (Dynamic<T> inside SharedServices) or guarded by Arc<Mutex<...>> is a separate design choice. The point: shared data lives at the app level, not stuffed inside CitizenState and not duplicated across panel structs.

The rule of thumb

Ask in this order:

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

Trying to extend CitizenState with business fields. It has a fixed shape from the library. Wrap it inside your own panel struct alongside your fields; don't try to extend it.

// WRONG — won't compile, and shouldn't
struct CitizenState {
    active: Dynamic<bool>,
    // ...
    bom_rows: Vec<BomRow>, // no
}

// RIGHT
struct BomPanel {
    citizen_id: CitizenId,
    citizen_state: CitizenState,
    panel_state: BomPanelState,    // bom_rows lives in here
}

Duplicating shared data across panels. If three panels need to read the project config, don't give each one its own copy and try to synchronize. Put it in SharedServices and pass &services into each panel's show().

Putting PanelState fields in Dynamic<T> "in case someone needs them later." Reactivity has real cost (every Dynamic<T> is an Arc plus a lock plus a notifier list). Promote to Dynamic<T> the day a second reader actually appears, not before.

Activation-driven business state

A common variant: "when panel A activates, panel B should switch to a particular view of the shared data." That data still lives in app-shared state — the trigger for the switch is panel A's citizen_state.active, which panel B reads reactively. Lifecycle drives the transition; the data being viewed never moves into CitizenState.

Identities

pub struct CitizenId(pub String);

A CitizenId is a stable string identifier. The same id must be used consistently across:

  • CitizenId::new("plot") when constructing the panel.
  • dispatcher.register(CitizenId::new("plot")) at startup.
  • dispatcher.activate(&CitizenId::new("plot")) when the user clicks the corresponding tab.

If the strings disagree, the dispatcher silently treats them as different citizens — activate("plt") will do nothing visible to a panel registered as "plot", and you'll burn an evening debugging why a click does nothing.

Define ids as constants once and reference them everywhere:

const PLOT_ID:     &str = "plot";
const SETTINGS_ID: &str = "settings";

dispatcher.register(CitizenId::new(PLOT_ID));
dispatcher.register(CitizenId::new(SETTINGS_ID));

dispatcher.activate(&CitizenId::new(PLOT_ID));

This turns a typo into a compile error rather than a silent runtime disconnect.

When to override the hooks

The defaulted hooks just flip the corresponding CitizenState flag. Override them when you need extra behavior alongside the flag flip:

impl Citizen for FetchPanel {
    // ... required methods ...

    fn on_activate(&mut self) {
        self.citizen_state_mut().active.set(true);
        self.start_background_fetch();         // app-specific
    }

    fn on_deactivate(&mut self) {
        self.citizen_state_mut().active.set(false);
        self.cancel_in_flight_fetch();
    }
}

In practice, most apps do not override the hooks. They let the dispatcher do the flag flip and route side-effect logic through CitizenMessage instead — backend threads receive Activated { id: "fetch" } and start the fetch from there. Override the hooks only when the response is genuinely synchronous and panel-local.

How the trait is used at runtime

The Citizen trait is a contract, not a polymorphism mechanism:

  • The dispatcher does not hold trait objects. It stores 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 a registry of citizens. Each citizen registers a CitizenState handle with it, and the registry coordinates one-hot activation and message buffering across the registered set. If you've built backend systems, this is the same pattern you've seen before — a central table that knows what's plugged in and routes accordingly. The citizens-as-plug-ins framing is the other side of this contract: plug-ins register, the registry coordinates, no party needs direct references to the others.

It is also opt-in infrastructure, not the entry point of an egui_citizen app. Many apps that share state between panels through Dynamic<T> never reach for one. You reach for a dispatcher when the reactive primitives don't already give you what you need:

  • One-hot activation arbitration. Exactly one panel "active" at a time, atomically, across an arbitrary number of citizens. Useful any time focus, selection, or panel priority matters — even in a pure-UI app with no backend.
  • A queue for outbound events. A place to push events that the update loop drains once per frame and forwards onward to backend threads, loggers, persistence, or anywhere else outside the UI tick.

Whichever of those you need, the dispatcher does three jobs:

  1. Owns the registered citizens' CitizenState handles. Every citizen you call register() on has its CitizenState cloned into a HashMap inside the dispatcher. Panels hold the other clone; both refer to the same Arc-backed storage.
  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.

Why the dispatcher, not just shared state?

If panels can share state through Dynamic<T> clones already, what does the dispatcher add?

Three things that don't fall out of shared Dynamic<T> alone:

  • Atomic one-hot activation. activate(&id) flips one citizen's active to true and clears every other registered citizen's flag in a single call. Doing this with shared state alone means wiring each panel to clear every other panel's flag — N² coordination and a new wire every time a panel is added.
  • Lookup by stable id. Panels and backend threads address each other by CitizenId, not by holding pointers to one another's structs. The dispatcher is the directory; backend threads in particular have no other way to find the right reactive cell to write to.
  • Frame-aligned event buffering. activate() and send() push lifecycle events into a queue that drain_messages() consumes once per frame. This is the seam between event-time (a tab was clicked just now) and frame-time (work reacts to it next tick) — backend threads see batched updates rather than per-mutation callbacks.

And three things it does not do, each a common confusion:

  • It is not a runtime gateway. Buffering events is not the same as performing the work those events trigger. The dispatcher does not spawn threads, schedule async tasks, or own JoinHandles. Its queue is the interface to the runtime boundary; the boundary itself is the backend thread your app starts and feeds from drain_messages().
  • It is not a reactive bus. The registry tracks CitizenState (six lifecycle fields) and nothing else. Your slider's Dynamic<f32> is invisible to the dispatcher until you explicitly call send(). See the coupling chapter for why that asymmetry is deliberate.
  • It does not observe dock layout or fire UI events on its own. A tab click triggers activate(...) because your on_tab_button calls it; the dispatcher never spies on the dock and fires activations for you. Give it an event, it routes; don't give it an event, it sits idle.

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.

Cancellation rides on the lifecycle queue

Long-running async work raises a third question alongside intent and outcome: what cancels in-flight work when the user moves on? The answer is already in the queue — it's the Deactivated { id } message that Dispatcher::activate() produces automatically whenever the previously-active panel loses its slot.

The pattern, drawn from the same forwarding loop above:

// Backend thread, consuming the channel:
for msg in rx {
    match msg {
        CitizenMessage::Activated   { id } if id.0 == "fetch" => start_fetch(),
        CitizenMessage::Deactivated { id } if id.0 == "fetch" => cancel_in_flight(),
        _ => {}
    }
}

The dispatcher does not return a work handle from send(). It cannot — it doesn't own any work. The backend thread that does own the work also owns its own in-flight state (a JoinHandle, a CancellationToken, an AbortHandle, whatever the runtime provides) and reacts to the deactivation message by aborting that work itself.

For synchronous, panel-local cleanup that runs on the UI thread, override Citizen::on_deactivate alongside the flag flip:

fn on_deactivate(&mut self) {
    self.citizen_state_mut().active.set(false);
    self.flush_local_buffers();   // synchronous, on the UI thread
}

For asynchronous cancellation — anything that involves stopping a thread, aborting a task, or interrupting IO — route it through the message queue, never through the override. The override runs on the UI thread and must not block.

The long-running work pattern, end to end

Putting intent, outcome, and cancellation together yields a predictable shape for any unit of async work in a citizen app:

  1. Intent message — a user-initiated event lands in the dispatcher queue (DrcRunRequested, ProjectOpenRequested) and gets drained into the backend channel.
  2. Backend dispatch — the backend thread spawns the work and remembers its in-flight handle (JoinHandle, AbortHandle, etc.) keyed by the originating panel's CitizenId.
  3. Cancellation lifecycle — if the user activates a different panel, Deactivated { id } arrives on the same channel; the backend thread aborts the remembered handle for that id.
  4. Outcome message — when work completes (or is cancelled), the backend pushes an outcome message back to the UI thread through a return channel, drained into the next frame's update().

Every step is a message. Every message flows through the same queue (or a paired return channel). The UI thread holds no futures, no join handles, no cancellation tokens — only Dynamic<T> cells that observe the results when outcome messages land.

This is the Elm discipline applied to a Rust GUI: a temporal sequence of events, fully inspectable, never a tangled call graph. The app can be paused, logged, and replayed by recording the message stream alone.

Sub-domain nesting

HotkeyPressed(Hotkey) is a single top-level variant that wraps a separate Hotkey enum. The flat alternative — HotkeyFlipPressed, HotkeyRotatePressed, HotkeyToggleUnitsPressed, … — bloats the top-level enum and forces hotkey-handling code to match on a sprawling pattern instead of a focused sub-enum.

Use sub-domain nesting whenever a domain has its own internal vocabulary that is likely to grow. Keyboard hotkeys, view controls, file operations, background-task progress reports, vendor-specific release packaging — all of these are good candidates for their own sub-enum nested under one outer variant.

Putting it together: the drain loop

The dispatcher's queue still carries only CitizenMessage. The app wraps each citizen message as it drains, and non-citizen variants are produced by app code emitting them directly through the same backend channel:

fn update(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) {
    DockArea::new(&mut self.tabs).show(ctx, &mut self.tab_viewer);

    // Drain citizen messages, wrap as AppMessage, forward.
    for msg in self.dispatcher.drain_messages() {
        let app_msg = AppMessage::Citizen(msg);
        self.event_log.push(app_msg.clone());
        let _ = self.tx_backend.send(app_msg);
    }

    // App code emits its own non-citizen variants through the same
    // channel — e.g. when a worker thread reports DRC results back:
    if let Some(result) = self.drc_worker.try_recv() {
        let _ = self.tx_backend.send(
            AppMessage::DrcCompleted { violation_count: result.len() },
        );
    }
}

One queue, many message families, one drain pass per frame. CopperForge runs this exact shape across twelve dockable panels.

What CitizenMessage is not

It is not a general-purpose event bus. The dispatcher does not provide subscriptions, filtering, prioritization, or replay. If you need those, build them on top — typically in your AppMessage layer or as a separate logger / event-store.

It is not — emphatically — a replacement for shared Dynamic<T> state. UI-to-UI coupling should still go through Path A (shared Dynamic<T>). Messages are reserved for genuine event signals (things happened) rather than continuous state updates (things are). A panel that needs to mirror another panel's slider value should clone the slider's Dynamic<f32> and read it; it should not subscribe to a SliderChanged message stream.

Summary

  • Six variants, all carrying at least a CitizenId.
  • 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.

What citizen is (and is not)

By this point you have met the Citizen trait, the Dispatcher, and CitizenMessage, the backend bridge. This chapter steps back from the parts and asks what the pattern is — by contrasting it with the architecture people most often assume it to be.

It is not Elm

Citizen has Elm-like aspects, and the comparison comes up often enough that it is worth being precise about where the two diverge.

The Elm architecture's update function is total: every message in the entire application funnels through one update. That totality is exactly what makes Elm predictable, and exactly what makes it rigid. There are no optional edges — every message is mandatory, through the one loop.

Citizen takes the opposite stance, and it is best stated as a single phrase: total observation, partial routing.

  • Total observation. The Dispatcher always tracks the state of every registered citizen. Nothing about a citizen's lifecycle is invisible to it.
  • Partial routing. Data is dispatched to the backend only when an atom requests it. And atoms in any citizen may share data directly with atoms in any other citizen, without the backend being involved at all.

That split is the actual invention. It is not Elm — Elm has no optional edges. It is also not a plain pub/sub bus — a bus forwards and forgets; it has no central state model. What citizen has is a stateful switchboard.

The topology that results is a graph, not a tree: atoms and citizens are nodes, dispatch connections are edges, and the Dispatcher is the layer that makes the graph observable even though it does not make the graph constrained. This is why the neural-network framing fits — it is a graph of nodes with signal propagation, not a hierarchy.

The invariant that keeps it sound

A graph topology where any atom can talk to any atom is powerful, and it is also the classic recipe for spaghetti. The very property that makes Elm restrictive on purpose is the property citizen gives up.

What saves citizen from that fate is the other half of the split. Communication is graph-shaped, but state stays centrally legible, because the Dispatcher observes everything.

That yields one invariant, and it must be protected permanently:

No atom shares data in a way the Dispatcher cannot see.

The moment a back-channel is introduced — "for performance," "just this once" — the central legibility is gone. A graph topology without central observability is genuinely harder to reason about than an Elm tree. The flexibility of citizen is only safe because of the discipline of total observation. The pattern and the rule are inseparable: keep every data path visible to the dispatcher, or you do not have the citizen pattern any more.

The panel-oriented middle ground

Citizen is a bet on a particular granularity of UI composition: the panel — larger than a component, smaller than an application. There is prior art that both validates the bet and warns about its failure mode.

Eclipse RCP (Rich Client Platform) made exactly this bet: the panel/view as the unit of a professional tool, a workbench that gives panels identity, a contribution model for wiring them together. The entire Eclipse IDE, and a generation of EDA, CAD, and finance tools, are RCP-shaped. That is the proof the granularity is right. The warning is also from RCP: it became a byword for heavyweight — XML extension points, lifecycle ceremony, sluggishness. Citizen's differentiator has to remain that it is lightweight and reactive, not declarative-config driven.

Qt's dock-widgets plus signals/slots is the other close relative. Citizen's atom-to-atom sharing is signals/slots — but value-typed and observed, rather than callback-typed and invisible.

So the honest peer set for citizen is Eclipse RCP, the VS Code contribution model, and Qt Creator's plugin architecture. It is not dioxus, leptos, or iced — those are component- or page-oriented and web-derived, solving a different problem at a different granularity. The claim that the panel-oriented middle ground is where the most value lies is really a claim that professional tools are built from panels, not pages or screens. The roster of tools above bears it out.

The pattern eats itself

The strongest sign that the abstraction is real is that the panel builder is recursive.

The egui_grafica canvas — nodes with ports, edges between ports, a registry as the backend model — is structurally identical to "citizens with atoms, dispatch connections between atoms, a Dispatcher as the model." A RAD tool where you drag citizens into a dock layout and draw atom-to-atom wires would, structurally, be egui_grafica pointed at its own framework. The canvas built for diagrams is the panel builder's canvas.

That is not a coincidence. Real abstractions tend to eat themselves like this — the same graph showing up at two scales (the fine-grained reactive value graph, and the coarse-grained citizen/dispatcher graph) is evidence the abstraction describes something true rather than something arbitrary.

Summary

AspectElmPub/sub busCitizen
State modelcentral, totalnonecentral, total
Message routingtotal (mandatory)partial (fire-and-forget)partial (optional)
Topologytree / loopgraphgraph
Observabilityinherentabsentinherent (via Dispatcher)
Failure moderigidityunobservable spaghettispaghetti if the invariant breaks

Citizen keeps Elm's observability without Elm's rigidity, by separating observing state from routing data. The price of that flexibility is a single non-negotiable rule: every data path is visible to the Dispatcher.

Coupling

Panel-to-panel coupling is what shared Dynamic<T> already gives you: one panel writes, another reads, egui redraws on the next frame. Most apps need nothing more than that to get state from one panel to another. This chapter calls that Path A, and it is the default.

There is also an opt-in second path — dispatcher.send() — for the specific case where a state change needs to leave the UI thread or land on a queue: a backend thread, an off-thread logger, a persistence sink, anything that wants events rather than just the current value. That's Path B. Most of this chapter exists to keep its trade-offs straight from Path A's, because conflating the two is where designs go sideways.

A single widget — what we'll call an atom (a widget inside a citizen panel) — can use either path or both at once.

PathMechanismGood forTiming
AShared Dynamic<T>Panel ↔ panel (default)Immediate
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, opt-in)

The settings panel explicitly enqueues a message; the app's update loop drains it after DockArea::show() and forwards it onward — to a backend thread, a logger sink, a persistence layer.

// In settings_panel.rs, when the slider changes:
dispatcher.send(AppMessage::SliderChanged(local));

// In App::update(), once per frame:
for msg in dispatcher.drain_messages() {
    match msg {
        AppMessage::SliderChanged(v) => tx_backend.send(v).unwrap(),
        // ...
    }
}

Path B carries events, not state. Each .send() enqueues one record; drain_messages() consumes the queue. Nothing is "shared" — the message is a value, not a handle.

Aside on Dynamic::on_change. egui_mobius_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.

Citizens as a propagation graph

Step back from the mechanism for a moment. What shape does an egui_mobius application actually have at runtime?

It's a graph. Citizens are the nodes; Dynamic<T> cells are the edges; the dispatcher is a registry that knows about every node but does not itself carry data between them.

Citizens as a propagation graph — three citizen nodes linked by Dynamic cells, with Derived auto-recomputing forward, the Dispatcher as a registry, and a backend thread connected through Path B.

Three propagation modes ride this graph:

  • Adjacent. A citizen writes Dynamic<T>, an adjacent citizen reads it on the next frame. Sync, in-frame, no queue. This is Path A from the previous section. Two panels sharing a slider value, a selection set, a cursor position — that's adjacent propagation. Topology is whatever clones share the same Arc-backed cell; siblings, cousins, however far apart in the panel tree, all see the same value.

  • Forward. A Derived<T> cell wraps a Dynamic<T> and recomputes automatically when the input changes. The new value flows downstream to whoever holds a clone of the Derived. This is the chain in the graph: input cell → derived cell → readers. Useful when one citizen owns the source-of-truth state and other citizens want a transformation of it without each computing the same transform locally.

  • Outbound. A citizen calls dispatcher.send(); a backend thread reads the queue. Async, queued, next-drain. This is Path B. The graph extends past the UI thread out to whatever does the heavy lifting — IO, compute, network — and the backend's responses come back through the same queue.

The combination matters. Most reactive frameworks bind state to a component lifetime: state lives inside the widget tree, and sharing across siblings means lifting up or threading context. The egui_mobius model inverts that. Dynamic<T> cells are free-standing reactive nodes anyone can hold a clone of — including backend threads that don't have an egui context at all. The widget tree borrows the cells; it doesn't own them. That's why the same primitive that wires Settings to Plotter also wires Plotter to a Tokio task.

The neural-network analogy is approximate but useful. A citizen graph propagates values to adjacent neighbours through cells and forward through derived chains. The dispatcher is more like a directory than a layer; it doesn't compute, it doesn't transform, it just knows who's plugged in and routes lifecycle and outbound events. The actual data movement happens through the cells.

Summary

  • Two coupling paths. Path A for UI-to-UI state sharing (shared Dynamic<T>). Path B for UI-to-backend events (dispatcher.send() + drain_messages()).
  • Atoms can wire to one path or both. Fan-out happens at the write site, not downstream.
  • Dual-wired atoms require a source-of-truth discipline: 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 background chapter on Dynamic<T> covers what the type looks like from outside: a thread-safe cell with get, set, lock, and on_change (via ValueExt). This chapter opens the box.

You don't need this material to use egui_citizen — Path A (shared-state polling, see Coupling paths) only needs the high-level API. But you do need it the moment you reach for on_change, profile reactive overhead, or wonder why two seemingly identical-looking subscriptions behave differently. Read this chapter before you write code that subscribes to a Dynamic<T>.

The struct, peeled outside-in

pub struct Dynamic<T> {
    inner: Arc<Mutex<T>>,
    notifiers: Arc<parking_lot::Mutex<Vec<Sender<()>>>>,
}

Two Arcs. The first holds the value; the second holds the notification machinery. The intro already covered the value side. Here we open the second one.

Arc<...>

Same role as on inner: every clone of a Dynamic<T> must refer to the same notifier list. If clone A registers a subscriber via on_change and clone B calls .set(...), B's set must wake A's subscriber — otherwise the whole "shared reactive cell" story collapses. Cloning the outer Dynamic<T> clones this Arc, so all clones share one list.

parking_lot::Mutex<...>

Note this is not std::sync::Mutex. The value side uses std::sync::Mutex<T>; the notifier side uses parking_lot::Mutex. Two reasons that matter in practice:

  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.

egui_lens — the reactive event logger

egui_lens is a citizen. A docked, movable, resizable panel with stable identity ("logger") and a known set of atoms inside — System Info, Filters, Logger Colors, Save Logs, Clear Logs, the column-toggle checkboxes, the scrollable log area itself. Like every other citizen panel, it observes shared reactive state through Dynamic<T> and participates in dispatcher-coordinated activation.

If "citizen" doesn't ring a bell yet, read What is a citizen? first — that chapter walks through the panel-level characteristics every citizen has, which egui_lens is the canonical example of.

What it does

egui_lens is the canonical event logger for the egui_mobius ecosystem. It provides a terminal-style log panel — log levels, per-type colors, filtering, file export — built on Dynamic<T> so log entries flow reactively between writers (any panel, any backend thread) and the panel that displays them.

As of v0.4.0, lens lives in crates/egui_lens/ inside the egui_mobius workspace. It supersedes the older egui_mobius_components::event_logger, which was built on the signal/slot architecture and is now deprecated.

Implementation note: the lens crate currently ships as a widget that consuming apps wrap in their own panel struct. The wrapper is small — a few lines — but it's an extra step that shouldn't be needed; lens is a citizen. A LoggerCitizen that implements the trait directly is on the roadmap, at which point the wrapper goes away. Until then, the consuming-app pattern in this chapter shows the wrapper.

The shape

Two types matter: state and view.

  • ReactiveEventLoggerState is the data — a Dynamic-wrapped buffer of log entries, plus filter and pagination metadata.
  • ReactiveEventLogger<'a> is the view — a per-frame widget borrowing references to the state. You construct it inside ui(), push entries via .log_info(...) / .log_warning(...) / etc., and render via .show(ui).
use egui_lens::{ReactiveEventLogger, ReactiveEventLoggerState};
use egui_mobius_reactive::Dynamic;

let logger_state = Dynamic::new(ReactiveEventLoggerState::new());

// Anywhere — UI thread, backend thread, lifecycle hook:
let logger = ReactiveEventLogger::new(&logger_state);
logger.log_info("Hello, world");
logger.log_warning("Slider out of range");
logger.log_custom("network", "Connected to 192.168.1.5");

// In a panel's `ui()`:
logger.show(ui);

Dynamic::clone() is cheap (Arc refcount bump), so you hand clones of logger_state to any thread that needs to write logs:

let state = logger_state.clone();
std::thread::spawn(move || {
    let logger = ReactiveEventLogger::new(&state);
    for i in 0..5 {
        logger.log_info(&format!("Background log #{i}"));
    }
});

Custom log types and colors

Beyond the standard levels (info, warning, error, debug), lens supports custom typed logs identified by string tags:

let log_colors = Dynamic::new(LogColors::default());
let mut colors = log_colors.get();
colors.set_custom_color("network", egui::Color32::from_rgb(100, 149, 237));
colors.set_custom_color("database", egui::Color32::from_rgb(106, 90, 205));
log_colors.set(colors);

// Use `with_colors` constructor to attach the color theme:
let logger = ReactiveEventLogger::with_colors(&logger_state, &log_colors);
logger.log_custom("network", "Client connected from 192.168.1.5");
logger.log_custom("database", "Inserted 5 records in 18ms");

Each custom type renders in its configured color. You can also set distinct colors for the level prefix vs the message body via set_custom_colors(level_color, message_color).

Wiring lens into a citizen panel

ReactiveEventLogger is a widget, not a citizen. To use it inside a docked citizen app, wrap it in a Citizen-impl panel struct that delegates rendering to the logger:

use egui_citizen::{Citizen, CitizenId, CitizenState};
use egui_lens::{ReactiveEventLogger, ReactiveEventLoggerState};

struct LoggerPanel {
    citizen_id: CitizenId,
    citizen_state: CitizenState,
    logger_state: Dynamic<ReactiveEventLoggerState>,
}

impl Citizen for LoggerPanel {
    fn id(&self) -> &CitizenId { &self.citizen_id }
    fn citizen_state(&self) -> &CitizenState { &self.citizen_state }
    fn citizen_state_mut(&mut self) -> &mut CitizenState { &mut self.citizen_state }
}

impl LoggerPanel {
    fn show(&self, ui: &mut egui::Ui) {
        let logger = ReactiveEventLogger::new(&self.logger_state);
        logger.show(ui);
    }
}

The citizen lifecycle (activation, click, deactivation) is unchanged — lens is just the rendering inside the panel.

See also

  • examples/logger_component — full working example (port of lens's basic_custom) demonstrating custom log types, colors, system info logging, and the with_colors constructor.
  • egui_mobius_components — the predecessor logger built on signal/slot. Deprecated as of v0.4.0; #[deprecated] attributes surface migration warnings on use.

Chapter last revised: 2026-05-03 — egui_mobius v0.4.0.

egui_quill — the syntax-highlighted editor

egui_quill is a citizen. A docked, movable, resizable panel with stable identity ("editor") and a known set of atoms inside — the editable monospace text area, the language picker, the theme picker. Like every other citizen panel, it observes shared reactive state through Dynamic<T> and participates in dispatcher-coordinated activation.

If "citizen" doesn't ring a bell yet, read What is a citizen? first.

What it does

egui_quill is the canonical text editor for the egui_mobius ecosystem. It provides a syntax-highlighted, monospace editing panel with language and theme pickers as in-panel controls. The buffer lives in a Dynamic<ReactiveEditorState>, so every keystroke is observable from any other panel or backend thread.

Out of the box: Rust, JSON, YAML, Python, JavaScript, Markdown, Plain Text. Themes from syntect's default set (base16-ocean.dark, Solarized, etc.).

Quill lives in crates/egui_quill/ as a sibling of egui_lens. It launched in egui_mobius v0.4.0 alongside the broader canonical-citizen-panels initiative.

Implementation note: like lens, quill currently ships as a widget that consuming apps wrap in a thin panel struct. Once a sibling EditorCitizen lands — parallel to lens's path — the wrapper goes away and the dock layout uses the citizen view directly.

The shape

Two types matter: state and view.

  • ReactiveEditorState is the data — text buffer, active language name, active theme name. Held in Dynamic<ReactiveEditorState> for cross-panel observability.
  • ReactiveEditor<'a> is the view — a per-frame widget borrowing the state. Construct inside ui(), render via .show(ui).
use egui_mobius_reactive::Dynamic;
use egui_quill::{ReactiveEditor, ReactiveEditorState};

// At app construction
let editor_state = Dynamic::new(
    ReactiveEditorState::new()
        .with_content("// hello, world\nfn main() {}\n")
        .with_language("Rust")
        .with_theme("base16-ocean.dark"),
);

// Per frame inside `ui()`
let editor = ReactiveEditor::new(&editor_state);
editor.show(ui);

That's the entire integration. Other panels and backend threads see edits by reading editor_state.get().content — same reactive pattern as lens, no callbacks, no wiring.

The atoms

Quill's atoms are the user-visible widgets inside the panel:

AtomTypeWhat it controls
Language pickerComboBoxActive syntax (Rust, JSON, YAML, etc.)
Theme pickerComboBoxColor theme (base16, Solarized, …)
Text areaTextEdit::multilineThe buffer content; emits edits to editor_state

Each atom corresponds to a field on ReactiveEditorState. Setting the field via the picker is reactive: a backend thread observing editor_state sees the language change immediately and could, for example, trigger a parser run.

Performance

Two layers of caching keep frame cost small:

  1. SyntaxSet and ThemeSet are thread_local — loaded once per thread, shared across all editor instances. Cold-load is ~50 ms; warm reads are zero-cost.
  2. The LayoutJob is cached keyed on (content_hash, language, theme). Egui calls the layouter every frame; with no edits and no picker changes, the cache returns the prior LayoutJob directly — no re-highlight cost.

For the typical editor session (occasional keystrokes, mostly read-only) the per-frame cost is dominated by egui's normal text layout, not syntax highlighting.

WASM

Quill is fully WASM-compatible. The syntect crate is configured with regex-fancy (pure Rust) instead of regex-onig (C bindings), so the crate compiles for wasm32-unknown-unknown. Bundle size impact in release wasm with wasm_opt = "z" is ~700KB additional for the syntect grammars and themes.

The filter_plotter example demonstrates this — its WASM build includes a working quill panel as one of its dock tabs, and the Pages-deployed demo is a click-and-edit reactive editor in the browser.

Custom languages — beyond the defaults

Out of the box, quill exposes whatever languages syntect's default syntax set ships. For domain-specific languages — including HDL (Verilog / SystemVerilog / VHDL) for engineering apps — the roadmap is hand-rolled parser crates following the lexer / parser / ast / error / highlight shape used elsewhere in the saturn77 ecosystem (see RustQt/venerate/svx_parser/ and simcore/simcore-lang/).

Each parser crate exposes a Highlighter trait impl that quill consumes; ReactiveEditorState carries an Option<Arc<dyn Highlighter>> to enable language-specific highlighting beyond the defaults.

Wiring quill into a citizen panel (current pattern)

Until EditorCitizen lands, the consuming app provides a thin wrapper panel struct:

use egui_quill::ReactiveEditor;
use crate::state::SharedState;

pub struct EditorPanel {}

impl EditorPanel {
    pub fn new() -> Self { Self {} }

    pub fn show(&mut self, ui: &mut egui::Ui, state: &SharedState) {
        let editor = ReactiveEditor::new(&state.editor);
        editor.show(ui);
    }
}

SharedState carries the Dynamic<ReactiveEditorState> field; the egui_dock TabViewer calls panel.show(ui, state) for the editor tab.

See also

  • examples/filter_plotter — full working example. Quill is the fourth dock tab grouped with Plot. Default content is the example's own backend/iir.rs so the editor renders real Rust syntax highlighting on first paint.
  • egui_lens — sibling citizen for logging. Same state/view shape; quill's API was designed to mirror lens for consistency.

Chapter last revised: 2026-05-04 — egui_mobius v0.4.0.

egui_3d_viewer — the 3D viewer citizen

egui_3d_viewer is a citizen. A docked, movable, resizable panel with stable identity, with hand-rolled OpenGL rendering through egui_glow's PaintCallback. Atoms include the grid / axes toggles, the measure tool, and the standard creature comforts — orbit, zoom, zoom-to-region, double-click reset.

If "citizen" doesn't ring a bell yet, read What is a citizen? first.

What it does

egui_3d_viewer is the canonical 3D viewport for egui_mobius applications. It renders consumer-supplied triangle and line meshes plus a default XYZ axes gizmo and ground grid, with mouse-driven orbit / zoom / pan and a measure tool on the Z=0 plane.

The crate sits at crates/egui_3d_viewer/ as a sibling of egui_lens and egui_quill. It launched in egui_mobius v0.4.0 extracted from CopperForge's render3d module. Backend is glow, the same low-level GL binding the rest of the eframe + egui_glow stack uses; the crate is wasm-portable through WebGL2.

The shape — divergent from lens / quill

Lens and quill follow a (state, view) split: state in a Dynamic<T>, a per-frame view that borrows the state. The 3D viewer can't follow that pattern cleanly. Persistent GL handles, the orbit camera, and in-flight drag state all belong on a struct that lives across frames, none of which fit cleanly inside a reactive cell.

So ViewerCitizen owns everything:

  • ReactiveViewerState — atom UI state: show_grid, show_axes, measure_active, background_color, plus a reset_view_requested command flag. Held in Dynamic<ReactiveViewerState> for cross-panel observability.
  • ViewerCitizen — the citizen struct itself. Carries the reactive state cell, the Camera, lazily-initialised GpuResources, in-flight drag state, and the CitizenState handle. Its show(ui, gl) method is the per-frame render call.
use egui_3d_viewer::ViewerCitizen;
use egui_citizen::{CitizenId, Dispatcher};

// At app construction
let mut dispatcher = Dispatcher::new();
let viewer_state = dispatcher.register(CitizenId::new("viewer"));
let mut viewer = ViewerCitizen::new("viewer", viewer_state);

// Per frame inside `ui()` — pass the glow context from eframe::Frame
viewer.show(ui, frame.gl());

That's the integration. Other panels read atom state via viewer.state().get().show_grid and similar; the viewer reads its own state each frame and acts on the toggles.

The atoms

The viewer's atoms — the user-facing controls inside the panel:

AtomTriggerWhat it does
OrbitLeft-drag on canvasYaw + pitch the camera around the scene
ZoomScroll wheel (canvas-hovered)Multiplicative camera distance
Zoom-to-regionRight-drag, releaseFrame the dragged box; un-projects to Z=0
Reset viewDouble-clickSnap back to the default tilted top-down
Toggle gridG key (canvas-hovered)Flip state.show_grid
MeasureM key (canvas-hovered)Flip state.measure_active; left-drag draws a Z=0 distance line
Toggle axesSet state.show_axesHide / show the axes gizmo + screen labels

Hover-gating on G and M matters — typing those keys in another panel must not flip the viewer's settings under the user. The zoom-to-region overlay and the measure-tool line are painted with egui's 2D painter on top of the GL pass so they stay visible regardless of camera angle.

Scene injection

The default scene is the axes gizmo + a ground grid; consumer apps push their own meshes through:

  • viewer.set_scene_triangles(verts) — flat xyz rgb buffer, six floats per vertex, drawn with the TRIANGLES primitive.
  • viewer.set_scene_lines(verts) — same stride, drawn as LINES. Useful for wireframe overlays or vector-style content.
  • viewer.clear_scene() — drop both back to the empty default.

Uploads are deferred to the next show() call — a glow context is only available there. The buffer format is intentionally minimal: the citizen knows nothing about the consumer's domain. Build your scene however makes sense — CSG with csgrs, gerber polygon extrusion, hand-built meshes — then convert to the float-buffer shape and hand it over.

let plate = build_plate_with_holes();         // your scene
let verts = mesh_to_xyz_rgb(&plate, color);   // your conversion
viewer.set_scene_triangles(verts);
viewer.set_axes_length(scene_max_dim * 0.15);
viewer.camera_mut().fit_to_bbox(scene_w, scene_h);

WASM

The viewer is wasm-portable through WebGL2. egui_glow ships a WebGL2 backend out of the box, and the underlying renderer code uses only OpenGL 3.3 / WebGL2-safe features — no compute shaders, no extension-gated texture formats. The same crate compiles for wasm32-unknown-unknown with default-features = false on egui_glow.

Performance

GPU resources are lazily initialised on the first frame where a glow context is in scope, then cached on the citizen. The shader program, axes mesh, grid mesh, and the two scene-mesh slots are allocated once. Re-uploads happen only when the consumer calls a set_scene_* method — the pending vec is drained inside show(), not on every frame.

Per-frame cost on an empty default scene is two line-draw calls plus the egui paint pass. Per-frame cost on a populated scene adds one or two more draw calls — depth-tested geometry first, then grid + lines + axes layered on top.

Backend stack — glow now, wgpu later

The viewer is built on hand-rolled OpenGL 3.3 through egui_glow's PaintCallback. The shader/mesh shape — single VAO+VBO meshes with xyz rgb stride, the Arc<Mutex<_>> callback wrapper — comes directly from Tim Schmidt's alumina-interface, which is the reference implementation for this integration pattern.

A future migration to wgpu is on the roadmap once the API is stable. The migration is mechanical: the public surface — ViewerCitizen, ReactiveViewerState, set_scene_* — does not need to change; only the underlying UnlitProgram / ColoredMesh implementations port to wgpu's pipeline / buffer model.

See also

  • examples/viewer3d_csgrs — full working example. Builds a 6"×4"×1/8" PCB mounting plate with six mounting holes via csgrs constructive solid geometry, hands the triangulated mesh to the viewer, demonstrates every creature comfort.
  • egui_lens — sibling citizen for logging.
  • egui_quill — sibling citizen for text editing.
  • alumina-interface — the reference implementation for egui_glow + glow + nalgebra 3D rendering inside an egui app.

Chapter last revised: 2026-05-05 — egui_mobius v0.4.0.

Tutorial: Writing a citizen app

This chapter walks through examples/filter_plotter/ end-to-end — the project layout, every module, and how the citizen pattern wires the pieces together. By the end you'll have written one citizen app, and most of the scaffolding carries over directly to the next one.

The app itself is small but realistic: a 50 Hz sine wave with 200 kHz noise added, run through a Butterworth biquad lowpass filter, plotted with linked-axis subplots (matplotlib-style). Three panels — a stacked input/output plot, a settings panel with sliders, and a scrolling log panel — wired together by the dispatcher.

Run it now

cargo run -p filter_plotter

Click Generate in the Settings panel. The noisy input on top gets cleaned up in the filtered output below. Drop the cutoff slider and click Generate again to see the noise creep back in.

Run it in the browser (WASM)

filter_plotter is also the workspace's reference implementation for browser deployment. One-time setup:

cargo install trunk
rustup target add wasm32-unknown-unknown

Then, from the example directory:

cd examples/filter_plotter
trunk serve --open       # development; opens http://127.0.0.1:8080
trunk build --release    # production; output in ./dist/

The release dist/ directory is a self-contained static site — drop it on any web host. Everything in this tutorial works in the browser identically: the citizen pattern, the dispatcher, the reactive cells, the IIR backend. The only platform-specific code is the #[cfg(target_arch = "wasm32")] entrypoint in main.rs that hands eframe a canvas instead of a native window. See examples/filter_plotter/README.md for the full wasm story.

The reusable scaffolding

Before the code, the punchline of the citizen pattern: most of what you're about to build also fits the next app you build. When you start the next citizen app, these files change very little:

  • dispatcher.rs — register citizens, drain messages, route AppMessage
  • tabs.rs — the TabKind enum, Tab struct, TabViewer impl
  • messages.rs — the AppMessage enum (specifically the Citizen variant)
  • main.rs — App struct + drain loop pattern
  • state.rs — SharedState shape with reactive parameters
  • theme.rs — visuals + font scaling

What does change app-to-app:

  • The contents of the panels (panels/)
  • The backend (backend/)
  • The non-Citizen variants of AppMessage

The dispatcher's plumbing is the part that scales sideways — write it once and you're 80% of the way through every future citizen app.

Project layout

examples/filter_plotter/
├── Cargo.toml
└── src/
    ├── main.rs              # eframe::App, dock layout, drain loop
    ├── theme.rs             # apply_visuals, apply_font_scale
    ├── tabs.rs              # TabKind, Tab, TabViewer
    ├── messages.rs          # AppMessage enum
    ├── dispatcher.rs        # register / drain / handle
    ├── state.rs             # SharedState, ParamsState
    ├── backend/
    │   ├── mod.rs           # BackendKind trait, FilterParams, Traces
    │   └── iir.rs           # InProcessIir biquad lowpass
    └── panels/
        ├── mod.rs
        ├── plot.rs          # linked stacked plots
        ├── settings.rs      # sliders + Generate button
        └── logger.rs      # log scrollback

Each file has one job. The settings panel doesn't know how the filter works; the backend doesn't know what egui is. The dispatcher routes messages between them.

The shape

The data flow on a "Generate" click:

[Settings panel] ── click ──> AppMessage::Generate
                                │
                                v  (settings.outbox)
[main.rs drain loop] ── handle Generate ──> backend.run(params)
                                              │
                                              v
                                      [SharedState::traces]
                                              │
                                              v
                                  [Plot panel reads + renders]

The settings panel does not call the backend directly. It pushes a message into its outbox; the drain loop in main.rs::update() picks it up, calls the backend, stores the result in SharedState, and the plot panel renders it on the next frame.

Shared state — state.rs

use eframe::egui;
use egui_lens::{LogColors, ReactiveEventLoggerState};
use egui_mobius_reactive::Dynamic;

use crate::backend::{FilterParams, Traces};

pub struct ParamsState {
    pub signal_freq_hz: Dynamic<f32>,
    pub noise_freq_hz:  Dynamic<f32>,
    pub cutoff_hz:      Dynamic<f32>,
    pub sample_rate_hz: Dynamic<f32>,
    pub duration_ms:    Dynamic<f32>,
}

pub struct SharedState {
    pub params: ParamsState,
    pub traces: Dynamic<Traces<f32>>,
    pub log: Dynamic<ReactiveEventLoggerState>,
    pub log_colors: Dynamic<LogColors>,
    pub plot_link: egui::Id,
}

Four reactive fields (Dynamic<T>) for things multiple places read or write: params (settings panel writes, backend reads on Generate), traces (drain loop writes, plot panel reads), log (drain loop writes, logger panel reads — backed by egui_lens::ReactiveEventLoggerState, see the lens chapter), and log_colors (configures per-type colors for custom log channels like "citizen" and "backend"). One non-reactive field plot_link because both plot widgets only need the same Id; it never changes after construction.

The f32 in Dynamic<Traces<f32>> is where this app commits to the in-process IIR backend's sample type — see The backend below for why Traces<T> is generic and how a fixed-point backend would change just this one type.

This is the app-shared services bucket from Where does state live? in concrete form.

The backend — backend/

Two plain data types and a trait. The data types come first because the trait signature uses them.

/// Parameters captured at "Generate" time — a snapshot of the reactive
/// fields on `SharedState::params` so the backend has a stable, owned
/// view of what to compute.
#[derive(Debug, Clone, Copy)]
pub struct FilterParams {
    pub signal_freq_hz:  f32,
    pub noise_freq_hz:   f32,
    pub noise_amplitude: f32,
    pub cutoff_hz:       f32,
    pub sample_rate_hz:  f32,
    pub duration_ms:     f32,
}

impl FilterParams {
    pub fn num_samples(&self) -> usize {
        (self.sample_rate_hz * self.duration_ms / 1000.0).round() as usize
    }
}

/// One pair of traces resulting from a Generate run.
///
/// `T` is the sample type. The in-process IIR backend uses `f32`; a
/// serial-port backend feeding raw ADC counts could use `i16` or `i32`
/// without a lossy upcast at the boundary. Time stays `f64` regardless
/// — timestamps are the same kind of value across all backends.
#[derive(Debug, Clone)]
pub struct Traces<T> {
    pub time:     Vec<f64>,  // seconds
    pub input:    Vec<T>,    // raw noisy signal
    pub filtered: Vec<T>,    // lowpass output
}

impl<T> Default for Traces<T> {
    fn default() -> Self {
        Self {
            time:     Vec::new(),
            input:    Vec::new(),
            filtered: Vec::new(),
        }
    }
}

impl<T> Traces<T> {
    pub fn is_empty(&self) -> bool { self.time.is_empty() }
}

Three things worth pointing out:

  • FilterParams is Copy because it's a plain bag of f32s. The settings panel writes reactive Dynamic<f32> fields; on Generate we snapshot them into a FilterParams (see state.rs::ParamsState::snapshot()) so the backend gets a stable, owned value. No reactivity crosses the trait boundary.
  • Traces<T> is generic over the sample type, not the time type. This is the difference between an emulator backend producing f32 samples and a serial-port backend producing i16 ADC counts — neither needs to lossily upcast at the boundary. The time axis stays Vec<f64> because seconds-since-start is the same kind of quantity everywhere; only the sample magnitudes vary in representation.
  • Traces uses parallel Vecs columnar, three vectors of the same length, not a Vec<(f64, T, T)> of points. Columnar is what the plot library wants — traces.time.iter() .zip(traces.input.iter()) to produce points only at render time — and it's what a streaming backend would also produce. Keeping the shape columnar from day one means swapping in a streaming backend later doesn't reshape the data.

Default is implemented manually instead of derived because the derive would inject a spurious T: Default bound; Vec::new() doesn't need it.

The BackendKind trait then abstracts what produces a Traces from a FilterParams. The sample type is an associated type, so each backend names exactly one:

pub trait BackendKind {
    type Sample;
    fn run(&mut self, params: &FilterParams) -> Traces<Self::Sample>;
    fn name(&self) -> &'static str;
}

The tutorial ships InProcessIir (in backend/iir.rs) with type Sample = f32; — it generates a sine wave, adds a 200 kHz tone for noise, applies a biquad lowpass, and returns both traces as Traces<f32>. A SerialPort impl would set type Sample = i16; (or whatever the ADC width is) and run would read samples off a port. The rest of the app — settings panel, plot panel, dispatcher — does not change shape. Swap the backend type and the wiring stays.

The one place that commits to a sample type is SharedState:

pub traces: Dynamic<Traces<f32>>,

The reactive cell has to hold a concrete T. Using a different backend means changing this f32 to match Backend::Sample, but the dispatcher's handle function uses B: BackendKind<Sample = f32> to enforce the match at compile time, so the wiring stays honest.

The biquad itself is direct-form-II-transposed Butterworth (Q = 1/√2):

fn lowpass(cutoff_hz: f32, sample_rate_hz: f32) -> Self {
    let q = std::f32::consts::FRAC_1_SQRT_2;
    let omega = 2.0 * std::f32::consts::PI * cutoff_hz / sample_rate_hz;
    let cos_w = omega.cos();
    let alpha = omega.sin() / (2.0 * q);

    let b0 = (1.0 - cos_w) / 2.0;
    /* ... a0/a1/a2 via bilinear transform ... */

    Self { /* normalized coefficients */ }
}

fn process(&mut self, x: f32) -> f32 {
    let y = self.b0 * x + self.z1;
    self.z1 = self.b1 * x + self.z2 - self.a1 * y;
    self.z2 = self.b2 * x - self.a2 * y;
    y
}

Standard textbook biquad — see the file for the full coefficients. The math is incidental to the tutorial; the point is that this struct lives in backend/iir.rs and only the BackendKind trait crosses the module boundary.

The settings panel — panels/settings.rs

pub struct SettingsPanel {
    pub citizen_id: CitizenId,
    pub citizen_state: CitizenState,
    pub outbox: Vec<AppMessage>,
}

Three fields. citizen_id and citizen_state are the boilerplate that lets the dispatcher route activation to this panel. The interesting one is outbox: a Vec<AppMessage> the panel pushes to when something interesting happens, drained each frame by main.rs.

The Generate button is one line:

if ui.add_sized([ui.available_width(), 28.0],
                egui::Button::new("Generate")).clicked() {
    self.outbox.push(AppMessage::Generate);
}

The panel does not call backend.run() directly. It does not call dispatcher.send() either. It just enqueues an AppMessage for the drain loop to handle. This keeps show() free of dependencies on the backend or the dispatcher's internals — the panel is testable in isolation, the message is the contract.

The sliders update reactive parameters via the standard get / set loop:

let mut cutoff = state.params.cutoff_hz.get();
if ui.add(egui::Slider::new(&mut cutoff, 100.0..=50_000.0)
            .text("Lowpass cutoff (Hz)")
            .logarithmic(true))
        .changed()
{
    state.params.cutoff_hz.set(cutoff);
}

Read the current value from the Dynamic<f32>, hand egui a &mut local, on change push the local back. Verbose, but transparent — nothing is happening behind a wrapper.

The plot panel — panels/plot.rs

const PLOT_STRIDE: usize = 50;

impl PlotPanel {
    pub fn show(&mut self, ui: &mut egui::Ui, state: &SharedState) {
        let traces = state.traces.get();
        if traces.is_empty() {
            ui.centered_and_justified(|ui| {
                ui.label("Click Generate to compute traces.");
            });
            return;
        }

        let half = (ui.available_height() - 8.0).max(120.0) / 2.0;

        ui.allocate_ui([ui.available_width(), half].into(), |ui| {
            Plot::new("input_plot")
                .link_axis(state.plot_link, [true, false])
                .height(half)
                .show(ui, |plot_ui| {
                    let pts: PlotPoints = traces.time.iter()
                        .zip(traces.input.iter())
                        .step_by(PLOT_STRIDE)
                        .map(|(&t, &y)| [t, y])
                        .collect();
                    plot_ui.line(Line::new("input", pts));
                });
        });

        // ...same shape for the filtered output...
    }
}

Two key bits:

  1. Linked axes via the same egui::Id. Both Plot::new(...) calls pass state.plot_link (the same Id) to link_axis, so panning or zooming the input plot drives the filtered plot too. That's the matplotlib-style behavior.
  2. Decimation. The backend computes 100,000 samples (1 MHz × 100 ms). Rendering all of them is wasteful — every 50th sample looks identical to the eye. step_by(PLOT_STRIDE) cheaply produces 2,000 plot points per trace.

The panel reads state.traces once at the top, holds the result locally for the rest of the frame.

The logger panel — panels/logger.rs

The logger panel is itself a citizen with named atomsegui_lens::ReactiveEventLogger provides the panel identity (the "logger" citizen) and the in-panel widgets (System Info, Filters, Logger Colors, Save Logs, Clear Logs, the column-toggle checkboxes, the scrollable log area) are its atoms. The panel struct in this app is a thin wrapper that hands lens its shared state and renders it:

use egui_lens::ReactiveEventLogger;
use crate::state::SharedState;

pub struct LoggerPanel {}

impl LoggerPanel {
    pub fn new() -> Self { Self {} }

    pub fn show(&mut self, ui: &mut egui::Ui, state: &SharedState) {
        let logger = ReactiveEventLogger::with_colors(
            &state.log,
            &state.log_colors,
        );
        logger.show(ui);
    }
}

The logger reads state.log (a Dynamic<ReactiveEventLoggerState>) and state.log_colors (a Dynamic<LogColors>); writes flow in through the dispatcher. The logger panel doesn't push entries itself — it just renders what the drain loop in dispatcher.rs puts there.

Forward-looking note: lens will eventually implement Citizen directly and the LoggerPanel wrapper here disappears entirely — the dock layout will use the citizen ReactiveEventLogger, or its sibling LoggerCitizen, without any custom panel type. For now this thin wrapper is the boundary.

What the lens-backed logger gives you

Compared to a hand-rolled Vec<String> log:

  • Per-type colors via Dynamic<LogColors> — the dispatcher routes citizen lifecycle events through log_custom("citizen", ...) and backend events through log_custom("backend", ...), each rendered in its own color (configured at app construction in state.rs).
  • Filters — info / warning / error / debug / custom / system toggles plus a text-search box, all stored on the shared state.
  • Save Logs — exports current contents via rfd::AsyncFileDialog, which works on both native and WASM (browser builds get a download; native opens an OS file dialog).
  • System Info button — sets a memory flag the consuming app drains. The "atom" pattern at work: lens publishes the click; the app provides the actual implementation (see platform/details.rs for native, platform/details_wasm.rs for the browser variant).
  • Cross-thread writesDynamic::clone() is cheap; hand a clone to any thread and call ReactiveEventLogger::new(&clone) .log_info(...) from anywhere.

The dispatcher module — dispatcher.rs

This is where the pattern earns its name. Three jobs:

pub fn register_citizens(dispatcher: &mut Dispatcher) -> RegisteredCitizens {
    let plot     = dispatcher.register(CitizenId::new(PLOT_ID));
    let settings = dispatcher.register(CitizenId::new(SETTINGS_ID));
    let logger   = dispatcher.register(CitizenId::new(LOGGER_ID));
    dispatcher.activate(&CitizenId::new(PLOT_ID));
    RegisteredCitizens { plot, settings, logger }
}

pub fn drain_citizen(dispatcher: &mut Dispatcher, state: &SharedState) {
    let logger = ReactiveEventLogger::with_colors(&state.log, &state.log_colors);
    for msg in dispatcher.drain_messages() {
        // "citizen" is a named custom log type — its color is
        // configured in state.rs and renders distinctly from
        // info/warning/error/debug.
        logger.log_custom("citizen", &format_citizen(&msg));
    }
}

pub fn handle<B>(msg: AppMessage, state: &SharedState, backend: &mut B)
where
    B: BackendKind<Sample = f32>,
{
    let logger = ReactiveEventLogger::with_colors(&state.log, &state.log_colors);
    match msg {
        AppMessage::Citizen(_) => {} // already drained directly
        AppMessage::Generate => {
            let params = state.params.snapshot();
            let traces = backend.run(&params);
            let n = traces.input.len();
            state.traces.set(traces);
            // "backend" is also a named custom log type — its
            // color is configured alongside "citizen" so the two
            // event streams are visually distinct.
            logger.log_custom("backend",
                &format!("{} produced {} samples", backend.name(), n));
        }
        AppMessage::GenerateCompleted { samples } => {
            logger.log_info(&format!("generate completed: {} samples", samples));
        }
    }
}

Compared to the older shape that pushed pre-formatted strings into a Vec<String>, the lens-aware version routes each event through its appropriate level (log_info/log_warning/log_error/ log_debug) or named custom type (log_custom("citizen", ...), log_custom("backend", ...)). The logger panel renders each in its configured color automatically. Less manual formatting; more visual signal in the panel.

register_citizens runs once at startup. drain_citizen and handle run once per frame. handle is generic over backend shape (B: BackendKind<Sample = f32>), which is what makes the dispatcher app-agnostic at the behavior layer — the same module would work with a SerialPort backend, a CsvImporter, or anything else implementing BackendKind whose Sample matches what SharedState::traces holds. Pinning the sample type at the where clause means the compiler catches any backend swap that forgot to update SharedState.

The tabs module — tabs.rs

egui_dock needs two things from your app: a tab type, and a TabViewer impl that knows how to render each tab. tabs.rs is where both live, plus the citizen IDs that name each panel.

pub const PLOT_ID:     &str = "plot";
pub const SETTINGS_ID: &str = "settings";
pub const LOGGER_ID:   &str = "logger";

#[derive(Clone, Copy)]
pub enum TabKind {
    Plot,
    Settings,
    Logger,
}

pub struct Tab {
    pub kind: TabKind,
}

impl Tab {
    pub fn new(kind: TabKind) -> Self { Self { kind } }

    pub fn title(&self) -> &'static str {
        match self.kind {
            TabKind::Plot     => "Plot",
            TabKind::Settings => "Settings",
            TabKind::Logger   => "Logger",
        }
    }

    pub fn citizen_id(&self) -> CitizenId {
        CitizenId::new(match self.kind {
            TabKind::Plot     => PLOT_ID,
            TabKind::Settings => SETTINGS_ID,
            TabKind::Logger   => LOGGER_ID,
        })
    }
}

TabKind is the closed set of panels the app knows about. Tab wraps a TabKind because egui_dock::DockState<T> stores T directly — wrapping it in a struct gives us a stable place to hang helpers like title() and citizen_id(). Adding a fourth panel is one new variant plus a match arm in each helper; no other file moves.

The citizen_id() method is what links egui_dock's tab-click event back into the citizen layer — clicking the Settings tab needs to activate CitizenId::new("settings") so the dispatcher knows that panel is now in focus. Keeping the IDs as pub const strings in this file means dispatcher.rs and tabs.rs agree by import, not by typo-prone string duplication.

The TabViewer bridge

egui_dock::TabViewer is the trait the dock area calls into to render each tab. Our impl is the one place in the app that holds mutable references to every panel and the dispatcher at once:

pub struct TabViewer<'a> {
    pub state: &'a SharedState,
    pub dispatcher: &'a mut Dispatcher,
    pub plot: &'a mut PlotPanel,
    pub settings: &'a mut SettingsPanel,
    pub logger: &'a mut LoggerPanel,
}

impl egui_dock::TabViewer for TabViewer<'_> {
    type Tab = Tab;

    fn title(&mut self, tab: &mut Self::Tab) -> egui::WidgetText {
        tab.title().into()
    }

    fn ui(&mut self, ui: &mut egui::Ui, tab: &mut Self::Tab) {
        match tab.kind {
            TabKind::Plot     => self.plot.show(ui, self.state),
            TabKind::Settings => self.settings.show(ui, self.state, self.dispatcher),
            TabKind::Logger   => self.logger.show(ui, self.state),
        }
    }

    fn on_tab_button(&mut self, tab: &mut Self::Tab, response: &egui::Response) {
        if response.clicked() {
            self.dispatcher.activate(&tab.citizen_id());
        }
    }
}

Three methods, each doing one thing:

  1. title — return whatever the tab strip should display. We delegate to Tab::title() so the strings live next to the enum.
  2. uiegui_dock calls this once per visible tab per frame. We match on tab.kind and dispatch to the corresponding panel's show(). Note that settings.show takes the dispatcher too — most panels won't need it, but the settings panel uses it for activation hooks. The other panels just need &SharedState.
  3. on_tab_button — fired when the user clicks a tab header. We forward the click into dispatcher.activate(...). This is the canonical citizen hook: egui_dock knows about the click; the dispatcher knows about activation; this method is the bridge. Even if your app doesn't currently do anything on activation, register the click — the dispatcher's queue stays accurate, and adding behavior later doesn't require revisiting this file.

The borrow story is worth a moment: TabViewer holds five &mut references at once, which would be a problem in a long-lived struct, but it's constructed inline in update() and dropped at the end of DockArea::show(). Short-lived, single-frame — the compiler is happy because no two methods on TabViewer borrow overlapping fields.

Why this file barely changes between apps

Look back at the reusable scaffolding list at the top of the chapter. tabs.rs is on it because:

  • The TabKind enum changes (new panels) but the shape doesn't.
  • The Tab struct, title(), citizen_id() pattern is verbatim across apps.
  • The TabViewer impl gains/loses fields as panels come and go, but the three methods (title, ui, on_tab_button) are always the same three methods doing the same three jobs.

Adding a panel is mechanical: new const, new TabKind variant, new title, new citizen_id arm, new &mut Panel field on TabViewer, new arm in ui. Six edits, no thinking — exactly the kind of change the citizen pattern is designed to make boring.

Wiring it together — main.rs

impl eframe::App for App {
    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
        DockArea::new(&mut self.dock_state).show(ctx, &mut TabViewer {
            state: &self.state,
            dispatcher: &mut self.dispatcher,
            plot: &mut self.plot,
            settings: &mut self.settings,
            logger: &mut self.logger,
        });

        dispatcher::drain_citizen(&mut self.dispatcher, &self.state.log);

        let outbox = std::mem::take(&mut self.settings.outbox);
        for msg in outbox {
            dispatcher::handle(msg, &self.state, &mut self.backend, &self.state.log);
        }
    }
}

Five lines of orchestration:

  1. Hand the dock area to egui_dock with our TabViewer.
  2. Drain citizen activation messages into the log so the logger panel can show them.
  3. Take the settings panel's outbox.
  4. Process each AppMessage through handle.

That's it. Adding a new panel means: register it, add a TabKind variant, wire it into TabViewer::ui(). Adding a new domain message means: a new variant in AppMessage and a match arm in handle. Neither is a refactor.

Where to take it next

Concrete extensions, ordered by ambition:

  • Replace InProcessIir with SerialPort. Implement BackendKind::run to read N samples off a port instead of generating them locally. UI doesn't change.
  • Stream the data instead of snapshotting. Spawn a worker thread in a Backend::start() method, push samples through a channel, drain the channel into a ring buffer in SharedState each frame. Change AppMessage::Generate to Start / Stop. Plot panel reads the ring buffer.
  • Add a second filter stage. Two Biquad sections cascaded for a 4th-order filter; expose a "filter order" combo box in Settings.
  • Persist the filter coefficients. Write ParamsState to a RON file when the app exits, restore on startup. Routes through another AppMessage::Save / Load.

Each of these is one or two new modules and zero changes to the dispatcher.rs, tabs.rs, or main.rs scaffolding. That's the citizen pattern's transplant value, made concrete.

Source

Full source lives in examples/filter_plotter/ in the egui_mobius repo. Run cargo run -p filter_plotter from the workspace root.

Stored vs stateless panels

Two lawful ways to use a citizen, both correct, and the choice depends on what state the panel itself holds.

  • Stored — the panel is a field on the app struct, constructed once in App::new(), rendered 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.

Common pitfalls

Six foot-guns that real egui_citizen apps hit. Each is presented as a concrete broken snippet, an explanation of why it fails, and the fix. None of them produce a panic or compile error — every one is a silent bug, which is what makes them worth their own chapter.

1. Constructing CitizenState fresh per frame

Broken:

match tab.kind {
    TabKind::Drc => DrcPanel::new(CitizenState::default())
                       .show(ui, &mut self),
}

What goes wrong: CitizenState::default() allocates fresh Arc<Mutex<...>> storage that the dispatcher knows nothing about. The dispatcher's activate(&drc_id) writes to its table; the panel reads from its freshly-allocated state; the two never agree. The DRC tab still highlights when clicked (egui_dock handles its own visual state), but anything reading drc_state.active.get() from elsewhere in the app sees false forever. Reactivity is silently severed.

Fix: obtain the CitizenState from Dispatcher::register(), store it somewhere durable (the app struct), and clone it into the per-frame panel:

struct App {
    dispatcher: Dispatcher,
    drc_state:  CitizenState,    // registered once, lives on the app
}

impl App {
    fn new(_: &eframe::CreationContext) -> Self {
        let mut dispatcher = Dispatcher::new();
        let drc_state = dispatcher.register(CitizenId::new("drc"));
        Self { dispatcher, drc_state }
    }
}

// In TabViewer:
match tab.kind {
    TabKind::Drc => DrcPanel::new(self.drc_state.clone())
                       .show(ui, &mut self),
}

Panels can be stateless; their CitizenState cannot. See Stored vs stateless panels and Reactive lifecycle: the trap.

2. Forgetting drain_messages()

Broken:

fn update(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) {
    DockArea::new(&mut self.tabs).show(ctx, &mut self.tab_viewer);
    // (no dispatcher.drain_messages() anywhere)
}

What goes wrong: Dispatcher::activate() and any explicit Dispatcher::send() calls push into an internal Vec<CitizenMessage> that has no upper bound. If nothing drains it, the vec grows forever. The app keeps running, the UI keeps rendering, but RSS climbs every minute the user holds the app open. No panic, no error log — just a slow leak.

Fix: drain once per frame, after DockArea::show():

fn update(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) {
    DockArea::new(&mut self.tabs).show(ctx, &mut self.tab_viewer);

    for msg in self.dispatcher.drain_messages() {
        // process or forward
    }
}

If you have nothing to do with the messages yet, drain into an ignored binding (let _ = self.dispatcher.drain_messages();) so the queue still empties. Don't leave the dispatcher's queue unattended — ever.

3. Calling activate() every frame unconditionally

Broken:

fn ui(&mut self, ui: &mut egui::Ui, tab: &mut Tab) {
    // The author wanted "this tab is rendering, mark it active":
    self.dispatcher.activate(&tab.citizen_id());
    tab.show(ui, self.app);
}

What goes wrong: ui() runs every frame for every visible tab. Each call to activate() emits an Activated message — even when the citizen was already active in the previous frame. The queue fills with redundant Activated events at the frame rate (60+/sec). Backend consumers see "fetch was just activated" 60 times a second and either dedupe defensively or kick off 60 fetches.

When two tabs are visible side-by-side, it's worse: each frame deactivates the other, so consumers see a flood of Activated/Deactivated pairs in alternation.

Fix: call activate() from TabViewer::on_tab_button gated on response.clicked(), never from ui():

fn on_tab_button(&mut self, tab: &mut Tab, response: &egui::Response) {
    if response.clicked() {
        self.dispatcher.activate(&tab.citizen_id());
    }
}

fn ui(&mut self, ui: &mut egui::Ui, tab: &mut Tab) {
    // ui() only renders. It does not write lifecycle state.
    tab.show(ui, self.app);
}

This is the single most common foot-gun and the reason the egui_dock background chapter draws the distinction between ui() (render) and on_tab_button (event) so sharply. ui() is for rendering; on_tab_button is for state transitions. Keep them separate.

4. Mixing panel-local state into CitizenState

Broken:

// Trying to model "this panel's slider value" through CitizenState
// (which has fixed library-defined fields):
self.citizen_state.active.set(true);  // hijacking `active` as
                                      // "is showing project X"

…or, equivalently:

struct LoggerPanel {
    citizen_id: CitizenId,
    citizen_state: CitizenState,
    log_buffer: Dynamic<Vec<LogEntry>>,  // overkill; nothing else
                                         // outside the panel reads it
}

What goes wrong: CitizenState has a fixed shape — six Dynamic<T> fields with library-defined semantics (active, clicked, selected, moved, location, visible). Reusing those fields to mean something domain-specific (e.g. active = "showing project X") breaks the dispatcher's invariants the moment you call activate(), which clobbers your overload.

The variant is treating every panel-local field as reactive "because reactivity feels good." Reactivity is not free — every Dynamic<T> is an Arc plus a lock plus a notifier list. If only the panel itself reads its log buffer, a plain Vec<LogEntry> is the right type.

Fix: use a separate PanelState struct alongside CitizenState on the panel struct, and use plain (non-reactive) types unless a second reader actually exists:

struct LoggerPanelState {
    log_buffer: Vec<LogEntry>,
    filter_text: String,
    follow_tail: bool,
}

struct LoggerPanel {
    citizen_id: CitizenId,
    citizen_state: CitizenState,    // library lifecycle, fixed
    panel_state: LoggerPanelState,  // panel-author's fields
}

See Where does state live? for the full three-bucket model.

5. Expecting visible to track egui_dock's open/closed state

Broken:

// Assumes `visible` updates automatically when egui_dock opens or
// closes the tab — it doesn't.
if self.plot_state.visible.get() {
    self.start_streaming_data();
}

What goes wrong: egui_citizen and egui_dock are independent crates. egui_dock does not know egui_citizen exists, so it never calls state.visible.set() on its own. The visible field starts at false, stays at false, and your "is the panel showing?" check returns false even when the user is staring at the panel.

Fix: drive visible yourself, either by setting it directly when you detect a tab open/close in TabViewer, or — preferred — by routing through the VisibilityChanged message:

// Detect close in TabViewer:
fn on_close(&mut self, tab: &mut Tab) -> bool {
    self.dispatcher.send(CitizenMessage::VisibilityChanged {
        id: tab.citizen_id(),
        visible: false,
    });
    true
}

// In the drain loop, sync the reactive flag:
for msg in self.dispatcher.drain_messages() {
    if let CitizenMessage::VisibilityChanged { id, visible } = &msg {
        if let Some(state) = self.dispatcher.get(id) {
            state.visible.set(*visible);
        }
    }
}

egui_citizen provides the vocabulary (a Dynamic<bool> field, a message variant). The plumbing from egui_dock's tab-close into that vocabulary is your code's responsibility, by design — it's the boundary that keeps egui_citizen independent of the dock crate.

6. Two dispatchers in one app

Broken:

struct App {
    plot_dispatcher:     Dispatcher,
    settings_dispatcher: Dispatcher,
    /* ... */
}

What goes wrong: the one-hot activation invariant is per-dispatcher. plot_dispatcher.activate(&plot_id) deactivates every other citizen registered with plot_dispatcher — but it cannot deactivate a citizen registered with settings_dispatcher, because the two dispatchers maintain entirely separate HashMap<CitizenId, CitizenState> tables. Two panels — one registered to each dispatcher — can both be "active" simultaneously, which the rest of the codebase does not expect.

Fix: one Dispatcher per app. Always.

struct App {
    dispatcher: Dispatcher,    // exactly one
    /* ... */
}

If you find yourself wanting a second dispatcher because "these panels are unrelated to those panels," the right answer is still one dispatcher with all panels registered. Citizen ids are namespaced strings — use "editor.plot", "sidebar.settings", etc., to disambiguate. The dispatcher does not care about logical grouping; it only enforces the one-hot invariant across everything it knows about.

Summary

Five of the six foot-guns above are silent — no panic, no compile error, just behavior that drifts from what the author expected. The defenses are vocabulary-level: hold the CitizenState somewhere durable, drain the queue every frame, never write lifecycle state in ui(), keep panel-local data out of CitizenState, drive visible yourself, and never run two dispatchers in one app. Once these become habits, the rest of egui_citizen works out of the box.

Reference

A single-page summary of the public API. For full signatures, doc comments, and version-tracked details, see docs.rs/egui_citizen.

Dispatcher

use egui_citizen::Dispatcher;
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