ouroboros-ui — Documentation
Token-first design system for egui — the shadcn/ui design language reimplemented natively in Rust. Not a web port: same vocabulary (semantic tokens, neutral zinc aesthetic, 4px scale), egui-native immediate-mode rendering.
This is the full documentation set: how the system is built, how to use it, and a reference page for every component.
▶ Live storybook — every component and token, running in your browser (WebAssembly). Source:
examples/storybook.rs.
Start here
| Doc | What it covers |
|---|---|
| Architecture | The layered model (core → semantic → component → atoms → cells → molecules → organisms), dependency direction, the primordial atomic-design law. |
| Governance | The law — use first, extend second, create last: the decision ladder, what is forbidden in studio chrome, the escapes, the component contribution pipeline, enforcement. |
| Usage | Install, bootstrap the theme, the builder pattern, common recipes, how to consume the crate. |
| Guards & conventions | The two enforcement tests (no_raw_values, no_painter_in_molecules), what they forbid, how to add a component without tripping them. |
Foundation reference
| Doc | What it covers |
|---|---|
| Tokens (core) | Raw primitives: color ramps, spacing, radius, shadows, sizing, motion, opacity. |
| Theming (semantic) | The Theme struct, the four palettes (dark / light / zinc-dark / zinc-light), Mode, install/apply/get. |
| Typography | Iosevka faces, weights, the named type styles (display…kbd), icon fonts. |
| Layout & auto-layout | Panel/grid/breakpoint tokens, the Layer z-order, and the Figma-style AutoLayout flow engine. |
Component reference
Every component has its own page under components/, grouped by layer:
- Atoms — 23 leaf components that paint with tokens only.
- Cells — 7 row/item building blocks.
- Molecules — 14 compositions of atoms.
- Organisms — 14 full UI sections.
- Graph — the node-editor peer layer (reactflow-style on
egui::Scene).
See the component catalog for the complete index.
Run the storybook
The storybook is the living visual reference — every token and component rendered, with a Dark/Light mode toggle.
cd ouroboros-ui
cargo run --example storybook
At a glance
- egui / eframe
0.34.1· egui_extras0.34· egui-phosphor0.12(Light variant) - Rust pinned to
1.92.0(rust-toolchain.toml) - Standalone crate — zero coupling to the Ouroboros monorepo workspace
- License: MIT © Type Zero Labs
Usage
How to depend on, bootstrap, and call ouroboros-ui.
Add the dependency
The crate is standalone (its own workspace) and not yet published to crates.io. Depend on it by git (or path, if vendored as a sibling):
[dependencies]
ouroboros-ui = { git = "https://github.com/type-zero-labs/ouroboros-ui" }
# or, vendored locally:
ouroboros-ui = { path = "../ouroboros-ui" }
You also need egui/eframe at the matching minor (0.34); the toolchain is pinned to
Rust 1.92.0 (rust-toolchain.toml).
Bootstrap the theme
Install the theme once when the egui context exists (in eframe::App setup). This
registers the bundled fonts and applies the palette — without it, text falls back to
egui defaults and Theme::get returns Theme::default().
#![allow(unused)]
fn main() {
use eframe::egui;
use ouroboros_ui::{Mode, Theme};
struct App { mode: Mode }
impl App {
fn new(cc: &eframe::CreationContext<'_>) -> Self {
Theme::install(&cc.egui_ctx, Mode::Dark); // fonts + palette, once
Self { mode: Mode::Dark }
}
}
}
Switch mode at runtime with apply (no font re-registration):
#![allow(unused)]
fn main() {
if toggled {
self.mode = match self.mode { Mode::Dark => Mode::Light, Mode::Light => Mode::Dark };
Theme::apply(ctx, self.mode);
}
}
See theming.md for the four palettes and what install/apply touch.
The builder pattern
Every component — atom to organism — follows the same shape:
#![allow(unused)]
fn main() {
Component::new(required_args)
.setter(value) // chainable, returns Self
.show(ui) // consumes self, paints, returns egui::Response
}
So call sites read top-to-bottom and optional props are explicit:
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::{Button, ButtonVariant};
use ouroboros_ui::egui_phosphor::light::FLOPPY_DISK;
let resp = Button::new("Save")
.variant(ButtonVariant::Default)
.icon_left(FLOPPY_DISK)
.show(ui);
if resp.clicked() {
// …
}
}
Many components offer shorthand setters in addition to the generic one — e.g.
Button::new("x").secondary().sm() is the same as
.variant(ButtonVariant::Secondary).size(Size::Sm). Check each component page.
Importing
Components are grouped by layer; pull from the layer module:
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::{Button, Input, Text, Icon, Badge, Switch};
use ouroboros_ui::cells::{ListItem, PropertyRow, MenuItem};
use ouroboros_ui::molecules::{Field, Card, Alert, Tabs, RadioGroup};
use ouroboros_ui::organisms::{Splitter, PanelSpec, Dialog, Table, TreeView, Toast};
}
Foundation re-exports live at the crate root:
#![allow(unused)]
fn main() {
use ouroboros_ui::{Mode, Size, Theme}; // common
use ouroboros_ui::theme::typography; // type styles
use ouroboros_ui::tokens::{core, layout}; // primitives
use ouroboros_ui::auto_layout::{AutoLayout, MainAlign, CrossAlign};
use ouroboros_ui::egui_phosphor::light; // icon glyphs
}
Common recipes
A labelled form field
#![allow(unused)]
fn main() {
use ouroboros_ui::molecules::Field;
use ouroboros_ui::atoms::Input;
Field::new("Display name")
.hint("Shown to other players")
.show(ui, |ui| { Input::new(&mut self.name).show(ui); });
}
A toolbar row with a trailing action
#![allow(unused)]
fn main() {
use ouroboros_ui::auto_layout::{AutoLayout, CrossAlign};
AutoLayout::horizontal()
.gap(core::SPACE_2).cross_align(CrossAlign::Center)
.hug(|ui| { Text::new("Scene").role(TextRole::Heading).show(ui); })
.fill(|ui| {}) // spacer
.hug(|ui| { Button::new("Add").sm().show(ui); })
.show(ui);
}
Reading the theme in your own widget
#![allow(unused)]
fn main() {
let theme = Theme::get(ui);
let stroke = egui::Stroke::new(core::BORDER_THIN, theme.border);
}
Prefer composing existing atoms over painting yourself — and if you do paint custom chrome, you are effectively writing an atom, so it must use tokens (see guards.md).
Run / develop
cd ouroboros-ui
cargo run --example storybook # the living visual reference
cargo test # unit tests + the two atomic-design guards
cargo fmt # CI runs `cargo fmt --check`
cargo clippy
Before pushing: run
cargo fmt— the project’s CI checks formatting and will fail an unformatted branch.
Architecture
ouroboros-ui is built in seven layers. The lower three are tokens (data); the upper four are components (atomic design). The single rule across the whole stack: a layer may reference the layer below it, and nothing below knows the layer above.
┌─────────────────────────────────────────────────────────────┐
│ organisms splitter, dialog, table, tree_view, toast… │ full UI sections
│ ↓ compose │
│ molecules field, card, alert, tabs, radio_group… │ compositions of atoms
│ ↓ compose │
│ cells list_item, menu_item, property_row, table_row… │ row/item building blocks
│ ↓ compose │
│ atoms button, input, text, icon, surface, badge… │ leaf widgets (paint here)
│ ↓ read │
│ component ButtonTokens, BadgeTokens… (tokens::component) │ per-component overrides
│ ↓ read │
│ semantic Theme { background, primary, border, ring… } │ shadcn vocabulary
│ ↓ read │
│ core ZINC_950, SPACE_4, RADIUS_MD, TEXT_BASE… │ raw primitives (const)
└─────────────────────────────────────────────────────────────┘
┌── graph ──┐ peer layer (node editor on egui::Scene) — reads the same tokens,
└───────────┘ but is the one place outside atoms allowed to paint. See below.
The crate root re-exports the four most-used names so consumers don’t reach deep:
#![allow(unused)]
fn main() {
pub use theme::typography::{TypeStyle, Weight};
pub use theme::Mode;
pub use tokens::core::Size;
pub use tokens::semantic::Theme;
pub use egui_phosphor; // icon glyphs, no separate dependency
}
The token layers (data)
1. core — raw primitives
src/tokens/core.rs. Pure consts with no meaning: the Zinc neutral ramp
(50→950), the Teal brand ramp (200→600), status hues, the 4px spacing scale, radius
scale, shadows, type sizes, control/icon sizing, motion durations + easing, opacity.
Nothing here references anything. It is a leaf. The only non-trivial logic is the
Easing curve math and two helpers (disabled_color, hover_t) that every atom shares
so state transitions are identical. See tokens.md.
2. semantic — the shadcn vocabulary
src/tokens/semantic.rs. The Theme struct maps shadcn’s semantic names
(background/foreground, primary, muted, accent, destructive, border,
ring, plus domain status pairs) onto core primitives. No raw colors live here —
every field is a core::* reference. Four palettes: dark(), light(), zinc_dark(),
zinc_light(). See theming.md.
3. component — per-component overrides
src/tokens/component.rs. A thin struct per component holding the exact values it
paints with, derived from semantic (never from core). This lets one component be
retuned — a denser button, a louder input focus — without touching global tokens.
ButtonTokens and BadgeTokens are the worked examples; most components default
straight to semantic tokens and never need a struct here.
The component layers (atomic design)
4. atoms
src/atoms/. The smallest components — the only layer allowed to paint
primitives (painter.rect_filled, galley, etc.). Each atom is a builder that paints
exclusively with foundation tokens; no hardcoded colors, sizes, radii, fonts, or motion.
An atom may compose smaller atoms (e.g. Button composes Icon + Text).
5. cells
src/cells/. Compound row/item building blocks that sit between molecules and
organisms — a property row, a list/menu/tree item, a table row, a toolbar button. Cells
compose, never paint.
6. molecules
src/molecules/. Components composed only from atoms (and smaller molecules) plus
auto_layout. Molecules compose, never paint.
7. organisms
src/organisms/. Full UI sections composed from cells, molecules, and atoms. Overlay
organisms use egui containers for placement: Dialog → Modal, Toast → Area,
Popover/DropdownMenu/Select/Menubar → Popup::menu. The casing is either a token
Surface atom (Toast, Toolbar, bordered Table) or themed egui visuals driven by Theme
tokens (the Modal/Popup-based ones) — placement is egui’s job, the look is always tokens.
graph — the node-editor peer layer
src/graph/. A peer layer beside the four above — a reactflow-style node editor
(GraphView) built on egui::Scene. It is the one place outside atoms that paints: a
node graph needs grid dots, bezier wires, handle circles and a marquee, none of which the
atom vocabulary covers. The atomic-design rules don’t fit it, so it has its own invariant
instead — paint, but only through tokens:
- It may call the painter, but every value still flows through a token (colors from
Themeresolved intoGraphTokens, geometry fromcore). Theno_raw_valuesguard is extended to scansrc/graph, so it has the same purity contract as atoms. - The
no_painter_in_moleculesguard deliberately skips it — painting here is allowed.
Internally it splits into a paint tier (viewport/grid/edge/handle/resizer) and
a compose tier (node/controls/minimap/toolbar/search, which reuse Surface +
atoms). And it follows a data-model-agnostic contract: the caller owns the node/edge
data, the library owns only view-state and reports back intents. Full docs:
components/graph.
The primordial law
An organism is built only from molecules and atoms. A molecule is built only from atoms and smaller molecules. Nothing above the atom layer hand-rolls a primitive — if a piece needs painting, it becomes an atom first.
This is not a style preference; it is mechanically enforced by two test guards that
run with cargo test:
tests/no_raw_values.rs— scanssrc/atoms/**andsrc/graph/**, fails on hardcodedColor32::from_rgb, namedColor32constants, or rawFontId::new. Atoms (and the graph layer) must source colors fromTheme/coreand fonts fromtheme::typography.tests/no_painter_in_molecules.rs— scanssrc/cells/**,src/molecules/**,src/organisms/**, fails on any painting call (ui.painter(),.rect_filled(),.circle_stroke(),Shape::*, …). Above atoms you compose; you never paint.src/graphis deliberately not scanned — it is the sanctioned exception that paints (still via tokens, enforced byno_raw_values).
The consequence: the missing-piece-becomes-an-atom discipline keeps the atom set complete and every higher layer pure composition. See guards.md.
Why a Rust/egui design system at all
The Ouroboros Studio (authoring IDE) and the engine HUD are both egui apps. A shared, token-driven component library means:
- One visual language across engine HUD and studio chrome (the
auto_layoutmodule even mirrors the engine HUD’sLayoutDirection/MainAlign/SizeModevocabulary). - Theme-able — swap
Mode::Dark/Light(or the zinc-neutral variants) at runtime with no consumer changes, because everything resolves throughTheme::resolve. - Auditable — the guards make “did someone hardcode a color” a CI failure, not a code-review judgment call.
Builder pattern everywhere
Every component, at every layer, follows the same shape:
#![allow(unused)]
fn main() {
Component::new(required_args)
.setter(value) // chainable, returns Self
.setter(value)
.show(ui) // consumes self, paints, returns egui::Response
}
This gives fluent call sites, makes optional props obvious, and keeps the return value a
plain egui::Response so components drop into any egui layout. See usage.md.
Standalone by design
ouroboros-ui is its own cargo workspace with zero dependency on the parent monorepo.
It can be cloned, built, and storybooked in isolation. The trade-off: shared vocabulary
with the engine HUD (auto_layout) is re-declared, not imported, to avoid the coupling.
Tokens — core primitives
src/tokens/core.rs. The bottom layer: raw const values with no semantic meaning.
The semantic layer maps meaning onto these; nothing here references
anything else. Every value was decided interactively, value-by-value, against shadcn.
Design rule: atoms never read a raw hex. They read either a
core::*primitive (for structural values like spacing/radius) or aThemefield (for colors). Theno_raw_valuesguard enforces it.
Color ramps
Neutral base — Zinc (cool-neutral)
The temperature of every gray surface/border/text token. Tailwind/shadcn zinc, 50→950.
| Const | RGB | Const | RGB |
|---|---|---|---|
ZINC_50 | 250 250 250 | ZINC_600 | 82 82 91 |
ZINC_100 | 244 244 245 | ZINC_700 | 63 63 70 |
ZINC_200 | 228 228 231 | ZINC_800 | 39 39 42 |
ZINC_300 | 212 212 216 | ZINC_900 | 24 24 27 |
ZINC_400 | 161 161 170 | ZINC_950 | 9 9 11 |
ZINC_500 | 113 113 122 |
Brand — Ouroboros turquoise (Teal)
The primary hue: buttons, progress/slider fill, switch-on, focus ring, selection.
Kept light (300/400 in dark) per brand. The zinc ramp is left untouched, so a pure-zinc
theme stays available (see Theme::zinc_dark).
| Const | RGB |
|---|---|
TEAL_200 | 153 246 228 |
TEAL_300 | 94 234 212 |
TEAL_400 | 45 212 191 |
TEAL_500 | 20 184 166 |
TEAL_600 | 13 148 136 |
Status hues — Tailwind 500
The semantic layer composites the soft *_bg variants by applying ~15% alpha
(STATUS_BG_ALPHA = 38) to these.
| Const | Meaning | RGB |
|---|---|---|
GREEN_500 | success | 34 197 94 |
RED_500 | error / destructive | 239 68 68 |
AMBER_500 | warning | 245 158 11 |
BLUE_400 | info (text) | 96 165 250 |
BLUE_500 | info (fill base) | 59 130 246 |
Spacing — 4px base
Tailwind numeric keys (key N = N × 4px). Contiguous 1–6, then 8/10/12 for larger
gaps. Used for padding, gaps, margins.
| Const | px | Const | px |
|---|---|---|---|
SPACE_0 | 0 | SPACE_5 | 20 |
SPACE_1 | 4 | SPACE_6 | 24 |
SPACE_2 | 8 | SPACE_8 | 32 |
SPACE_3 | 12 | SPACE_10 | 40 |
SPACE_4 | 16 | SPACE_12 | 48 |
SPACE_0 is the semantic “no gap / no padding” sentinel (tight tables, full-bleed).
Corner radius
shadcn classic base (0.5rem). FULL is the pill/circle sentinel.
| Const | px | Const | px | |
|---|---|---|---|---|
RADIUS_NONE | 0 | RADIUS_LG | 8 | |
RADIUS_SM | 4 | RADIUS_XL | 12 | |
RADIUS_MD | 6 | RADIUS_FULL | 9999 |
RADIUS_NONE is the “square corners” sentinel (full-bleed rows, flush panels).
Shadows
Dark-tuned (high alpha to read on the zinc background).
| Const | Use | offset / blur / alpha |
|---|---|---|
SHADOW_SM | fields, chips | [0,1] / 2 / 61 |
SHADOW_MD | cards, pills, popovers | [0,2] / 4 / 82 |
SHADOW_LG | modals, overlays | [0,8] / 24 / 48 |
shadow(offset, blur, spread, color) is a const fn builder for elevations beyond the
fixed triple (egui’s Shadow is foreign, hence a free fn not an inherent constructor).
Typography primitives
Raw values only — the typography layer composes these into named styles.
Type sizes (px) — dense IDE calibration; body anchors at TEXT_BASE (14).
| Const | px | Const | px |
|---|---|---|---|
TEXT_XS | 12 | TEXT_XL | 20 |
TEXT_SM | 13 | TEXT_2XL | 24 |
TEXT_BASE | 14 | TEXT_3XL | 30 |
TEXT_LG | 16 |
Line-height multipliers (× font size): LEADING_TIGHT 1.2 (headings/display),
LEADING_NORMAL 1.45 (body), LEADING_RELAXED 1.6 (long-form).
Letter-spacing (px) — scale is inverse to size for legibility (big titles stay
NORMAL, smaller text gets wider tracking): TRACKING_TIGHT −0.25, TRACKING_NORMAL 0,
TRACKING_SM 0.4, TRACKING_MD 0.6, TRACKING_LG 0.8, TRACKING_WIDE 1.0.
Sizing
Control heights — CONTROL_SM 26 · CONTROL_MD 32 · CONTROL_LG 38.
Icon box — ICON_SM 14 · ICON_MD 16 · ICON_LG 20 · ICON_XL 24.
Strokes — BORDER_THIN 1 (divider) · BORDER_FOCUS 2 (focus ring) · RING_OFFSET 2
(gap to ring). Hit target — HIT_MIN 32 (minimum interactive size).
Size enum — the shared control scale
One source of truth for every form control’s footprint, so density (compact toolbar vs. roomy panel) is expressible uniformly.
#![allow(unused)]
fn main() {
pub enum Size { Sm, Md /* default */, Lg }
}
| Method | Sm | Md | Lg |
|---|---|---|---|
height() | 26 | 32 | 38 |
icon_size() | 14 | 16 | 20 |
pad_x() | 12 | 16 | 16 |
text_style()¹ | label | label | body_strong |
¹ defined in theme::typography (keeps core a leaf — see typography.md).
Motion
Animation durations (seconds) + easing curves. egui drives hover/focus transitions by
duration (ctx.animate_*); Easing shapes the progress.
Durations — DURATION_INSTANT 0 · DURATION_FAST 0.10 · DURATION_NORMAL 0.18 ·
DURATION_SLOW 0.30. Delays — DURATION_DELAY_SHORT 0.15 · DURATION_DELAY_LONG 0.50.
Easing enum
#![allow(unused)]
fn main() {
pub enum Easing { Linear, EaseOut /* default */, EaseInOut, Spring, Bounce }
}
| Variant | Curve | Use |
|---|---|---|
Linear | identity | constant motion |
EaseOut | decelerate | enter/hover (the default) |
EaseInOut | accel then decel | moves/reorders |
Spring | overshoot then settle (ease-out-back) | playful enters, springy toggles |
Bounce | decaying bounces (ease-out-bounce) | drops, attention pulls |
Easing::apply(t) maps a normalized progress t ∈ 0..=1 through the curve.
Opacity & overlays
| Const | Value | Meaning |
|---|---|---|
OPACITY_DISABLED | 0.5 | the disabled veil |
OPACITY_MUTED | 0.7 | secondary/muted content |
HOVER_OVERLAY | 0.06 | white veil over a surface on hover |
PRESS_OVERLAY | 0.12 | stronger veil on press |
SCRIM | black @ 60% | backdrop behind modals |
Shared helpers
Two functions every atom uses so state is identical everywhere:
#![allow(unused)]
fn main() {
/// Blend a color to its disabled appearance (alpha × OPACITY_DISABLED).
/// Atoms gate it behind `if !enabled` so the veil is applied exactly once.
pub fn disabled_color(c: Color32) -> Color32
/// Eased hover progress in 0..=1 for a widget — animates `hovered` over
/// DURATION_FAST, shaped by EaseOut. Pass a stable id (e.g. response.id).
pub fn hover_t(ctx: &egui::Context, id: egui::Id, hovered: bool) -> f32
}
These are the reason hover/disabled states look the same across the whole library: the math lives in one place, not copy-pasted into 23 atoms.
Theming — semantic tokens & modes
src/tokens/semantic.rs + src/theme/mod.rs. The semantic layer maps shadcn’s
vocabulary onto core primitives; the theme layer resolves a palette for a
Mode and installs it into the egui context.
No raw colors live in this layer. Every
Themefield references acore::*primitive. That keeps the brand hue swappable in one place.
The Theme struct
A resolved palette — every color token the design system exposes. Grouped:
Surfaces (layered)
Dark mode layers the zinc ramp by elevation: background 950 → card/popover 900 →
muted 800.
| Token | Dark | Role |
|---|---|---|
background / foreground | ZINC_950 / ZINC_50 | deepest layer — panels, window fill |
card / card_foreground | ZINC_900 / ZINC_50 | raised surface — cards, elevated panels |
popover / popover_foreground | ZINC_900 / ZINC_50 | floating surface — popovers, menus, tooltips |
muted / muted_foreground | ZINC_800 / ZINC_400 | inputs, secondary fills / labels, placeholders |
disabled_foreground | ZINC_600 | disabled text |
Interactive
| Token | Dark | Role |
|---|---|---|
primary / primary_foreground | TEAL_200 / ZINC_950 | default action — turquoise fill, dark text |
secondary / secondary_foreground | ZINC_800 / ZINC_50 | secondary action |
accent / accent_foreground | ZINC_800 / ZINC_50 | hover/active surface (shadcn accent, not brand) |
destructive / destructive_foreground | RED_500 / ZINC_50 | destructive action |
Borders & focus
| Token | Dark | Role |
|---|---|---|
border | ZINC_800 | default border / divider |
border_strong | ZINC_700 | emphasized border |
input | ZINC_800 | input border |
ring | TEAL_300 | focus ring |
hover_overlay | white @ 6% | hover veil (dark veil in light mode) |
press_overlay | white @ 12% | pressed veil |
scrim | black @ 60% (core::SCRIM) | backdrop veil behind modals and loading overlays — black in both modes (a scrim dims, it doesn’t invert). Need a lighter/heavier scrim? derive it (theme.scrim.gamma_multiply(..)), don’t mint a new literal. |
Status (solid + soft bg)
Each status has a solid hue and a soft *_bg (the hue tinted to ~15% alpha).
| Token | Solid | Soft *_bg |
|---|---|---|
success | GREEN_500 | green @ 15% |
warning | AMBER_500 | amber @ 15% |
error | RED_500 | red @ 15% |
info | BLUE_400 | blue @ 15% |
neutral | ZINC_500 | zinc @ 15% |
The four palettes
| Constructor | Look |
|---|---|
Theme::dark() | default. Zinc surfaces (950/900/800), teal-200 primary, teal-300 ring. |
Theme::light() | Off-white surfaces (zinc-50/100), teal-400 primary, dark text, dark hover veils. |
Theme::zinc_dark() | Dark with the neutral zinc primary (zinc-50 fill, no brand hue) — the pre-Ouroboros look. |
Theme::zinc_light() | Light with neutral zinc primary (zinc-900 fill). |
Theme::default() is dark().
Note on light mode: the crate README and a
Modedoc-comment still describe Light as a “stub that resolves to Dark.” That is stale —Theme::light()is fully populated andTheme::resolve(Mode::Light)returns it. Light mode works today.
Mode & resolution
#![allow(unused)]
fn main() {
pub enum Mode { Dark /* default */, Light }
}
The system always resolves through Theme::resolve(mode) so a palette can change without
touching consumers:
#![allow(unused)]
fn main() {
impl Theme {
pub fn resolve(mode: Mode) -> Self; // Dark => dark(), Light => light()
}
}
resolvecovers the twoModevariants (dark/light). The zinc-neutral palettes are opt-in via the constructors directly — install them withapplyafter resolving, or store them yourself.
Installing the theme
Call once at startup, then optionally re-apply to switch mode at runtime.
#![allow(unused)]
fn main() {
use ouroboros_ui::{Mode, Theme};
// In eframe::App::new / setup:
Theme::install(ctx, Mode::Dark); // registers fonts + applies the palette
}
install does two things:
typography::register(&mut fonts)— loads the bundled Iosevka faces + Phosphor icons.Theme::apply(ctx, mode)— applies visuals, stores the resolved theme, sets text styles.
Switching mode at runtime
apply reapplies the palette without re-registering fonts — use it for a Dark/Light
toggle:
#![allow(unused)]
fn main() {
Theme::apply(ctx, Mode::Light);
}
apply also flips egui’s own ThemePreference so built-in chrome (clear color, native
scrollbars) follows the mode, and wires panel/window/extreme/faint fills + the five
egui TextStyles (Heading→h2, Body/Button→body, Monospace→code, Small→caption).
Reading the theme inside a widget
Components fetch the installed theme from the context (falling back to Theme::default()):
#![allow(unused)]
fn main() {
let theme = Theme::get(ui); // from a &Ui
let theme = Theme::get_from_ctx(ctx); // from a &Context
}
The theme is stored in egui’s temp data under Id::NULL. Every atom calls Theme::get
at the top of its show to know what to paint with — this is the mechanism that makes
the whole library theme-reactive for free.
Adding a new semantic token
- Add the field to
Themeinsemantic.rs. - Populate it in all four constructors (
dark,light,zinc_dark,zinc_light), referencing onlycore::*— never a raw color (the guard will reject raw colors in atoms, and convention keeps semantic clean). - Consume it from an atom via
Theme::get(ui).your_token.
If the value is component-specific (one button variant, one input state), prefer a
tokens::component struct over a global semantic field — see
architecture.md.
Typography
src/theme/typography.rs. Registers the bundled fonts and exposes composite type styles
(family + size + line-height + tracking) for the named roles. Sizes and leadings come
from core; this layer composes them into usable styles.
Faces
Two type families are vendored under assets/fonts/ and embedded with include_bytes!:
- Iosevka (UI) — five weights: Light, Regular, Medium, SemiBold, Bold.
- IosevkaTerm (code/keyboard) — Regular, Bold.
- Phosphor Light — icon glyphs, via
egui-phosphor.
Each weight is registered under its own named FontFamily::Name, so a TypeStyle can
target an exact face. The default Proportional stack is Iosevka Regular; Monospace is
IosevkaTerm. Phosphor is appended as an icon fallback to every face, so inline icons
resolve no matter which type style renders them.
#![allow(unused)]
fn main() {
pub enum Weight { Light, Regular, Medium, SemiBold, Bold }
}
Named type styles
Each function returns a TypeStyle { family, size, line_height, tracking }. Build an
egui::FontId with .font_id(); the line-height and tracking are applied by the text
atom when it lays out the galley.
| Style | Face / weight | Size | Tracking | Use |
|---|---|---|---|---|
display() | Iosevka Bold | 30 | normal | largest title |
h1() | Iosevka SemiBold | 24 | normal | page title |
h2() | Iosevka SemiBold | 20 | normal | section title |
heading() | Iosevka SemiBold | 16 | sm | sub-section heading |
body() | Iosevka Light | 14 | md | default body text |
body_strong() | Iosevka Medium | 14 | md | emphasized body |
label() | Iosevka Light | 13 | lg | default label |
label_strong() | Iosevka Medium | 13 | lg | emphasized label |
caption() | Iosevka Regular | 12 | wide | small / caption |
code() | IosevkaTerm Regular | 13 | lg | inline code |
kbd() | IosevkaTerm Bold | 12 | wide | keyboard key cap |
Notes on the choices:
- Body and label default to Light, not Regular — the dense IDE aesthetic. Reach for
the
_strong(Medium) variants when a line needs to assert itself. - Headings keep normal tracking; the smaller the text, the wider the tracking (legibility scale is inverse to size, defined in core).
kbdis Bold mono because a Medium mono weight isn’t vendored, and Bold reads as a key cap anyway.
TypeStyle
#![allow(unused)]
fn main() {
pub struct TypeStyle {
pub family: FontFamily, // includes weight (named family)
pub size: f32,
pub line_height: f32, // px = size × leading
pub tracking: f32, // extra px per glyph
}
impl TypeStyle {
pub fn font_id(&self) -> FontId; // family + size
}
}
line_height and tracking are not part of egui’s FontId, so they are carried on the
TypeStyle and applied by the Text atom during layout.
That is why text should go through the Text/Heading atoms rather than raw
ui.label — only the atoms honor the full type style.
Icons
#![allow(unused)]
fn main() {
pub fn icon_font(size: f32) -> FontId; // Phosphor glyph at `size`
}
Phosphor glyphs are PUA codepoints resolved via the proportional stack’s icon fallback.
Atoms call icon_font instead of building a FontId by hand. Glyph constants come from
the re-exported crate:
#![allow(unused)]
fn main() {
use ouroboros_ui::egui_phosphor::light::GEAR; // a &'static str glyph
}
The Icon atom is the normal way to render one; the raw
font is for atoms that fold an icon into a larger galley (e.g. Button).
The Size → type-style mapping
The mapping from the shared control Size
scale to a type style lives here, not in core, so the token layer stays a leaf
(theme may reference tokens, not the reverse):
#![allow(unused)]
fn main() {
impl Size {
pub fn text_style(self) -> TypeStyle {
match self {
Size::Lg => body_strong(), // roomy controls
Size::Sm | Size::Md => label(), // dense controls
}
}
}
}
Layout & auto-layout
Two pieces: layout tokens (src/tokens/layout.rs) — fixed dimensions and z-order
roles a layout reads — and the AutoLayout engine (src/auto_layout.rs) — a
Figma-style flow layout for egui.
Layout tokens
egui is immediate-mode (no CSS grid), so these are primitives a component or helper reads. Tune them to the real studio shell.
Panels (px)
| Const | px | Role |
|---|---|---|
SIDEBAR_WIDTH | 240 | left nav / tree sidebar |
INSPECTOR_WIDTH | 300 | right properties / inspector |
PANEL_MIN | 180 | min a resizable panel may shrink to |
PANEL_MAX | 480 | max a resizable panel may grow to |
TOOLBAR_HEIGHT | 40 | top toolbar |
STATUSBAR_HEIGHT | 24 | bottom status bar |
Content grid
GRID_COLUMNS 12 · GRID_GUTTER 16 (= SPACE_4) · CONTAINER_MAX 1200 (max readable
width before centering).
Breakpoints (window width, px)
| Const | px | Below this |
|---|---|---|
BREAKPOINT_COMPACT | 720 | compact — single column, collapsed panels |
BREAKPOINT_NORMAL | 1024 | normal — one side panel |
BREAKPOINT_WIDE | 1440 | wide — both side panels, roomy |
Component-level thresholds: FIELD_HORIZONTAL_MIN 480 (a Field
goes side-by-side at/above this, else stacks), PROPERTY_LABEL_WIDTH 120 (fixed label
column for PropertyRow), TABLE_ROW_HEIGHT 28.
SizeClass
#![allow(unused)]
fn main() {
pub enum SizeClass { Compact, Normal, Wide }
SizeClass::from_width(available_width) -> SizeClass
}
Classifies an available width against the breakpoints (< NORMAL → Compact,
< WIDE → Normal, else Wide) so a component can adapt density.
Layer — z-order roles
Stacking roles for floating surfaces, mapped onto egui::Order. Ordered base → tooltip.
#![allow(unused)]
fn main() {
pub enum Layer { Base, Dropdown, Popover, Modal, Toast, Tooltip }
}
| Method | Returns |
|---|---|
order() | the egui::Order (Base→Middle; Dropdown/Popover/Modal/Toast→Foreground; Tooltip→Tooltip) |
priority() | relative priority within a shared order (higher = on top; the enum’s discriminant) |
egui’s order set is coarse; finer ordering within a layer is by creation/priority.
AutoLayout — Figma-style flow
A flexbox-like flow layout for egui that mirrors the exact vocabulary of the studio’s
HUD model (ouroboros-hud::model) — LayoutDirection, MainAlign, CrossAlign, Gap,
Padding, SizeMode — so designers get one mental model across the engine HUD and the
studio UI. It is re-declared (not imported) to keep ouroboros-ui standalone.
Model
| Type | Variants | Meaning |
|---|---|---|
LayoutDirection | Horizontal, Vertical (default) | the main axis children flow along |
MainAlign | Start (default), Center, End | alignment of the child block on the main axis |
CrossAlign | Start (default), Center, End | per-child alignment on the cross axis |
Gap | Fixed(px) (default 0), Auto | spacing; Auto = space-between (distributes leftover, ignores MainAlign) |
SizeMode | Fixed(px), Hug (default), Fill | per-child main-axis sizing |
Sizing | { mode, min, max } | a SizeMode plus optional px clamps (a bare SizeMode converts) |
Padding — all(v) or symmetric(x, y); fields top/right/bottom/left.
Sizing — mode × min/max
Each child’s main-axis size is a Sizing: a mode plus optional min/max floors and
ceilings (min wins over max, like the HUD solver). Constructors are const:
Sizing::fixed(px) / ::hug() / ::fill(), then .min(px) / .max(px) /
.clamped(min, max).
| Mode | Without clamps | min | max |
|---|---|---|---|
Fixed(px) | exactly px | floors px | caps px |
Hug | sizes to content (bounded by the budget) | never shrinks below min, even when content is smaller | caps content, even when content wants more |
Fill | shares leftover space with other fills | never shrinks below min — a responsive column that won’t collapse | stops growing at max; the excess is redistributed to the other fills |
Hug measures content against the budget: a greedy child (one that expands to
available_width) measures as the whole budget — for “should fill” controls use Fill
(optionally clamped) instead of Hug.
Builder
#![allow(unused)]
fn main() {
AutoLayout::horizontal() // or ::vertical()
.gap(8.0) // fixed gap…
.gap_auto() // …or space-between
.gap_cross(8.0) // gap between wrapped lines (defaults to main gap)
.pad(12.0) // .padding(Padding) / .pad_xy(x, y)
.main_align(MainAlign::Center)
.cross_align(CrossAlign::Center)
.wrap() // reflow onto new lines (horizontal only)
.allow_overflow() // opt out of budget clamping + cell clipping
.fixed(28.0, |ui| { /* icon */ }) // child with fixed main size
.fill(|ui| {}) // flexible spacer / growing child
.fill_min(220.0, |ui| {}) // fill that floors at 220px
.fill_clamped(80.0, 160.0, |ui| {})// fill clamped to [80, 160]px
.hug(|ui| { /* button */ }) // child sized to content
.hug_max(240.0, |ui| {}) // hug capped at 240px
.child(SizeMode::Fill, |ui| {}) // explicit form…
.sized(Sizing::fill().min(120.0), |ui| {}) // …or with a prebuilt Sizing
.show(ui) -> Response
}
Example — toolbar with a trailing button
#![allow(unused)]
fn main() {
AutoLayout::horizontal()
.gap(8.0).pad(12.0).cross_align(CrossAlign::Center)
.fixed(28.0, |ui| { Icon::new(GEAR).show(ui); })
.fill(|ui| {}) // spacer pushes the next child to the end
.hug(|ui| { Button::new("Save").show(ui); })
.show(ui);
}
Example — responsive columns (fill_min)
Two form columns that share the panel but never collapse below a readable width — when
the panel is squeezed under 2 × 220 + gap, the cells keep their floors and the frame
clips as a last resort instead of overlapping:
#![allow(unused)]
fn main() {
AutoLayout::horizontal()
.gap(24.0)
.fill_min(220.0, |ui| left_column(ui))
.fill_min(220.0, |ui| right_column(ui))
.show(ui);
}
Example — stat grid (wrap)
One row when wide, reflowing to 2–3 lines when narrow; each cell floors at 72px and the fills on a line share that line’s remainder:
#![allow(unused)]
fn main() {
AutoLayout::horizontal().wrap().gap(8.0).gap_cross(8.0)
.fill_min(72.0, |ui| stat(ui, "STR"))
.fill_min(72.0, |ui| stat(ui, "AGI"))
// … 4 more cells
.show(ui);
}
How it works
Child closures are FnMut: they run once invisibly to measure (a sizing_pass ui),
then once for real at computed cells. The algorithm:
- Measure pass A (bounded) — render each
Fixed/Hugchild invisibly, bounded on both axes by the frame’s budget (the available space), and clampHugby itsmin/max. Content can never measure wider than the panel it lives in. - Resolve
Fill— distribute the leftover main-axis space amongFillchildren,min/max-aware: whoever clamps is pinned and its excess is redistributed among the rest (the HUD solver’sdistribute_fill). - Measure pass B — measure each
Fillchild’s cross size at its resolved main size, so wrapping content (labels, alerts) reports its real height. - Container sizing — the frame never exceeds a finite budget: with
Fill,Gap::Auto, or non-Startalign it claims the available main axis; otherwise it hugs content, clamped to the budget. - Distribution — leftover space goes to:
Auto→ even gaps between children;Fillchildren → already consumed; otherwise → a start offset perMainAlign. - Render — allocate the frame, then place each child in an explicit cell rect. Each cell is clipped as a last resort (with a small bleed for focus rings): with correct sizing it never bites, it only stops legitimate overflow (e.g. floors inside a panel squeezed below their sum) from painting over siblings.
allow_overflow() opts out of both the budget clamp and the cell clipping — the legacy
behavior, for the rare container that scrolls itself.
Wrap
wrap() (horizontal only) reflows children onto new lines when they don’t fit — Figma’s
“wrap”. Line breaking is greedy over each child’s intrinsic contribution (Fixed/Hug →
natural size, Fill → its min or 0), with at least one child per line; then each line
is laid out like a non-wrapping row, so a Fill child takes the remainder of its line.
Spacing between lines comes from gap_cross(px) (defaults to the main gap). Not
supported by the rect-returning layout() path.
Responsive contract (anti-ratchet)
The frame’s budget comes from the parent — a Splitter panel rect, a window — which is
exogenous to the content. Because measurement is bounded by that budget and never feeds
back into it, layout is idempotent per frame: dragging a panel out and back yields the
same rects, with no ratchet (content can’t “remember” the widest it ever was). Inside a
scroll axis there is no finite budget; measurement is effectively unbounded there and
Fill resolves to its floor.
Cost note: every child renders twice (measure + real). It is cheap for typical toolbar/row counts, but don’t nest deeply with heavy children in a hot per-frame path.
Governance — use first, extend second, create last
This is the law of the design system. architecture.md explains how the system is built; this page rules how it is consumed and grown. Every rule here is mechanically enforced (see Enforcement) — a violation is a red build, not a review nit.
The rule in one sentence
Studio UI is built from
ouro_dscomponents. If a component is missing a capability, you contribute it to the DS — you never hand-roll raw egui chrome and never hardcode a color outside a canvas.
The studio (and every studio crate) is a consumer. The DS is the single place visual decisions live, so one fix retunes every screen, the Dark/Light toggle keeps working everywhere, and “did someone hardcode a color” stays a CI failure instead of a judgment call.
Decision ladder
When you need a piece of UI, walk this ladder top-down and stop at the first rung that works.
1. Use — the catalog first
The DS has 71 components across atoms → cells → molecules → organisms plus the graph peer layer. Before building anything, check whether it already exists:
-
Component catalog — every component documented by layer, with design intent, API, and usage examples.
-
The storybook — the living visual reference, every component and token rendered:
cargo run --example storybook
Most “I need a custom widget” cases are an existing component plus a builder you hadn’t
seen (Text alone has roles, .muted(), .color(), .wrap(), .underline(),
.italic()).
2. Extend — a new builder on an existing component
If the component exists but lacks one capability, add a builder setter to it instead
of forking or hand-rolling. Real example: the studio needed checkable View-menu rows;
instead of ui.checkbox(..) inside a menu, MenuItem gained
.checked(bool) — a check mark when true, a
reserved slot when false so siblings stay aligned. One small PR, and every menu in
every studio crate can now have toggle items.
An extension still walks the contribution pipeline below (tokens → storybook → test → doc), just scoped to the new setter.
3. Create — a new component
Only when use and extend genuinely don’t fit: the piece is a new shape, not a variant of an existing one. Follow the component contribution pipeline — starting with the spec-lite paragraph that says why the first two rungs were not enough.
What is forbidden in studio chrome
The studio’s ds_governance guard scans every studio crate and hard-fails on the
patterns below. This table is the exact mirror of that guard — the substitution is always
a DS component or a theme token.
| Forbidden pattern | Use instead |
|---|---|
ui.label(..) | atoms::Text |
ui.button(..) | atoms::Button |
ui.checkbox(..) | atoms::Checkbox / cells::MenuItem::checked |
ui.separator() | atoms::Divider::horizontal() / ::vertical() |
ui.heading(..) | atoms::Heading |
ui.selectable_label(..) | cells::ListItem::new(..).selected(..) / cells::MenuItem |
ui.text_edit_* / TextEdit::* | atoms::Input / atoms::Textarea |
ComboBox::* | organisms::Select |
DragValue | atoms::NumericField |
egui::Slider::* | atoms::Slider |
RichText::new(..) | atoms::Text builders (.muted() / .caption() / .color() / .wrap() / .italic()) |
egui::Button::new(..) | atoms::Button / cells::MenuItem |
ui.radio_value(..) | atoms::Radio / molecules::ToggleGroup |
Color32::from_*(<literal>) and Color32::<CONST> (except TRANSPARENT) | Theme tokens (theme.hover_overlay, theme.muted, theme.scrim, theme.info, …) |
FontId::* | theme::typography |
ui.menu_buttonis allowed — as a container. egui owns the popup placement; the rows inside it arecells::MenuItem/atoms::Divider, never raw widgets.
Escapes
Two sanctioned escape hatches, both designed to be visible — never silent.
CANVAS_ALLOWLIST — content rendering is not chrome
Some studio modules render content: world viewport canvases, node-graph surfaces,
sprite previews. There, a color is data (a tile’s terrain hue, an event wire), not a
design decision — painting pixels is the feature. These paths are listed in the
CANVAS_ALLOWLIST of the studio guard
(crates/ouroboros-studio/tests/ds_governance.rs):
ouroboros-studio/src/modules/events/canvas.rs
ouroboros-studio/src/modules/interface/canvas.rs
ouroboros-studio/src/modules/world/ui/map_canvas/
ouroboros-level-editor/src/render/
ouroboros-sprite-studio/src/render/
ouroboros-visualizer/src/render/
Inside the allowlist, the color/paint class is exempt. The chrome class is not — a toolbar over a canvas is still built from DS components. A stale allowlist entry (file moved or renamed) fails the guard, so the list can’t rot.
// ds-allow: <reason> — the one-off escape
For a single legitimate exception, annotate the line (or the line directly above):
#![allow(unused)]
fn main() {
// ds-allow: egui demo window needs its own raw label for the comparison screenshot
ui.label("raw egui baseline");
}
The reason is mandatory — a bare ds-allow: is itself a violation. The annotation is
the review trail: every escape is grep-able and visible in the diff, so a reviewer can
challenge it.
Component contribution pipeline
How a new component (or an extension to one) enters the DS. The
mad.component skill in claude-skills walks these steps guided.
0. Spec-lite
One paragraph: the concrete use case, and why use and extend are not enough. This goes in the PR description. If you can’t write the paragraph, you’re on the wrong rung of the ladder.
1. Pick the layer
- atom — it needs to paint primitives (fill, stroke, galley). Atoms are the only layer allowed to touch the painter.
- cell / molecule / organism — it composes existing pieces. These layers never
paint — enforced by the
no_painter_in_moleculesguard. If you reach for a painter here, stop: the missing piece becomes an atom first. - graph — the node-editor peer layer; paints, but only through tokens.
See architecture.md for the full layer model.
2. Tokens first
Any new visual value (a color, a spacing, a radius, an overlay alpha) becomes a token
before it is used: a core primitive in src/tokens/core.rs, surfaced through a Theme
field in src/tokens/semantic.rs if it carries meaning. Never a literal in the component
— the no_raw_values guard rejects it.
3. Builder pattern
Every component, every layer, the same shape:
#![allow(unused)]
fn main() {
Component::new(required_args)
.setter(value) // chainable, returns Self
.show(ui) // consumes self, returns egui::Response
}
4. Storybook
Add an entry to the Page enum and a page (or extend the existing page) in
examples/storybook.rs. Without a demo there is nothing to validate visually — and
nothing for the next person walking rung 1 to find.
5. Test
A kittest test in tests/atoms.rs (or the layer’s suite): the Harness +
Theme::install pattern — at minimum a smoke render, ideally an interaction or layout
assertion.
6. Doc
A page in docs/components/<layer>/<name>.md following the
page template, plus a line in the
catalog index. For an extension: update the existing page’s
API table.
7. Green build
cargo test # includes the guards
cargo clippy --all-targets -- -D warnings
cargo fmt
8. Ship
PR against ouroboros-ui (develop) → merge → bump the ui/ submodule in the studio
repo (with the follow-up studio PR adopting the new API, when applicable).
Enforcement
The ladder is not honor-system. Three layers of machinery:
The DS’s own guards (this repo, run in CI)
tests/no_raw_values.rs— atoms and graph paint only with tokens: no literalColor32, no named color consts, no rawFontId/ stroke / radius.tests/no_painter_in_molecules.rs— cells / molecules / organisms compose, never paint.
Both run with cargo test, and CI runs the full test suite — the guards are part of
the gate, not optional extras. Details: guards.md.
The studio guard (consumer side)
crates/ouroboros-studio/tests/ds_governance.rs in the studio repo — hard fail, two
classes:
- Chrome class — the raw-widget patterns from the
table above, scanned across every studio crate.
Escape:
// ds-allow: <reason>. - Color/paint class — literal
Color32values andFontIdconstruction outside theCANVAS_ALLOWLIST.
Clippy disallowed_methods
The studio’s clippy.toml disallows the constructors clippy can resolve by path —
egui::ComboBox::*, egui::DragValue::new, egui::Slider::new — so those are caught
at lint time with a pointer back to this document. The guard test covers the
method-call patterns (ui.label(..)-style) that clippy cannot.
Guards & conventions
The atomic-design rules are not honor-system — two test guards in tests/ enforce them,
and they run with cargo test (so a violation is a red build, not a review nit). This
page documents exactly what they catch and how to add a component without tripping them.
Guard 1 — atoms (and graph) paint only with tokens
tests/no_raw_values.rs → test atoms_use_only_tokens. Recursively scans
src/atoms/**/*.rs and src/graph/**/*.rs and fails on any hardcoded design value.
Comments are stripped first, so prose mentioning a pattern is fine.
The graph layer is allowed to paint (a node editor needs grid dots, wires, handles), but it is held to the same purity contract as atoms — its colors resolve from
Theme(viaGraphTokens) and geometry fromcore. That is why this guard scans it too.
| Pattern flagged | Why | Use instead |
|---|---|---|
Color32::from_*(…) | hardcoded color | a Theme field or core::* color |
Color32::<CONST> (e.g. Color32::WHITE) | named color constant | a Theme/core color |
FontId::new(…) | hand-built font | theme::typography (TypeStyle::font_id, icon_font) |
Stroke::new(<digit>…) | raw stroke width | core::BORDER_THIN / BORDER_FOCUS |
CornerRadius::same(<digit>…) | raw radius | core::RADIUS_* |
.expand(<digit>…) | raw offset | a token (e.g. core::RING_OFFSET) |
The numeric checks are heuristic: tokens are named consts, so an argument starting with a
letter or ( is accepted; a leading digit means a raw value and trips the guard.
(Color32 as a bare type is fine — only Color32:: followed by an uppercase name or
from_ is flagged.)
Sizes/radii/spacing passed positionally elsewhere aren’t all machine-checkable — those are caught in review. The guard covers the high-frequency mistakes.
Guard 2 — above atoms you compose, never paint
tests/no_painter_in_molecules.rs → test molecules_compose_never_paint. Scans
src/cells/, src/molecules/, and src/organisms/ (despite the file name) and fails
on any direct painting call:
ui.painter( .painter() rect_filled rect_stroke circle_filled
circle_stroke layout_no_wrap layout_job .galley( Shape::line
hline( vline(
The rule: if a cell/molecule/organism needs to paint something, the missing piece
becomes an atom (atoms are the only layer allowed to paint — see src/atoms/surface.rs,
the painting primitive everything composes for fills/borders).
src/graphis deliberately not scanned by this guard. The node-editor layer is the sanctioned exception that paints directly (grid/wires/handles/marquee) — painting there is by design. It is still held to token purity by Guard 1, which does scan it.
The conventions behind the guards
- Builder +
show(ui) -> Response. Every component, every layer. Required args innew, optional props as chainable setters,showconsumes self and returns anegui::Response. - Read the theme at the top of
show.let theme = Theme::get(ui);— this is what makes a component react to mode switches for free. - State transitions go through the shared helpers. Hover via
core::hover_t, disabled viacore::disabled_color, so every component animates identically. - Domain extensions are marked. Where the system extends shadcn (status variants,
Sizedensities), the addition is ours by intent — base variants/anatomy follow shadcn. - New component → storybook page. Every new component or variant gets an entry in
examples/storybook.rs; without a demo there is nothing to validate visually.
Adding a component without tripping the guards
A new atom
src/atoms/<name>.rs— builder struct,show(self, ui) -> Response.- Source all colors from
Theme::get(ui)/core::*, fonts fromtypography, strokes/radii fromcore::*. NoColor32::from_, noFontId::new, no raw-digitStroke::new/CornerRadius::same/.expand. - Animate state with
core::hover_t/core::disabled_color. - Register in
src/atoms/mod.rs(pub mod+pub use). - Add a storybook page.
cargo test(guard 1 must stay green) +cargo fmt.
A new cell / molecule / organism
- File under the right layer dir; builder +
show. - Compose atoms only — no painting calls from the forbidden list. If you reach for a painter, stop and extract an atom first.
- Use
auto_layout/ egui layout for arrangement; useSurface(atom) for any fill/border/casing. - Overlay organisms place with egui
Modal/Area/Popup; the casing is either aSurfaceatom or themed egui visuals driven byThemetokens — never hand-painted. - Register in the layer
mod.rs, add a storybook page,cargo test+cargo fmt.
Running the guards
cargo test # both guards + unit tests
cargo test --test no_raw_values # atoms-only
cargo test --test no_painter_in_molecules
A failure prints every offending path:line with the suggested token, so fixes are
mechanical.
Component catalog
60 components across four atomic-design layers, plus the graph peer layer (node editor). Each has its own page with design intent, anatomy, variants/states, API, and usage examples. Layer rules: atoms paint, everything above composes; the graph layer is the sanctioned exception that paints (still via tokens). See guards.
Atoms
23 leaf components — the only layer that paints primitives. Each is a token-driven builder.
Typography & content
- Text — body/label/caption text honoring the full type style
- Heading — display→h-levels titles
- Icon — a Phosphor glyph at a token size
- Kbd — keyboard key cap
Actions
- Button — the worked-example atom; 6 variants, 3 sizes, icons, loading
- Toggle — a pressable on/off button
Form controls
- Input — single-line text field
- Textarea — multi-line text field
- NumericField — numeric input with step
- Checkbox — boolean check
- Radio — single-choice dot
- Switch — on/off slider
- Slider — range selector
Display & feedback
- Badge — status/label pill
- Avatar — user image/initials
- Progress — determinate progress bar
- Spinner — indeterminate loading
- Skeleton — content placeholder
- Tooltip — hover hint
Structural
- Surface — the painting primitive (fill/border/radius/shadow) everything composes
- Divider — horizontal/vertical rule
- ColorSwatch — a painted color chip
- SplitterHandle — drag handle for resizable panels
Cells
8 compound row/item building blocks. Compose atoms; never paint.
- ListItem — selectable list row
- MenuItem — menu row (icon + label + shortcut)
- PropertyRow — inspector row (fixed label column + control)
- ResponsiveRow — inspector row that stacks label↔control when narrow
- TableCell — a single table cell with alignment
- TableRow — a row of table cells
- ToolbarButton — dense toolbar control
- TreeNode — tree row with expand/select
Molecules
14 compositions of atoms (and smaller molecules).
Forms
- Field — label + control wrapper (+
FieldGroup,FieldSet,FieldSeparator) - InputGroup — input with prefix/suffix slots
- SearchField — input preset for search
- ColorField — color input field
- VectorField — multi-component numeric (x/y/z) field
- RadioGroup — grouped radios
- ToggleGroup — segmented toggle buttons
- CheckboxCard — checkbox as a selectable card
- RadioCard — radio as a selectable card
Containers & navigation
- Card — styled surface container
- Alert — inline banner (default/success/warning/error/info)
- Tabs — tab switcher (default/pill)
- Breadcrumb — navigation trail
- Collapsible — expand/collapse section
Organisms
14 full UI sections composed from cells, molecules, and atoms.
Layout shells
- Splitter — the single layout primitive: screen root + resizable panes (
PanelSpec, withfixed(px)non-resizable chrome bands) - Panel — docked panel chrome: bg + flush edge border + header/footer + padded scroll body (
PanelEdge) - Sidebar — navigation panel
- Toolbar — top/bottom tool bar
- Menubar — application menu bar
Overlays
- Dialog — modal dialog
- Popover — floating anchored surface
- DropdownMenu — menu in a popup
- Toast — transient notification
- Select — select/combo dropdown
Data & views
- Table — column-defined data table (
Column,ColWidth) - TreeView — hierarchical tree (
TreeItem) - TabView — tabbed content view
- Accordion — stacked collapsible sections (
AccordionCtx)
Graph
The graph peer layer — a reactflow-style node editor on
egui::Scene. The one place outside atoms that paints (still via tokens). Caller owns the
data; the library owns view-state and reports intents.
- Graph layer overview — invariant, two tiers, data-model contract, lifecycle
- identity —
NodeId/PortId/NodeKindId/PortSide/Port/Connection - canvas —
GraphView,GraphCtx,GraphResponse - state —
GraphViewState+ drag structs - tokens —
GraphTokens - node —
NodeFrame/NodeResult/NodeStatus+ctx.node - edge —
EdgeStyle/EdgeResult+ctx.edge - handle —
HandleSpec/HandleVariant(ports) - search —
NodeSearchpalette - viewport — standalone world↔screen transform helper
- extras —
grid,resizer,minimap,toolbar,controls
Page template
Every component page follows the same structure: what it is → Design (purpose, anatomy, variants/sizes/states, tokens consumed, a11y) → API (builder methods) → Usage (minimal + realistic examples) → Composition → Notes.
Avatar
Layer: atom · Path:
src/atoms/avatar.rs· Exports:avatar::{Avatar, AvatarSize}
A circular avatar that renders centered, uppercased initials over a muted-filled disc. This wave is initials-only — image loading is a documented later addition. The diameter and the type style both scale together with [AvatarSize].
Design
-
Purpose / when to use — represent a user/entity compactly when you have no image. Reach for it in lists, headers, mention chips. Do NOT use it for arbitrary iconography (that is
Icon). -
Anatomy — a filled circle (
theme.muted) + a centered initials galley intheme.foreground. Initials are uppercased at render time. -
Variants / sizes / states — three sizes, no interactive states (sense is
hoveronly):Size Diameter token Type style Smcore::CONTROL_SM(26px)typography::caption()Md(default)core::CONTROL_MD(32px)typography::label()Lgcore::CONTROL_LG(38px)typography::body_strong() -
Tokens consumed —
theme.muted(disc fill),theme.foreground(initials),core::CONTROL_*(diameter), typography styles viatheme::typography. -
Accessibility — none beyond the bare
Response; it allocates withSense::hover()and emits nowidget_info.
API
| Signature | Effect |
|---|---|
Avatar::new(initials: impl Into<String>) -> Self | Construct with initials text. |
.size(size: AvatarSize) -> Self | Set the size. |
.sm(self) -> Self | Shorthand for AvatarSize::Sm. |
.lg(self) -> Self | Shorthand for AvatarSize::Lg. |
.show(self, ui: &mut Ui) -> Response | Allocate, paint, return the hover Response. |
AvatarSize (enum): Sm, Md (default), Lg.
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Avatar;
Avatar::new("JD").show(ui);
}
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::{Avatar, AvatarSize};
let resp = Avatar::new("ab").size(AvatarSize::Lg).show(ui); // renders "AB"
if resp.hovered() { /* … */ }
}
Composition
Atom: paints directly with tokens (circle_filled + a layout-job galley). It does not compose other atoms; the initials galley is built inline rather than via Text.
Notes
- Initials are
.to_uppercase()d internally — pass any case. - The galley uses
extra_letter_spacing = style.trackingand is centered both axes. - No truncation/length cap: long strings will overflow the disc; pass 1–2 chars.
See tokens · theming · typography.
Badge
Layer: atom · Path:
src/atoms/badge.rs· Exports:badge::{Badge, BadgeVariant}
A static, non-interactive pill label with the shadcn variant set plus three domain status variants (Success/Warning/Info). Variant resolves to a [BadgeTokens] bundle (fill/foreground/border/underline); the atom paints a rounded pill, an optional leading status dot, and a centered text galley.
Design
-
Purpose / when to use — annotate state, count, or category inline (e.g. “Beta”, “3”, a status dot). It is read-only; for a clickable chip use
ButtonorToggle. -
Anatomy — pill rect (radius = half-height) → optional fill → optional border stroke → optional leading dot (in
foreground) → centered text galley. -
Variants / sizes / states — no hover/focus/disabled (sense is
hoveronly).Variants (
BadgeVariant):Default,Secondary,Destructive,Outline,Ghost,Link,Success,Warning,Info. Each maps to aBadgeTokens::*constructor;Linkcarriesunderline = true.Size Padding (x, y) Text style Sm(SPACE_1, SPACE_1)caption()Md(default)(SPACE_2, SPACE_1)caption()Lg(SPACE_3, SPACE_1)label() -
Tokens consumed —
BadgeTokens(fill/border/foreground/underline, per variant),core::SPACE_1..3(padding/gap/dot),core::BORDER_THIN(border + underline stroke). Fill/border are only painted when alpha > 0. -
Accessibility — none beyond the bare
Response; nowidget_info.
API
| Signature | Effect |
|---|---|
Badge::new(text: impl Into<String>) -> Self | Construct with label text. |
.variant(variant: BadgeVariant) -> Self | Set variant. |
.size(size: Size) -> Self | Set size (core::Size). |
.sm(self) -> Self / .lg(self) -> Self | Size shorthands. |
.secondary() / .destructive() / .outline() / .ghost() / .link() / .success() / .warning() / .info() | Variant shorthands. |
.dot(self) -> Self | Show a leading colored status dot. |
.show(self, ui: &mut Ui) -> Response | Paint and return the hover Response. |
BadgeVariant (enum): Default (default), Secondary, Destructive, Outline, Ghost, Link, Success, Warning, Info.
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Badge;
Badge::new("New").show(ui);
}
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::{Badge, BadgeVariant};
Badge::new("Online").success().dot().show(ui);
Badge::new("Deprecated").variant(BadgeVariant::Destructive).show(ui);
}
Composition
Atom: paints the pill and builds the text galley inline with token fonts/colors. Does not compose Text or Icon.
Notes
- The dot uses
core::SPACE_2diameter and is filled withbt.foreground. Linkis the only variant that draws an underline (viaBadgeTokens.underline).- Pill corner radius is computed from the final rect height, so it stays fully rounded at any size.
See tokens · theming · typography.
Button
Layer: atom · Path:
src/atoms/button.rs· Exports:button::{Button, ButtonVariant}
A labeled, optionally-iconed click control with the shadcn variant set. Icon(s) and label render as a single galley with each section valign-centered, so mixed icon/text fonts share one optical baseline. Fill/foreground/border come from [ButtonTokens]; hover/press/disabled/focus all come from motion/opacity/border tokens, so light+dark and every state stay token-driven.
Design
-
Purpose / when to use — the primary click affordance. Use
icon_only()for toolbar buttons,loading(true)for in-flight actions. For a two-state pressable useToggle; for a boolean setting useSwitch. -
Anatomy — filled rect (variant fill) → animated hover overlay → press overlay → optional border stroke → focus ring (on focus) → centered content galley (icon-left, label, icon-right) or an indeterminate spinner arc when loading.
-
Variants / sizes / states
Variants (
ButtonVariant):Default,Secondary,Destructive,Outline,Ghost,Link. Each maps to aButtonTokens::*constructor;Linkunderlines its label.Size Height Icon size Pad-x Text style SmCONTROL_SM26ICON_SM14SPACE_3Size::text_style()Md(default)CONTROL_MD32ICON_MD16SPACE_4… LgCONTROL_LG38ICON_LG20SPACE_4… States: hover (
theme.hover_overlayramped bycore::hover_t), pressed (theme.press_overlay), focused (focus ring), disabled (enabled(false)→ colors viacore::disabled_color, sense drops to hover), loading (spinner arc, clicks ignored, width preserved). -
Tokens consumed —
ButtonTokens(fill/foreground/border/radius/underline),theme.hover_overlay,theme.press_overlay,theme.ring,core::SPACE_2(icon gap),core::BORDER_THIN/BORDER_FOCUS,core::hover_t,core::disabled_color,core::Size,typography::icon_font. -
Accessibility — emits
WidgetInfo::labeled(WidgetType::Button, enabled, label). Focus ring viafocus::focus_ring_rect. Hit target = full allocated rect (square whenicon_only).
API
| Signature | Effect |
|---|---|
Button::new(label: impl Into<String>) -> Self | Construct with a label. |
.variant(v: ButtonVariant) -> Self | Set variant. |
.secondary() / .destructive() / .outline() / .ghost() / .link() | Variant shorthands. |
.size(s: Size) -> Self / .sm() / .lg() | Size (core::Size). |
.icon_only(self) -> Self | Square button, label dropped. |
.loading(loading: bool) -> Self | Replace content with a spinner; ignore clicks; preserve width. |
.icon_left(glyph: &'static str) -> Self | Leading Phosphor glyph. |
.icon_right(glyph: &'static str) -> Self | Trailing Phosphor glyph. |
.enabled(enabled: bool) -> Self / .disabled() | Enable/disable. |
.id_source(id: impl Hash) -> Self | Stable id for the hover animation (else response.id). |
.show(self, ui: &mut Ui) -> Response | Paint and return the Response (clicked, hovered, …). |
ButtonVariant (enum): Default (default), Secondary, Destructive, Outline, Ghost, Link.
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Button;
if Button::new("Save").show(ui).clicked() {
// …
}
}
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Button;
use ouroboros_ui::egui_phosphor::light;
let saving = true;
let resp = Button::new("Save")
.destructive()
.icon_left(light::FLOPPY_DISK)
.loading(saving)
.show(ui);
if resp.clicked() && !saving { /* … */ }
// icon-only toolbar button
Button::new("").icon_left(light::GEAR).icon_only().ghost().sm().show(ui);
}
Composition
Atom: paints fill/overlays/border/ring directly. Builds icon+label as one inline LayoutJob (does NOT compose the Icon/Text atoms — single-galley alignment is intentional). The loading arc is the same form as the Spinner atom, inlined.
Notes
loading(true)overrides clicks regardless ofenabled; width is computed from the content galley so the button does not resize when toggled into loading.icon_only()drops the label from layout but the label string is still reported inwidget_info— pass a meaningful label for a11y even withicon_only.- The hover animation keys off
id_sourceif set; give icon-only buttons in a loop a stableid_sourceto avoid animation cross-talk.
See tokens · theming · typography · guards.
Checkbox
Layer: atom · Path:
src/atoms/checkbox.rs· Exports:checkbox::Checkbox
A boolean checkbox bound to a &mut bool, with an optional trailing label. The box is painted from tokens (border input; when on, primary fill + a centered check glyph in primary_foreground); the label is rendered via the Text atom. Supports an indeterminate (dash) presentation, focus ring, and disabled dim.
Design
-
Purpose / when to use — toggle a single boolean, or one item in a multi-select set. For an on/off setting that reads like a switch use
Switch; for mutually-exclusive options useRadio. -
Anatomy — square box (left, vertically centered) → optional
primaryfill when on → border stroke → hover veil → check/minus glyph when on → focus ring → optional labelTextto the right. -
Variants / sizes / states
Size Box size SmICON_SM14Md(default)ICON_MD16LgICON_LG20States: checked (
primaryfill +light::CHECK), indeterminate (light::MINUS, takes visual precedence over checked, presentational only), hover (hover_tveil), focus (ring), disabled (disabled_colordim; sense drops to hover), non-interactive (interactive(false)— display-only, no toggle). -
Tokens consumed —
theme.primary,theme.primary_foreground,theme.input,theme.foreground,theme.hover_overlay,theme.ring,core::RADIUS_SM,core::SPACE_2(gap),core::BORDER_THIN,core::hover_t,core::disabled_color,typography::body,typography::icon_font. -
Accessibility — emits
WidgetInfo::selected(WidgetType::Checkbox, enabled, on, label). Focus ring viafocus::focus_ring_rect. Hit target spans box + gap + label.
API
| Signature | Effect |
|---|---|
Checkbox::new(checked: &mut bool) -> Self | Bind to a boolean. |
.interactive(interactive: bool) -> Self | Display-only when false (no click/toggle). |
.indeterminate(indeterminate: bool) -> Self | Show the mixed/dash state (visual precedence). |
.size(s: Size) -> Self / .sm() / .lg() | Size (core::Size). |
.label(label: impl Into<String>) -> Self | Add a trailing label. |
.enabled(enabled: bool) -> Self / .disabled() | Enable/disable. |
.id_source(id: impl Hash) -> Self | Stable id (else response.id). |
.show(self, ui: &mut Ui) -> Response | Toggle on click, mark_changed, return Response. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Checkbox;
let mut agreed = false;
if Checkbox::new(&mut agreed).label("I agree").show(ui).changed() {
// agreed flipped
}
}
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Checkbox;
// tri-state header in a select-all pattern
let mut all = false;
Checkbox::new(&mut all)
.indeterminate(some_selected && !all)
.label("Select all")
.show(ui);
}
Composition
Atom: paints the box and glyph directly; composes the Text atom for the label inside a child Ui.
Notes
- Binding is
&mut bool. On a click the atom flips*checkedand callsmark_changed(), so check.changed(). indeterminate(true)only changes the painted glyph (dash); it does not alter*checked. The consumer is expected to clear it on the next interaction.interactive(false)makes it display-only — clicks pass through (useful inside a clickable card).- Label width reserves extra room for the role’s letter-spacing so it does not clip.
See tokens · theming · typography · guards.
ColorSwatch
Layer: atom · Path:
src/atoms/color_swatch.rs· Exports:color_swatch::ColorSwatch
A swatch displaying an arbitrary Color32 (consumer data, not a theme token) in a token-bordered square or circle. Senses clicks so a parent can open a color picker. Modeled on Unity’s ColorField.
Design
- Purpose / when to use — the base of a color field: show the current color and let the user click to edit. The fill color is application data, not a token.
- Anatomy — a filled square (
RADIUS_SMcorners) or circle, with atheme.borderstroke. Fill = the suppliedColor32. - Variants / sizes / states — square (default) or
circle(). Size is a freef32(defaultcore::ICON_LG= 20px). Sensesclickbut paints no hover/focus/disabled state. - Tokens consumed —
theme.border(stroke),core::RADIUS_SM(square corners),core::ICON_LG(default size). The fill is intentionally non-token (consumer color). - Accessibility — bare
ResponsefromSense::click(); nowidget_info.
API
| Signature | Effect |
|---|---|
ColorSwatch::new(color: Color32) -> Self | Construct with the color to display. |
.size(size: f32) -> Self | Set side/diameter in px (default ICON_LG). |
.circle(self) -> Self | Render as a circle instead of a rounded square. |
.show(self, ui: &mut Ui) -> Response | Paint and return the click Response. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::ColorSwatch;
use egui::Color32;
ColorSwatch::new(Color32::from_rgb(220, 80, 80)).show(ui);
}
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::ColorSwatch;
let mut color = some_color;
if ColorSwatch::new(color).size(28.0).circle().show(ui).clicked() {
// open a picker, mutate `color`
}
}
Composition
Atom: paints fill + border directly with rect_filled/rect_stroke (or circle_*). Composes no other atoms.
Notes
- The fill is a raw
Color32from the consumer — it deliberately bypasses the token system, unlike every other painted atom. - No picker is built in;
showonly returns the click. Wire it to your own color-editing UI.
Divider
Layer: atom · Path:
src/atoms/divider.rs· Exports:divider::{Axis, Divider}
A hairline rule in the border token, horizontal or vertical. Default thickness is core::BORDER_THIN; a horizontal divider fills the available width, a vertical one fills the available height. Color and weight are overridable.
Design
-
Purpose / when to use — separate content groups, underline a tab indicator (
thick()), or draw a red rule (destructive()). For an interactive resize band between panels useSplitterHandle. -
Anatomy — a single
hline/vlinestroke. Horizontal: width =ui.available_width(), allocated height = weight. Vertical: height =ui.available_height(), allocated width = weight. -
Variants / sizes / states
Builder Effect horizontal()Axis::Horizontal, bordercolor,BORDER_THIN.vertical()Axis::Vertical, bordercolor,BORDER_THIN..thick()Weight = BORDER_FOCUS(2px) — e.g. tab underline..destructive()Color = theme.destructive..color(c)Explicit color override (wins over destructive). No hover/focus/disabled states (sense is
hoveronly). -
Tokens consumed —
theme.border(default color),theme.destructive(whendestructive()),core::BORDER_THIN(default weight),core::BORDER_FOCUS(whenthick()). -
Accessibility — bare hover
Response; nowidget_info.
API
| Signature | Effect |
|---|---|
Divider::horizontal() -> Self | Construct a horizontal rule. |
Divider::vertical() -> Self | Construct a vertical rule. |
.color(color: Color32) -> Self | Override color. |
.destructive(self) -> Self | Use the destructive token. |
.thick(self) -> Self | Use BORDER_FOCUS weight. |
.show(self, ui: &mut Ui) -> Response | Paint and return the hover Response. |
Axis (enum): Horizontal (default), Vertical.
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Divider;
Divider::horizontal().show(ui);
}
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Divider;
// tab underline indicator
Divider::horizontal().thick().color(theme.primary).show(ui);
// vertical separator in a toolbar row
Divider::vertical().show(ui);
}
Composition
Atom: paints a single stroke directly. Composes no other atoms.
Notes
- A horizontal divider greedily takes the full available width; place it where that is intended, or constrain the parent
Ui. Axisis re-exported and reused bySplitterHandle.
Heading
Layer: atom · Path:
src/atoms/heading.rs· Exports:heading::{Heading, HeadingLevel}
A title rendered in the foreground token at one of four heading levels. Each level maps to a foundation typography type style (font, size, line-height, tracking). Sibling of Text, specialized for titles.
Design
-
Purpose / when to use — section/page titles. For body copy, labels, captions, or code use
Text. -
Anatomy — a single non-selectable
egui::Labelbuilt from aRichTextcarrying the level’sfont_id,line_height,tracking, andtheme.foreground. Wrap mode isExtend(no wrap). -
Variants / sizes / states
Level Type style Displaytypography::display()(largest)H1typography::h1()H2(default)typography::h2()Headingtypography::heading()(smallest)No interactive states.
-
Tokens consumed —
theme.foreground(color), the per-leveltypographystyle (font/size/line-height/tracking). -
Accessibility — renders a non-selectable label; returns the
Label’sResponse.
API
| Signature | Effect |
|---|---|
Heading::new(content: impl Into<String>) -> Self | Construct with title text. |
.level(level: HeadingLevel) -> Self | Set the level. |
.display() / .h1() / .h2() / .heading() | Level shorthands. |
.show(self, ui: &mut Ui) -> Response | Render and return the Response. |
HeadingLevel (enum): Display, H1, H2 (default), Heading.
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Heading;
Heading::new("Settings").show(ui);
}
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::{Heading, HeadingLevel};
Heading::new("Welcome").level(HeadingLevel::Display).show(ui);
Heading::new("Section").heading().show(ui);
}
Composition
Atom: renders directly via egui::Label/RichText with token styling. Does not compose Text (it is a parallel specialization).
Notes
- Wrap mode is
Extend— headings do not wrap; size the parent or keep titles short. - Unlike
Text, there is no color override; the color is alwaystheme.foreground.
See tokens · theming · typography.
Icon
Layer: atom · Path:
src/atoms/icon.rs· Exports:icon::Icon
A single Phosphor glyph at an icon-size token, in a theme color. The glyph is a &'static str constant from egui_phosphor::light (re-exported at the crate root). The font always comes from typography::icon_font — atoms never build a FontId directly.
Design
-
Purpose / when to use — standalone iconography (status marks, decorative glyphs, list bullets). For an icon inside a button, prefer
Button::icon_left/icon_right(single-galley alignment) rather than composing this atom. -
Anatomy — a non-selectable
egui::LabelwithRichText::new(glyph).font(icon_font(size)).color(color). -
Variants / sizes / states
Size shorthand Token .sm()ICON_SM14.md()(default)ICON_MD16.lg()ICON_LG20.xl()ICON_XL24.size(f32)arbitrary px Color: defaults to
theme.foreground;.muted()→theme.muted_foreground;.color(c)→ explicit. No interactive states. -
Tokens consumed —
theme.foreground/theme.muted_foreground(color),core::ICON_SM..XL(size),typography::icon_font(font). -
Accessibility — rendered as non-selectable so the glyph never steals a click from an interactive parent. Returns the
LabelResponse.
API
| Signature | Effect |
|---|---|
Icon::new(glyph: &'static str) -> Self | Construct from a light::* constant. |
.size(size: f32) -> Self | Set size in px. |
.sm() / .md() / .lg() / .xl() | Token-size shorthands. |
.muted(self) -> Self | Use muted_foreground. |
.color(color: Color32) -> Self | Explicit color. |
.show(self, ui: &mut Ui) -> Response | Render and return the Response. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Icon;
use ouroboros_ui::egui_phosphor::light;
Icon::new(light::GEAR).show(ui);
}
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Icon;
use ouroboros_ui::egui_phosphor::light;
Icon::new(light::CHECK_CIRCLE).lg().color(theme.success).show(ui);
Icon::new(light::INFO).muted().show(ui);
}
Composition
Atom: renders directly via egui::Label/RichText. Composes no other atoms. Note: Button/Toggle do not compose this atom — they inline the glyph into a single galley.
Notes
glyphmust be a&'static str(the Phosphor constants are). Arbitrary runtime strings will render whatever codepoints they contain in the icon font..color()wins over.muted().
See tokens · theming · typography.
Input
Layer: atom · Path:
src/atoms/input.rs· Exports:input::Input
A single-line text field over a &mut String. A token-painted box (fill muted, border input/destructive/ring, animated hover veil) wraps a frameless egui::TextEdit::singleline — egui owns the editing, the casing is all token. States: default / focus / disabled / error. (Size variants live here; leading-icon / labels belong to a Field molecule.)
Design
-
Purpose / when to use — free-form single-line text entry. For multi-line use
Textarea; for numbers useNumericField. -
Anatomy — filled rect (
muted) → hover veil → inner framelessTextEdit(left-aligned,bodyfont,foregroundtext,muted_foregroundplaceholder) → border stroke whose color/weight encodes state. -
Variants / sizes / states
Size Height Pad-x SmCONTROL_SM26SPACE_3Md(default)CONTROL_MD32SPACE_4LgCONTROL_LG38SPACE_4Border state (precedence): error →
destructive@BORDER_THIN; else focused →ring@BORDER_FOCUS; elseinput@BORDER_THIN. Disabled dims fill + border + text viadisabled_colorand usesadd_enabled(false, …). -
Tokens consumed —
theme.muted(fill),theme.input/theme.destructive/theme.ring(border),theme.foreground(text),theme.muted_foreground(placeholder),theme.hover_overlay,core::RADIUS_MD,core::BORDER_THIN/BORDER_FOCUS,core::hover_t,core::disabled_color,core::Size,typography::body. -
Accessibility — focus is egui’s
TextEditfocus (the border switches to the ring). Width =ui.available_width().
API
| Signature | Effect |
|---|---|
Input::new(buf: &mut String) -> Self | Bind to a string buffer. |
.placeholder(text: impl Into<String>) -> Self | Hint text when empty. |
.error(error: bool) -> Self | Force the destructive border. |
.size(s: Size) -> Self / .sm() / .lg() | Size (core::Size). |
.enabled(enabled: bool) -> Self / .disabled() | Enable/disable. |
.id_source(id: impl Hash) -> Self | Stable id for the inner TextEdit (else auto-id). |
.show(self, ui: &mut Ui) -> Response | Return the TextEdit Response (changed when edited). |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Input;
let mut name = String::new();
Input::new(&mut name).placeholder("Your name").show(ui);
}
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Input;
let mut email = String::new();
let invalid = !email.contains('@') && !email.is_empty();
let resp = Input::new(&mut email)
.placeholder("[email protected]")
.error(invalid)
.id_source("email_field")
.show(ui);
if resp.changed() { /* validate */ }
}
Composition
Atom: paints the box/border/veil directly and embeds a frameless egui::TextEdit in a child Ui. Composes no other DS atoms.
Notes
- Binding is
&mut String; the returnedResponseis the innerTextEdit’s, so.changed()/.has_focus()/.lost_focus()reflect editing. - Greedily takes
ui.available_width()— constrain the parent for a fixed width. - In a loop or repeated layout, set
id_sourceso the field keeps focus/cursor across frames.
See tokens · theming · typography · guards.
Kbd
Layer: atom · Path:
src/atoms/kbd.rs· Exports:kbd::Kbd
A keyboard-key chip: mono (kbd) text in a small token-bordered box — e.g. ⌘K, Ctrl, Esc. Modeled on shadcn’s Kbd.
Design
- Purpose / when to use — display a keyboard shortcut inline (menus, tooltips, command palettes). Read-only.
- Anatomy — rounded rect (
mutedfill +borderstroke,RADIUS_SM) with(SPACE_2, SPACE_1)padding around a centeredkbd-style galley inmuted_foreground. - Variants / sizes / states — none; single appearance, sense is
hoveronly. - Tokens consumed —
theme.muted(fill),theme.border(stroke),theme.muted_foreground(text),core::RADIUS_SM,core::SPACE_2/SPACE_1(padding),core::BORDER_THIN,typography::kbd. - Accessibility — bare hover
Response; nowidget_info.
API
| Signature | Effect |
|---|---|
Kbd::new(keys: impl Into<String>) -> Self | Construct with the key text. |
.show(self, ui: &mut Ui) -> Response | Paint and return the hover Response. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Kbd;
Kbd::new("Esc").show(ui);
Kbd::new("⌘K").show(ui);
}
Composition
Atom: paints the box and builds the text galley inline with the kbd type style. Does not compose Text, though TextRole::Kbd uses the same style.
Notes
- The whole string is rendered in one chip; for multi-key combos either pass
"Ctrl+K"as one string or place severalKbds with separators.
See tokens · theming · typography.
NumericField
Layer: atom · Path:
src/atoms/numeric_field.rs· Exports:numeric_field::NumericField
A scrubbable numeric input bound to a &mut f32, modeled on Unity’s Numeric Field. A token box wraps an egui DragValue (drag to scrub, click to type); the value is right-aligned. .stepper() flanks it with ghost −/+ icon buttons, and .suffix() appends a unit. The editing substrate is egui’s; the casing is token.
Design
-
Purpose / when to use — numeric entry where dragging/scrubbing helps (positions, scales, counts). For a bounded value you mostly slide, use
Slider; for text useInput. -
Anatomy — filled rect (
muted) → hover veil → inner content: a right-alignedDragValue, optionally flanked by−(left) and+(right) ghostButtons → border stroke encoding state. -
Variants / sizes / states
Size Height SmCONTROL_SM26Md(default)CONTROL_MD32LgCONTROL_LG38Border state (precedence): error →
destructive; else focused →ring@BORDER_FOCUS; elseinput. Disabled dims and disables theDragValue+ stepper buttons. -
Tokens consumed —
theme.muted,theme.input/theme.destructive/theme.ring,theme.hover_overlay,core::RADIUS_MD,core::SPACE_2(inner inset),core::BORDER_THIN/BORDER_FOCUS,core::hover_t,core::disabled_color,core::Size. -
Accessibility — focus/keyboard come from egui’s
DragValue; the border switches to the ring on focus.
API
| Signature | Effect |
|---|---|
NumericField::new(value: &mut f32) -> Self | Bind to an f32. |
.range(min: f32, max: f32) -> Self | Clamp range (default -INF..INF). |
.speed(speed: f32) -> Self | Drag sensitivity (default 0.1). |
.step(step: f32) -> Self | Stepper increment (default 1.0). |
.stepper(self) -> Self | Flank with −/+ buttons. |
.suffix(suffix: impl Into<String>) -> Self | Append a unit string. |
.full_width(self) -> Self | Fill the available width (drops the FIELD_NUM_W cap; the floor still applies). |
.fixed_width(self) -> Self | Pin a constant width (NUMERIC_STEPPER_W), ignoring available_width — for a stepper in a squeezed panel so the value never slides behind the −. Takes precedence over .full_width(). |
.enabled(enabled: bool) -> Self / .disabled() | Enable/disable. |
.error(error: bool) -> Self | Force the destructive border. |
.size(s: Size) -> Self / .sm() / .lg() | Size (core::Size). |
.show(self, ui: &mut Ui) -> Response | Return the inner DragValue Response. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::NumericField;
let mut scale = 1.0_f32;
NumericField::new(&mut scale).range(0.0, 10.0).speed(0.05).show(ui);
}
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::NumericField;
let mut count = 3.0_f32;
NumericField::new(&mut count)
.range(0.0, 99.0)
.step(1.0)
.stepper()
.suffix(" items")
.show(ui);
}
Composition
Atom: paints the box/border/veil directly and embeds an egui DragValue in a child Ui. Composes the Button atom (ghost, sm, icon-only) for the −/+ steppers.
Notes
- Binding is
&mut f32. The stepper buttons mutate the value directly (clamped to range) when clicked; the returnedResponseis theDragValue’s. - Stepper buttons get glyphs
light::MINUS/light::PLUS; the+is pinned right and the value fills the remaining width. - Width model: by default fills
available_widthclamped toNUMERIC_MIN_W..=FIELD_NUM_W(stepper floors higher atNUMERIC_STEPPER_MIN_W) so numbers stay column-aligned..full_width()drops the cap;.fixed_width()ignoresavailable_widthentirely and pinsNUMERIC_STEPPER_W— use it for steppers in resizable inspector panels where the box must not shrink under the value.
See tokens · theming · guards.
Progress
Layer: atom · Path:
src/atoms/progress.rs· Exports:progress::Progress
A determinate progress indicator (fraction in 0..=1), rendered as a continuous bar, a stepped (segmented) bar, or a circular ring. Modeled on shadcn Progress / Unity Progress Bar. For indeterminate loading use Spinner.
Design
-
Purpose / when to use — show known completion progress. Use the ring for compact/inline placement, steps for discrete stages, the bar for general progress.
-
Anatomy
- Bar (continuous): pill track (
muted) + aprimaryfill rect from the left, width =track × fraction. - Bar (stepped):
nequal pills withSPACE_1gaps; the firstround(fraction × n)areprimary, the restmuted. - Ring: a
mutedcircle stroke + aprimaryarc sweepingfraction × 360°from 12 o’clock.
- Bar (continuous): pill track (
-
Variants / sizes / states
Builder Render new(fraction)continuous bar, SPACE_2tall, full width.steps(n)nsegments (≥1).circular()ring at CONTROL_LG(38px) diameter.circular_size(d)ring at diameter dNo interactive states (sense is
hover). -
Tokens consumed —
theme.muted(track),theme.primary(fill/arc),core::SPACE_2(bar height / circular thickness viaSPACE_1),core::SPACE_1(segment gap & ring thickness),core::CONTROL_LG(default ring size). -
Accessibility — bare hover
Response; nowidget_info.
API
| Signature | Effect |
|---|---|
Progress::new(fraction: f32) -> Self | Construct; fraction clamped to 0..=1. |
.steps(n: usize) -> Self | Render n discrete segments (min 1). |
.circular(self) -> Self | Render as a ring at the default diameter. |
.circular_size(size: f32) -> Self | Render as a ring at size diameter. |
.show(self, ui: &mut Ui) -> Response | Paint and return the hover Response. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Progress;
Progress::new(0.42).show(ui);
}
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Progress;
Progress::new(0.6).steps(5).show(ui); // stepped bar
Progress::new(0.6).circular().show(ui); // ring
}
Composition
Atom: paints rects/strokes/arc directly. Composes no other atoms.
Notes
fractionis clamped on construction — out-of-range values are safe.- The bar variant takes
ui.available_width(); the ring is fixed-size. - This is determinate only; there is no indeterminate bar — use
Spinner.
Radio
Layer: atom · Path:
src/atoms/radio.rs· Exports:radio::Radio
A single radio button taking selected: bool (by value, not a binding) with an optional trailing label. It is a standalone atom: it reports clicks via its Response; the consumer (or a future RadioGroup molecule) owns single-selection. Circle is token-painted (border input; when selected, an inner primary dot); the label is rendered via the Text atom.
Design
-
Purpose / when to use — one option within a mutually-exclusive group. For independent booleans use
Checkbox. -
Anatomy — outline circle (left, vertically centered; border
primarywhen selected elseinput) → hover veil → innerprimarydot (radius × 0.5) when selected → focus ring → optional labelText. -
Variants / sizes / states
Size Circle size SmICON_SM14Md(default)ICON_MD16LgICON_LG20States: selected (border
primary+ inner dot), hover (hover_tveil), focus (ring), disabled (disabled_color; sense → hover), non-interactive (interactive(false)— display-only). -
Tokens consumed —
theme.primary,theme.input,theme.foreground,theme.hover_overlay,theme.ring,core::SPACE_2(gap),core::BORDER_THIN,core::hover_t,core::disabled_color,typography::body. -
Accessibility — emits
WidgetInfo::selected(WidgetType::RadioButton, enabled, selected, label). Focus ring viafocus::focus_ring_circle. Hit target spans circle + gap + label.
API
| Signature | Effect |
|---|---|
Radio::new(selected: bool) -> Self | Construct with current selection state. |
.interactive(interactive: bool) -> Self | Display-only when false. |
.size(s: Size) -> Self / .sm() / .lg() | Size (core::Size). |
.label(label: impl Into<String>) -> Self | Add a trailing label. |
.enabled(enabled: bool) -> Self / .disabled() | Enable/disable. |
.id_source(id: impl Hash) -> Self | Stable id (else response.id). |
.show(self, ui: &mut Ui) -> Response | Paint and return the Response (clicked to flip at the consumer). |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Radio;
#[derive(PartialEq)] enum Mode { A, B }
let mut mode = Mode::A;
if Radio::new(mode == Mode::A).label("Option A").show(ui).clicked() {
mode = Mode::A;
}
if Radio::new(mode == Mode::B).label("Option B").show(ui).clicked() {
mode = Mode::B;
}
}
Composition
Atom: paints the circle/dot directly; composes the Text atom for the label inside a child Ui.
Notes
- Unlike
Checkbox/Switch,Radiodoes not take a&mutbinding and does not mutate state — it only reports.clicked(). The caller updates the selected value. (Nomark_changed; check.clicked(), not.changed().) - Label width reserves extra room for the role’s tracking so it does not clip.
See tokens · theming · typography · guards.
Skeleton
Layer: atom · Path:
src/atoms/skeleton.rs· Exports:skeleton::Skeleton
A loading placeholder block: a muted rounded rect that gently pulses (opacity) while content loads. Modeled on shadcn Skeleton. Implements Default.
Design
- Purpose / when to use — reserve layout space and signal loading before real content arrives. For an active spinner use
Spinner; for known progress useProgress. - Anatomy — a single
muted-filled rounded rect (RADIUS_SM). When pulsing, its opacity oscillates via a sine ofui.input(time)betweenOPACITY_MUTEDand1.0, requesting a repaint each frame. - Variants / sizes / states —
width(f32)(default:ui.available_width()),height(f32)(defaultSPACE_4= 16px),still()to disable the pulse. No interactive states (sensehover). - Tokens consumed —
theme.muted(fill),core::RADIUS_SM,core::SPACE_4(default height),core::OPACITY_MUTED(pulse floor). - Accessibility — bare hover
Response; nowidget_info.
API
| Signature | Effect |
|---|---|
Skeleton::new() -> Self | Construct (pulsing, full-width, 16px tall). |
Skeleton::default() | Same as new(). |
.width(width: f32) -> Self | Fixed width. |
.height(height: f32) -> Self | Set height. |
.still(self) -> Self | Disable the pulse animation. |
.show(self, ui: &mut Ui) -> Response | Paint and return the hover Response. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Skeleton;
Skeleton::new().show(ui); // full-width pulsing line
}
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Skeleton;
// an avatar-sized still placeholder
Skeleton::new().width(32.0).height(32.0).still().show(ui);
}
Composition
Atom: paints one rect directly. Composes no other atoms.
Notes
- Default width is greedy (
ui.available_width()); pass.width(..)for a fixed block. - The pulse drives continuous repaints — call
.still()for large grids of placeholders to avoid animating many at once.
Slider
Layer: atom · Path:
src/atoms/slider.rs· Exports:slider::Slider
A draggable numeric value over a range, bound to a &mut f32. A muted track + a primary filled portion + a primary thumb; drag or click anywhere on the track to set. Modeled on shadcn/Unity/O3DE sliders. For precise numeric typing use NumericField.
Design
-
Purpose / when to use — pick a continuous (or stepped) value within a known range where coarse adjustment is fine. Default range is
0.0..=1.0. -
Anatomy — thin pill track (
SPACE_1tall,muted) →primaryfill from left to thumb → circularprimarythumb with abackgroundstroke ring → hover veil on the thumb → focus ring on the thumb. -
Variants / sizes / states
Size Control height (thumb area) SmICON_SM14Md(default)ICON_MD16LgICON_LG20States: drag/click sets value (
Sense::click_and_drag,mark_changed); hover (hover_tveil on thumb); focus (ring on thumb, only when enabled); disabled (disabled_color, sense → hover). -
Tokens consumed —
theme.muted(track),theme.primary(fill + thumb),theme.background(thumb stroke),theme.hover_overlay,theme.ring,core::SPACE_1(track thickness),core::BORDER_THIN,core::hover_t,core::disabled_color,core::Size. -
Accessibility — focus ring via
focus::focus_ring_circle. (No explicitwidget_infois set.)
API
| Signature | Effect |
|---|---|
Slider::new(value: &mut f32) -> Self | Bind to an f32 (range defaults 0..=1). |
.range(min: f32, max: f32) -> Self | Set the range. |
.step(step: f32) -> Self | Quantize to multiples of step. |
.enabled(enabled: bool) -> Self / .disabled() | Enable/disable. |
.size(s: Size) -> Self / .sm() / .lg() | Size (core::Size). |
.show(self, ui: &mut Ui) -> Response | Drag/click sets value, mark_changed, return Response. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Slider;
let mut volume = 0.5_f32;
if Slider::new(&mut volume).show(ui).changed() {
// volume changed
}
}
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Slider;
let mut zoom = 1.0_f32;
Slider::new(&mut zoom).range(0.25, 4.0).step(0.25).show(ui);
}
Composition
Atom: paints track/fill/thumb directly. Composes no other atoms.
Notes
- Binding is
&mut f32; the value is clamped to range and (ifstep > 0) snapped. Check.changed(). - Greedily takes
ui.available_width(). - Clicking anywhere on the track jumps the thumb to that position (not just dragging the thumb).
See tokens · theming · guards.
Spinner
Layer: atom · Path:
src/atoms/spinner.rs· Exports:spinner::Spinner
An indeterminate loading arc: a ~270° stroked arc that rotates over time (requesting a repaint each frame). Size and color are tokens. Implements Default. For known progress use Progress.
Design
- Purpose / when to use — signal in-flight work of unknown duration. Inline or centered in a panel; the same arc form is inlined into
Button::loading. - Anatomy — a single 32-point polyline approximating a
0.75·TAU(270°) arc, stroked atBORDER_FOCUS, with the start angle =time × TAUso it spins. - Variants / sizes / states —
.sm()(ICON_SM14), defaultICON_MD16,.lg()(ICON_LG20), or.size(f32). Color defaults tomuted_foreground, overridable via.color(c). No interactive states. - Tokens consumed —
theme.muted_foreground(default color),core::ICON_SM/MD/LG(size),core::BORDER_FOCUS(stroke width + radius inset). - Accessibility — bare hover
Response; nowidget_info. Animates continuously (request_repaint).
API
| Signature | Effect |
|---|---|
Spinner::new() -> Self | Construct (ICON_MD, muted_foreground). |
Spinner::default() | Same as new(). |
.size(size: f32) -> Self | Set diameter. |
.sm() / .lg() | Token-size shorthands. |
.color(color: Color32) -> Self | Override color. |
.show(self, ui: &mut Ui) -> Response | Paint the spinning arc and return the hover Response. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Spinner;
Spinner::new().show(ui);
}
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Spinner;
Spinner::new().lg().color(theme.primary).show(ui);
}
Composition
Atom: paints the arc directly via a Shape::line polyline. Composes no other atoms.
Notes
- Calls
ui.ctx().request_repaint()every frame it is shown — only render it while actually loading. - The arc form is duplicated (not composed) inside
Button::loading.
SplitterHandle
Layer: atom · Path:
src/atoms/splitter_handle.rs· Exports:splitter_handle::SplitterHandle
The draggable divider band between two Splitter panels. It fills its area as a drag hit-target and paints a centered hairline (token border) that fades to ring on hover/drag, setting the resize cursor. The owning Splitter organism reads the returned Response (drag delta, double-click) — the atom paints, the organism composes.
Design
- Purpose / when to use — only inside a Splitter organism, as the resize grip between panes. For a static rule use
Divider. - Anatomy — the full
ui.max_rect()allocated as aclick_and_draghit-target → a centeredborderhairline (vline/hline) → on hover/drag/active, aringoverlay line atBORDER_FOCUS, ramped byhover_t. SetsResizeHorizontal/ResizeVerticalcursor while interacting. - Variants / sizes / states
line: Axis— orientation of the visible rule:Verticalfor a left/right (horizontal) split,Horizontalfor a top/bottom (vertical) split..active(bool)— force the highlighted state (e.g. mid-drag, or when a neighbor is collapsed).- States: hover/drag highlight (cursor + ring fade), plus forced
active.
- Tokens consumed —
theme.border(hairline),theme.ring(highlight),core::BORDER_THIN(hairline weight),core::BORDER_FOCUS(highlight weight),core::hover_t(fade). - Accessibility — sets the appropriate resize cursor while hovered/dragged; returns the full
Responsefor the organism to interpret (drag_delta,double_clicked).
API
| Signature | Effect |
|---|---|
SplitterHandle::new(line: Axis) -> Self | Construct; line = orientation of the visible rule. |
.active(active: bool) -> Self | Force the highlighted state. |
.show(self, ui: &mut Ui) -> Response | Allocate the band, paint, set cursor, return the Response. |
Axis — re-exported from Divider: Horizontal, Vertical.
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::{SplitterHandle, Axis};
// inside a left/right split: visible rule is vertical
let resp = SplitterHandle::new(Axis::Vertical).show(ui);
if resp.dragged() {
let dx = resp.drag_delta().x;
// adjust the left pane width by dx
}
}
Composition
Atom: paints the hairline/highlight directly and sets the cursor. Composes no other atoms; it is itself composed by the Splitter organism.
Notes
- Pass the orientation of the visible line, not the split direction —
Axis::Verticalfor a horizontal (left/right) split. This is the documented gotcha. showconsumes the wholeui.max_rect()as the hit-target; give it a dedicated childUisized to the desired grab width.
Surface
Layer: atom · Path:
src/atoms/surface.rs· Exports:surface::{Surface, SurfaceBorder, SurfaceFill}
The one place a “box” is painted — fill, border, radius, shadow, and padding — so molecules can compose a surface instead of hand-rolling an egui::Frame. Optionally interactive (clickable) and selected (ring border) for card-style selectors. Atoms may paint; molecules may not, so reach for this whenever you need a container.
Design
-
Purpose / when to use — wrap any content group in a themed container: cards, panels, popovers, selectable tiles. This is the canonical container atom; do not build raw
egui::Frames in higher layers. -
Anatomy — an
egui::Framewith corner radius, inner margin (padding), optional fill, optional stroke, optional shadow; runscontentinside. Wheninteractive, re-interacts the painted rect for clicks and draws aborder_strongoutline on hover. -
Variants / sizes / states
SurfaceFill:Card(default →theme.card),Muted(theme.muted),Background(theme.background),None(no fill).SurfaceBorder:None,Default(default →theme.border),Strong(theme.border_strong).Other knobs:
radius(f32)(defaultRADIUS_LG),elevated()(addsSHADOW_MD),pad(f32)(defaultSPACE_4),interactive(),selected(bool).States:
selected(true)overrides the border with aringstroke atBORDER_FOCUS;interactiveadds aborder_stronghover outline. -
Tokens consumed —
theme.card/theme.muted/theme.background(fill),theme.border/theme.border_strong(border),theme.ring(selected),core::RADIUS_LG(default radius),core::SPACE_4(default padding),core::BORDER_THIN/BORDER_FOCUS,core::SHADOW_MD(elevation). -
Accessibility — when
interactive, the rect is re-interacted withSense::click(); the returned response carries the click. Non-interactive surfaces return the frame’s response.
API
| Signature | Effect |
|---|---|
Surface::new() -> Self / Surface::default() | Card fill, default border, RADIUS_LG, SPACE_4 padding. |
.fill(f: SurfaceFill) -> Self | Set fill. |
.muted() / .background() / .fill_none() | Fill shorthands. |
.border(b: SurfaceBorder) -> Self | Set border. |
.border_none() / .border_strong() | Border shorthands. |
.radius(radius: f32) -> Self | Corner radius. |
.elevated(self) -> Self | Add SHADOW_MD. |
.pad(padding: f32) -> Self | Inner margin. |
.interactive(self) -> Self | Sense clicks + hover outline. |
.selected(selected: bool) -> Self | Ring border (overrides border). |
.id_source(id: impl Hash) -> Self | Stable id for the interaction (else response id). |
.show<R>(self, ui: &mut Ui, content: impl FnOnce(&mut Ui) -> R) -> InnerResponse<R> | Paint the box, run content, return InnerResponse. |
SurfaceFill: Card (default), Muted, Background, None. SurfaceBorder: None, Default (default), Strong.
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Surface;
use ouroboros_ui::atoms::Text;
Surface::new().show(ui, |ui| {
Text::new("Card body").show(ui);
});
}
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Surface;
let mut chosen = false;
let r = Surface::new()
.interactive()
.selected(chosen)
.elevated()
.show(ui, |ui| { /* tile content */ });
if r.response.clicked() { chosen = !chosen; }
}
Composition
Atom: the only atom that exposes a content closure and paints the container box. Higher layers compose Surface rather than building egui::Frames — this is enforced by the layer rules (see guards).
Notes
showreturnsegui::InnerResponse<R>—.inneris your closure’s value,.responseis the surface’s (click-bearing wheninteractive).selected(true)takes precedence over anyborder(..)setting.- For repeated/clickable surfaces in a list, set
id_sourceto keep interaction stable.
See tokens · theming · guards.
Switch
Layer: atom · Path:
src/atoms/switch.rs· Exports:switch::Switch
A boolean toggle bound to a &mut bool, with an animated sliding thumb. A pill track (primary when on, border_strong when off) holds a background-filled thumb that slides via animate_bool_with_time. All dimensions derive from tokens; includes a focus ring and disabled dim.
Design
-
Purpose / when to use — an on/off setting that applies immediately (the “iOS toggle”). For a checkbox-style boolean (especially in forms/lists with labels) use
Checkbox; for a pressable two-state button useToggle. -
Anatomy — pill track → hover veil → circular thumb sliding left↔right with the animated
t→ focus ring. Track width =track_h + SPACE_4. -
Variants / sizes / states
Size Track height Thumb diameter SmICON_MD16ICON_SM14Md(default)ICON_LG20ICON_MD16LgICON_XL24ICON_LG20States: on (
primarytrack) / off (border_strongtrack — chosen overmutedso the thumb stays legible in dark mode); hover (hover_tveil); focus (ring); disabled (disabled_color, sense → hover). -
Tokens consumed —
theme.primary(on track),theme.border_strong(off track),theme.background(thumb),theme.hover_overlay,theme.ring,core::ICON_*(dims),core::SPACE_4(track extra width),core::DURATION_FAST(thumb slide),core::hover_t,core::disabled_color,core::Size. -
Accessibility — emits
WidgetInfo::selected(WidgetType::Checkbox, enabled, on, "")(no label). Focus ring viafocus::focus_ring_rect.
API
| Signature | Effect |
|---|---|
Switch::new(on: &mut bool) -> Self | Bind to a boolean. |
.enabled(enabled: bool) -> Self / .disabled() | Enable/disable. |
.size(s: Size) -> Self / .sm() / .lg() | Size (core::Size). |
.id_source(id: impl Hash) -> Self | Stable id for animation/interaction (else response.id). |
.show(self, ui: &mut Ui) -> Response | Toggle on click, mark_changed, return Response. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Switch;
let mut dark = true;
if Switch::new(&mut dark).show(ui).changed() {
// apply theme
}
}
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Switch;
let mut enabled = false;
Switch::new(&mut enabled).lg().id_source("notifications").show(ui);
}
Composition
Atom: paints track + thumb directly. Composes no other atoms. The Switch carries no label — pair it with a Text atom in a molecule/row for a labeled setting.
Notes
- Binding is
&mut bool. On click it flips*onand callsmark_changed(); check.changed(). - The thumb position animates over
DURATION_FASTkeyed byid_source(orresponse.id) — setid_sourcefor switches in loops to avoid animation cross-talk. - Off-state track is
border_strong, intentionally notmuted, for dark-mode legibility.
See tokens · theming · guards.
Text
Layer: atom · Path:
src/atoms/text.rs· Exports:text::{Text, TextRole}
A run of text at a typography role, in a theme color. Every visual comes from a token: font/size/line-height/tracking from a typography type style, color from the [Theme]. The DS’s universal text primitive; many other atoms compose it for their labels.
Design
-
Purpose / when to use — all body copy, labels, captions, inline code, and key glyphs. For titles use
Heading. -
Anatomy — a non-selectable
egui::Labelbuilt fromRichTextwith the role’sfont_id+tracking+ color, optional underline, and a wrap mode. -
Variants / sizes / states
TextRole→ type style:Role Style Body(default)typography::body()BodyStrongbody_strong()Labellabel()LabelStronglabel_strong()Captioncaption()Codecode()Kbdkbd()Color:
theme.foreground(default),.muted()→muted_foreground,.color(c)→ explicit..wrap()enables wrapping (and line-height);.underline()for links;.italic()for asides/hints (combines freely, e.g..muted().italic()). No interactive states. -
Tokens consumed —
theme.foreground/theme.muted_foreground(color), the per-roletypographystyle. -
Accessibility — rendered non-selectable so UI text never captures the pointer (it would steal clicks from interactive parents and show a text cursor).
API
| Signature | Effect |
|---|---|
Text::new(content: impl Into<String>) -> Self | Construct with text. |
.role(role: TextRole) -> Self | Set role. |
.body_strong() / .label() / .label_strong() / .caption() / .code() / .kbd() | Role shorthands. |
.muted(self) -> Self | Use muted_foreground. |
.color(color: Color32) -> Self | Explicit color (e.g. theme.success). |
.wrap(self) -> Self | Wrap on available width (default: extend/no wrap). |
.underline(self) -> Self | Underline (e.g. a link). |
.italic(self) -> Self | Italicize (e.g. an aside/hint nuance). |
.show(self, ui: &mut Ui) -> Response | Render and return the Response. |
TextRole (enum): Body (default), BodyStrong, Label, LabelStrong, Caption, Code, Kbd.
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Text;
Text::new("Hello world").show(ui);
}
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Text;
Text::new("Saved").caption().color(theme.success).show(ui);
Text::new("A long paragraph that should wrap…").wrap().show(ui);
Text::new("No layers selected").muted().italic().show(ui);
}
Composition
Atom: renders directly via egui::Label/RichText with token styling. It is itself composed by Checkbox, Radio, and Tooltip for their labels.
Notes
- Default wrap mode is
Extend(no wrap); line-height is only applied when.wrap()is set — applying leading to a single line inflates the row and decenters the glyph inside parents like buttons. .color()wins over.muted().
See tokens · theming · typography.
Textarea
Layer: atom · Path:
src/atoms/textarea.rs· Exports:textarea::Textarea
A multi-line text field over a &mut String — the sibling of Input. A token-painted box (fill muted, border input/destructive/ring, animated hover veil) wraps a frameless multiline egui::TextEdit. Height derives from the requested row count.
Design
- Purpose / when to use — multi-line free text (descriptions, notes, JSON blobs). For single-line use
Input. - Anatomy — filled rect (
muted) → hover veil → inner frameless multilineTextEdit(bodyfont,foregroundtext,muted_foregroundplaceholder,SPACE_2inset) → state border stroke. - Variants / sizes / states
rows(n)(min 1, default 3) — sets height =n × body.line_height + 2·SPACE_2.- Border state (precedence): error →
destructive; else focused →ring@BORDER_FOCUS; elseinput. Disabled dims and usesadd_enabled(false, …). (No size-scale variants, unlike Input.)
- Tokens consumed —
theme.muted(fill),theme.input/theme.destructive/theme.ring(border),theme.foreground(text),theme.muted_foreground(placeholder),theme.hover_overlay,core::RADIUS_MD,core::SPACE_2(padding),core::BORDER_THIN/BORDER_FOCUS,core::hover_t,core::disabled_color,typography::body. - Accessibility — focus is egui’s
TextEditfocus; the border switches to the ring on focus.
API
| Signature | Effect |
|---|---|
Textarea::new(buf: &mut String) -> Self | Bind to a string buffer (3 rows). |
.rows(rows: usize) -> Self | Visible row count (min 1). |
.placeholder(text: impl Into<String>) -> Self | Hint text when empty. |
.error(error: bool) -> Self | Force the destructive border. |
.enabled(enabled: bool) -> Self / .disabled() | Enable/disable. |
.id_source(id: impl Hash) -> Self | Stable id for the inner TextEdit (else auto-id). |
.show(self, ui: &mut Ui) -> Response | Return the TextEdit Response (changed when edited). |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Textarea;
let mut notes = String::new();
Textarea::new(&mut notes).placeholder("Notes…").show(ui);
}
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Textarea;
let mut desc = String::new();
let resp = Textarea::new(&mut desc).rows(6).id_source("desc").show(ui);
if resp.changed() { /* … */ }
}
Composition
Atom: paints the box/border/veil directly and embeds a frameless multiline egui::TextEdit in a child Ui. Composes no other DS atoms.
Notes
- Binding is
&mut String; the returnedResponseis the innerTextEdit’s. - Height is fixed by
rows(it does not auto-grow with content); width isui.available_width(). - Unlike
Input, there is noSizevariant — onlyrows.
See tokens · theming · typography · guards.
Toggle
Layer: atom · Path:
src/atoms/toggle.rs· Exports:toggle::Toggle
A two-state toggle button (distinct from Switch) bound to a &mut bool. It looks like a ghost button and fills with accent while on. Icon and/or label render as a single valign-centered galley, the same technique as Button. Modeled on shadcn Toggle.
Design
- Purpose / when to use — a pressable that latches on/off, like a formatting-bar “Bold” button. For an on/off setting that reads as a slider use
Switch; for a non-latching action useButton. - Anatomy — a rounded rect (
RADIUS_MD) that is transparent off,hover_overlayon hover, andaccent-filled when on; with a centered single galley of optional icon + optional label. Icon-only (no label) → square (height =CONTROL_SM). - Variants / sizes / states — fixed height
CONTROL_SM(26), icon sizeICON_MD(16). No size variants. States: on (accentfill), hover (hover_overlay), disabled (disabled_color, sense → hover). No focus ring. - Tokens consumed —
theme.accent(on fill),theme.hover_overlay(hover),theme.foreground(content),core::CONTROL_SM(height),core::ICON_MD(icon),core::RADIUS_MD,core::SPACE_1(icon↔label gap),core::SPACE_2(pad-x),core::TRACKING_NORMAL,core::disabled_color,typography::body/icon_font. - Accessibility — emits
WidgetInfo::selected(WidgetType::Button, enabled, on, label).
API
| Signature | Effect |
|---|---|
Toggle::new(on: &mut bool) -> Self | Bind to a boolean. |
.icon(glyph: &'static str) -> Self | Leading Phosphor glyph. |
.label(label: impl Into<String>) -> Self | Add a label. |
.enabled(enabled: bool) -> Self / .disabled() | Enable/disable. |
.id_source(id: impl Hash) -> Self | Stable id. |
.show(self, ui: &mut Ui) -> Response | Toggle on click, mark_changed, return Response. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Toggle;
use ouroboros_ui::egui_phosphor::light;
let mut bold = false;
Toggle::new(&mut bold).icon(light::TEXT_B).show(ui); // icon-only, square
}
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::Toggle;
let mut grid = true;
if Toggle::new(&mut grid).label("Grid").show(ui).changed() {
// grid visibility flipped
}
}
Composition
Atom: paints the fill directly and builds icon+label as one inline LayoutJob (same single-galley approach as Button; does not compose Icon/Text).
Notes
- Binding is
&mut bool; on click it flips*onand callsmark_changed()— check.changed(). - With no
label, it renders icon-only and square; theid_sourcefield exists but the on/hover fill is not animated (it is an instantaneous overlay, nohover_t). - No focus ring (unlike
Button/Switch).
See tokens · theming · typography · guards.
Tooltip
Layer: atom · Path:
src/atoms/tooltip.rs· Exports:tooltip::Tooltip
A DS-styled hover tooltip attached to an existing Response. Tooltip::new("…").show(response) shows the text on hover, composing the Text atom inside egui’s on_hover_ui. Unlike every other atom, its show takes a Response (not a &mut Ui).
Design
- Purpose / when to use — attach explanatory hover text to any widget’s response (icon buttons, truncated labels, controls). Keep it short; for rich popovers build a molecule.
- Anatomy — egui’s hover container, with the body rendered by a single
Textatom (defaultBodyrole /foregroundcolor). - Variants / sizes / states — none; shown only while the host response is hovered.
- Tokens consumed — indirectly via the
Textatom (typographybody,theme.foreground). The hover container chrome is egui’s default styling. - Accessibility — uses egui’s
on_hover_ui(hover-triggered).
API
| Signature | Effect |
|---|---|
Tooltip::new(text: impl Into<String>) -> Self | Construct with the tooltip text. |
.show(self, response: Response) -> Response | Attach the tooltip to response (shown on hover); returns the same Response for chaining. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::atoms::{Button, Tooltip};
use ouroboros_ui::egui_phosphor::light;
let resp = Button::new("").icon_left(light::TRASH).icon_only().ghost().show(ui);
let resp = Tooltip::new("Delete").show(resp);
if resp.clicked() { /* … */ }
}
Composition
Atom: composes the Text atom for its body inside Response::on_hover_ui. Paints nothing itself.
Notes
showtakes and returns aResponse— it wraps an already-shown widget rather than allocating in aUi. This is the API outlier among the atoms.- It returns the response, so you can chain
.clicked()/.hovered()directly.
See tokens · theming · typography.
ListItem
Layer: cell · Path:
src/cells/list_item.rs· Exports:list_item::ListItem
A selectable list row built from an optional leading icon, a title, and an optional subtitle. Modelled on the shadcn Item. Selection is a stateless input (selected: bool) and a click yields a plain [Response] — the consumer owns the selected index and reacts to .clicked().
Design
-
Purpose / when to use — Vertically stacked, single-line-or-two rows in a list/panel where one item can be highlighted (file lists, asset pickers, project entries).
-
Anatomy — A
Surface(interactive, padded) wrapping a horizontal layout: optionalIcon(muted) + a vertical stack ofText(title) and an optional muted captionText(subtitle). -
Variants / states
State Effect default Surface::fill_none().border_none()(transparent)selected ( selected(true))Surface::muted()fillhover/click Surface::interactive()provides hover feedback + sense; the returnedResponsecarries.clicked() -
Tokens / layout consumed —
core::SPACE_2(8px outer pad + icon→text gap),core::RADIUS_SM(4px). See tokens. -
Accessibility — Inherits the
Surface’s interactive sense; selection state is purely visual (mutedfill), so pair with a real selection model in the consumer.
API
| Method | Signature | Effect |
|---|---|---|
new | new(title: impl Into<String>) -> Self | Construct with a title; icon/subtitle/selected default off. |
icon | icon(self, glyph: &'static str) -> Self | Leading muted icon (a phosphor glyph). |
subtitle | subtitle(self, subtitle: impl Into<String>) -> Self | Second line, rendered caption + muted. |
selected | selected(self, selected: bool) -> Self | Toggle the muted selected fill. |
id_source | id_source(self, id: impl std::hash::Hash) -> Self | Stable id for the underlying Surface (needed when rows share otherwise-equal layout). |
show | show(self, ui: &mut Ui) -> Response | Render; returns the Surface response (.clicked(), .hovered()). |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::cells::ListItem;
use ouroboros_ui::egui_phosphor::light;
ListItem::new("Cube").icon(light::CUBE).subtitle("Mesh").show(ui);
}
#![allow(unused)]
fn main() {
// realistic — a selectable list owning the selected index
let mut sel: usize = /* persisted state */ 0;
for (i, (icon, title, sub)) in [
(light::CUBE, "Cube", "Mesh"),
(light::STAR, "Light", "Point"),
(light::GEAR, "Settings", "Project"),
].iter().enumerate() {
if ListItem::new(*title)
.icon(icon)
.subtitle(*sub)
.selected(sel == i)
.id_source(("li", i))
.show(ui)
.clicked()
{
sel = i;
}
}
}
Composition
Composes the Surface, Icon, and Text atoms only. It performs no painting — all visuals come from Surface (fill/border/interaction) and the atoms. Enforced by tests/no_painter_in_molecules.rs.
Notes
- Selection is input, not state:
ListItemnever remembers it. Drive it from ausize/HashSetin the parent and feed.selected(...). - Give each row a distinct
id_source(e.g.("li", i)) so the interactive surfaces don’t collide.
MenuItem
Layer: cell · Path:
src/cells/menu_item.rs· Exports:menu_item::MenuItem
A single menu row: optional leading icon, a label, and an optional right-aligned keyboard shortcut rendered as a Kbd. Modelled on the shadcn DropdownMenu item (.checked(..) mirrors the shadcn CheckboxItem). Clicking yields a [Response]; a disabled item drops its interactive sense.
Design
-
Purpose / when to use — Rows inside dropdowns, context menus, and command lists where each entry pairs an action label with an optional accelerator.
-
Anatomy — A
Surface(transparent, padded) wrapping a horizontal layout: optional mutedIcon+ aTextlabel + (right-to-left) an optionalKbdshortcut. -
Variants / states
State Effect default ( enabled(true))Surface::interactive()— hover feedback + click sensedisabled ( enabled(false))Surfaceis non-interactive (no hover/sense)with shortcut trailing Kbdpinned right viaLayout::right_to_left(Align::Center)checkable ( checked(true))leading check-mark Iconbefore icon/label (shadcnCheckboxItem)checkable ( checked(false))the mark’s slot ( core::ICON_MD + core::SPACE_2) is reserved so checked/unchecked siblings stay aligned -
Tokens / layout consumed —
core::SPACE_1(4px outer pad),core::SPACE_2(icon→label gap),core::RADIUS_SM(4px),core::ICON_MD(reserved check slot). See tokens. -
Accessibility — Disabled rows simply lose interactivity; they are not greyed by the cell itself.
API
| Method | Signature | Effect |
|---|---|---|
new | new(label: impl Into<String>) -> Self | Construct with a label; enabled defaults true. |
icon | icon(self, glyph: &'static str) -> Self | Leading muted icon (phosphor glyph). |
shortcut | shortcut(self, shortcut: impl Into<String>) -> Self | Right-aligned Kbd shortcut text. |
enabled | enabled(self, enabled: bool) -> Self | When false, the surface is not interactive. |
checked | checked(self, checked: bool) -> Self | Checkable item (a View-menu toggle): true shows a check mark, false reserves the mark’s width so siblings line up. Unset (default) = plain action row, no slot. |
id_source | id_source(self, id: impl std::hash::Hash) -> Self | Stable id for the underlying Surface. |
show | show(self, ui: &mut Ui) -> Response | Render; returns the Surface response (.clicked()). |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::cells::MenuItem;
use ouroboros_ui::egui_phosphor::light;
MenuItem::new("Copy").icon(light::COPY).shortcut("Ctrl C").id_source("mi_c").show(ui);
}
#![allow(unused)]
fn main() {
// realistic — a small context menu column
MenuItem::new("Copy").icon(light::COPY).shortcut("Ctrl C").id_source("mi_c").show(ui);
MenuItem::new("Paste").icon(light::CLIPBOARD).shortcut("Ctrl V").id_source("mi_v").show(ui);
MenuItem::new("Delete").icon(light::TRASH).id_source("mi_d").show(ui);
MenuItem::new("Disabled").enabled(false).id_source("mi_x").show(ui);
}
#![allow(unused)]
fn main() {
// checkable — a View-menu toggle section; unchecked rows keep the labels aligned
MenuItem::new("Show Grid").checked(show_grid).id_source("mi_g").show(ui);
MenuItem::new("Show Gizmos").checked(show_gizmos).id_source("mi_z").show(ui);
MenuItem::new("Snap to Grid").checked(snap).shortcut("Ctrl G").id_source("mi_s").show(ui);
}
Composition
Composes the Surface, Icon, Text, and Kbd atoms only. No painting — visuals come entirely from Surface + atoms. Enforced by tests/no_painter_in_molecules.rs.
Notes
- The shortcut is plain text (e.g.
"Ctrl C"), rendered byKbd; it does not bind a real accelerator — wire the key handling separately. - Give each row a distinct
id_sourceto avoid surface id collisions. checkedonly displays state — flip your ownboolon.clicked(). Mixing checkable and plain rows in one menu is fine, but keep all rows of a toggle section checkable so the reserved slot keeps their labels aligned.
PropertyRow
Layer: cell · Path:
src/cells/property_row.rs· Exports:property_row::PropertyRow
An aligned inspector row in the Unity-style two-column layout: a fixed-width muted label on the left and an arbitrary control on the right. The control is supplied as a closure at show time, so any widget (numeric field, color field, switch, …) can sit in the value column while labels stay vertically aligned across rows.
Design
- Purpose / when to use — Inspector / property panels where many heterogeneous controls must share a single aligned label gutter.
- Anatomy — A horizontal layout: a label slot of fixed
label_width×core::CONTROL_MDholding a mutedText, followed by the consumer-provided control. - Variants / states — None of its own; visual state lives in the control closure.
- Tokens / layout consumed —
layout::PROPERTY_LABEL_WIDTH(default label column = 120px),core::CONTROL_MD(32px label slot height, matching the standard control height). See layout tokens and tokens. - Accessibility — Label and control are separate widgets; the alignment is purely visual.
API
| Method | Signature | Effect |
|---|---|---|
new | new(label: impl Into<String>) -> Self | Construct with the label; label_width defaults to layout::PROPERTY_LABEL_WIDTH (120px). |
label_width | label_width(self, width: f32) -> Self | Override the label column width (e.g. for wider inspectors). |
show | show(self, ui: &mut Ui, control: impl FnOnce(&mut Ui) -> Response) -> Response | Render the label column, then run control for the value column; returns the control’s Response. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::cells::PropertyRow;
use ouroboros_ui::atoms::NumericField;
let mut mass = 1.0_f32;
PropertyRow::new("Mass").show(ui, |ui| NumericField::new(&mut mass).speed(0.05).show(ui));
}
#![allow(unused)]
fn main() {
// realistic — a column of aligned inspector rows
let mut vals = [1.0_f32, 0.05, 0.6];
for (i, name) in ["Mass", "Drag", "Bounce"].iter().enumerate() {
PropertyRow::new(*name).show(ui, |ui| {
NumericField::new(&mut vals[i]).speed(0.05).show(ui)
});
}
}
Composition
Composes the Text atom for the label; the value column is whatever the caller’s closure adds. No painting of its own — it only allocates the label slot and delegates. Enforced by tests/no_painter_in_molecules.rs.
Notes
- The
controlclosure must return aResponse(e.g. the inner widget’sshow(ui)result);PropertyRow::showforwards it. - The label slot height is fixed at
core::CONTROL_MD(32px), so controls taller than that will not vertically center against the label. - Keep
label_widthconsistent across rows in the same panel (use the default) so labels line up.
ResponsiveRow
Layer: cell · Path:
src/cells/responsive_row.rs· Exports:responsive_row::ResponsiveRow
The responsive sibling of PropertyRow. Wide, it keeps the Unity-style aligned label column with a right-anchored control; once the available width drops below INSPECTOR_ROW_STACK_MIN it stacks the label above a full-width control, so a squeezed side panel never clips the label↔control pair. The control is supplied as a closure at show time, like PropertyRow.
Design
- Purpose / when to use — Inspector / property panels that live in resizable side panels, where a fixed two-column row would clip when the panel is dragged narrow. For panels that never get narrow, the simpler
PropertyRowis fine. - Anatomy
- Wide (
available_width >= threshold): a label slot oflabel_width×core::CONTROL_MDholding a mutedText, then the consumer control anchored right (Layout::right_to_left) — the gap flexes on resize. - Narrow (
available_width < threshold): a vertical stack — muted label,core::SPACE_1gap, then the control filling the width.
- Wide (
- Variants / states — no visual variants; the single axis is the wide↔narrow switch driven by available width. Mirrors
Field’s responsive orientation, with a lower default threshold tuned for inspector panels. - Tokens / layout consumed —
layout::PROPERTY_LABEL_WIDTH(default label column),layout::INSPECTOR_ROW_STACK_MIN(default stack threshold),core::CONTROL_MD,core::SPACE_1. - Layering — cell: composes the
Textatom + the consumer control; never paints (theno_painter_in_moleculesguard scans cells). - Accessibility — inherits from the embedded control.
API
| Signature | Effect |
|---|---|
ResponsiveRow::new(label: impl Into<String>) -> Self | A row labelled label; defaults PROPERTY_LABEL_WIDTH / INSPECTOR_ROW_STACK_MIN. |
.label_width(width: f32) -> Self | Override the aligned label-column width (wide layout). |
.threshold(px: f32) -> Self | Override the available width below which the row stacks. |
.show(self, ui: &mut Ui, control: impl FnOnce(&mut Ui) -> Response) -> Response | Lay out label + control; returns the control’s Response. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::cells::ResponsiveRow;
use ouroboros_ui::atoms::NumericField;
let mut mass = 1.0_f32;
ResponsiveRow::new("Mass").show(ui, |ui| {
NumericField::new(&mut mass).speed(0.05).show(ui)
});
// Wide panel → "Mass [ 1.0 ]" on one line; narrow panel → "Mass" above "[ 1.0 ]".
}
Composition
Cell: same two-column construction as PropertyRow in the wide branch, plus a vertical-stack branch chosen by ui.available_width() against the threshold. Composes the Text atom and the consumer-provided control.
Notes
- Stacking decision reads
ui.available_width(), which is set by the parent panel/splitter — the same exogenous-budget model the rest of the layout uses. PropertyRowis not replaced: pickResponsiveRowwhen the row sits in a resizable inspector that can get narrow; keepPropertyRowfor fixed-width contexts (e.g. data-table value columns).
See tokens · theming · guards.
TableCell
Layer: cell · Path:
src/cells/table_cell.rs· Exports:table_cell::{CellAlign, TableCell}
One cell of a table: a container that places its content (text by default, or an arbitrary widget) inside the column it is handed. A cell and a header are the same container — they differ chiefly in text weight (header = label_strong). Padding and alignment are token-driven; optionally a leading status dot (ColorSwatch) precedes the content. Carries a lifetime 'a because custom may borrow.
Design
-
Purpose / when to use — Building block for the
Tableorganism (and ad-hoc fixed-width row layouts). Usetext(...)for the common case,custom(...)to embed any widget. -
Anatomy — A
ui.with_layout(...)block: leadingcore::SPACE_2pad, optional circularColorSwatchstatus dot +core::SPACE_1gap, then the content — aTextatom (weight/muted per flags) or thecustomclosure’s widget. -
Variants / states
Modifier Effect text(s)text content (default) custom(add)arbitrary widget content header()Text::label_strong()(stronger weight)muted()Text::muted()foreground (text content only; ignored forcustom)status(color)leading circular color dot, core::SPACE_2diameteralign: align(CellAlign)/center()/end()content alignment within the cell -
Tokens / layout consumed —
core::SPACE_2(leading pad + status dot size),core::SPACE_1(dot→content gap). See tokens.
CellAlign
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] — chooses the cell’s egui Layout:
| Variant | Layout |
|---|---|
Start (default) | Layout::left_to_right(Align::Center) |
Center | Layout::centered_and_justified(Direction::LeftToRight) |
End | Layout::right_to_left(Align::Center) |
API
| Method | Signature | Effect |
|---|---|---|
text | text(text: impl Into<String>) -> Self | Text cell (the default content kind). |
custom | custom(add: impl FnMut(&mut Ui) + 'a) -> Self | Cell holding an arbitrary widget via the closure. |
header | header(self) -> Self | Render as a header (strong text weight). |
align | align(self, align: CellAlign) -> Self | Set horizontal alignment. |
center | center(self) -> Self | Shorthand for align(CellAlign::Center). |
end | end(self) -> Self | Shorthand for align(CellAlign::End). |
status | status(self, color: Color32) -> Self | Leading status dot in color. |
muted | muted(self) -> Self | Muted text foreground (text cells only). |
show | show(self, ui: &mut Ui) -> Response | Fill the column cell it is given; returns the layout block Response. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::cells::{TableCell, CellAlign};
TableCell::text("Name").header().show(ui); // header cell
TableCell::text("2.1 MB").end().show(ui); // right-aligned value
TableCell::text("ref").status(theme.success).show(ui); // leading status dot
}
#![allow(unused)]
fn main() {
// realistic — building rows for the Table organism (see TableRow)
use ouroboros_ui::cells::{TableCell, TableRow};
TableRow::new([
TableCell::text(&d.name),
TableCell::text(&d.size).end(),
TableCell::text(&d.status).status(color),
]);
}
#![allow(unused)]
fn main() {
// custom widget inside a cell
TableCell::custom(|ui| { Button::new("Open").show(ui); }).center().show(ui);
}
Composition
Composes the Text atom and (optionally) the ColorSwatch atom; custom cells embed whatever the closure adds. The cell never paints — alignment is an egui Layout, visuals come from the atoms. Enforced by tests/no_painter_in_molecules.rs.
Notes
muted()is ignored forcustomcontent (only text honors it).- The cell fills the width it is handed; the surrounding column width is set by the
Tableorganism (viaegui_extras) — the cell itself does not size the column. 'alifetime: acustomclosure may borrow from the surrounding scope, which propagates throughTableRow<'a>.
TableRow
Layer: cell · Path:
src/cells/table_row.rs· Exports:table_row::TableRow
The row model for the Table organism: a Vec of TableCells plus row-level state (selected / selectable / key). It is a descriptor, not a renderer — TableRow has no show. The Table organism lays the cells out across the column widths (via egui_extras) and reads this state to drive selection. Carries lifetime 'a from its cells (a custom cell may borrow).
Design
-
Purpose / when to use — Always paired with
Table: construct oneTableRowper data row and pass the collection toTable::rows(...). -
Anatomy — Fields (crate-visible, consumed by the organism):
cells: Vec<TableCell<'a>>,selected: bool,selectable: bool(defaulttrue),key: Option<u64>. -
Variants / states
Modifier Effect selected(true)row highlighted by the Table organism selectable(false)row cannot be selected (default is selectable) key(u64)stable identity for selection / tree operations -
Tokens / layout consumed — None directly; the organism applies row height (
layout::TABLE_ROW_HEIGHT, 28px, or a size variant) and selection styling. See layout tokens.
API
| Method | Signature | Effect |
|---|---|---|
new | new(cells: impl IntoIterator<Item = TableCell<'a>>) -> Self | Build a row from its cells; selected=false, selectable=true, key=None. |
selected | selected(self, selected: bool) -> Self | Mark the row selected (highlighted by the organism). |
selectable | selectable(self, selectable: bool) -> Self | Whether the row may be selected (default true). |
key | key(self, key: u64) -> Self | Stable identity for selection / tree operations. |
There is no show — TableRow is consumed by Table::rows(...), not rendered standalone.
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::cells::{TableRow, TableCell};
TableRow::new([
TableCell::text("width"),
TableCell::text("1920").end(),
]);
}
#![allow(unused)]
fn main() {
// realistic — feeding the Table organism with selection
use ouroboros_ui::cells::{TableRow, TableCell};
use ouroboros_ui::organisms::{Table, Column};
let rows = data.iter().enumerate().map(|(i, d)| {
TableRow::new([
TableCell::text(&d.name),
TableCell::text(&d.size).end(),
TableCell::text(&d.status).status(d.color),
])
.key(i as u64)
.selected(selected == Some(i as u64))
});
Table::new()
.columns([Column::auto(), Column::auto().end(), Column::remainder()])
.rows(rows)
.show(ui);
}
Composition
Composes TableCells only and holds plain state. It paints nothing and renders nothing on its own — rendering is the Table organism’s job. Consistent with the cells rule enforced by tests/no_painter_in_molecules.rs.
Notes
- Pure data: the cells are stored, not drawn, until the organism iterates them across columns.
- Provide a stable
keywhen the table supports selection or tree state so identity survives reordering. - The
'alifetime flows fromTableCell<'a>(acustomcell may borrow); keep borrowed data alive until the table is shown.
ToolbarButton
Layer: cell · Path:
src/cells/toolbar_button.rs· Exports:toolbar_button::ToolbarButton
An icon toggle button with an optional hover tooltip, modelled on Unity/O3DE toolbars. It wraps the Toggle atom in icon mode and binds it to a &mut bool (the active state). When a tooltip is set the response is wrapped by the Tooltip atom. Carries lifetime 'a from the mutable borrow.
Design
-
Purpose / when to use — Tool palettes / toolbars where each button is a momentary-or-sticky icon toggle (select / move / rotate, view modes, snapping flags).
-
Anatomy — A
Togglein.icon(glyph)mode bound to the&mut bool, optionally decorated by aTooltipover the resulting response. -
Variants / states
State Source active / inactive the bound &mut bool(mutated in place byToggle)tooltip on hover present only when tooltip(...)was set -
Tokens / layout consumed — Inherited from the
Toggleatom (sizing/fill);ToolbarButtonadds none of its own. See tokens. -
Accessibility — Tooltip provides the textual label for an otherwise icon-only control; prefer always setting
tooltip(...).
API
| Method | Signature | Effect |
|---|---|---|
new | new(active: &'a mut bool, glyph: &'static str) -> Self | Bind to the active flag with a phosphor glyph. |
tooltip | tooltip(self, tooltip: impl Into<String>) -> Self | Hover tooltip text. |
id_source | id_source(self, id: impl std::hash::Hash) -> Self | Stable id forwarded to the underlying Toggle. |
show | show(self, ui: &mut Ui) -> Response | Render the toggle (mutating active); returns the Toggle response (tooltip-wrapped if set). |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::cells::ToolbarButton;
use ouroboros_ui::egui_phosphor::light;
let mut active = true;
ToolbarButton::new(&mut active, light::CURSOR).tooltip("Select").id_source("tb0").show(ui);
}
#![allow(unused)]
fn main() {
// realistic — a horizontal tool palette over an array of flags
let mut state = [true, false, false];
ui.horizontal(|ui| {
ToolbarButton::new(&mut state[0], light::CURSOR).tooltip("Select").id_source("tb0").show(ui);
ToolbarButton::new(&mut state[1], light::ARROWS_OUT).tooltip("Move").id_source("tb1").show(ui);
ToolbarButton::new(&mut state[2], light::ARROWS_CLOCKWISE).tooltip("Rotate").id_source("tb2").show(ui);
});
}
Composition
Composes the Toggle atom (icon mode) and, when a tooltip is set, the Tooltip atom. It paints nothing — all visuals come from Toggle. Enforced by tests/no_painter_in_molecules.rs.
Notes
- Binding:
activeis&mut bool— the toggle flips it in place; there is noselected/return-value to read for state, only.clicked()on the response for reacting to a press. - Give each button an
id_source(e.g."tb0"); buttons sharing the same glyph would otherwise collide on the toggle’s auto id. - Mutually-exclusive tools (radio-like) are the consumer’s responsibility — reset the other flags when one is clicked.
TreeNode
Layer: cell · Path:
src/cells/tree_node.rs· Exports:tree_node::TreeNode
An indented hierarchy row: a depth-based indent, an optional expand/collapse caret, an optional icon, and a label. Modelled on Unity/O3DE tree views. Expansion and selection are inputs, not state — show returns the [Response] and the consumer toggles expand/selection itself.
Design
-
Purpose / when to use — Scene graphs, file trees, outliners — any flattened hierarchy rendered row-by-row with per-row
depth. -
Anatomy — A
Surface(interactive, padded) wrapping a horizontal layout: indent space (depth × core::SPACE_4), a caretIcon(CARET_DOWN/CARET_RIGHT, small, muted) or ancore::ICON_SMspacer when not expandable, optional mutedIcon, then theTextlabel. -
Variants / states
State Effect default Surface::fill_none().border_none()selected ( selected(true))Surface::muted()fillexpandable + collapsed light::CARET_RIGHTcaretexpandable + expanded light::CARET_DOWNcaretnot expandable core::ICON_SMspacer (keeps labels aligned with carets)indent depth as f32 * core::SPACE_4leading space -
Tokens / layout consumed —
core::SPACE_4(16px per depth level),core::SPACE_1(4px outer pad + gaps),core::ICON_SM(14px caret slot),core::RADIUS_SM(4px). See tokens. -
Accessibility — Selection/expansion are visual inputs; pair with a real tree model.
API
| Method | Signature | Effect |
|---|---|---|
new | new(label: impl Into<String>) -> Self | Construct at depth=0, not expandable, not selected. |
depth | depth(self, depth: usize) -> Self | Indent level (× core::SPACE_4). |
icon | icon(self, glyph: &'static str) -> Self | Leading muted icon (after the caret slot). |
expandable | expandable(self, expanded: bool) -> Self | Mark expandable and set the expanded flag (caret direction) in one call. |
selected | selected(self, selected: bool) -> Self | Toggle the muted selected fill. |
id_source | id_source(self, id: impl std::hash::Hash) -> Self | Stable id for the underlying Surface. |
show | show(self, ui: &mut Ui) -> Response | Render; returns the Surface response (.clicked()). |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::cells::TreeNode;
use ouroboros_ui::egui_phosphor::light;
TreeNode::new("Scene").icon(light::FOLDER).expandable(true).id_source("tn0").show(ui);
}
#![allow(unused)]
fn main() {
// realistic — a flattened tree with depths and selection
TreeNode::new("Scene").icon(light::FOLDER).expandable(true).id_source("tn0").show(ui);
TreeNode::new("Player").depth(1).icon(light::CUBE).expandable(false).selected(true).id_source("tn1").show(ui);
TreeNode::new("Camera").depth(1).icon(light::CUBE).id_source("tn2").show(ui);
TreeNode::new("Mesh").depth(2).icon(light::CUBE).id_source("tn3").show(ui);
}
Composition
Composes the Surface, Icon (caret + leading icon), and Text atoms only. It paints nothing — visuals come from Surface + atoms. Enforced by tests/no_painter_in_molecules.rs.
Notes
expandable(expanded)does double duty: it both marks the node expandable and sets the caret direction. There is no separate “is expandable” flag — calling it at all makes the node expandable.- Expansion/selection are not remembered by the cell. Drive them from a tree model and toggle on
.clicked()(and detect a caret-vs-row click yourself if needed). - Non-expandable rows reserve an
core::ICON_SMspacer so their labels align with sibling carets. - Caret glyphs come straight from
egui_phosphor::light(CARET_DOWN/CARET_RIGHT).
Alert
Layer: molecule · Path:
src/molecules/alert.rs· Exports:alert::{Alert, AlertVariant}
A status callout — an inline banner that surfaces a build result, validation outcome, or notice. The variant drives both the leading glyph and an accent color pulled from the active [Theme]. Analogous to the shadcn Alert / Unity Help Box.
Design
-
Purpose / when to use — Communicate a contextual status (info / success / warning / error) inline in a panel. Not a toast; it stays in the layout flow.
-
Anatomy —
Surfacecontainer, paddedSPACE_3→ horizontal row of a statusIcon(accent-colored) + a vertical text stack of an optional accentTexttitle (body_strong) and the muted message body. -
Variants / states
Variant Glyph ( egui_phosphor::light)Theme color Info(default)INFOtheme.infoSuccessCHECK_CIRCLEtheme.successWarningWARNINGtheme.warningErrorWARNING_CIRCLEtheme.error -
Tokens / layout consumed —
core::SPACE_3(surface pad),SPACE_2(icon→text gap),SPACE_1(title→message gap); colors fromTheme. See tokens.
API
| Method | Effect |
|---|---|
Alert::new(message: impl Into<String>) -> Self | Construct with the body message; variant defaults to Info. |
.title(title: impl Into<String>) -> Self | Optional accent-colored title above the message. |
.variant(variant: AlertVariant) -> Self | Set the variant explicitly. |
.info() / .success() / .warning() / .error() -> Self | Sugar for .variant(...). |
.show(self, ui: &mut Ui) -> Response | Render; returns the surface’s Response. |
AlertVariant — Info (default), Success, Warning, Error.
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::molecules::Alert;
// minimal
Alert::new("Build finished in 2.3s.").show(ui);
}
#![allow(unused)]
fn main() {
use ouroboros_ui::molecules::{Alert, AlertVariant};
// realistic — titled error
Alert::new("Shader failed to compile.")
.title("Notice")
.variant(AlertVariant::Error)
.show(ui);
}
Composition
Composes Surface + Icon + Text only, laid out with ui.horizontal / ui.vertical. It never paints primitives — see the guards.
Notes
- The message is required; the title is optional and renders in the variant accent color.
- Color resolution happens at
showtime viaTheme::get(ui), so alerts re-theme automatically.
Breadcrumb
Layer: molecule · Path:
src/molecules/breadcrumb.rs· Exports:breadcrumb::Breadcrumb
A horizontal path trail. Every item except the last renders as a link-style Button separated by caret icons; the last item is plain strong text (the current location). show reports which crumb was clicked this frame. Analogous to the shadcn Breadcrumb.
Design
- Purpose / when to use — Show hierarchical location (asset path, navigation trail) and let the user jump back to an ancestor.
- Anatomy —
ui.horizontalrow of: per non-last item a small linkButtonfollowed by a mutedCARET_RIGHTIcon; the final item abody_strongText. - Tokens / layout consumed — none directly; relies on atom sizing (
Button::sm,Icon::sm). See tokens.
API
| Method | Effect |
|---|---|
Breadcrumb::new() -> Self | Empty trail. (Default also available.) |
.items<S: Into<String>>(items: impl IntoIterator<Item = S>) -> Self | Set the ordered crumbs. |
.show(self, ui: &mut Ui) -> Option<usize> | Render; returns Some(i) for the crumb clicked this frame, else None. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::molecules::Breadcrumb;
// minimal
Breadcrumb::new().items(["Home", "Library"]).show(ui);
}
#![allow(unused)]
fn main() {
use ouroboros_ui::molecules::Breadcrumb;
// realistic — react to a click
if let Some(i) = Breadcrumb::new()
.items(["Assets", "Models", "Characters", "hero.fbx"])
.show(ui)
{
navigate_to_depth(i);
}
}
Composition
Composes Button (ButtonVariant::Link), Icon, and Text. It never paints — see the guards.
Notes
- Returns
Option<usize>rather than aResponse— the index is the actionable signal. - Per-crumb button ids are derived via
.id_source(("crumb", i)), so multiple breadcrumbs in one frame are safe. - The last crumb is non-interactive by design (it’s the current node).
Card
Layer: molecule · Path:
src/molecules/card.rs· Exports:card::{Card, CardSize}
An elevated Surface with an optional header (title + description + a top-right action slot), arbitrary content (a closure), and an optional footer separated by a divider. size scales the internal padding and gaps. Models the shadcn Card (Header / Content / Footer / CardAction).
Design
-
Purpose / when to use — Group related content into a raised panel: a settings block, a summary, a property editor section.
-
Anatomy —
Surface::elevated()(padded by size) → vertical stack of: -
Sizes
CardSizepad header/footer gap DefaultSPACE_4SPACE_3SmSPACE_3SPACE_2 -
Tokens / layout consumed —
core::SPACE_4 / SPACE_3 / SPACE_2 / SPACE_1; elevation fromSurface. See tokens.
API
| Method | Effect |
|---|---|
Card::new() -> Self | Empty card, CardSize::Default. (Default also available.) |
.title(title: impl Into<String>) -> Self | Header title (renders a Heading). |
.description(description: impl Into<String>) -> Self | Header sub-text (muted caption). |
.action(action: impl FnOnce(&mut Ui) + 'a) -> Self | Top-right header slot — a button/menu/badge. |
.footer(footer: impl FnOnce(&mut Ui) + 'a) -> Self | Footer slot below a divider. |
.size(size: CardSize) -> Self | Set the spacing scale. |
.sm() -> Self | Sugar for CardSize::Sm. |
.show(self, ui: &mut Ui, content: impl FnOnce(&mut Ui)) -> Response | Render; content is the card body. Returns the surface Response. |
CardSize — Default (default), Sm.
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::molecules::Card;
use ouroboros_ui::atoms::Text;
// minimal
Card::new().title("Compact").show(ui, |ui| {
Text::new("Body content.").show(ui);
});
}
#![allow(unused)]
fn main() {
use ouroboros_ui::molecules::Card;
use ouroboros_ui::atoms::{Button, Text};
use ouroboros_ui::egui_phosphor::light;
// realistic — header action + footer buttons + closure body
Card::new()
.title("Project settings")
.description("Manage your project preferences")
.action(|ui| {
Button::new("")
.icon_left(light::DOTS_THREE)
.icon_only()
.ghost()
.sm()
.id_source("card_menu")
.show(ui);
})
.footer(|ui| {
ui.horizontal(|ui| {
Button::new("Save").id_source("card_save").show(ui);
Button::new("Cancel").ghost().id_source("card_cancel").show(ui);
});
})
.show(ui, |ui| {
Text::new("Card body content goes here.").show(ui);
});
}
Composition
Composes Surface + Heading + Text + Divider. Body, action, and footer are caller-supplied closures. It never paints — see the guards.
Notes
Card<'a>carries a lifetime: theaction/footerclosures are boxed (Box<dyn FnOnce(&mut Ui) + 'a>) and may borrow from the surrounding scope.contentis a plainimpl FnOnce(&mut Ui)(not boxed) and always runs, even with no header/footer.- The header is omitted entirely when none of title/description/action are set.
- Give interactive widgets inside the slots stable
id_sources when multiple cards share a frame.
CheckboxCard
Layer: molecule · Path:
src/molecules/checkbox_card.rs· Exports:checkbox_card::CheckboxCard
A selectable card bound to a &mut bool: an interactive Surface wrapping a display-only Checkbox plus a label and optional description. The whole card is the click target — clicking anywhere toggles the bound bool (the inner checkbox is non-interactive, so there is no double-toggle).
Design
- Purpose / when to use — A larger, more legible alternative to a bare checkbox for opt-in settings, where a description helps.
- Anatomy —
Surface::new().interactive().selected(checked)paddedSPACE_3→ horizontal row of a displayCheckbox(.interactive(false)) + a vertical stack of abody_strongTextlabel and an optional muted caption description. - States — selected visual driven by
Surface::selected(*checked); hover/press handled bySurface::interactive(). - Tokens / layout consumed —
core::SPACE_3(pad + checkbox→text gap). See tokens.
API
| Method | Effect |
|---|---|
CheckboxCard::new(checked: &'a mut bool, label: impl Into<String>) -> Self | Bind state + set label. |
.description(description: impl Into<String>) -> Self | Optional muted caption under the label. |
.id_source(id: impl std::hash::Hash) -> Self | Stable surface id (use when several share a frame). |
.show(self, ui: &mut Ui) -> Response | Render; toggles *checked on click. Returns the surface Response. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::molecules::CheckboxCard;
// minimal
let mut on = true;
CheckboxCard::new(&mut on, "Enable notifications").show(ui);
}
#![allow(unused)]
fn main() {
use ouroboros_ui::molecules::CheckboxCard;
// realistic — with description + stable id
CheckboxCard::new(&mut on, "Enable notifications")
.description("Email + in-app alerts")
.id_source("cc")
.show(ui);
}
Composition
Composes Surface + Checkbox + Text. It never paints — see the guards.
Notes
- Two-way binding:
showwrites*self.checked = !*self.checkedwhen the surface is clicked. - The inner checkbox is mirrored display state (
.interactive(false)), so it doesn’t compete for the click. - Set
id_sourceto avoid surface-id collisions when stacking multiple cards.
Collapsible
Layer: molecule · Path:
src/molecules/collapsible.rs· Exports:collapsible::Collapsible
A caret-headed section that hides or reveals its content. Open state persists in egui temp memory keyed by id (or by the title), so it survives across frames. The content closure only runs when open. Analogous to the shadcn Collapsible / Unity Foldout.
Design
- Purpose / when to use — Inspector sections, settings groups, anything foldable to manage vertical density.
- Anatomy — A clickable header row: a muted caret
Icon(CARET_DOWNwhen open,CARET_RIGHTwhen closed) +SPACE_1+ abody_strongTexttitle. When open,SPACE_2then thecontentclosure. - States — open / closed, toggled by clicking the header. Persisted via
ui.datatemp storage under the resolved id. - Tokens / layout consumed —
core::SPACE_1(caret→title),SPACE_2(header→content). See tokens.
API
| Method | Effect |
|---|---|
Collapsible::new(title: impl Into<String>) -> Self | Construct; defaults to closed. |
.default_open(open: bool) -> Self | Initial open state when no persisted value exists. |
.id_source(id: impl std::hash::Hash) -> Self | Explicit id for persistence + interaction (defaults to format!("collapsible::{title}")). |
.show(self, ui: &mut Ui, content: impl FnOnce(&mut Ui)) -> Response | Render; content runs only when open. Returns the header interaction Response. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::molecules::Collapsible;
use ouroboros_ui::atoms::Text;
// minimal
Collapsible::new("Rendering").show(ui, |ui| {
Text::new("Material, shadows…").muted().show(ui);
});
}
#![allow(unused)]
fn main() {
use ouroboros_ui::molecules::Collapsible;
use ouroboros_ui::atoms::Text;
// realistic — open by default, explicit id
Collapsible::new("Transform")
.default_open(true)
.id_source("inspector::transform")
.show(ui, |ui| {
Text::new("Position / Rotation / Scale").muted().show(ui);
});
}
Composition
Composes Icon + Text, with ui.interact over the header row’s rect for the click target. It never paints — see the guards.
Notes
- Open state lives in
ui.datatemp memory under the resolved id; the interaction id isid.with("header"). - The returned
Responseis the header (use.clicked()to react to toggles beyond the visual). - If two collapsibles share a title, pass distinct
id_sources to avoid shared open state.
ColorField
Layer: molecule · Path:
src/molecules/color_field.rs· Exports:color_field::ColorField
A color editor bound to a &mut Color32: a ColorSwatch preview next to an editable hex Input. Clicking the swatch opens a full HSV/RGB/hex picker in a popover. Inspired by Unity / Figma color fields.
Design
- Purpose / when to use — Edit a single color value: a tint, a material albedo, a UI accent override.
- Anatomy —
ui.horizontalrow of: aColorSwatchshowing the current color,SPACE_2, then anInputholding the hex string (#RRGGBB). A popover menu anchored on the swatch hosts egui’scolor_picker_color32. - States — alpha editing optional via
.alpha(true)(switches the picker’sAlphamode fromOpaquetoOnlyBlend). - Tokens / layout consumed —
core::SPACE_2(swatch→input gap),core::CONTROL_LG * 6.0(picker popover max width). See tokens.
API
| Method | Effect |
|---|---|
ColorField::new(color: &'a mut Color32) -> Self | Bind the color. Alpha off by default. |
.alpha(alpha: bool) -> Self | Enable editing the alpha channel in the picker. |
.id_source(id: impl std::hash::Hash) -> Self | Stable id for the hex input (defaults to "color_field"). |
.show(self, ui: &mut Ui) -> Response | Render; returns the hex Input Response. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::molecules::ColorField;
use egui::Color32;
// minimal
let mut c = Color32::from_rgb(26, 188, 156);
ColorField::new(&mut c).id_source("cf1").show(ui);
}
#![allow(unused)]
fn main() {
use ouroboros_ui::molecules::ColorField;
// realistic — alpha editing
ColorField::new(&mut c)
.alpha(true)
.id_source("material_albedo")
.show(ui);
}
Composition
Composes ColorSwatch + Input, plus egui::Popup::menu hosting egui::color_picker::color_picker_color32 — a built-in egui widget, not a paint call. It never paints primitives directly — see the guards.
Notes
- Two-way binding: typing a valid hex into the input (
Color32::from_hex) writes back into*color; the picker writes directly into*color. - The hex string is recomputed from the bound color every frame as
#{:02X}{:02X}{:02X}(RGB only — alpha is edited through the picker). - Pass a unique
id_sourceper field — the default"color_field"collides if several share a frame. - The returned
Responseis the hex input’s, so.changed()fires on hex edits.
Field (family)
Layer: molecule · Path:
src/molecules/field.rs· Exports:field::{Field, FieldGroup, FieldOrientation, FieldSeparator, FieldSet}
The form-layout family. Field wraps any control with a label and a hint/error line in one of three orientations. FieldGroup stacks fields with a standard gap; FieldSet groups them semantically under a legend; FieldSeparator divides groups with an optional inline label. FieldOrientation selects vertical / horizontal / responsive layout. Modeled on the shadcn Field primitives.
Design
- Purpose / when to use — Build consistent forms: every labeled control through
Field, grouped byFieldGroup/FieldSet, divided byFieldSeparator. - Anatomy (Field) — A label row (
Text::label+ an optional*intheme.destructivewhen required) and a control closure, with a hint or error caption below. Layout is vertical or horizontal per orientation. - Tokens / layout consumed —
core::SPACE_4(horizontal label↔control gap; FieldGroup item gap; FieldSet pad),SPACE_2,SPACE_1;layout::FIELD_HORIZONTAL_MIN(=480.0) for responsive switching. See tokens and layout. - Accessibility — required state is marked with a destructive-colored asterisk; error text uses
theme.errorand replaces the hint when present.
Field
A labeled form field. show runs the control closure and lays out the label + hint/error around it.
API
| Method | Effect |
|---|---|
Field::new(label: impl Into<String>) -> Self | Construct with a label; orientation Vertical. |
.required() -> Self | Append a destructive * after the label. |
.hint(hint: impl Into<String>) -> Self | Muted caption below the control (suppressed if an error is set). |
.error(error: impl Into<String>) -> Self | Error caption below the control (takes priority over hint). |
.orientation(orientation: FieldOrientation) -> Self | Set layout mode. |
.horizontal() -> Self | Sugar for FieldOrientation::Horizontal. |
.responsive() -> Self | Sugar for FieldOrientation::Responsive. |
.show(self, ui: &mut Ui, control: impl FnOnce(&mut Ui) -> Response) -> Response | Render; returns the control’s Response (not a wrapper). |
Note the closure signature:
controlmust return aResponse(e.g. the return of anInput/Switch/Slider.show(ui)).
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::molecules::Field;
use ouroboros_ui::atoms::Input;
// minimal — vertical
Field::new("Email").show(ui, |ui| {
Input::new(&mut email).placeholder("[email protected]").show(ui)
});
}
#![allow(unused)]
fn main() {
use ouroboros_ui::molecules::Field;
use ouroboros_ui::atoms::{Input, Switch};
// required + hint, then error state
Field::new("Email")
.required()
.hint("We never share it")
.show(ui, |ui| Input::new(&mut email).show(ui));
Field::new("Username")
.error("Already taken")
.show(ui, |ui| Input::new(&mut user).error(true).show(ui));
// horizontal — label ↔ control (good for switches)
Field::new("Vsync")
.horizontal()
.show(ui, |ui| Switch::new(&mut vsync).show(ui));
}
FieldOrientation
Field layout mode.
| Variant | Behavior |
|---|---|
Vertical (default) | Label above, control below. |
Horizontal | Label and control side by side (SPACE_4 gap), hint/error under the control. |
Responsive | Horizontal when ui.available_width() >= layout::FIELD_HORIZONTAL_MIN (480.0), else vertical. |
Responsive evaluates the available width at show time each frame, so a field re-stacks as its container resizes. See layout.
FieldGroup
A zero-config stacker: runs a content closure inside a vertical layout with item_spacing.y = SPACE_4, so consecutive fields get uniform spacing.
API
| Method | Effect |
|---|---|
FieldGroup::new() -> Self | Construct. (Default also available.) |
.show(self, ui: &mut Ui, content: impl FnOnce(&mut Ui)) -> Response | Render the stacked content. Returns the vertical layout Response. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::molecules::{Field, FieldGroup};
FieldGroup::new().show(ui, |ui| {
Field::new("Name").show(ui, |ui| Input::new(&mut name).show(ui));
Field::new("Email").show(ui, |ui| Input::new(&mut email).show(ui));
});
}
FieldSet
A semantic group with an optional legend, rendered inside a Surface with SurfaceFill::None (padding only, no fill).
API
| Method | Effect |
|---|---|
FieldSet::new() -> Self | Construct. (Default also available.) |
.legend(legend: impl Into<String>) -> Self | Optional heading label above the content (Text::label). |
.show(self, ui: &mut Ui, content: impl FnOnce(&mut Ui)) -> Response | Render the legend + content. Returns the surface Response. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::molecules::{FieldSet, RadioGroup};
FieldSet::new().legend("Display").show(ui, |ui| {
RadioGroup::new(&mut sel)
.options(["Windowed", "Fullscreen"])
.show(ui);
});
}
FieldSeparator
A horizontal Divider between field groups, optionally with a centered inline caption below the rule.
(v1: rule + centered caption; true inline line–text–line is a later refinement.)
API
| Method | Effect |
|---|---|
FieldSeparator::new() -> Self | Plain divider. (Default also available.) |
.label(label: impl Into<String>) -> Self | Add a centered muted caption (e.g. "OR"). |
.show(self, ui: &mut Ui) -> Response | Render; returns the divider Response. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::molecules::FieldSeparator;
FieldSeparator::new().show(ui); // bare rule
FieldSeparator::new().label("OR").show(ui); // rule + centered caption
}
Composition
The family composes Text, Surface (with SurfaceFill::None), and Divider only; controls and grouped content are caller closures. Nothing here paints — see the guards.
Notes
Field::show’scontrolclosure must return aResponse— pass through the inner atom’s.show(ui).- Error wins over hint: when both are set, only the error caption renders.
- The label row is omitted (and no label→control gap added) when the label string is empty.
Responsivereadsavailable_widthlive, so wrap it in a sized region if you need deterministic behavior.
InputGroup
Layer: molecule · Path:
src/molecules/input_group.rs· Exports:input_group::{InputGroup, Slot}
A text input (or multi-line Textarea) with addons sharing one muted Surface. Addons — icons, text, or buttons — sit in four slots: leading/trailing inline (on the field’s centerline) and block start/end (their own rows above/below). .multiline(rows) switches the editing substrate to a Textarea. Models shadcn’s InputGroupAddon / InputGroupText / InputGroupButton.
Design
- Purpose / when to use — Inputs that need affordances: a leading search icon, a trailing clear button, a
$/unit prefix, or a labeled block addon over a textarea. - Anatomy —
Surface::muted().pad(SPACE_1).radius(RADIUS_MD)→ vertical stack: aBlockStartrow (if any) → the field → aBlockEndrow (if any). In single-line mode the field is a fixed-height (CONTROL_MD) center-aligned row ofLeadingInlineaddons + a framelessTextEdit::singleline(placeholder + text styled from typography) +TrailingInlineaddons. In multiline mode it’s aTextarea(inline addons ignored). - Slots (
Slot) —LeadingInline,TrailingInline,BlockStart,BlockEnd. - Addon kinds — icon (muted
Icon), text (mutedText), button (ghost icon-onlyButton, runs anFnMuton click). - Tokens / layout consumed —
core::SPACE_1(surface pad / block gaps),SPACE_2(addon spacing + trailing reserve),CONTROL_MD(inline row height),RADIUS_MD. See tokens.
API
| Method | Effect |
|---|---|
InputGroup::new(buf: &'a mut String) -> Self | Bind the text buffer. |
.placeholder(text: impl Into<String>) -> Self | Hint text shown when empty. |
.multiline(rows: usize) -> Self | Switch to a Textarea of rows rows (≥1; inline addons ignored). |
.id_source(id: impl std::hash::Hash) -> Self | Stable id for the editor. |
.icon(slot: Slot, glyph: &'static str) -> Self | Add an icon addon in slot. |
.text(slot: Slot, text: impl Into<String>) -> Self | Add a text addon in slot. |
.button(slot: Slot, glyph: &'static str, action: impl FnMut() + 'a) -> Self | Add a clickable icon-button addon; action runs on click. |
.leading_icon(glyph: &'static str) -> Self | Sugar — icon in LeadingInline. |
.trailing_icon(glyph: &'static str) -> Self | Sugar — icon in TrailingInline. |
.leading_text(text: impl Into<String>) -> Self | Sugar — text in LeadingInline. |
.show(self, ui: &mut Ui) -> Response | Render; returns the field Response (.changed() on edit). |
Slot — LeadingInline, TrailingInline, BlockStart, BlockEnd.
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::molecules::{InputGroup, Slot};
use ouroboros_ui::egui_phosphor::light;
// minimal — leading search icon + trailing clear button
InputGroup::new(&mut query)
.leading_icon(light::MAGNIFYING_GLASS)
.button(Slot::TrailingInline, light::X, || { query.clear(); })
.placeholder("Search…")
.id_source("ig_search")
.show(ui);
}
#![allow(unused)]
fn main() {
use ouroboros_ui::molecules::{InputGroup, Slot};
// text prefix/suffix
InputGroup::new(&mut price)
.leading_text("$")
.text(Slot::TrailingInline, "USD")
.placeholder("0.00")
.id_source("ig_price")
.show(ui);
// block addon over a multiline textarea
InputGroup::new(&mut note)
.text(Slot::BlockStart, "Description")
.multiline(3)
.placeholder("Markdown supported…")
.id_source("ig_note")
.show(ui);
}
Composition
Composes Surface + Textarea + Icon + Text + Button. The single-line editor is an egui::TextEdit::singleline (the editing substrate, not a paint call), styled from typography. It never paints primitives — see the guards.
Notes
InputGroup<'a>carries a lifetime — the buttonactionisBox<dyn FnMut() + 'a>and may capture/mutate surrounding state (and even the bound buffer, as in the clear example).- In single-line mode, trailing-inline addons reserve
(CONTROL_MD + SPACE_2)width each so the text doesn’t run under them. .multiline(rows)ignores inline addons — only block addons apply to a textarea.- The returned
Responseis the field’s;.changed()is true when the text was edited this frame. SearchFieldis a thin preset over this molecule.
RadioCard
Layer: molecule · Path:
src/molecules/radio_card.rs· Exports:radio_card::RadioCard
A selectable card for single-choice options: an interactive Surface wrapping a display-only Radio plus a label and optional description. Stateless like the Radio atom — it reports clicks via its Response; the consumer owns exclusivity across the set.
Design
- Purpose / when to use — Present a small set of mutually exclusive options as legible cards (plan tiers, modes) instead of bare radio buttons.
- Anatomy —
Surface::new().interactive().selected(selected)paddedSPACE_3→ horizontal row of a displayRadio(.interactive(false)) + a vertical stack of abody_strongTextlabel and an optional muted caption description. - States — selected visual driven by
Surface::selected(selected); hover/press bySurface::interactive(). Selection itself is caller-managed. - Tokens / layout consumed —
core::SPACE_3(pad + radio→text gap). See tokens.
API
| Method | Effect |
|---|---|
RadioCard::new(selected: bool, label: impl Into<String>) -> Self | Set the selected visual + label. Note selected is a plain bool, not a binding. |
.description(description: impl Into<String>) -> Self | Optional muted caption under the label. |
.id_source(id: impl std::hash::Hash) -> Self | Stable surface id (use one per card). |
.show(self, ui: &mut Ui) -> Response | Render; returns the surface Response (check .clicked()). |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::molecules::RadioCard;
// realistic — consumer manages exclusivity over a list
let mut sel = 0usize;
for (i, (title, desc)) in [
("Starter", "Up to 10 projects"),
("Pro", "Unlimited projects"),
].iter().enumerate() {
if RadioCard::new(sel == i, *title)
.description(*desc)
.id_source(("rc", i))
.show(ui)
.clicked()
{
sel = i;
}
}
}
Composition
Composes Surface + Radio + Text. It never paints — see the guards.
Notes
- Unlike
CheckboxCard, this takesselected: bool(not&mut) and does not mutate anything — react to.clicked()and update your own selection index. - The inner radio is display-only (
.interactive(false)); the whole card is the click target. - Give each card a distinct
id_source(e.g.("rc", i)) to avoid surface-id collisions.
RadioGroup
Layer: molecule · Path:
src/molecules/radio_group.rs· Exports:radio_group::RadioGroup
A single-select group of Radio atoms bound to a &mut usize index. Composes one labeled radio per option, vertically (default) or horizontally; clicking an option writes its index back into the binding.
Design
- Purpose / when to use — Pick exactly one option from a short list with full per-option labels visible (vs. a compact
ToggleGroup). - Anatomy — A vertical or horizontal layout of
Radioatoms (Radio::new(selected == i).label(option)), each spaced bySPACE_1. - States — exactly one radio reflects
*selected == i. - Tokens / layout consumed —
core::SPACE_1(inter-option gap). See tokens.
API
| Method | Effect |
|---|---|
RadioGroup::new(selected: &'a mut usize) -> Self | Bind the selected index. |
.options<S: Into<String>>(options: impl IntoIterator<Item = S>) -> Self | Set the option labels. |
.horizontal() -> Self | Lay out in a row instead of a column. |
.show(self, ui: &mut Ui) -> Response | Render; writes *selected = i on click. Returns the layout Response. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::molecules::RadioGroup;
// minimal — vertical
let mut sel = 0usize;
RadioGroup::new(&mut sel)
.options(["Small", "Medium", "Large"])
.show(ui);
}
#![allow(unused)]
fn main() {
use ouroboros_ui::molecules::RadioGroup;
// horizontal
RadioGroup::new(&mut sel)
.options(["Windowed", "Fullscreen"])
.horizontal()
.show(ui);
}
Composition
Composes Radio atoms inside ui.vertical / ui.horizontal. It never paints — see the guards.
Notes
- Two-way binding:
showmutates*selectedto the clicked index. - Per-radio ids are derived via
.id_source(("radio_group", i)), so a single group is collision-free; nest underFieldSetfor a labeled group. - Returns the container
Response, not a per-option signal — selection is communicated through the binding.
SearchField
Layer: molecule · Path:
src/molecules/search_field.rs· Exports:search_field::SearchField
A search input preset: a thin wrapper over InputGroup configured with a leading magnifier icon. Inspired by Unity’s Search Field.
Design
- Purpose / when to use — Any filter/search box. Use it instead of hand-wiring an
InputGroupwith a search glyph. - Anatomy — An
InputGroupbound to your buffer, withleading_icon(light::MAGNIFYING_GLASS)and an optional placeholder. - Tokens / layout consumed — inherited from
InputGroup. See tokens.
API
| Method | Effect |
|---|---|
SearchField::new(buf: &'a mut String) -> Self | Bind the search buffer. |
.placeholder(text: impl Into<String>) -> Self | Hint text shown when empty. |
.show(self, ui: &mut Ui) -> Response | Render; returns the field Response (.changed() on edit). |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::molecules::SearchField;
// minimal
SearchField::new(&mut query).show(ui);
}
#![allow(unused)]
fn main() {
use ouroboros_ui::molecules::SearchField;
// with placeholder; react to edits
if SearchField::new(&mut query)
.placeholder("Search assets…")
.show(ui)
.changed()
{
refilter(&query);
}
}
Composition
Composes InputGroup only (which in turn composes Surface + Icon + the text editor). It never paints — see the guards.
Notes
- A deliberately minimal molecule — no id_source setter; the underlying
InputGroupfalls back to egui’s auto id. If you need a stable id or trailing clear button, useInputGroupdirectly. - The returned
Responseis the field’s;.changed()fires on edit.
Tabs
Layer: molecule · Path:
src/molecules/tabs.rs· Exports:tabs::{Tabs, TabsVariant}
A single-select tab bar bound to a &mut usize. Two looks: Container (default — segmented chips inside a Surface, so the bar reads as a unit, not loose buttons) and Line (an underlined row where the active tab gets a primary rule the width of the tab). Each tab is a label with an optional leading icon. Models the shadcn / radix Tabs.
Design
-
Purpose / when to use — Switch between panels/views in a panel header. The molecule is the bar only; render the active panel yourself based on the selected index.
-
Anatomy —
-
Variants (
TabsVariant)Variant Look Container(default)Segmented chips in a surface; active = raised secondary button. LineUnderlined row; active = ghost button + primary underline. -
Tokens / layout consumed —
core::SPACE_1(surface pad / underline gap),RADIUS_MD,BORDER_FOCUS(underline thickness);theme.primary(underline color). See tokens.
API
| Method | Effect |
|---|---|
Tabs::new(selected: &'a mut usize) -> Self | Bind the active index. |
.tabs<S: Into<String>>(tabs: impl IntoIterator<Item = S>) -> Self | Set tabs from labels (no icons). |
.tab(label: impl Into<String>, icon: &'static str) -> Self | Append one tab with a leading icon. |
.variant(variant: TabsVariant) -> Self | Set the look. |
.line() -> Self | Sugar for TabsVariant::Line. |
.show(self, ui: &mut Ui) -> Response | Render; writes *selected = i on click. Returns the layout Response. |
TabsVariant — Container (default), Line.
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::molecules::Tabs;
// minimal — container, labels only
let mut sel = 0usize;
Tabs::new(&mut sel)
.tabs(["Overview", "Stats", "Notes"])
.show(ui);
}
#![allow(unused)]
fn main() {
use ouroboros_ui::molecules::Tabs;
use ouroboros_ui::egui_phosphor::light;
// container with per-tab icons
Tabs::new(&mut sel)
.tab("Scene", light::CUBE)
.tab("Game", light::PLAY)
.tab("Assets", light::FOLDER)
.show(ui);
// line variant
Tabs::new(&mut sel)
.tabs(["Overview", "Geometry", "Materials"])
.line()
.show(ui);
// render the active panel yourself
match sel { 0 => panel_scene(ui), 1 => panel_game(ui), _ => panel_assets(ui) }
}
Composition
Composes Surface + Button (+ Divider in the line variant). It never paints — the underline is a Divider atom, not a paint call. See the guards.
Notes
.tabs(...)replaces the tab list with icon-less tabs;.tab(...)pushes individual tabs — mixing them, the last.tabs(...)wins for the bulk and.tab(...)appends.- Two-way binding via
&mut usize; the molecule renders only the bar, not panels. - Per-tab ids use
("tab", i), so multiple bars per frame are safe.
ToggleGroup
Layer: molecule · Path:
src/molecules/toggle_group.rs· Exports:toggle_group::ToggleGroup
A segmented single-select control bound to a &mut usize. Options render as a connected row of Buttons inside a Surface container: the selected segment is a raised Secondary button (looks like a real button), the rest Ghost. Models the shadcn Toggle Group / Button Group.
Design
- Purpose / when to use — Compact mutually-exclusive choice with short labels (gizmo space: Local/World, alignment, view mode). For longer labels or descriptions use
RadioGroup/RadioCard. - Anatomy —
Surface::pad(SPACE_1).radius(RADIUS_MD)→ horizontal row of smallButtons; active =ButtonVariant::Secondary, othersButtonVariant::Ghost. - States — exactly one segment is the raised secondary button (
*selected == i). - Tokens / layout consumed —
core::SPACE_1(surface pad),RADIUS_MD. See tokens.
API
| Method | Effect |
|---|---|
ToggleGroup::new(selected: &'a mut usize) -> Self | Bind the active index. |
.options<S: Into<String>>(options: impl IntoIterator<Item = S>) -> Self | Set the segment labels. |
.show(self, ui: &mut Ui) -> Response | Render; writes *selected = i on click. Returns the surface Response. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::molecules::ToggleGroup;
// minimal
let mut sel = 0usize;
ToggleGroup::new(&mut sel)
.options(["Local", "World"])
.show(ui);
}
Composition
Composes Surface + Button. It never paints — see the guards.
Notes
- Two-way binding via
&mut usize. - Per-segment ids use
("toggle_group", i), so multiple groups per frame are safe. - Visually near-identical to
TabsContainer, but semantically a value selector rather than a view switcher.
VectorField
Layer: molecule · Path:
src/molecules/vector_field.rs· Exports:vector_field::VectorField
N numeric components edited side-by-side in a row — a Vec2/Vec3/Vec4 editor bound to a &mut [f32] slice. Each component is an axis label (X/Y/Z/W) plus a draggable NumericField. Inspired by Unity’s Vector Field.
Design
- Purpose / when to use — Edit transform-style vectors (position, rotation, scale) or any fixed set of float components inline.
- Anatomy —
ui.horizontalrow; per component: a muted captionTextaxis label ("X"/"Y"/"Z"/"W", or"·"beyond 4) +SPACE_1+ a width-allocatedNumericField(CONTROL_MDtall) +SPACE_2. - Tokens / layout consumed —
core::SPACE_6(per-component overhead reserve),SPACE_12(minimum field width),SPACE_1/SPACE_2(gaps),CONTROL_MD(field height). The available row width is split evenly across components. See tokens.
API
| Method | Effect |
|---|---|
VectorField::new(values: &'a mut [f32]) -> Self | Bind the component slice; default drag speed 0.1. |
.speed(speed: f32) -> Self | Drag sensitivity passed to each NumericField. |
.show(self, ui: &mut Ui) | Render. Returns () — edits land directly in the bound slice. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::molecules::VectorField;
// minimal — a Vec3
let mut v = [1.0f32, 0.0, -1.0];
VectorField::new(&mut v).show(ui);
}
#![allow(unused)]
fn main() {
use ouroboros_ui::molecules::VectorField;
// finer drag speed
VectorField::new(&mut v).speed(0.05).show(ui);
}
Composition
Composes Text (axis labels) + NumericField atoms. It never paints — see the guards.
Notes
- Binds a
&mut [f32]of arbitrary length; component count follows the slice (axis labels only cover X/Y/Z/W, others show·). showreturns(), not aResponse— mutation flows through the slice; observe values directly.- Row width is divided evenly by component count with a per-field floor of
SPACE_12, so it stays usable in narrow inspectors.
Accordion
Layer: organism · Path:
src/organisms/accordion.rs· Exports:accordion::{Accordion, AccordionCtx}
Stacked collapsible sections (shadcn Accordion). show hands you an AccordionCtx whose section(title, body) appends a Collapsible molecule separated from the previous one by a horizontal Divider. Each section owns its open/closed state in egui memory — the organism keeps no state itself.
Design
-
Purpose / when to use — group related option blocks that should fold away (inspector groups: Transform / Rendering / Physics). Use when only some sections need to be visible at once.
-
Anatomy — a vertical stack; each entry is a
Collapsible(header + body), with aDivider::horizontal()+SPACE_2padding inserted before every section after the first. Optionally wrapped in a cardSurface. -
Variants / states
Variant / state How plain Accordion::new()— bare vertical stackcard .card()— wraps the stack in aSurface::new()cardsection open/closed owned per- Collapsiblein egui memory (not by Accordion) -
Tokens / layout consumed —
core::SPACE_2(inter-section gap); card casing viaSurface. -
Accessibility — folding handled by the
Collapsiblemolecule (click header to toggle).
API
Accordion
| Method | Effect |
|---|---|
Accordion::new() -> Self | Bare (no card). |
Accordion::default() | Same as new(). |
.card() -> Self | Wrap the section group in a card Surface. |
.show(ui, build: impl FnOnce(&mut AccordionCtx)) -> Response | Run build, which adds sections via the ctx. Returns the vertical (or Surface) Response. |
AccordionCtx<'u>
Section builder handed to show. Holds ui: &mut Ui and a first flag internally.
| Method | Effect |
|---|---|
.section(title: impl Into<String>, body: impl FnOnce(&mut Ui)) | Add one collapsible section. Inserts a divider + spacing before all but the first. body paints arbitrary widgets into the section. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::organisms::Accordion;
Accordion::new().show(ui, |acc| {
acc.section("Transform", |ui| { /* fields */ });
acc.section("Rendering", |ui| { /* fields */ });
});
}
#![allow(unused)]
fn main() {
// realistic — card variant with arbitrary widgets per section (from storybook)
use ouroboros_ui::organisms::Accordion;
use ouroboros_ui::molecules::{Field, VectorField};
use ouroboros_ui::atoms::{Switch, Text};
Accordion::new().card().show(ui, |acc| {
acc.section("Transform", |ui| {
let mut p = [1.0_f32, 0.0, -1.0];
VectorField::new(&mut p).speed(0.05).show(ui);
});
acc.section("Rendering", |ui| {
Field::new("Cast shadows")
.horizontal()
.show(ui, |ui| Switch::new(&mut on).show(ui));
});
acc.section("Physics", |ui| {
Text::new("Collider, mass, drag").muted().show(ui);
});
});
}
Composition
Composes the Collapsible molecule (one per section), the Divider atom (separators), and optionally the Surface atom (card casing). Never paints directly — see guards.
Notes
- Open/closed state lives in egui memory, owned by each
Collapsible(keyed by its title) — not byAccordion. Identical section titles within one accordion would collide on memory id. sectiontakesFnOncebodies — the body closure runs immediately duringshow.- The first section never gets a leading divider; ordering of
sectioncalls is the visual order.
Dialog
Layer: organism · Path:
src/organisms/dialog.rs· Exports:dialog::Dialog
A modal dialog with title / optional description + a free body (shadcn Dialog / Unity Overlay). Built on egui::Modal — a scrim + centered frame that inherits the themed window visuals. Render it only while your “open” flag is true; show returns true when the modal should close (backdrop click or Esc).
Design
-
Purpose / when to use — confirmations, destructive-action prompts, focused forms that must block the rest of the UI until resolved.
-
Anatomy —
egui::Modalcasing →Heading(h2, the title) → optionalText(muted, the description) →SPACE_4→ the body closure (your buttons/fields). Max width clamped tolayout::PANEL_MAX. -
Variants / states
State How open render Dialogthis frameclosed don’t render it (consumer owns the flag) with/without description .description(...)optionaldismissed showreturnstrueon backdrop click / Esc (Modal::should_close()) -
Tokens / layout consumed —
core::SPACE_1(title→description gap),core::SPACE_4(header→body gap),layout::PANEL_MAX(max width). See tokens / layout. -
Layering — uses
egui::Modal(scrim overlay, centered). The frame is the themed modal/window visuals (not a manualSurface); content atoms supply the casing. -
Accessibility —
Modalprovides the focus scrim and Esc-to-dismiss; backdrop click also closes. Both surface through theboolreturn.
API
| Method | Effect |
|---|---|
Dialog::new(title: impl Into<String>) -> Self | New dialog. Default id is Id::new(format!("dialog::{title}")). |
.id_source(id: impl Hash) -> Self | Override the Modal id (use when titles collide). |
.description(description: impl Into<String>) -> Self | Add a muted sub-line under the title. |
.show(ctx: &Context, body: impl FnOnce(&mut Ui)) -> bool | Render the modal; returns true when it should close. |
Note show takes a &Context (e.g. ui.ctx()), not a &mut Ui.
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::organisms::Dialog;
if open {
if Dialog::new("Rename layer")
.description("Enter a new name.")
.show(ui.ctx(), |ui| { /* field + buttons */ })
{
open = false; // backdrop / Esc
}
}
}
#![allow(unused)]
fn main() {
// realistic — destructive confirm; consumer owns `open` (from storybook)
use ouroboros_ui::organisms::Dialog;
use ouroboros_ui::atoms::{Button, ButtonVariant};
if open {
let mut dismiss = false;
let close = Dialog::new("Delete asset?")
.description("This action cannot be undone.")
.show(ui.ctx(), |ui| {
ui.horizontal(|ui| {
if Button::new("Delete")
.variant(ButtonVariant::Destructive)
.id_source("dlg_del")
.show(ui).clicked()
{
dismiss = true; // perform delete
}
if Button::new("Cancel").ghost().id_source("dlg_cancel").show(ui).clicked() {
dismiss = true;
}
});
});
if close || dismiss { open = false; }
}
}
Composition
Overlay organism: egui::Modal container (scrim + centering) + themed window visuals for the casing; content composed from Heading and Text atoms, then your body. It never paints raw shapes — see guards.
Notes
- State ownership — the consumer owns the open/closed flag.
Dialogonly reports the should-close signal; act on both that return and your own button-driven dismissals (the storybook pattern ORsclose || dismiss). - Default id derives from the title — give two same-titled dialogs distinct
id_sourcevalues. bodyisFnOnce, executed only while the modal is shown.- Width capped at
PANEL_MAX; for wider content set width inside the body closure.
DropdownMenu
Layer: organism · Path:
src/organisms/dropdown_menu.rs· Exports:dropdown_menu::DropdownMenu
A popover list of MenuItem cells opened from a trigger widget (shadcn DropdownMenu / ContextMenu). Built on egui::Popup::menu anchored to a trigger Response. show returns the index of the clicked item, if any, and auto-closes on selection.
Design
-
Purpose / when to use — action menus hung off a button or context menus (Copy / Paste / Delete). Use when each entry is a one-shot command, not a persistent selection.
-
Anatomy —
egui::Popup::menu(trigger)casing → oneMenuItemper entry, each keyed("dropdown", i), optional left glyph. -
Variants / states
State How item with icon .item(icon, label)text-only item .text_item(label)item clicked returns Some(index), callsui.close()nothing clicked / closed returns None -
Tokens / layout consumed — themed menu visuals via
Popup+MenuItem(no direct token use here). -
Layering — uses
egui::Popup(menu style), anchored to the trigger response. Casing is the themed menu frame. -
Accessibility —
Popuphandles outside-click / Esc dismiss; selection closes it.
API
| Method | Effect |
|---|---|
DropdownMenu::new() -> Self | Empty menu. |
DropdownMenu::default() | Same as new(). |
.item(icon: &'static str, label: impl Into<String>) -> Self | Add an item with a leading glyph. |
.text_item(label: impl Into<String>) -> Self | Add an item with no glyph. |
.show(trigger: &Response) -> Option<usize> | Open from trigger; returns the clicked item index. |
show takes a &Response (the trigger widget), not a &mut Ui.
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::organisms::DropdownMenu;
use ouroboros_ui::atoms::Button;
use ouroboros_ui::egui_phosphor::light;
let trigger = Button::new("Actions").icon_right(light::CARET_DOWN).id_source("dd").show(ui);
if let Some(i) = DropdownMenu::new()
.item(light::COPY, "Copy")
.item(light::CLIPBOARD, "Paste")
.show(&trigger)
{
// act on index `i`
}
}
#![allow(unused)]
fn main() {
// realistic — persist last choice (from storybook)
use ouroboros_ui::organisms::DropdownMenu;
use ouroboros_ui::atoms::Button;
use ouroboros_ui::egui_phosphor::light;
let resp = Button::new("Actions").icon_right(light::CARET_DOWN).id_source("dd_btn").show(ui);
if let Some(i) = DropdownMenu::new()
.item(light::COPY, "Copy")
.item(light::CLIPBOARD, "Paste")
.item(light::TRASH, "Delete")
.show(&resp)
{
ui.data_mut(|d| d.insert_temp(egui::Id::new("dd_last"), i));
}
}
Composition
Overlay organism: egui::Popup::menu container + themed menu frame for the casing; entries are MenuItem cells. It never paints — see guards.
Notes
- Item indices follow insertion order across
item/text_item. - Closes itself via
ui.close()on click; the consumer only handles the returned index. - The trigger is a separate widget you draw and pass in; the menu does not draw the trigger.
- Glyphs come from
ouroboros_ui::egui_phosphor::light::NAME.
Menubar
Layer: organism · Path:
src/organisms/menubar.rs· Exports:menubar::Menubar
An application menu bar — a horizontal row of menu triggers, each a dropdown (shadcn Menubar). Composes Button (ghost, sm) triggers with egui::Popup::menu dropdowns of MenuItem cells. show returns (menu_index, item_index) when an item is chosen.
Design
-
Purpose / when to use — top-of-window File / Edit / View bars. Use for command menus organized by top-level category.
-
Anatomy —
ui.horizontalrow → per menu: a ghost-smButton(keyed("menubar", mi)) →Popup::menuon its response → oneMenuItemper entry (keyed("menubar_item", mi, ii)). -
Variants / states
State How menu trigger ghost smbuttonitem chosen returns Some((menu_idx, item_idx)), callsui.close()nothing chosen returns None -
Tokens / layout consumed — themed visuals via
Button/Popup/MenuItem; horizontal layout spacing from egui defaults. -
Layering — each menu uses
egui::Popupanchored to its trigger button; themed menu frame is the casing. -
Accessibility —
Popupdismiss on outside-click / Esc; selection closes.
API
| Method | Effect |
|---|---|
Menubar::new() -> Self | Empty bar. |
Menubar::default() | Same as new(). |
.menu<S: Into<String>>(label: impl Into<String>, items: impl IntoIterator<Item = S>) -> Self | Append a menu with its item labels. |
.show(ui) -> Option<(usize, usize)> | Render the bar; returns (menu_idx, item_idx) of the chosen item. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::organisms::Menubar;
if let Some((m, i)) = Menubar::new()
.menu("File", ["New", "Open", "Save"])
.menu("Edit", ["Undo", "Redo"])
.show(ui)
{
// dispatch on (m, i)
}
}
#![allow(unused)]
fn main() {
// realistic — full bar, persist last choice (from storybook)
use ouroboros_ui::organisms::Menubar;
if let Some((m, i)) = Menubar::new()
.menu("File", ["New", "Open", "Save", "Quit"])
.menu("Edit", ["Undo", "Redo", "Preferences"])
.menu("View", ["Zoom in", "Zoom out", "Reset"])
.show(ui)
{
ui.data_mut(|d| d.insert_temp(egui::Id::new("mb_last"), (m, i)));
}
}
Composition
Composes Button atoms (triggers), the egui::Popup container (dropdowns), and MenuItem cells (entries). It never paints — see guards.
Notes
- Menu and item indices follow insertion order; the return distinguishes them as
(menu, item). - Triggers are keyed by menu index and items by
(menu, item), so duplicate labels are safe. - Selection auto-closes via
ui.close(); the consumer handles only the returned tuple.
Panel
Layer: organism · Path:
src/organisms/panel.rs· Exports:panel::{Panel, PanelEdge}
A canonical docked panel: a background, an optional flush hairline on its docking edge, an optional header (title + action slot) and footer action bar, and a scrollable, token-padded body. Mounts inside a rect — typically a Splitter band via a child Ui. Unlike the elevated, rounded Card, a Panel is flush (no radius, no shadow) with a single edge border — the studio inspector / properties chrome, so panels stop hand-rolling egui::Frame margins and manually painted borders/headers.
Design
-
Purpose / when to use — Any docked side panel / inspector / properties pane. For a free-floating elevated container use
Card; for the resizable bands themselves useSplitter(a Panel goes inside a band). -
Anatomy — background
Surfacefilling the mounted rect → optional flush edgeDividercarved off the docking side → optional header (Heading+ right-aligned action, then a full-width divider) → padded, scrollable body → optional footer (full-width divider + action bar pinned to the bottom). -
Variants / states
Axis Options edge(PanelEdge)None(default) ·Left·Right·Top·Bottomfill(SurfaceFill)Background(default) ·Card·Muted·None(module paints its own bg)scrollon (default) · .no_scroll()header / footer absent unless .title()/.action()/.footer()set -
Tokens / layout consumed —
layout::PANEL_PAD(body/header/footer inset),layout::PANEL_GAP(row gap),core::BORDER_THIN(edge weight), the chosenSurfaceFilltoken. -
Layering — organism: composes
Surface,Divider,Heading; never paints directly (theno_painter_in_moleculesguard scans organisms too). -
Accessibility — scrolling/keyboard come from egui’s
ScrollArea; the body width is pinned so fill controls don’t ratchet (egui #1297).
API
| Signature | Effect |
|---|---|
Panel::new(id: impl Hash) -> Self | New panel; id keys the body ScrollArea. |
.title(title: impl Into<String>) -> Self | Header title (a Heading) above a full-width divider. |
.action(f: impl FnOnce(&mut Ui) + 'a) -> Self | Top-right header slot (button / menu / badge). |
.footer(f: impl FnOnce(&mut Ui) + 'a) -> Self | Bottom action bar above a full-width divider. |
.edge(e: PanelEdge) -> Self / .left_edge() / .right_edge() | Flush hairline on the docking edge. |
.fill(f: SurfaceFill) -> Self | Background fill (default Background). |
.no_scroll() -> Self | Don’t wrap the body in a ScrollArea. |
.body_pad(px: f32) -> Self | Body inner padding (default PANEL_PAD); 0.0 = flush for content that manages its own insets (e.g. full-bleed accordion section headers). |
.show(self, ui: &mut Ui, content: impl FnOnce(&mut Ui)) -> Response | Paint the chrome; run content in the padded body. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::organisms::Panel;
use ouroboros_ui::cells::ResponsiveRow;
use ouroboros_ui::atoms::NumericField;
// Right-docked inspector mounted in a Splitter band rect (a child Ui).
Panel::new("world_inspector")
.left_edge()
.title("Inspector")
.show(ui, |ui| {
ResponsiveRow::new("Mass").show(ui, |ui| {
NumericField::new(&mut mass).speed(0.05).show(ui)
});
});
}
Composition
Carves the edge hairline off ui.max_rect() with Rect::split_*_at_*, draws a Divider in the strip, and stacks header / scrollable body / footer in the remainder via child Uis. The body is a port of the studio’s panel_body helper: ScrollArea::vertical().auto_shrink([true, false]) + a Surface (no fill) padded by PANEL_PAD, with the content width pinned via set_min_width and a PANEL_GAP row spacing. A footer is laid bottom-up so it pins to the panel’s bottom edge.
Notes
- The body keeps horizontal auto-shrink on (egui #1297): a fill control inside a scroll area with horizontal auto-shrink off ratchets the content width on resize. Don’t change this.
fill(SurfaceFill::None)leaves the background to the host module (the legacy studio pattern) while still giving the canonical edge/header/body chrome.- Replaces hand-rolled studio panel chrome: per-section
Frame::inner_margin, manualline_segment/rect_fillededges, and hand-painted section headers.
See tokens · theming · guards.
Popover
Layer: organism · Path:
src/organisms/popover.rs· Exports:popover::Popover
Free content anchored to a trigger widget, opened on click (shadcn Popover). A thin wrapper over egui::Popup::menu whose frame inherits the themed menu visuals. This is the substrate for color pickers, selects, combobox and menus — anything that needs click-anchored floating content.
Design
-
Purpose / when to use — anchored panels (pickers, mini-forms, hover cards) hung off a button or any widget
Response. Reach forDropdownMenu/Selectwhen the content is a list of items; reach forPopoverwhen the content is arbitrary. -
Anatomy —
egui::Popup::menu(trigger)casing → yourcontentclosure (any widgets). -
Variants / states
State How closed trigger not clicked open opens on trigger click; dismiss via outside-click / Esc -
Tokens / layout consumed — themed menu frame via
Popup(no direct token use). -
Layering — uses
egui::Popup(menu style) anchored to the triggerResponse; the themed menu frame is the casing. -
Accessibility —
Popuphandles outside-click / Esc dismiss.
API
| Method | Effect |
|---|---|
Popover::new() -> Self | Construct. (Unit struct; no fields.) |
Popover::default() | Same as new(). |
.show(trigger: &Response, content: impl FnOnce(&mut Ui)) | Open from trigger on click; render content. Returns (). |
show takes a &Response (the trigger) plus a content closure; it returns nothing — wire any result through your own captured state.
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::organisms::Popover;
use ouroboros_ui::atoms::{Button, Text};
let resp = Button::new("Open").id_source("pop").show(ui);
Popover::new().show(&resp, |ui| {
Text::new("Anchored content").show(ui);
});
}
#![allow(unused)]
fn main() {
// realistic (from storybook)
use ouroboros_ui::organisms::Popover;
use ouroboros_ui::atoms::{Button, Text};
use ouroboros_ui::tokens::core;
let resp = Button::new("Open popover").id_source("pop_btn").show(ui);
Popover::new().show(&resp, |ui| {
Text::new("Popover content").body_strong().show(ui);
ui.add_space(core::SPACE_1);
Text::new("Anchored to the trigger.").muted().caption().show(ui);
});
}
Composition
Overlay organism: egui::Popup::menu container + themed menu frame for the casing; the body is whatever atoms/molecules you compose inside content. It never paints — see guards.
Notes
- The trigger is a separate widget you draw and pass by reference;
Popoverdoes not draw the trigger. showreturns(); to capture a result (e.g. a picked value), mutate a variable closed over bycontent.contentisFnOnce, run only while the popover is open.
Select
Layer: organism · Path:
src/organisms/select.rs· Exports:select::Select
A dropdown single-select (shadcn Select / Unity Dropdown / O3DE Dropdown): a trigger Button showing the current option + a egui::Popup::menu of MenuItem cells. Bound to a &mut usize index — clicking an item writes the index and closes the popup. show returns the trigger Response.
Design
-
Purpose / when to use — pick one value from a fixed list (blend mode, quality level). For arbitrary anchored content use
Popover; for command menus useDropdownMenu. -
Anatomy — trigger:
Button(Outlinevariant, right-sideCARET_DOWNglyph, keyed"select_trigger") showing the selected option or placeholder →Popup::menuon its response → oneMenuItemper option (keyed("select", i)). -
Variants / states
State How no/invalid selection trigger shows placeholder(default"Select…")selected trigger shows options[*selected]option clicked writes *selected = i, callsui.close()size Size::Sm/Md(default) /Lgvia.size/.sm()/.lg() -
Tokens / layout consumed — trigger height follows the shared
Sizescale (hover animation lives inButton); themed menu frame viaPopup. -
Layering — uses
egui::Popupanchored to the trigger; themed menu frame is the casing. -
Accessibility —
Popupdismiss on outside-click / Esc; selection closes.
API
| Method | Effect |
|---|---|
Select::new(selected: &'a mut usize) -> Self | Bind to a selection index. |
.options<S: Into<String>>(options: impl IntoIterator<Item = S>) -> Self | Set the option labels. |
.placeholder(text: impl Into<String>) -> Self | Text shown when the index is out of range (default "Select…"). |
.size(size: Size) -> Self | Set trigger size. |
.sm() -> Self / .lg() -> Self | Size shortcuts. |
.show(ui) -> Response | Render trigger + popup; on click writes *selected. Returns the trigger Response. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::organisms::Select;
let mut sel = 0usize;
Select::new(&mut sel)
.options(["Opaque", "Cutout", "Transparent"])
.placeholder("Blend mode…")
.show(ui);
}
#![allow(unused)]
fn main() {
// realistic — persist selection across frames (from storybook)
use ouroboros_ui::organisms::Select;
let id = egui::Id::new("select_demo");
let mut sel = ui.data(|d| d.get_temp::<usize>(id).unwrap_or(0));
Select::new(&mut sel)
.options(["Opaque", "Cutout", "Transparent", "Additive"])
.placeholder("Blend mode…")
.show(ui);
ui.data_mut(|d| d.insert_temp(id, sel));
}
Composition
Overlay organism: a Button atom trigger + the egui::Popup::menu container holding MenuItem cells. It never paints — see guards.
Notes
- State ownership — the consumer owns the
&mut usize; persist it yourself (e.g. egui temp data) to survive frames, as the storybook does. - An out-of-range index renders the placeholder rather than panicking.
- The trigger id is fixed (
"select_trigger"); push a uniqueui.push_id(...)scope when rendering several selects in one container (storybook does this for the size row).
Sidebar
Layer: organism · Path:
src/organisms/sidebar.rs· Exports:sidebar::Sidebar
A vertical navigation list (shadcn Sidebar / Navigation Menu) bound to a &mut usize selection. By default show composes one ListItem cell per entry; .icons_only() collapses it to an icon rail of icon-only Buttons. show returns the vertical Response; clicking an entry writes the selection in place.
Design
-
Purpose / when to use — primary nav for a view (Home / Assets / Settings), as the left band of a screen’s root
Splitter(fixed-px rail, or a resizable panel). Use the icon rail when horizontal space is tight. -
Anatomy —
ui.vertical→ per entry, either:- list mode — a
ListItem(.selected(active), keyed("sidebar", i), optional leading glyph), or - icons-only mode — an icon-only
Button(Secondarywhen active elseGhost, keyed("sidebar_icon", i), optionalicon_left).
- list mode — a
-
Variants / states
State How item with icon .item(icon, label)text-only item .text_item(label)selected *selected == i→ListItem.selected(true)/ButtonSecondaryicon rail .icons_only() -
Tokens / layout consumed — themed visuals through
ListItem/Button; vertical layout spacing from egui defaults. -
Accessibility — selection is click-driven; active row/button is visually distinguished.
API
| Method | Effect |
|---|---|
Sidebar::new(selected: &'a mut usize) -> Self | Bind to a selection index. |
.item(icon: &'static str, label: impl Into<String>) -> Self | Add an entry with a leading glyph. |
.text_item(label: impl Into<String>) -> Self | Add an entry with no glyph. |
.icons_only() -> Self | Collapse to an icon-only rail. |
.show(ui) -> Response | Render the list; clicking writes *selected. Returns the vertical Response. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::organisms::Sidebar;
use ouroboros_ui::egui_phosphor::light;
let mut sel = 0usize;
Sidebar::new(&mut sel)
.item(light::HOUSE, "Home")
.item(light::CUBE, "Assets")
.item(light::GEAR, "Settings")
.show(ui);
}
#![allow(unused)]
fn main() {
// realistic — list + icon rail sharing one selection (from storybook)
use ouroboros_ui::organisms::Sidebar;
use ouroboros_ui::egui_phosphor::light;
let mut sel = 0usize;
Sidebar::new(&mut sel)
.item(light::HOUSE, "Home")
.item(light::CUBE, "Assets")
.show(ui);
Sidebar::new(&mut sel) // shares `sel`
.item(light::HOUSE, "Home")
.item(light::CUBE, "Assets")
.icons_only()
.show(ui);
}
Composition
Composes ListItem cells (list mode) or icon-only Button atoms (rail mode). It never paints — see guards.
Notes
- State ownership — the consumer owns the
&mut usize; persist it across frames yourself. - In icons-only mode an entry added via
text_item(no glyph) renders a glyph-less icon button — supply icons for the rail. - Two sidebars sharing the same
&mut usizestay in sync (storybook pairs a list and a rail this way).
Splitter
Layer: organism · Path:
src/organisms/splitter.rs· Exports:splitter::{PanelSpec, Splitter}
Resizable panes split by draggable dividers (Element Plus Splitter). Horizontal (side-by-side) or vertical (stacked); each panel carries min/max bounds and may be resizable and/or collapsible. Panel sizes persist for the session in egui memory (keyed by id_source), not to disk. Composes the SplitterHandle atom per divider; never paints directly.
Design
-
Purpose / when to use — the single layout primitive: it is the root scaffold of every screen. Fixed chrome bands (header/footer/toolbar/rail) use
PanelSpec::fixed(px)(non-resizable); the body is a flex panel; resizable regions nest anotherSplitter. Content inside each leaf panel is arranged withAutoLayout. -
Anatomy —
ui.allocate_exact_size(available)→ along the main axis, alternating panel cells (each a clipped childUi) and aSplitterHandledivider of widthcore::SPACE_2after every panel but the last. Cross-axis fills the rect. -
Variants / states
State How orientation Splitter::horizontal()/Splitter::vertical()panel sized PanelSpec::size(fraction)(else equal share of remainder)resizing drag a divider — grows one neighbor, shrinks the other, clamped to both [min, max]collapsed double-click a divider toggles an adjacent collapsiblepanel (prefers right neighbor)non-resizable pair a divider is inert unless both adjacent panels are resizable -
Tokens / layout consumed —
core::SPACE_2(divider thickness);PanelSpecdefaultslayout::PANEL_MIN/layout::PANEL_MAX. See tokens / layout. -
Accessibility — drag to resize, double-click to collapse; the handle atom shows the resize affordance and an
activestate when a neighbor is collapsed.
API
Splitter<'a>
| Method | Effect |
|---|---|
Splitter::horizontal() -> Self | Panels left→right, dividers drag horizontally. |
Splitter::vertical() -> Self | Panels top→bottom, dividers drag vertically. |
.id_source(id: impl Hash) -> Self | Key for session-persisted sizes (defaults to the allocated response id). |
.panel(cfg: PanelSpec, add: impl FnMut(&mut Ui) + 'a) -> Self | Add a panel with its config + content closure. |
.show(ui) -> Response | Lay out panels + dividers, apply drags/toggles, persist state. Returns the allocated Response. |
PanelSpec
#[derive(Clone, Copy, Debug)] per-panel config. Builder; pair with a content closure via Splitter::panel.
| Method | Effect |
|---|---|
PanelSpec::new() -> Self | Defaults: size = None (equal share), min = PANEL_MIN, max = PANEL_MAX, resizable = true, collapsible = false. |
PanelSpec::default() | Same as new(). |
.size(fraction: f32) -> Self | Initial size as a main-axis fraction (clamped 0.0..=1.0). |
.min(px: f32) -> Self | Minimum size in px. |
.max(px: f32) -> Self | Maximum size in px. |
.resizable(resizable: bool) -> Self | Whether dividers touching it can drag. |
.collapsible(collapsible: bool) -> Self | Whether a double-click can collapse it. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::organisms::{Splitter, PanelSpec};
Splitter::horizontal()
.id_source("editor")
.panel(PanelSpec::new().min(180.0).max(420.0), |ui| { /* hierarchy */ })
.panel(PanelSpec::new(), |ui| { /* viewport */ })
.panel(PanelSpec::new().collapsible(true), |ui| { /* inspector */ })
.show(ui);
}
#![allow(unused)]
fn main() {
// realistic — nested vertical split inside a horizontal one (from storybook)
use ouroboros_ui::organisms::{Splitter, PanelSpec};
Splitter::horizontal()
.id_source("outer")
.panel(PanelSpec::new().min(120.0).max(280.0), |ui| panel(ui, "Hierarchy"))
.panel(PanelSpec::new(), |ui| {
Splitter::vertical()
.id_source("inner")
.panel(PanelSpec::new(), |ui| panel(ui, "Viewport"))
.panel(PanelSpec::new().size(0.3).collapsible(true), |ui| panel(ui, "Console"))
.show(ui);
})
.panel(PanelSpec::new().min(160.0).collapsible(true), |ui| panel(ui, "Inspector"))
.show(ui);
}
Composition
Composes the SplitterHandle atom (one per divider, Axis::Vertical for horizontal splitters and vice-versa) plus your panel content closures into clipped child Uis. It never paints — see guards.
Notes
- State ownership — fractions + collapse flags persist for the session in egui memory (
SplitterState, keyed byid_source). State resets if the panel count changes (stored fracs length must matchn). Distinct splitters need distinctid_source— nested splitters especially. - Resizing follows the adjacent-pair rule (one neighbor grows, the other shrinks), with
apply_dragclampingato a range that honors both panels’[min, max]; if the bounds can’t be jointly satisfied the drag is ignored. - Collapse prefers the right neighbor of the divider if collapsible, else the left; collapsed panels contribute zero and their fraction is redistributed.
- A divider only drags when both adjacent panels are
resizable. showconsumesui.available_size(); constrain viaallocate_uiif needed.
TabView
Layer: organism · Path:
src/organisms/tab_view.rs· Exports:tab_view::TabView
A tab bar plus the selected panel (shadcn Tabs). Bound to a &mut usize active index. show draws the Tabs molecule, a Divider, then calls your panel(ui, index) closure to render the active body. The selection is written back to *selected after the frame.
Design
-
Purpose / when to use — switch between sibling views sharing one region (Scene / Game / Console). Use when exactly one panel is visible at a time.
-
Anatomy —
ui.vertical→Tabsbar (bound to a local copy of the index) →SPACE_2→Divider::horizontal()→SPACE_3→panel(ui, idx)body. -
Variants / states
State How active tab *selected(mirrored into theTabsmolecule)panel switch panel(ui, idx)renders the body for the current index -
Tokens / layout consumed —
core::SPACE_2(bar→divider) andcore::SPACE_3(divider→body). See tokens. -
Accessibility — tab keyboard/click behavior comes from the
Tabsmolecule.
API
| Method | Effect |
|---|---|
TabView::new(selected: &'a mut usize) -> Self | Bind to an active-tab index. |
.tabs<S: Into<String>>(tabs: impl IntoIterator<Item = S>) -> Self | Set the tab labels. |
.show(ui, panel: impl FnOnce(&mut Ui, usize)) -> Response | Draw the bar + divider, then panel(ui, active_index). Returns the vertical Response; writes *selected. |
Note panel receives (&mut Ui, usize) — the second arg is the currently selected tab index.
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::organisms::TabView;
use ouroboros_ui::atoms::Text;
let mut sel = 0usize;
TabView::new(&mut sel)
.tabs(["Scene", "Game", "Console"])
.show(ui, |ui, idx| {
Text::new(match idx { 0 => "Viewport", 1 => "Preview", _ => "Logs" }).show(ui);
});
}
#![allow(unused)]
fn main() {
// realistic — persist selection across frames (from storybook)
use ouroboros_ui::organisms::TabView;
use ouroboros_ui::atoms::Text;
let id = egui::Id::new("tabview_demo");
let mut sel = ui.data(|d| d.get_temp::<usize>(id).unwrap_or(0));
TabView::new(&mut sel)
.tabs(["Scene", "Game", "Console"])
.show(ui, |ui, idx| {
let body = match idx { 0 => "3D scene viewport.", 1 => "Game preview.", _ => "Log output…" };
Text::new(body).muted().show(ui);
});
ui.data_mut(|d| d.insert_temp(id, sel));
}
Composition
Composes the Tabs molecule (the bar) and the Divider atom (separator), then defers the body to your panel closure. It never paints — see guards.
Notes
- State ownership — the consumer owns the
&mut usize.showmirrors it into a localidx, letsTabsmutate that copy, and writes it back at the end — persist across frames yourself. panelisFnOnce; render the body foridxdirectly (amatchon the index is the common shape).
Table
Layer: organism · Path:
src/organisms/table.rs· Exports:table::{ColWidth, Column, Table}
A column-defined data table on egui_extras::TableBuilder, Element-Plus-flavored. Columns describe layout (width / min / header align); rows carry TableCells wrapped in TableRow. egui_extras provides sizing, a sticky header, scrolling and striping; zebra / selection / hover colors come from the theme via table_visuals (set on the ui, never painted). Cells render through TableCell; the organism composes — it does not paint.
Design
-
Purpose / when to use — tabular data with aligned columns, sticky header, optional scrolling and row selection (asset lists, key/value property grids).
-
Anatomy — optional
borderwraps everything in a cardSurface(pad 0,RADIUS_MD). Inside: aui.scopewithtable_visualsapplied →TableBuilderwith oneegui_extras::ColumnperColumn(all.clip(true)) → header row (TableCell::text(label).header().align(col.align)) → body rows (oneTableCellper cell). Loading and empty states short-circuit to a centeredSpinner/ mutedText. -
Variants / states
State How striped .striped(true)— zebra rowsbordered .border(true)— outer card surfacesizes Size::Sm/Md(default) /Lg→ row heightfixed height .height(px)— header sticks, body scrollsfluid + cap .max_height(px)— grows then scrollsselectable .selectable(true)— click a row to select (persisted)loading .loading(true)— centeredSpinnerempty no rows → muted empty_text(default"No data") -
Tokens / layout consumed — row height from the
Sizescale (size.height());core::SPACE_6(loading/empty padding),core::SPACE_0+core::RADIUS_MD(border surface). Theme colors viatable_visuals. -
Accessibility — selection via
Sense::click()on rows whenselectable; header is sticky on scroll.
API
Table<'a>
| Method | Effect |
|---|---|
Table::new() -> Self | Empty table; defaults empty_text = "No data", others off. |
Table::default() | Same as new(). |
.columns(impl IntoIterator<Item = Column>) -> Self | Set the columns. |
.rows(impl IntoIterator<Item = TableRow<'a>>) -> Self | Set the rows. |
.row(TableRow<'a>) -> Self | Append one row. |
.size(Size) -> Self / .sm() / .lg() | Row height. |
.striped(bool) -> Self | Zebra rows. |
.border(bool) -> Self | Outer card border. |
.height(px) -> Self | Fixed height; sticky header, body scrolls. |
.max_height(px) -> Self | Fluid height capped at px. |
.selectable(bool) -> Self | Click-to-select rows (persisted for the session). |
.loading(bool) -> Self | Replace the grid with a centered spinner. |
.empty_text(impl Into<String>) -> Self | Placeholder when there are no rows. |
.id_source(id: impl Hash) -> Self | Stable id (drives selection + TableBuilder id salt). |
.show(ui) -> Response | Render; returns the area Response. |
Column
A header label + layout descriptor.
| Method | Effect |
|---|---|
Column::new(label: impl Into<String>) -> Self | New column; default width Remainder, align Start. |
.width(ColWidth) -> Self | Set sizing mode. |
.exact(px) / .initial(px) / .auto() / .remainder() | Width shortcuts (ColWidth variants). |
.min_width(px) -> Self | Floor the column width (at_least). |
.align(CellAlign) -> Self / .center() / .end() | Header alignment (cells carry their own alignment). |
ColWidth
#[derive(Clone, Copy, Debug, Default, PartialEq)] — how a column is sized.
| Variant | Maps to | Meaning |
|---|---|---|
Auto | ExtraColumn::auto() | Size to content. |
Exact(f32) | ExtraColumn::exact(w) | Fixed px width. |
Initial(f32) | ExtraColumn::initial(w) | Initial px, resizable/sharable. |
Remainder (default) | ExtraColumn::remainder() | Share leftover width. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::organisms::{Table, Column};
use ouroboros_ui::cells::{TableRow, TableCell};
Table::new()
.columns([Column::new("Key"), Column::new("Value").end()])
.rows([TableRow::new([TableCell::text("width"), TableCell::text("1920").end()])])
.border(true)
.show(ui);
}
#![allow(unused)]
fn main() {
// realistic — striped, selectable, sticky header, status cells (from storybook)
use ouroboros_ui::organisms::{Table, Column};
use ouroboros_ui::cells::{TableRow, TableCell};
Table::new()
.id_source("tbl_main")
.columns([
Column::new("Name"),
Column::new("Type").exact(110.0),
Column::new("Size").exact(90.0).end(),
Column::new("Status").exact(110.0),
])
.rows(data.iter().map(|(n, t, s, c)| TableRow::new([
TableCell::text(*n),
TableCell::text(*t).muted(),
TableCell::text(*s).end(),
TableCell::text("ref").status(*c),
])))
.striped(true)
.border(true)
.selectable(true)
.max_height(150.0)
.show(ui);
}
Composition
Composes TableCell cells (header + body) wrapped in TableRow, over egui_extras::TableBuilder for layout/scroll/striping. The optional border is a Surface atom; loading/empty use the Spinner atom and Text atom. Theme colors are pushed onto ui.visuals_mut() via table_visuals so egui_extras’ built-in striping/selection/hover read DS tokens — no painting. See guards and theming.
table_visuals
Private helper that maps theme tokens onto ui (no painting): faint_bg_color = theme.muted (zebra), selection.bg_fill = theme.accent, selection.stroke.color = theme.accent_foreground, hovered/active weak_bg_fill = theme.muted.
Notes
- State ownership — when
selectable, the current row index persists in egui temp data keyed by the table id (id_source, orui.id().with("table")if unset). Distinct tables need distinctid_sourceto avoid selection-state collisions; the same id also saltsTableBuilder. - Per-row opt-out: a row is only clickable/selectable if
TableRow.selectableis true andTable.selectableis on. - Column alignment is header-only; each
TableCellcarries its own cell alignment. - All columns are clipped (
.clip(true));vscrollengages only whenheightormax_heightis set. loadingand empty (rows.is_empty()) short-circuit before any grid is built.
Toast
Layer: organism · Path:
src/organisms/toast.rs· Exports:toast::Toast
A transient notification anchored top-right (shadcn Sonner / Unity notifications). Composes an Alert molecule inside a foreground egui::Area. The consumer owns visibility and timing — render the Toast only while it should be visible.
Design
-
Purpose / when to use — short status confirmations (“Build finished in 2.3s”), errors, warnings that don’t block interaction. Drive its lifetime yourself (a flag, a timer).
-
Anatomy —
egui::Area(foreground order, anchoredRIGHT_TOPwith offset(-SPACE_4, SPACE_4)) →ui.set_max_width(INSPECTOR_WIDTH)→ anAlertcarrying the message + variant. -
Variants / states
Variant How default Toast::new(msg)success .success()warning .warning()error .error()custom .variant(AlertVariant) -
Tokens / layout consumed —
core::SPACE_4(anchor inset from the top-right corner),layout::INSPECTOR_WIDTH(max width). See tokens / layout. -
Layering — uses
egui::AreaatOrder::Foreground, anchoredAlign2::RIGHT_TOP. (Not aModal/Popup— it doesn’t capture input or scrim.) -
Accessibility — non-blocking overlay; dismissal/timing is the consumer’s responsibility.
API
| Method | Effect |
|---|---|
Toast::new(message: impl Into<String>) -> Self | New toast; default id Id::new("toast"), default variant. |
.id_source(id: impl Hash) -> Self | Override the Area id (required for multiple simultaneous toasts). |
.variant(variant: AlertVariant) -> Self | Set the alert variant. |
.success() -> Self / .warning() -> Self / .error() -> Self | Variant shortcuts. |
.show(ctx: &Context) | Place it top-right. Returns (). |
show takes a &Context (e.g. ui.ctx()), not a &mut Ui.
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::organisms::Toast;
if show_toast {
Toast::new("Saved").success().show(ui.ctx());
}
}
#![allow(unused)]
fn main() {
// realistic — consumer-owned visibility (from storybook)
use ouroboros_ui::organisms::Toast;
let id = egui::Id::new("toast_show");
let mut show = ui.data(|d| d.get_temp::<bool>(id).unwrap_or(false));
if Button::new(if show { "Hide toast" } else { "Show toast" }).id_source("toast_btn").show(ui).clicked() {
show = !show;
}
if show {
Toast::new("Build finished in 2.3s").success().show(ui.ctx());
}
ui.data_mut(|d| d.insert_temp(id, show));
}
Composition
Overlay organism: an egui::Area (foreground) container holding an Alert molecule for the casing/content. It never paints — see guards.
Notes
- State ownership — the consumer owns visibility and timing; there is no built-in auto-dismiss.
- The default id is the literal
"toast"— give each concurrent toast a distinctid_source, or they overlap in the sameArea. - Anchored top-right with a
SPACE_4inset; width capped atINSPECTOR_WIDTH.
Toolbar
Layer: organism · Path:
src/organisms/toolbar.rs· Exports:toolbar::Toolbar
A horizontal action bar (Unity / O3DE Toolbar). show lays your content closure out horizontally inside a muted, borderless Surface. A thin chrome wrapper — you fill it with ToolbarButtons, Buttons, Dividers, etc.
Design
- Purpose / when to use — the action strip of a view (tool toggles, play/pause, separators), typically the fixed header band (
PanelSpec::fixed) of a screen’s rootSplitter. - Anatomy — a
Surface(muted,border_none,pad SPACE_1,RADIUS_MD) →ui.horizontal(content). - Variants / states — none of its own; appearance comes from the muted surface and the widgets you place inside.
- Tokens / layout consumed —
core::SPACE_1(surface padding),core::RADIUS_MD(corner radius); muted fill from the theme. See tokens / theming. - Accessibility — n/a (container; behavior comes from child widgets).
API
| Method | Effect |
|---|---|
Toolbar::new() -> Self | Construct. (Unit struct; no fields.) |
Toolbar::default() | Same as new(). |
.show(ui, content: impl FnOnce(&mut Ui)) -> Response | Render the muted surface and lay content out horizontally. Returns the Surface Response. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::organisms::Toolbar;
use ouroboros_ui::atoms::Button;
use ouroboros_ui::egui_phosphor::light;
Toolbar::new().show(ui, |ui| {
Button::new("Play").icon_left(light::PLAY).sm().id_source("play").show(ui);
});
}
#![allow(unused)]
fn main() {
// realistic — tool toggles + divider + action (from storybook)
use ouroboros_ui::organisms::Toolbar;
use ouroboros_ui::cells::ToolbarButton;
use ouroboros_ui::atoms::{Button, Divider};
use ouroboros_ui::egui_phosphor::light;
Toolbar::new().show(ui, |ui| {
ToolbarButton::new(&mut s[0], light::CURSOR).tooltip("Select").id_source("tba").show(ui);
ToolbarButton::new(&mut s[1], light::ARROWS_OUT).tooltip("Move").id_source("tbb").show(ui);
ToolbarButton::new(&mut s[2], light::ARROWS_CLOCKWISE).tooltip("Rotate").id_source("tbc").show(ui);
Divider::vertical().show(ui);
Button::new("Play").icon_left(light::PLAY).sm().id_source("tb_play").show(ui);
});
}
Composition
Composes a single Surface atom (muted casing) plus whatever you place in the horizontal content closure — typically ToolbarButton cells, Button and Divider atoms. It never paints — see guards.
Notes
- The bar holds no state; toggle/selection state lives in the widgets you place inside (e.g. each
ToolbarButton’s&mut bool). contentisFnOnce, run inside anui.horizontalscope.- Give each child widget a distinct
id_sourcewhen several share the bar.
TreeView
Layer: organism · Path:
src/organisms/tree_view.rs· Exports:tree_view::{TreeItem, TreeView}
A hierarchy of TreeNode cells (Unity / O3DE Tree View), bound to a &mut usize selection. The tree is a flat list of TreeItems carrying their own depth; the view renders them as nested rows, hiding descendants of collapsed nodes. Expand/collapse state lives in egui memory; clicking an expandable node toggles it. show returns the index clicked, if any.
Design
-
Purpose / when to use — scene hierarchies, file trees, any depth-indented selectable list. (Wrap in a
ScrollAreafor scrolling — the view itself does not scroll.) -
Anatomy — a loop over the flat
items, each rendered as aTreeNode(.depth(item.depth),.selected(*selected == i), keyed(id, "node", i), optional glyph,.expandable(is_open)when the item is expandable). Descendants of a collapsed node (deeperdepth) are skipped via acollapse_untilthreshold. -
Variants / states
State How leaf TreeItem::new(label)with no.expanded(...)expandable .expanded(open)— marks it toggleable, seeds default open stateopen / collapsed tracked in a HashSet<usize>in egui memoryselected *selected == idepth .depth(n)— indentation level -
Tokens / layout consumed — themed indentation/visuals via the
TreeNodecell (no direct token use here). -
Accessibility — click a row to select; clicking an expandable node both selects and toggles it.
API
TreeView<'a>
| Method | Effect |
|---|---|
TreeView::new(selected: &'a mut usize) -> Self | Bind to a selection index; default id Id::new("tree_view"). |
.items(impl IntoIterator<Item = TreeItem>) -> Self | Set the flat item list (order + depth define the tree). |
.id_source(id: impl Hash) -> Self | Key for the persisted expand/collapse set. |
.show(ui) -> Option<usize> | Render; returns the clicked index, writes *selected, persists expansion. |
TreeItem
One row of the tree.
| Method | Effect |
|---|---|
TreeItem::new(label: impl Into<String>) -> Self | New leaf at depth = 0, no icon, not expandable. |
.depth(depth: usize) -> Self | Indentation level (defines nesting). |
.icon(glyph: &'static str) -> Self | Leading glyph. |
.expanded(open: bool) -> Self | Mark expandable; open seeds the first-frame open state. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::organisms::{TreeView, TreeItem};
use ouroboros_ui::egui_phosphor::light;
let mut sel = 0usize;
TreeView::new(&mut sel)
.items([
TreeItem::new("Scene").icon(light::FOLDER).expanded(true),
TreeItem::new("Player").depth(1).icon(light::CUBE),
])
.show(ui);
}
#![allow(unused)]
fn main() {
// realistic — scene hierarchy, persisted selection (from storybook)
use ouroboros_ui::organisms::{TreeView, TreeItem};
use ouroboros_ui::egui_phosphor::light;
let id = egui::Id::new("treeview_demo");
let mut sel = ui.data(|d| d.get_temp::<usize>(id).unwrap_or(1));
TreeView::new(&mut sel)
.items([
TreeItem::new("Scene").icon(light::FOLDER).expanded(true),
TreeItem::new("Player").depth(1).icon(light::CUBE),
TreeItem::new("Camera").depth(1).icon(light::CUBE),
TreeItem::new("Environment").depth(1).icon(light::FOLDER).expanded(false),
])
.show(ui);
ui.data_mut(|d| d.insert_temp(id, sel));
}
Composition
Composes TreeNode cells (one per visible row). It never paints — see guards.
Notes
- Flat model — the tree is a flat
Vec<TreeItem>wheredepthencodes nesting; a collapsed expandable node hides every following item with greaterdepth(thecollapse_untilthreshold) until depth returns to its level. - State ownership — selection is the consumer’s
&mut usize(persist it yourself). Expand/collapse is aHashSet<usize>in egui memory keyed byid_source; on the first frame it seeds from each item’s.expanded(true)default. Indices in the set are positional — reordering items shifts which nodes are “open”. - Distinct trees need distinct
id_source(default is the literal"tree_view"). - No internal scrolling — wrap in a
ScrollAreaif the tree can overflow.
Graph — the node-editor layer
A peer layer beside atoms/cells/molecules/organisms, blueprinted on
reactflow.dev/ui but built entirely from the design system’s
tokens and atoms. It renders a pannable/zoomable node canvas — nodes, ports, bezier wires,
a dot grid, selection, drag, connect — on top of egui::Scene.
Source: src/graph/ (14 files, ~1.8k lines). Single public entry point:
GraphView.
The paint-but-token invariant
graph is the one place outside atoms that paints. A node graph genuinely needs grid
dots, bezier wires, handle circles and a marquee — none of which the atom vocabulary covers
— so the no_painter_in_molecules guard deliberately does not scan it.
But the purity contract still holds: every value flows through a token. Colors come from
Theme (resolved once per frame into GraphTokens),
geometry from core. No raw Color32::from_*, no bare stroke/radius
literals — the no_raw_values guard is extended to scan src/graph, giving this layer
the same purity contract the atoms have. See guards.
Two internal tiers
| Tier | Modules | Rule |
|---|---|---|
| paint | viewport, grid, edge, handle, resizer | touch the painter, but only via tokens |
| compose | node, controls, minimap, toolbar, search | reuse Surface + atoms; never paint inline |
Data-model-agnostic contract
The caller owns the data — node/edge identity, world positions, the edge list. The
library owns only view state — pan/zoom (scene_rect), selection, what’s mid-drag —
held in GraphViewState inside egui memory. Each frame the library reports
intents in GraphResponse (node_moved, connection, delete_edge,
delete_nodes, edge_clicked, create_request, …) for the caller to commit. The library
never sees the caller’s domain types — only the identity vocabulary
(NodeId/PortId/Port/Connection), which the caller defines.
caller model ──describe──▶ GraphView::show(|ctx| { ctx.node(..); ctx.edge(..); })
▲ │
└──────────── commit intents ◀── GraphResponse ┘
Frame lifecycle
GraphView::new(id).grid(true).controls(true).minimap(true)— configure..show(ui, |ctx| { … })allocates the canvas, runsegui::Scene(pan/zoom), paints the grid, reserves an under-node edge layer, and runs your closure in scene (world) coordinates.- Inside the closure you emit nodes (
ctx.node) — each declaring its ports (handles) and body — and wires (ctx.edge). Edges are accumulated and flushed under the nodes at scope end, independent of call order. showreturnsGraphResponsewith the frame’s intents.
Emit nodes before edges. Edge routing reads handle positions recorded as nodes are emitted; an edge whose endpoint handle wasn’t recorded yet resolves to nothing.
Pages
| Page | Covers |
|---|---|
| identity | NodeId, PortId, NodeKindId, PortSide, Port, Connection — the caller-defined vocabulary. |
| canvas | GraphView (entry point), GraphCtx (per-frame emit surface), GraphResponse (intents). |
| state | GraphViewState + the in-flight drag structs. |
| tokens | GraphTokens — the single resolve point for everything the layer paints. |
| node | NodeFrame/NodeResult/NodeStatus + ctx.node(...). Compose-tier. |
| edge | EdgeStyle/EdgeResult + ctx.edge(...). Paint-tier bezier wires. |
| handle | HandleSpec/HandleVariant — ports, declared on NodeFrame. Paint-tier. |
| search | NodeSearch — the node-creation palette. Compose-tier. |
| viewport | Viewport — a standalone world↔screen transform helper (the live canvas uses egui::Scene, not this). |
| extras | Internal support pieces: grid, resizer, minimap, toolbar, controls. |
Minimal usage
#![allow(unused)]
fn main() {
use ouroboros_ui::graph::{GraphView, EdgeStyle};
let resp = GraphView::new("my_graph")
.grid(true).controls(true).minimap(true)
.show(ui, |ctx| {
for n in &model.nodes {
ctx.node(n.id, n.world_pos, n.frame(), |ui| { /* node body */ });
}
for e in &model.edges {
ctx.edge(e.from, e.to, EdgeStyle::Default);
}
});
// Commit the intents to your own model:
for (id, delta) in resp.node_moved { model.move_node(id, delta); }
if let Some(c) = resp.connection { model.add_edge(c.from, c.to); }
for id in resp.delete_nodes { model.remove_node(id); }
}
See the storybook (page_graph_live, page_graph_node, page_graph_edge,
page_graph_search) for the full intent-commit loop: cargo run --example storybook.
GraphView · GraphCtx · GraphResponse
Layer: graph · Path:
src/graph/canvas.rs· Exports:GraphView,GraphCtx<'a>,GraphResponse
The single public entry point to the node-editor. GraphView is a builder; its show method allocates the canvas, runs an [egui::Scene] (which owns pan/zoom and scales the real DS widgets uniformly, n8n-style), opens a per-frame emit scope, and returns a GraphResponse of intents. The caller describes its nodes and edges inside the show closure every frame using GraphCtx; the library owns only the view-state (GraphViewState), the caller owns the data. Everything is drawn in scene (world) coordinates inside Scene’s transformed sublayer.
Design
- Purpose / when to use. Any reactflow-style node graph: pipeline editors, behaviour trees, dataflow. Use it whenever the caller has node + edge data it owns and wants pan/zoom, selection, drag-move, resize, connect-by-drag, delete, fit, minimap, and node-search for free.
- Frame lifecycle.
showruns once per frame: resolveGraphTokens→ allocaterect+ paint surface/border → loadGraphViewStatefrom egui memory (keyed by the builder id) →Scene::showagainst&mut state.scene_rect→ paint grid (culled when on-screen dot spacing <grid::MIN_DOT_SPACING) → reserve an under-node edge layer viaPainter::add(Shape::Noop)→ build theGraphCtxand run the caller’s closure → resolve any completed connect-drag against recorded handle positions → flush accumulated edge shapes into the reserved slot (always under nodes, regardless of caller interleaving) → draw the pending connect-wire on top → apply selection / delete / controls / minimap → persist state → returnGraphResponse. - Coordinate convention. A node emitted at world
poslands there; Scene scales it.GraphCtx::scaleis the live scene→screen factor;to_globalis theTSTransform. - Zoom range. Canvas Scene
zoom_rangeisMIN_ZOOM = 0.2..MAX_ZOOM = 4.0(private consts). This is the live range — note it is not theViewporthelper’s0.25..2.5; the canvas does not useViewport. - Pan binding. Scene pan is bound to
DragPanButtons::MIDDLE | SECONDARYonly; primary drag is reserved for node move / marquee / connect so it never double-moves against Scene’s background pan.
GraphResponse fields (every field)
| Field | Type | Meaning |
|---|---|---|
response | egui::Response | Scene background interaction (pan response). Use for focus / context-menu hooks. |
connection | Option<Connection> | A connect-drag completed onto a valid target port. Always oriented Out → In. |
delete_edge | Option<(Port, Port)> | Selected edge + Delete/Backspace. Deleted before nodes. |
delete_nodes | Vec<NodeId> | Selected nodes + Delete/Backspace (only when no edge was selected). |
edge_clicked | Option<(Port, Port)> | An edge was clicked this frame. |
node_moved | Vec<(NodeId, Vec2)> | World-space move deltas to apply (caller owns positions). |
node_resized | Vec<(NodeId, Vec2)> | World-space size deltas from the node resizer. |
create_request | Option<(NodeKindId, Pos2)> | “Create a node of this kind at this world position” (from node search). |
selection | HashSet<NodeId> | Current selection, mirrored out (e.g. to drive per-node toolbars next frame). |
fit_requested | bool | The user hit the controls’ fit-to-content button this frame. |
Tokens consumed
show resolves a GraphTokens and threads it through the paint helpers (grid, edge, handle). Canvas chrome uses Theme directly: theme.background fill, theme.border stroke at core::BORDER_THIN, corner radius core::RADIUS_LG. Fit padding is core::SPACE_8.
API
GraphView (builder)
| Method | Signature | Effect |
|---|---|---|
new | fn new(id_source: impl Hash) -> Self | Construct with a stable id; the view-state is keyed by it. |
size | fn size(self, size: Vec2) -> Self | Explicit canvas size. Default: full available width × 420.0. |
grid | fn grid(self, on: bool) -> Self | Toggle the dot grid (default true). |
controls | fn controls(self, on: bool) -> Self | Toggle the floating zoom/fit overlay (default false). |
minimap | fn minimap(self, on: bool) -> Self | Toggle the minimap overlay (default false). |
show | fn show(self, ui: &mut egui::Ui, build: impl FnOnce(&mut GraphCtx)) -> GraphResponse | Run the canvas and return intents. |
GraphCtx<'a> (per-frame emit surface)
Public methods. Node/edge/toolbar emit methods (node, edge, node_toolbar) are added by other modules via separate impl GraphCtx blocks (node.rs, edge.rs, toolbar.rs) — see layer README. All fields are crate-private; interact only through these methods.
| Method | Signature | Effect |
|---|---|---|
scale | fn scale(&self) -> f32 | The scene→screen scale (current zoom factor). |
visible_rect | fn visible_rect(&self) -> Rect | The visible region in scene (world) coordinates. |
tokens | fn tokens(&self) -> GraphTokens | The resolved graph paint tokens. |
screen_delta_to_world | fn screen_delta_to_world(&self, delta: Vec2) -> Vec2 | Convert a screen-space delta (e.g. a Response::drag_delta) to a world delta. |
screen_to_world | fn screen_to_world(&self, screen: Pos2) -> Pos2 | Convert a global screen point to a scene (world) point. |
Emit methods (documented under their own pages):
| Method | Signature |
|---|---|
node | fn node(&mut self, id: NodeId, world_pos: Pos2, frame: NodeFrame, body: impl FnOnce(&mut egui::Ui)) -> NodeResult |
edge | fn edge(&mut self, from: Port, to: Port, style: EdgeStyle) -> EdgeResult |
node_toolbar | fn node_toolbar(&mut self, node: NodeId, content: impl FnOnce(&mut egui::Ui)) |
GraphResponse
#[derive(Clone, Debug)]. All fields public; see the field table above. Defaults to “nothing happened” (empty/None).
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::graph::{GraphView, GraphCtx, GraphResponse};
use ouroboros_ui::graph::{NodeId, PortId, Port, PortSide, EdgeStyle, NodeFrame};
// Caller owns nodes + edges; lib owns only view-state.
let resp: GraphResponse = GraphView::new("my_graph")
.size(egui::vec2(720.0, 420.0))
.grid(true)
.controls(true)
.minimap(true)
.show(ui, |g: &mut GraphCtx| {
// Nodes first, so their handle positions are recorded before edges resolve.
for (id, pos, label) in &nodes {
let frame = NodeFrame::base().title(label.clone()).input(0).output(1);
g.node(NodeId(*id), *pos, frame, |ui| {
Text::new("body content").muted().show(ui);
});
}
// Edges after nodes (anchored on handles, drawn under the nodes).
for (from, to) in &edges {
g.edge(
Port { node: NodeId(*from), port: PortId(1), side: PortSide::Out },
Port { node: NodeId(*to), port: PortId(0), side: PortSide::In },
EdgeStyle::Default,
);
}
});
// Commit the intents back into caller-owned data.
for (moved_id, delta) in &resp.node_moved {
if let Some(pos) = positions.get_mut(moved_id) { *pos += *delta; }
}
if let Some(c) = resp.connection { edges.push((c.from.node.0, c.to.node.0)); }
if let Some((from, to)) = resp.delete_edge { /* drop edge */ }
for n in &resp.delete_nodes { /* drop node + its edges */ }
}
Composition / Notes
- Emit order matters. Emit nodes before edges —
edgeanchors on handle positions recorded bynode, and returns a default (no-op)EdgeResultif either endpoint hasn’t been emitted yet. Within a frame the paint order is fixed regardless of caller interleaving: edges flush into a reserved slot under the nodes. - Ownership. The lib persists exactly one value per
GraphViewid in egui temp memory —GraphViewState(camera + selection + transient drag state). All node/edge data is the caller’s; every mutation is reported as an intent inGraphResponse, never applied to caller data by the lib. - Connect resolution. A released connect-drag is resolved at scope end: first a precise handle hit within
tokens.handle_hit_radius, else the nearest compatible (opposite-side) port of whatever node body the release landed in. Output is orientedOut → In. - Paint tier vs compose tier. The canvas itself is the paint shell (surface, grid, edge layer, connect-wire) plus the compose overlays (
controls,minimap) it drives fromGraphViewflags.node/edge/node_toolbarare compose-tier and live in sibling modules. - Identity.
NodeId,PortId,Port,PortSide,Connection,NodeKindId— see identity. - Foundation: architecture · tokens · theming · guards.
EdgeStyle · EdgeResult
Layer: graph (paint-tier) · Path:
src/graph/edge.rs· Exports:EdgeStyle,EdgeResult
An edge is a cubic-bezier wire between two ports. It is paint-tier: it samples a bezier between the two handle anchors and pushes a token-colored polyline into the canvas’s reserved under-node slot (edge_shapes), so wires always draw beneath nodes. Control points leave each port perpendicular to its side (horizontal flow: Out → +x, In → −x), so wires fan out cleanly even when nodes overlap vertically.
Edges carry no egui::Response — they are too thin for a hit rect. Hover/selection are hit-tested geometrically against the cursor in scene space, using a grab radius held constant in screen pixels (the token radius is divided by the current zoom). Clicks select the wire and report it as an intent.
Design
- Purpose — connect an output port to an input port. A connection always runs
Out→In(see identity). - Anatomy — four control points
[a, c1, c2, b]wherec1 = a + side_dir(a) * reach,c2 = b + side_dir(b) * reach, andreach = max(|a.x − b.x| * 0.5, MIN_REACH=40.0)so even short edges bow. The curve is flattened intoSAMPLES + 1 = 25points for both drawing and hit-testing. Midpoint decorations (button/label) anchor att = 0.5. - Anchoring — endpoints are resolved via
handle_pos(port)against positions recorded bynode. If either port has no recorded handle,edgereturnsEdgeResult::default()and paints nothing — emit nodes first. - Width —
edge_widthtoken normally;core::EDGE_WIDTH + 0.5when hovered or selected.
Variants / states (EdgeStyle)
| Variant | Behavior |
|---|---|
EdgeStyle::Default (default) | A plain wire. |
EdgeStyle::Animated | A dot travels along the wire (i.time-driven, t = time % 1.0), drawn on top; requests repaint each frame. |
EdgeStyle::WithButton | A small ghost icon-button (phosphor X, an “x” delete affordance) at the midpoint; click surfaces via EdgeResult::button_clicked. |
EdgeStyle::WithLabel | A Badge (“edge”) at the midpoint. |
EdgeStyle is Clone + Copy + Debug + Default + PartialEq + Eq.
Color state: edge_selected when selected, else edge_hover when hovered, else edge.
Tokens consumed
From GraphTokens: edge, edge_hover, edge_selected, edge_width, edge_hit_radius (geometric grab radius, screen-constant), handle_hit_radius (sizes the WithButton icon-button). Geometry from core::EDGE_WIDTH. The midpoint atoms (Button, Badge) carry their own foundation tokens.
API
Emit method (on GraphCtx)
#![allow(unused)]
fn main() {
impl GraphCtx<'_> {
pub fn edge(&mut self, from: Port, to: Port, style: EdgeStyle) -> EdgeResult
}
}
Emits a bezier edge from port from to port to. Anchored on the ports’ handles, so it must be emitted after both nodes in the same show closure. Runs the geometric hover/click hit-test, records selection into edge_selection and clicks into edge_clicked (surfaced via GraphResponse::edge_clicked), pushes the wire (and any animated dot) into the under-node edge_shapes slot, and renders any midpoint decoration. Returns EdgeResult. If either endpoint handle is unknown, returns EdgeResult::default().
EdgeResult (read-back)
| Field | Type | Meaning |
|---|---|---|
hovered | bool | Cursor is within the grab radius of the wire this frame. |
selected | bool | This (from, to) is the current edge_selection. |
clicked | bool | The wire was clicked this frame (selects it). |
button_clicked | bool | The WithButton midpoint button was clicked this frame. |
EdgeResult is Clone + Copy + Debug + Default.
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::graph::{GraphView, NodeFrame, EdgeStyle, NodeId, PortId, Port, PortSide};
GraphView::new("wires").show(ui, |g| {
// Nodes first so their handle positions are recorded.
g.node(NodeId(1), egui::pos2(20.0, 40.0),
NodeFrame::base().title("Source").output(1), |_ui| {});
g.node(NodeId(2), egui::pos2(360.0, 40.0),
NodeFrame::base().title("Sink").input(0), |_ui| {});
// Then the edge: Out → In.
let e = g.edge(
Port { node: NodeId(1), port: PortId(1), side: PortSide::Out },
Port { node: NodeId(2), port: PortId(0), side: PortSide::In },
EdgeStyle::WithButton,
);
if e.button_clicked {
// caller deletes the connection from its own model
}
});
}
Composition / Notes
- Paint-tier. Edges push raw
Shapes intoedge_shapes; they do not allocate egui widgets except the optional midpointButton/Badgefor the decorated variants. - Drawn under nodes. The canvas flushes
edge_shapesinto a layer beneath the nodes at scope end, so wires never occlude node bodies regardless of emit order — but endpoints still require their nodes to have been emitted earlier so the anchors exist. - Geometric hit-test. No
Response. Hover usesdist_to_polyline(samples, cursor) <= edge_hit_radius / zoom; click on hover sets selection. To delete a wire, watchEdgeResult::button_clicked(WithButton) orGraphResponse::edge_clickedand remove it from caller data — the library never deletes connections itself (it only reportsdelete_edge/edge_clickedintents). - Animated edges request a repaint every frame; use sparingly.
- See also: node, handle, identity, layer README, guards.
Graph extras — internal / advanced support pieces
Layer: graph · Paths:
src/graph/{grid,resizer,minimap,toolbar,controls}.rs
These are the internal/advanced support pieces of the graph layer — mostly module-level show/paint helpers (plus one GraphCtx method) that the canvas wires up for you. None of them has a top-level re-export in mod.rs; reach them through their modules (ouroboros_ui::graph::{grid, resizer, minimap, toolbar, controls}) only when building a custom canvas. In normal use you never call them directly — GraphView toggles them via .grid(bool), .controls(bool), .minimap(bool), and GraphCtx::node_toolbar, and surfaces their results on GraphResponse.
Several have pub(crate) visibility (resizer, minimap, controls::show), i.e. they are crate-internal; grid and controls::ControlsAction are pub. All obey the layer’s paint-but-token invariant — colours from GraphTokens/Theme, sizes from tokens::core — checked by guards. See the layer README for the paint-vs-compose tier split and identity for NodeId/Pos2/Vec2 vocabulary.
Grid
What it is. The canvas backdrop dot-grid. Paints dots on a spacing-aligned lattice in scene (world) coordinates, so it pans and zooms uniformly with the nodes (n8n-style), rather than as a fixed screen overlay.
Tier. paint. Touches the painter directly, but every value comes from [GraphTokens].
Module: ouroboros_ui::graph::grid (pub).
#![allow(unused)]
fn main() {
/// Below this on-screen dot spacing (px) the grid is too dense to read — caller skips it.
pub const MIN_DOT_SPACING: f32 = 6.0;
pub fn paint(
painter: &egui::Painter,
visible: egui::Rect, // scene-space rect to cover (e.g. ui.clip_rect() inside the Scene)
spacing: f32, // GraphTokens.grid_spacing
dot_radius: f32, // GraphTokens.grid_dot_radius
color: egui::Color32, // GraphTokens.grid_dot
);
}
paint returns early if spacing <= 0.0. It snaps the first dot to the lattice (floor(visible.left()/spacing)*spacing, same for top) and fills circle_filled dots across the visible rect. dot_radius/color are scene-space token values.
How the canvas uses it. Inside the Scene closure, in scene coords, the canvas culls before painting:
#![allow(unused)]
fn main() {
if show_grid && tokens.grid_spacing * to_global.scaling >= grid::MIN_DOT_SPACING {
grid::paint(sui.painter(), sui.clip_rect(),
tokens.grid_spacing, tokens.grid_dot_radius, tokens.grid_dot);
}
}
i.e. when the on-screen spacing (grid_spacing × Scene zoom) drops below MIN_DOT_SPACING, the grid is skipped entirely (too dense to be useful). Enabled via GraphView::new(id).grid(true).
Resizer
What it is. A bottom-right corner grip for resizing a node that was given an explicit size. Only meaningful for nodes with NodeFrame::size — content-hugging nodes have no size to drive. Draws the grip and (via the canvas’s drag handling) reports a world-space size delta the caller applies to its own width/height.
Tier. paint.
Module: ouroboros_ui::graph::resizer (items are pub(crate) — crate-internal).
#![allow(unused)]
fn main() {
/// Side length (world px) of the corner grip.
pub(crate) const GRIP: f32 = 10.0;
/// Scene rect of the grip at a node's bottom-right corner.
pub(crate) fn grip_rect(node: egui::Rect) -> egui::Rect;
/// Paint the grip — a small filled square in the node's selection-ring color.
pub(crate) fn paint(painter: &egui::Painter, node: egui::Rect, tokens: &GraphTokens);
}
grip_rect centres a GRIP × GRIP square on node.right_bottom(). paint fills it with radius core::RADIUS_SM in tokens.node_selected_ring.
How the canvas uses it. Drawn per sized node; the resulting drag is accumulated into CtxOut.node_resized and surfaced on GraphResponse.node_resized: Vec<(NodeId, Vec2)> (world-space size deltas). The caller owns sizes and applies the deltas. See canvas.
MiniMap
What it is. A fixed-size corner overview of the whole graph with the current viewport outlined. Each node is a small token-coloured rect; the visible region is a stroked outline. Click or drag inside it to recenter the view on that world point.
Tier. compose (overlay). Drawn in a foreground [Area] above the Scene so it receives clicks; it paints geometric rects only (token-coloured). O(nodes) per frame — fine for hundreds of nodes.
Module: ouroboros_ui::graph::minimap (show is pub(crate)).
#![allow(unused)]
fn main() {
/// MiniMap footprint (screen px) — internal const.
const MINI: Vec2 = Vec2::new(168.0, 120.0);
pub(crate) fn show(
ui: &mut egui::Ui,
canvas: egui::Rect, // screen rect of the canvas (anchors top-right)
nodes: &[(NodeId, egui::Rect)], // node rects, scene coords
world_bounds: egui::Rect, // union of content, scene coords
view: egui::Rect, // current visible region, scene coords
) -> Option<egui::Pos2>; // requested new view center (world) on click/drag
}
It maps the region world_bounds.union(view).expand(core::SPACE_4) into a fixed MINI-sized panel pinned to the canvas’s top-right (offset -MINI.x - SPACE_3, SPACE_3). Returns None if that source region is degenerate (zero width/height). On click or drag whose pointer is inside the panel, it returns the corresponding world Pos2. Colours: backdrop theme.popover, border theme.border, nodes tokens.minimap_node, viewport outline tokens.minimap_view.
How the canvas uses it. Enabled via GraphView::new(id).minimap(true). The canvas calls minimap::show(...) after the Scene; a returned center recenters the view by mutating the Scene’s scene_rect. See canvas.
Toolbar
What it is. A floating action bar anchored just above a node, hosting caller-supplied DS widgets (e.g. a delete button). Placed in scene coordinates, so it tracks and scales with the node. Typically shown only while the node is selected. Unlike the others this is a method on GraphCtx, not a free show fn.
Tier. compose (overlay). A Surface bar — never paints inline.
Module: ouroboros_ui::graph::toolbar (provides an impl GraphCtx<'_> method).
#![allow(unused)]
fn main() {
impl GraphCtx<'_> {
/// Draw a toolbar above `node`. No-op if the node wasn't emitted yet this frame.
/// `content` lays its actions out left-to-right inside an elevated surface.
pub fn node_toolbar(&mut self, node: NodeId, content: impl FnOnce(&mut egui::Ui));
}
}
It looks up node’s rect from self.node_rects (emitted earlier this frame) and returns early if absent — so call it after the node. The bar height is core::CONTROL_MD + core::SPACE_2; it anchors at the node’s left_top minus (0, height + SPACE_2), width max(node.width(), 120.0), inside an .elevated().pad(core::SPACE_1) [Surface] laying content out horizontally.
How the canvas uses it. Called by the caller inside the GraphView::show closure, once per node that should show a toolbar (commonly gated on selection). Example in examples/storybook.rs (page_graph_live): a trash Button per selected node, pushing into the caller’s own delete list.
Controls
What it is. A floating zoom/fit cluster overlaid on the canvas in screen space (bottom-left). Holds a zoom-out (−), zoom-in (+), a percent readout, and a fit (corners-out) button. It mutates nothing itself — it returns the requested action for GraphView to apply to the scene_rect.
Tier. compose (overlay). A [Surface] of DS [Button]s drawn in a foreground [Area] so it sits above the Scene and receives clicks.
Module: ouroboros_ui::graph::controls (ControlsAction is pub; show is pub(crate)).
#![allow(unused)]
fn main() {
/// What the user asked the controls to do this frame.
#[derive(Clone, Copy, Debug, Default)]
pub struct ControlsAction {
pub zoom_in: bool,
pub zoom_out: bool,
pub fit: bool,
}
pub(crate) fn show(
ui: &mut egui::Ui,
canvas: egui::Rect, // screen rect of the canvas (anchors bottom-left)
percent: i32, // current zoom %, shown in the readout
) -> ControlsAction;
}
Anchors at canvas.left_bottom() + (core::SPACE_3, -SPACE_3 - core::CONTROL_LG). Buttons are ghost, sm, icon-only ([egui_phosphor] MINUS/PLUS/CORNERS_OUT); the readout is Text::new(format!("{percent}%")).caption().muted(). Each press flips the matching ControlsAction flag for that frame.
How the canvas uses it. Enabled via GraphView::new(id).controls(true). The canvas calls controls::show(ui, rect, percent) after the Scene and applies the returned action to the Scene’s scene_rect — zoom_in/zoom_out adjust zoom, fit requests fit-to-content (also surfaced as GraphResponse.fit_requested: bool). See canvas.
HandleSpec · HandleVariant
Layer: graph (paint-tier) · Path:
src/graph/handle.rs· Exports:HandleSpec,HandleVariant
A handle is a port: a connection anchor painted on a node’s edge. It is paint-tier — it draws the port circle (filled disc + border ring, token-colored) and owns the connect-drag detection. Inputs anchor on a node’s left edge, outputs on the right, distributed evenly down the side. HandleSpec is a pure declaration attached to a NodeFrame; the actual painting + interaction run inside GraphCtx::draw_handles (a private helper invoked by node) — there is no public ctx.handle(..) emit method, handles are emitted as part of the node.
Design
- Purpose — declare where wires can attach to a node and which of those attachment points accept a drag.
- Anatomy (per handle) — a filled circle (
handle_fill) of radiushandle_radius, with acore::BORDER_THINborder ring (handle_border). On hover or while it is the active connect source, an extracore::BORDER_FOCUSring inedge_selectedis drawn. TheLabeledvariant adds a muted captionText(width 72, 18 high) just inside the node beside the dot — left-aligned for inputs, right-aligned for outputs. - Anchoring —
anchor(rect, side, index, count)places handleindexofcountaty = rect.top() + height * (index+1)/(count+1)(even vertical distribution) andx = rect.left()forIn/rect.right()forOut. Inputs and outputs are counted and indexed independently. The computed scene-space position is recorded intohandle_positionskeyed byPort{node, port, side}, which is whatedgereads back to anchor wires. - Connect-drag — a connectable handle gets an interaction rect of
2 * handle_radiussquare.drag_startedopens aConnectDrag { from, from_world, cursor_world }; while dragging,cursor_worldtracks the pointer in world space (a preview wire follows it on top);drag_stoppedrecords the world release point. The canvas resolves the release against all known handle positions (handle_hit_radius) into aGraphResponse::connection(Out→In). Afixed()(non-connectable) handle is painted but skips all interaction.
Variants / states (HandleVariant)
| Variant | Behavior |
|---|---|
HandleVariant::Base (default) | Plain port circle. |
HandleVariant::Labeled(&'static str) | Port circle with a caption beside it, inside the node. |
HandleVariant is Clone + Copy + Debug + Default + PartialEq + Eq.
Tokens consumed
From GraphTokens: handle_fill, handle_border, handle_radius, handle_hit_radius, and edge_selected (hover/active-connect highlight ring). Geometry from core::BORDER_THIN, core::BORDER_FOCUS. Caption labels use foundation tokens via the Text atom.
API
Handles are declared on a NodeFrame (.handle(..), .input(..), .output(..)); there is no standalone emit method on GraphCtx. Painting/hit-testing happens inside GraphCtx::node.
HandleSpec fields
| Field | Type | Meaning |
|---|---|---|
id | PortId (u32 newtype) | Port id, unique within the node’s side. |
side | PortSide | In (left edge) or Out (right edge). |
connectable | bool | Whether the port accepts a connect-drag. |
variant | HandleVariant | Visual style (Base / Labeled). |
HandleSpec is Clone + Copy + Debug.
HandleSpec builder
| Method | Effect |
|---|---|
HandleSpec::input(id: u32) | Input port on the left edge; connectable = true, Base. |
HandleSpec::output(id: u32) | Output port on the right edge; connectable = true, Base. |
.fixed() | Mark the port non-connectable (decorative only). |
.label(text: &'static str) | Switch to HandleVariant::Labeled(text) — caption beside the dot. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::graph::{GraphView, NodeFrame, HandleSpec, NodeId};
GraphView::new("ports").show(ui, |g| {
g.node(
NodeId(1),
egui::pos2(20.0, 20.0),
NodeFrame::base()
.title("Mixer")
.handle(HandleSpec::input(0).label("audio in"))
.handle(HandleSpec::input(1).label("gain"))
.handle(HandleSpec::output(2).label("out"))
.handle(HandleSpec::output(3).fixed()), // decorative, no connect
|ui| { Text::new("body").muted().show(ui); },
);
});
}
Convenience shorthands on NodeFrame (.input(0) / .output(1)) expand to Base handles.
Composition / Notes
- Paint-tier, but
Labeledborrows theTextatom for its caption — still no hand-rolled values (all geometry/colors via tokens /core), keeping theno_raw_valuesguard green. - Feeds the wire system. Every handle records its world position into
handle_positions;edgeanchors on those, and connect-drag releases hit-test against them (handle_hit_radius) to produce aconnection. So handle ids/sides are the join key between nodes and edges — keep them stable in caller data. - Connection direction is enforced downstream: a connect-drag is resolved to
Out→Inby the canvas, regardless of which end the drag started from. fixed()handles render but never start a connect nor accept a release — use for read-only / display-only ports.- See also: node, edge, identity (
PortId,PortSide,Port,Connection), layer README, canvas.
Graph identity vocabulary
Layer: graph · Path:
src/graph/mod.rs· Exports:NodeId,PortId,NodeKindId,PortSide,Port,Connection
The small set of identifier and address types the graph layer carries around. They are the only vocabulary shared between caller and library — the data-model-agnostic contract means the library never sees the caller’s domain types, only these.
The caller assigns all ids. The library hashes nothing on its own — it just carries these values and reports them back in
GraphResponse. Ids must be stable across frames (e.g. a hash of the node’s domain key), or selection/drag/connect state won’t line up frame-to-frame.
Identifiers
| Type | Definition | Meaning |
|---|---|---|
NodeId | pub struct NodeId(pub u64) | Stable id of a node, assigned by the caller. |
PortId | pub struct PortId(pub u32) | Id of a port within a node’s port list, caller-assigned. |
NodeKindId | pub struct NodeKindId(pub u64) | Id of a node kind offered by search — caller-defined. |
All three are Clone + Copy + PartialEq + Eq + Hash + Debug newtypes — wrap your own
identity (an index, a slotmap key, a hash) and hand it in.
Port addressing
#![allow(unused)]
fn main() {
pub enum PortSide { In, Out }
pub struct Port {
pub node: NodeId,
pub port: PortId,
pub side: PortSide,
}
}
PortSide— which side of a node a port lives on. Inputs anchor on the left/top, outputs on the right/bottom. A connection always runsOut → In.Port— a fully-qualified port: a node, a port within it, and the side it sits on. This is the address used everywhere edges and handles are referenced.
Connection
#![allow(unused)]
fn main() {
pub struct Connection {
pub from: Port, // the Out side
pub to: Port, // the In side
}
}
A requested edge between two ports, emitted by the library on a successful connect-drag
(in GraphResponse.connection). The library orients it Out → In
regardless of which end the user dragged from, so from is always the output port and to
the input. The caller commits it to its own edge list.
How they flow
caller defines ids ──▶ ctx.node(NodeId, …) / NodeFrame.input(PortId) / .output(PortId)
│
user drags Out handle → In handle
│
GraphResponse.connection: Option<Connection {from: Out, to: In}>
│
caller: model.add_edge(c.from, c.to)
Edges the caller passes back in are addressed the same way: ctx.edge(from: Port, to: Port, …). Deletion intents (delete_edge: Option<(Port, Port)>, edge_clicked,
delete_nodes: Vec<NodeId>) all speak this same vocabulary.
See node for declaring ports on a NodeFrame, handle for how a
port renders, and canvas for the full intent set.
NodeFrame · NodeResult · NodeStatus
Layer: graph (compose-tier) · Path:
src/graph/node.rs· Exports:NodeFrame,NodeResult,NodeStatus
A node is a draggable, selectable box drawn from the DS Surface atom hosting an arbitrary caller closure of DS widgets. It is compose-tier: it does not paint inline shapes for its body — it reuses Surface (card fill, border, radius, elevation, selection ring) plus atoms (Heading, Divider, Badge, Text, Tooltip) and lets the caller fill the body with a normal egui::Ui. Because the whole node lives inside the GraphView scene layer, it scales with zoom for free.
Ports (handles) painted on the node edges are delegated to the paint-tier handle helpers. Position is in world coordinates; the library reports drag deltas back via GraphResponse so the caller moves its own data — the library never mutates node positions.
Design
- Purpose — emit one logical node of a node-graph. Use whenever you need a draggable box with optional header, status badge, body, footer and ports.
- Anatomy (top → bottom, inside one
Surface):- Header (optional):
Heading(title) + right-aligned statusBadge(dot,sm), followed by a horizontalDivider. - Body: the caller’s
body(&mut egui::Ui)closure. - Appendix (optional): horizontal
Divider+ muted captionText. - Handles: drawn on the left (
In) / right (Out) edges bydraw_handles(see handle.md). - Resizer grip: painted only when the node has an explicit
size(..)and is selected.
- Header (optional):
- Surface mode —
placeholder()nodes useSurface::fill_none().border_strong()(muted empty slot, no shadow/body chrome); all others useSurface::elevated(). Selection ring is driven bySurface::selected(..). - Sizing — without
size(..), the body hugs its content up to a max width ofNODE_MAX_W = 240.0world units. Withsize(..), the node takes a fixed world-space size and gains the resizer grip when selected.
Variants / states (NodeStatus)
| Variant | Badge variant | Header label |
|---|---|---|
NodeStatus::Ok | Success | ok |
NodeStatus::Warning | Warning | warn |
NodeStatus::Error | Destructive | error |
NodeStatus::Running | Info | running |
Status renders only when NodeFrame::title(..) is also set (the badge lives in the header row).
Tokens consumed
Node chrome inherits from Surface / atoms (foundation tokens). Directly from GraphTokens via draw_handles / resizer: handle_radius, handle_fill, handle_border, handle_hit_radius, edge_selected (port hover/connect highlight), plus node_selected_ring (through the resizer/selection path). Geometry constants come from core::* (BORDER_THIN, BORDER_FOCUS).
API
Emit method (on GraphCtx)
#![allow(unused)]
fn main() {
impl GraphCtx<'_> {
pub fn node(
&mut self,
id: NodeId,
world_pos: Pos2,
frame: NodeFrame,
body: impl FnOnce(&mut egui::Ui),
) -> NodeResult
}
}
Emits one node at world position world_pos; body draws content with a normal egui::Ui already inside the scene transform. Records handle positions for edge/connection anchoring, claims the primary-drag gesture (so dragging a node moves it instead of panning the Scene), pushes any drag delta into node_moved (the whole multi-selection moves together when the dragged node is part of a selection of >1), records clicks into the selection machinery, and returns NodeResult. Drag deltas are already in world coordinates (no zoom division).
NodeFrame builder
| Method | Effect |
|---|---|
NodeFrame::base() | Plain node: optional titled header over a body (default). |
NodeFrame::placeholder() | Muted dashed-looking empty slot — no shadow, no body chrome (fill_none + border_strong). |
.title(impl Into<String>) | Header title, rendered as a Heading over a Divider. |
.status(NodeStatus) | Status badge in the header (requires a title). |
.appendix(impl Into<String>) | Muted secondary line (caption) under the body, after a Divider. |
.tooltip(impl Into<String>) | Hover tooltip on the node body (Tooltip atom). |
.size(Vec2) | Explicit world-space size; enables the resizer grip when selected. Without it the node hugs content. |
.handle(HandleSpec) | Add a port. See HandleSpec. |
.input(u32) | Convenience: .handle(HandleSpec::input(id)). |
.output(u32) | Convenience: .handle(HandleSpec::output(id)). |
NodeFrame is Clone + Debug + Default (default == base()).
NodeResult (read-back)
| Field | Type | Meaning |
|---|---|---|
clicked | bool | The node body was clicked this frame. |
dragged | Option<Vec2> | World-space move delta applied this frame (caller commits it), None if not dragged. |
rect | Rect | The node’s rect in scene (world) coordinates. |
NodeResult is Clone + Copy + Debug. Note: the actual position commit happens through GraphResponse::node_moved at scope end — dragged is the same delta, surfaced inline for convenience.
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::graph::{GraphView, NodeFrame, NodeStatus, NodeId, HandleSpec};
GraphView::new("my_graph").show(ui, |g| {
let res = g.node(
NodeId(1),
egui::pos2(20.0, 20.0),
NodeFrame::base()
.title("Status")
.status(NodeStatus::Running)
.appendix("last run 2s ago")
.tooltip("a node with a status badge")
.handle(HandleSpec::input(0).label("in"))
.handle(HandleSpec::output(1).label("out")),
|ui| {
Text::new("body content").muted().show(ui);
},
);
if res.clicked {
// react to selection
}
});
}
A placeholder slot and a fixed-size (resizable) node:
#![allow(unused)]
fn main() {
g.node(NodeId(2), egui::pos2(300.0, 20.0),
NodeFrame::placeholder().title("Placeholder"),
|ui| { Text::new("drop a node here").muted().show(ui); });
g.node(NodeId(3), egui::pos2(20.0, 200.0),
NodeFrame::base().title("Sized").size(egui::vec2(180.0, 96.0)).input(0),
|ui| { Text::new("fixed size (select to resize)").muted().show(ui); });
}
Composition / Notes
- Compose-tier, not paint-tier. The body never hand-rolls shapes — it composes
Surface+ atoms. Ports/resizer are the only painted parts and those route through paint-tier helpers (handle.md) andresizer. - Emit nodes before edges.
node(..)records each handle’s world position intohandle_positions;edgelooks those up to anchor wires, so all nodes a wire touches must be emitted first in the sameshowclosure. - Intents, not mutation. Drags push
(NodeId, Vec2)intonode_moved; resizes push intonode_resized; clicks feed the selection. The caller reads these fromGraphResponseaftershowreturns and commits them to its own data model. - Multi-select drag moves every selected node by the same delta.
- See also: identity (
NodeId,Port,PortSide), layer README, guards (no_raw_values— graph paints only via tokens).
NodeSearch
Layer: graph (compose-tier) · Path:
src/graph/search.rs· Exports:NodeSearch
A command-palette popover for picking a node kind to create. It is a Popover holding a text Input filter over a caller-supplied list of node kinds, each rendered as a MenuItem. It is data-model-agnostic: the caller owns the kind list and decides where the chosen kind is placed. show returns the picked NodeKindId; the caller then emits a create_request into GraphResponse (or just spawns the node directly).
It paints nothing of its own — it composes existing atoms/cells/organisms, honouring the compose-tier contract (see layer README and guards).
Design
Purpose. Turn a click on an “Add node” trigger into a filtered menu of node kinds, returning the kind the user picked. It does not place the node — placement (and the (NodeKindId, Pos2) pairing) is the caller’s job.
Anatomy.
- A [
Popover] anchored to atrigger: &Response(typically aButton). - Inside: an [
Input] with placeholder"Search nodes…", whose query is persisted inui.dataunderui.id().with("node_search_query")(survives across frames while the popover is open). - Below: one [
MenuItem] per kind whose label (case-insensitively) contains the query. Empty query shows all kinds. Clicking a [MenuItem] records that kind as the chosen result.
API surface. A small consuming builder: new() → chain .kind(id, label) once per kind → terminal .show(ui, trigger). show takes self by value (consumes the builder) and returns Option<NodeKindId>.
Tokens. None applied directly — all colour/spacing flows through the composed atoms ([Input], [MenuItem], [Popover]), which resolve from Theme. NodeSearch does not touch GraphTokens.
API
use ouroboros_ui::graph::NodeSearch;
| Item | Signature | Notes |
|---|---|---|
| field | kinds: Vec<(NodeKindId, String)> | private; populated via .kind() |
NodeSearch::new | fn new() -> Self | empty palette (also #[derive(Default)]) |
.kind | fn kind(self, id: NodeKindId, label: impl Into<String>) -> Self | append one selectable kind; chainable |
.show | fn show(self, ui: &mut egui::Ui, trigger: &egui::Response) -> Option<NodeKindId> | consumes self; returns the kind picked this frame, else None |
NodeKindId(pub u64) is a caller-defined identifier for a node kind (not a node instance). See identity. The picked id is Copy; read .0 for the raw u64.
Usage
Realistic flow: a trigger button feeds NodeSearch, and the picked kind is paired with a world position to fill GraphResponse.create_request. Because NodeSearch::show returns only the kind, the caller supplies the Pos2 (e.g. the canvas centre, or the last right-click point).
#![allow(unused)]
fn main() {
use egui::{pos2, Pos2};
use ouroboros_ui::atoms::Button;
use ouroboros_ui::graph::{NodeKindId, NodeSearch};
// 1. Trigger.
let trigger = Button::new("Add node")
.icon_left(egui_phosphor::light::PLUS)
.id_source("add_node")
.show(ui);
// 2. Palette anchored to the trigger.
let chosen: Option<NodeKindId> = NodeSearch::new()
.kind(NodeKindId(1), "Trigger")
.kind(NodeKindId(2), "Condition")
.kind(NodeKindId(3), "Action")
.kind(NodeKindId(4), "Delay")
.show(ui, &trigger);
// 3. Pair the kind with a drop point and hand it to the caller's create handler.
// (Mirrors GraphResponse.create_request: Option<(NodeKindId, Pos2)>.)
if let Some(kind) = chosen {
let drop_at: Pos2 = pos2(120.0, 80.0); // caller-chosen world position
spawn_node(kind, drop_at); // caller commits to its own model
}
}
The graph canvas exposes the committed form of this on its response:
#![allow(unused)]
fn main() {
let resp = GraphView::new("graph").show(ui, |g| { /* … */ });
if let Some((kind, world_pos)) = resp.create_request {
// caller adds a node of `kind` at `world_pos` to its data model
}
}
NodeSearchitself does not populateGraphResponse.create_request— it is a standalone palette. The canvas field is filled by the canvas’s own internal create path;NodeSearchis the recommended UI for producing theNodeKindIdhalf of that pair. See canvas.
Composition / Notes
- Tier: compose. Reuses [
Popover], [Input], [MenuItem] — no inline painting, satisfying the graph layer’s compose-tier rule. - Stateless across instances: the only persisted state is the filter query, keyed off
ui.id(). Use distinct parent ids if you host two palettes in oneUito avoid query bleed. - Caller owns identity & placement:
NodeKindIds are caller-defined and the drop position is caller-chosen; the library never sees domain types. This is the same data-agnostic contract the rest of the graph layer follows. - Filtering: substring, case-insensitive, on the label only. No fuzzy match, no keyboard navigation, no scroll virtualization — fine for the tens-of-kinds palettes this targets.
GraphViewState · NodeDrag · ConnectDrag · MarqueeDrag
Layer: graph · Path:
src/graph/state.rs· Exports:GraphViewState,NodeDrag,ConnectDrag,MarqueeDrag
The view-state: the only thing the library owns across frames. The caller owns the node/edge data; this struct owns the view — where the camera is, what’s selected, and what’s mid-drag. It is stored in egui’s temp memory keyed by the GraphView’s id, so it must be Clone + Default to live in the temp store.
Design
- Purpose. Persist the minimum the graph needs between frames. Loaded at the top of
GraphView::show(get_temp(id).unwrap_or_default()), mutated through the frame, written back withinsert_tempat the end. The caller almost never touches it directly — it observes effects viaGraphResponseinstead. scene_rect. The [egui::Scene] view window expressed in world coordinates.GraphView::showhands a&mutto it intoScene::show, and Scene mutates it in place on pan/zoom. Controls’ zoom buttons rescale it around its center; fit replaces it with the content bounds expanded bycore::SPACE_8; the minimap recenters it.Rect::ZEROsentinel. TheDefaultscene_rectisRect::ZERO, the “uninitialised” marker — Scene interprets it as “no camera yet” and auto-fits the content on the first frame. After the first frame it holds a real world rect.- Selection model.
selection: HashSet<NodeId>plus anOption<(Port, Port)>edge selection (mutually managed: selecting a node clears the edge selection and vice-versa). Delete/Backspace removes the selected edge first, else drains the selected nodes. - Transient drag state. Three optional sub-structs, each present only while a drag is live:
drag(node move),connect(wire),marquee(box-select).connectandedge_selectionround-trip through the Scene closure (read in, resolved, written out);drag/marquee/hovered_nodeare maintained across frames.
GraphViewState fields
| Field | Type | Role |
|---|---|---|
scene_rect | Rect | Scene window in world coords. Rect::ZERO ⇒ auto-fit first frame. |
selection | HashSet<NodeId> | Currently selected nodes. |
edge_selection | Option<(Port, Port)> | Currently selected edge (the two endpoint ports). |
hovered_node | Option<NodeId> | Node under the cursor. |
drag | Option<NodeDrag> | Active node-move drag. |
connect | Option<ConnectDrag> | Active connect (wire) drag. |
marquee | Option<MarqueeDrag> | Active box-select drag. |
#[derive(Clone, Debug)]; Default sets scene_rect = Rect::ZERO, all collections empty, all options None.
Helper structs
NodeDrag — #[derive(Clone, Copy, Debug)]
| Field | Type | Role |
|---|---|---|
node | NodeId | Which node grabbed the drag. |
accum_world | Vec2 | Accumulated world-space delta. Recomputed from origin each frame (accumulator pattern) to avoid drift on slow drags. |
ConnectDrag — #[derive(Clone, Copy, Debug)]
| Field | Type | Role |
|---|---|---|
from | Port | Port the wire was dragged out from. |
from_world | Pos2 | World anchor of from’s handle. |
cursor_world | Pos2 | Current cursor position (world); the wire trails to it. |
MarqueeDrag — #[derive(Clone, Copy, Debug)]
| Field | Type | Role |
|---|---|---|
start_world | Pos2 | Drag origin in world coords (so the box tracks under pan/zoom). |
cursor_world | Pos2 | Current cursor (world). |
additive | bool | Shift held at drag start ⇒ additive selection. |
API
These are plain data structs — no methods. Construct/inspect their public fields directly. GraphViewState::default() is the canonical constructor; the canvas does this for you. All four derive Clone (state) / Clone + Copy (the three drags) so they round-trip through egui temp storage and the Scene closure cheaply.
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::graph::GraphViewState;
// The canvas owns this; you rarely touch it. But you *can* read/seed it:
let id = egui::Id::new("my_graph");
// Pre-position the camera (skip the first-frame auto-fit):
ui.data_mut(|d| {
let mut st: GraphViewState = d.get_temp(id).unwrap_or_default();
st.scene_rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(800.0, 600.0));
d.insert_temp(id, st);
});
// Inspect the persisted selection out-of-band (normally use GraphResponse::selection):
let selected: usize = ui.data(|d| {
d.get_temp::<GraphViewState>(id).map(|s| s.selection.len()).unwrap_or(0)
});
}
Composition / Notes
- Single source of camera truth. There is exactly one
GraphViewStateperGraphViewid; clobbering it (e.g. inserting a freshdefault()) resets the camera to the auto-fit sentinel and clears selection. scene_rectis world, not screen. A smallerscene_rectmeans more zoomed in (the window covers less world). This is the opposite mental model from a screen rect.- Not the same as
Viewport.Viewportis a standalone pan+zoom transform helper that the live canvas does not use; the canvas drivesscene_rectthroughegui::Scene. Don’t confuse the two camera representations. - Identity vocab.
NodeId,Port,PortSide— see identity. - Foundation: architecture · tokens · theming · guards. Layer overview: README.
GraphTokens
Layer: graph · Path:
src/graph/tokens.rs· Exports:GraphTokens
The single resolve point for everything the graph layer paints. A flat Copy struct of the exact resolved paint values (grid, edge, handle, selection, marquee, minimap), built from a Theme (colors) and core (geometry). It mirrors the ButtonTokens pattern: the paint-tier modules (grid, edge, handle, …) read from a resolved GraphTokens instead of touching Theme fields or core colors ad-hoc — which keeps the no_raw_values guard green and gives one place to retune the graph’s look.
Design
- Purpose / when to use. Resolve once per frame at the top of
GraphView::showand thread the value through the paint helpers. Read it inside the emit closure viaGraphCtx::tokens(). Never readTheme/coredirectly in graph paint code — go throughGraphTokens. - Anatomy. A
#[derive(Clone, Copy, Debug)]struct of 17 leaf fields in six groups (grid / edges / handles / node selection / marquee / minimap). No nested structs, no methods beyond the two constructors. - Color vs geometry split. Every color is a pure
Themetoken; every size/radius/width is acore::*constant. The one synthesized value ismarquee_fill, the focus ring re-tinted translucent viacore::tint(theme.ring, core::MARQUEE_ALPHA).
Field → source map
Background grid
| Field | Type | Source |
|---|---|---|
grid_dot | Color32 | theme.border |
grid_dot_radius | f32 | core::GRID_DOT_RADIUS |
grid_spacing | f32 | core::GRID_SPACING |
Edges (wires)
| Field | Type | Source |
|---|---|---|
edge | Color32 | theme.muted_foreground |
edge_hover | Color32 | theme.primary |
edge_selected | Color32 | theme.ring |
edge_width | f32 | core::EDGE_WIDTH |
edge_hit_radius | f32 | core::EDGE_HIT_RADIUS |
Handles (ports)
| Field | Type | Source |
|---|---|---|
handle_fill | Color32 | theme.primary |
handle_border | Color32 | theme.border_strong |
handle_radius | f32 | core::HANDLE_RADIUS |
handle_hit_radius | f32 | core::HANDLE_RADIUS * 2.0 |
Node selection
| Field | Type | Source |
|---|---|---|
node_selected_ring | Color32 | theme.ring |
Box-select marquee
| Field | Type | Source |
|---|---|---|
marquee_fill | Color32 | core::tint(theme.ring, core::MARQUEE_ALPHA) (translucent ring) |
marquee_border | Color32 | theme.ring |
Minimap
| Field | Type | Source |
|---|---|---|
minimap_node | Color32 | theme.muted_foreground |
minimap_view | Color32 | theme.ring |
API
| Method | Signature | Effect |
|---|---|---|
resolve | fn resolve(theme: &Theme) -> Self | Map a Theme (+ core geometry) onto all paint values. |
get | fn get(ui: &egui::Ui) -> Self | Convenience: resolve straight from the theme installed in ui (Self::resolve(&Theme::get(ui))). |
All fields are public and the struct is Copy, so pass it by value freely.
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::graph::GraphTokens;
// Resolve once (the canvas does this internally each frame):
let gt = GraphTokens::get(ui); // or GraphTokens::resolve(&theme);
// Paint-tier helper reading resolved values:
painter.circle_filled(handle_pos, gt.handle_radius, gt.handle_fill);
painter.line_segment([a, b], egui::Stroke::new(gt.edge_width, gt.edge_selected));
// Inside the GraphView::show closure:
GraphView::new("g").show(ui, |g| {
let t = g.tokens();
let hit = t.edge_hit_radius / g.scale().max(f32::EPSILON); // screen-px hit → world
// ...
});
}
Composition / Notes
- Two tiers, one token source. Both the paint tier (viewport/grid/edge/handle/resizer) and the compose tier (node/controls/minimap) draw only through
GraphTokens(colors) andcore::*(sizes). This is the layer invariant: graph is the one place outsideatomsthat paints, but every value still flows through a token. - Hit radii vs draw radii.
handle_hit_radiusis2×the draw radius;edge_hit_radiusis a screen-px grab distance that callers divide by zoom to test in world space. - Retuning. Change the graph’s look in exactly one place —
GraphTokens::resolve. Adding a new paint value means a new field here plus itscore/Themesource, never a literal in a paint module. - Foundation: tokens · theming · architecture · guards. Layer overview: README.
Viewport
Layer: graph · Path:
src/graph/viewport.rs· Exports:Viewport
A pure, Copy world↔screen transform value type — pan + zoom, nothing else. It stores only pan and zoom, never the canvas rect; the canvas origin is passed in per call so the math stays testable without an egui::Ui and the whole thing is trivially storable in egui memory. The zoom-anchored math is ported from the studio’s events/canvas.rs, cleaned up and unit-tested.
CRITICAL — the live canvas does not use this.
GraphViewdrives its camera throughegui::Scenedirectly (zoom range0.2..4.0, stored asscene_rectinGraphViewState).Viewportis a standalone utility with its own clamps (0.25..2.5) and is not currently wired into the canvas. Treat it as a reusable transform helper / reference implementation, not the canvas’s camera.
Design
- Purpose / when to use. A self-contained pan+zoom transform you can use anywhere you need world↔screen math without
egui::Scene— custom overlays, off-canvas hit-testing, or porting the studio’s camera. For the live node editor, the camera isScene/scene_rect, not this. - Conventions.
worldis the graph’s own coordinate space (node positions live here);screenis egui pixels.panis the screen-space offset of the world origin relative to the canvas top-left;zoomis screen-px per world-unit.canvas_origin(the canvas rect’sleft_top()) is supplied per call. - State. Two public fields only:
pan: Vec2,zoom: f32.Defaultispan 0, zoom 1. DerivesClone, Copy, Debug, PartialEq. - Clamps.
MIN_ZOOM = 0.25,MAX_ZOOM = 2.5(associated consts). Applied byzoom_aroundandfit. (Distinct from the canvas’s0.2..4.0.)
API
Associated constants
| Const | Value | Meaning |
|---|---|---|
Viewport::MIN_ZOOM | 0.25 | Lower zoom clamp — nodes never shrink to dust. |
Viewport::MAX_ZOOM | 2.5 | Upper zoom clamp — nodes never balloon past usefulness. |
Fields
| Field | Type | Meaning |
|---|---|---|
pan | Vec2 | Screen-space offset of the world origin relative to the canvas top-left. |
zoom | f32 | Screen px per world unit. |
Methods
| Method | Signature | Effect |
|---|---|---|
default | fn default() -> Self | pan 0, zoom 1. |
world_to_screen | fn world_to_screen(&self, canvas_origin: Pos2, world: Pos2) -> Pos2 | canvas_origin + pan + world.to_vec2() * zoom. |
screen_to_world | fn screen_to_world(&self, canvas_origin: Pos2, screen: Pos2) -> Pos2 | Inverse of world_to_screen. |
scale | fn scale(&self, world_len: f32) -> f32 | Scale a world length to on-screen length (world_len * zoom). |
pan_by | fn pan_by(&mut self, delta_screen: Vec2) | Pan by a screen-space delta (e.g. a drag delta). |
zoom_around | fn zoom_around(&mut self, canvas_origin: Pos2, anchor: Pos2, factor: f32) | Multiply zoom by factor, keeping the world point under anchor (a screen point, usually the cursor) pinned. Clamped to MIN_ZOOM..=MAX_ZOOM; no-op when the clamp pins it. |
fit | fn fit(&mut self, content_world: Rect, canvas: Rect, margin: f32) | Frame content_world centered inside canvas, leaving margin screen px each side. No-op for an empty/degenerate content rect; resulting zoom is clamped. |
Usage
#![allow(unused)]
fn main() {
use ouroboros_ui::graph::Viewport;
let canvas = ui.max_rect();
let origin = canvas.left_top();
let mut vp = Viewport::default();
// Pan from a drag delta:
vp.pan_by(response.drag_delta());
// Zoom toward the cursor on scroll:
if let Some(cursor) = ui.ctx().pointer_latest_pos() {
let factor = 1.0 + ui.input(|i| i.smooth_scroll_delta.y) * 0.001;
vp.zoom_around(origin, cursor, factor);
}
// Fit all nodes with a 32px margin:
vp.fit(content_bounds_world, canvas, 32.0);
// Project a node position to the screen for painting:
let screen = vp.world_to_screen(origin, node_world_pos);
}
Composition / Notes
- Paint-tier utility.
Viewportsits in the paint tier alongside grid/edge/handle — a value-level transform with noUidependency. It does not own a canvas rect, selection, or any drag state (that isGraphViewState). - Not the canvas camera (again). Because the live canvas uses
egui::Scene, changingViewport’s clamps or math has no effect onGraphView. If you need to alter the live zoom range, edit theMIN_ZOOM/MAX_ZOOMconsts incanvas.rs(0.2/4.0), not here. - Unit tests.
viewport.rscarries a#[cfg(test)]module that locks the contract:world_screen_round_trips—screen_to_world(world_to_screen(w)) == wacross sample points with non-trivial pan/zoom.zoom_keeps_point_under_cursor— the world point under the anchor is invariant acrosszoom_around, and zoom lands on the requested factor.zoom_clamps— repeated zoom-in/out saturates exactly atMAX_ZOOM/MIN_ZOOM.fit_centers_content— content center lands on canvas center and fits within the margins.fit_ignores_degenerate— a zero-size content rect leaves the viewport unchanged.
- Foundation: architecture · tokens · theming · guards. Layer overview: README. Identity: identity.